mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 15:13:48 -06:00
66dee798b5
This reverts commit 96798953b2.
502 lines
15 KiB
TypeScript
502 lines
15 KiB
TypeScript
import { installChannelDmPolicyContractSuite } from "openclaw/plugin-sdk/channel-test-helpers";
|
|
// Slack tests cover setup surface plugin behavior.
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import {
|
|
createQueuedWizardPrompter,
|
|
createSetupWizardAdapter,
|
|
createTestWizardPrompter,
|
|
runSetupWizardConfigure,
|
|
runSetupWizardPrepare,
|
|
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createSlackSetupWizardBase, slackSetupContract } from "./setup-core.js";
|
|
import { buildSlackSetupLines } from "./setup-shared.js";
|
|
|
|
const slackSetupWizard = createSlackSetupWizardBase({
|
|
promptAllowFrom: async ({ cfg }) => cfg,
|
|
resolveAllowFromEntries: async ({ entries }) =>
|
|
entries.map((entry) => ({
|
|
input: entry,
|
|
resolved: false,
|
|
id: null,
|
|
})),
|
|
resolveGroupAllowlist: async ({ entries }) => entries,
|
|
});
|
|
|
|
const credentialOnlySlackSetupWizard = {
|
|
...slackSetupWizard,
|
|
dmPolicy: undefined,
|
|
allowFrom: undefined,
|
|
groupAccess: undefined,
|
|
finalize: undefined,
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
const baseCfg = {
|
|
channels: {
|
|
slack: {
|
|
botToken: "xoxb-test",
|
|
appToken: "xapp-test",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
function requireFirstStringArg(mock: ReturnType<typeof vi.fn>, label: string): string {
|
|
const [call] = mock.mock.calls;
|
|
if (!call || typeof call[0] !== "string") {
|
|
throw new Error(`expected ${label}`);
|
|
}
|
|
return call[0];
|
|
}
|
|
|
|
describe("slackSetupWizard.prepare", () => {
|
|
it("keeps the manifest out of framed intro note lines", () => {
|
|
const lines = buildSlackSetupLines();
|
|
|
|
expect(lines.join("\n")).not.toContain("Manifest (JSON):");
|
|
expect(lines.join("\n")).not.toContain('"display_information"');
|
|
expect(lines).toContain("Manifest JSON follows as plain text for copy/paste.");
|
|
});
|
|
|
|
it("prints the manifest as plain JSON when Slack is not configured", async () => {
|
|
const plain = vi.fn<NonNullable<WizardPrompter["plain"]>>(async () => {});
|
|
const note = vi.fn(async () => {});
|
|
|
|
await runSetupWizardPrepare({
|
|
prepare: slackSetupWizard.prepare,
|
|
cfg: { channels: { slack: {} } } as OpenClawConfig,
|
|
prompter: createTestWizardPrompter({
|
|
plain,
|
|
note,
|
|
}),
|
|
});
|
|
|
|
expect(plain).toHaveBeenCalledTimes(1);
|
|
expect(note).toHaveBeenCalledWith(buildSlackSetupLines().join("\n"), expect.any(String));
|
|
const manifest = requireFirstStringArg(plain, "Slack manifest plain text");
|
|
expect(JSON.parse(manifest)).toEqual({
|
|
display_information: {
|
|
name: "OpenClaw",
|
|
description: "OpenClaw connector for OpenClaw",
|
|
},
|
|
features: {
|
|
bot_user: {
|
|
display_name: "OpenClaw",
|
|
always_online: true,
|
|
},
|
|
app_home: {
|
|
home_tab_enabled: true,
|
|
messages_tab_enabled: true,
|
|
messages_tab_read_only_enabled: false,
|
|
},
|
|
agent_view: {
|
|
agent_description: "OpenClaw connects Slack Agent View conversations to OpenClaw agents.",
|
|
suggested_prompts: [
|
|
{
|
|
title: "What can you do?",
|
|
message: "What can you help me with?",
|
|
},
|
|
{
|
|
title: "Summarize this channel",
|
|
message: "Summarize the recent activity in this channel.",
|
|
},
|
|
{
|
|
title: "Draft a reply",
|
|
message: "Help me draft a reply.",
|
|
},
|
|
],
|
|
},
|
|
slash_commands: [
|
|
{
|
|
command: "/openclaw",
|
|
description: "Send a message to OpenClaw",
|
|
should_escape: false,
|
|
},
|
|
],
|
|
},
|
|
oauth_config: {
|
|
scopes: {
|
|
bot: [
|
|
"app_mentions:read",
|
|
"assistant:write",
|
|
"channels:history",
|
|
"channels:read",
|
|
"chat:write",
|
|
"commands",
|
|
"emoji:read",
|
|
"files:read",
|
|
"files:write",
|
|
"groups:history",
|
|
"groups:read",
|
|
"im:history",
|
|
"im:read",
|
|
"im:write",
|
|
"mpim:history",
|
|
"mpim:read",
|
|
"mpim:write",
|
|
"pins:read",
|
|
"pins:write",
|
|
"reactions:read",
|
|
"reactions:write",
|
|
"usergroups:read",
|
|
"users:read",
|
|
],
|
|
},
|
|
},
|
|
settings: {
|
|
socket_mode_enabled: true,
|
|
event_subscriptions: {
|
|
bot_events: [
|
|
"app_home_opened",
|
|
"app_mention",
|
|
"app_context_changed",
|
|
"channel_rename",
|
|
"member_joined_channel",
|
|
"member_left_channel",
|
|
"message.channels",
|
|
"message.groups",
|
|
"message.im",
|
|
"message.mpim",
|
|
"pin_added",
|
|
"pin_removed",
|
|
"reaction_added",
|
|
"reaction_removed",
|
|
],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("collects only the user token and Socket Mode transport token for user identity", async () => {
|
|
const queued = createQueuedWizardPrompter({
|
|
selectValues: ["user"],
|
|
textValues: ["test-user-token", "test-app-token"],
|
|
});
|
|
const configure = createSetupWizardAdapter({
|
|
plugin: {
|
|
id: "slack",
|
|
meta: { label: "Slack" },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
defaultAccountId: () => "default",
|
|
},
|
|
setupContract: slackSetupContract,
|
|
} as never,
|
|
wizard: credentialOnlySlackSetupWizard,
|
|
}).configure;
|
|
|
|
const result = await runSetupWizardConfigure({
|
|
configure,
|
|
cfg: {} as OpenClawConfig,
|
|
prompter: queued.prompter,
|
|
options: { secretInputMode: "plaintext" as const },
|
|
});
|
|
|
|
expect(result.cfg.channels?.slack).toMatchObject({
|
|
enabled: true,
|
|
postAs: "user",
|
|
userToken: "test-user-token",
|
|
appToken: "test-app-token",
|
|
});
|
|
expect(result.cfg.channels?.slack?.botToken).toBeUndefined();
|
|
expect(
|
|
queued.text.mock.calls.map(([params]) => (params as { message: string }).message),
|
|
).toEqual(["Enter Slack user OAuth token", "Enter Slack app token (xapp-...)"]);
|
|
expect(queued.note).toHaveBeenCalledTimes(1);
|
|
const instructions = requireFirstStringArg(queued.note, "Slack user identity instructions");
|
|
expect(instructions).toContain("User Token Scopes");
|
|
expect(instructions).toContain("Subscribe to events on behalf of users");
|
|
expect(instructions).toContain("/channels/slack#user-identity-post-as-a-real-person");
|
|
expect(queued.select.mock.invocationCallOrder[0]).toBeLessThan(
|
|
queued.note.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
|
);
|
|
});
|
|
|
|
it("collects a signing secret instead of an app token for HTTP user identity", async () => {
|
|
const queued = createQueuedWizardPrompter({
|
|
selectValues: ["user"],
|
|
textValues: ["test-user-token", "test-signing-secret"],
|
|
});
|
|
const configure = createSetupWizardAdapter({
|
|
plugin: {
|
|
id: "slack",
|
|
meta: { label: "Slack" },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
defaultAccountId: () => "default",
|
|
},
|
|
setupContract: slackSetupContract,
|
|
} as never,
|
|
wizard: credentialOnlySlackSetupWizard,
|
|
}).configure;
|
|
|
|
const result = await runSetupWizardConfigure({
|
|
configure,
|
|
cfg: { channels: { slack: { mode: "http" } } } as OpenClawConfig,
|
|
prompter: queued.prompter,
|
|
options: { secretInputMode: "plaintext" as const },
|
|
});
|
|
|
|
expect(result.cfg.channels?.slack).toMatchObject({
|
|
enabled: true,
|
|
postAs: "user",
|
|
mode: "http",
|
|
userToken: "test-user-token",
|
|
signingSecret: "test-signing-secret",
|
|
});
|
|
expect(result.cfg.channels?.slack?.botToken).toBeUndefined();
|
|
expect(result.cfg.channels?.slack?.appToken).toBeUndefined();
|
|
});
|
|
|
|
it("continues user setup after preserving a user-token SecretRef", async () => {
|
|
const queued = createQueuedWizardPrompter({
|
|
selectValues: ["user"],
|
|
confirmValues: [true],
|
|
textValues: ["test-app-token"],
|
|
});
|
|
const configure = createSetupWizardAdapter({
|
|
plugin: {
|
|
id: "slack",
|
|
meta: { label: "Slack" },
|
|
config: {
|
|
listAccountIds: () => ["work"],
|
|
defaultAccountId: () => "work",
|
|
},
|
|
setupContract: slackSetupContract,
|
|
} as never,
|
|
wizard: credentialOnlySlackSetupWizard,
|
|
}).configure;
|
|
const userTokenRef = {
|
|
source: "env" as const,
|
|
provider: "default",
|
|
id: "TEST_SLACK_USER_TOKEN",
|
|
};
|
|
|
|
const result = await runSetupWizardConfigure({
|
|
configure,
|
|
cfg: {
|
|
channels: {
|
|
slack: {
|
|
accounts: {
|
|
work: { postAs: "user", userToken: userTokenRef },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
prompter: queued.prompter,
|
|
options: { secretInputMode: "plaintext" as const },
|
|
});
|
|
|
|
expect(result.cfg.channels?.slack?.accounts?.work).toMatchObject({
|
|
postAs: "user",
|
|
userToken: userTokenRef,
|
|
appToken: "test-app-token",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ name: "new setup", cfg: {} as OpenClawConfig },
|
|
{
|
|
name: "switch from user identity",
|
|
cfg: { channels: { slack: { postAs: "user" } } } as OpenClawConfig,
|
|
},
|
|
])("keeps bot identity implicit for $name", async ({ cfg }) => {
|
|
vi.stubEnv("SLACK_BOT_TOKEN", "");
|
|
vi.stubEnv("SLACK_APP_TOKEN", "");
|
|
const queued = createQueuedWizardPrompter({
|
|
selectValues: ["bot"],
|
|
textValues: ["test-bot-token", "test-app-token"],
|
|
});
|
|
const configure = createSetupWizardAdapter({
|
|
plugin: {
|
|
id: "slack",
|
|
meta: { label: "Slack" },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
defaultAccountId: () => "default",
|
|
},
|
|
setupContract: slackSetupContract,
|
|
} as never,
|
|
wizard: credentialOnlySlackSetupWizard,
|
|
}).configure;
|
|
|
|
const result = await runSetupWizardConfigure({
|
|
configure,
|
|
cfg,
|
|
prompter: queued.prompter,
|
|
options: { secretInputMode: "plaintext" as const },
|
|
});
|
|
|
|
expect(result.cfg.channels?.slack).toEqual({
|
|
enabled: true,
|
|
botToken: "test-bot-token",
|
|
appToken: "test-app-token",
|
|
});
|
|
expect(JSON.stringify(result.cfg.channels?.slack)).toBe(
|
|
'{"enabled":true,"botToken":"test-bot-token","appToken":"test-app-token"}',
|
|
);
|
|
expect(result.cfg.channels?.slack).not.toHaveProperty("postAs");
|
|
});
|
|
|
|
it("keeps a named bot override when the channel default is user identity", async () => {
|
|
const queued = createQueuedWizardPrompter({
|
|
selectValues: ["bot"],
|
|
textValues: ["test-bot-token", "test-app-token"],
|
|
});
|
|
const configure = createSetupWizardAdapter({
|
|
plugin: {
|
|
id: "slack",
|
|
meta: { label: "Slack" },
|
|
config: {
|
|
listAccountIds: () => ["work"],
|
|
defaultAccountId: () => "work",
|
|
},
|
|
setupContract: slackSetupContract,
|
|
} as never,
|
|
wizard: credentialOnlySlackSetupWizard,
|
|
}).configure;
|
|
|
|
const result = await runSetupWizardConfigure({
|
|
configure,
|
|
cfg: {
|
|
channels: {
|
|
slack: {
|
|
postAs: "user",
|
|
userToken: "test-user-token",
|
|
appToken: "test-user-app-token",
|
|
accounts: {
|
|
work: { userToken: "", appToken: "" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
prompter: queued.prompter,
|
|
options: { secretInputMode: "plaintext" as const },
|
|
});
|
|
|
|
expect(result.cfg.channels?.slack?.postAs).toBe("user");
|
|
expect(result.cfg.channels?.slack?.accounts?.work).toMatchObject({
|
|
enabled: true,
|
|
postAs: "bot",
|
|
botToken: "test-bot-token",
|
|
appToken: "test-app-token",
|
|
});
|
|
});
|
|
|
|
it("lets a configured user identity switch back to implicit bot", async () => {
|
|
const queued = createQueuedWizardPrompter({ selectValues: ["bot"] });
|
|
|
|
const result = await runSetupWizardPrepare({
|
|
prepare: slackSetupWizard.prepare,
|
|
cfg: {
|
|
channels: {
|
|
slack: {
|
|
postAs: "user",
|
|
userToken: "test-user-token",
|
|
appToken: "test-app-token",
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
prompter: queued.prompter,
|
|
});
|
|
|
|
expect(queued.select).toHaveBeenCalledTimes(1);
|
|
expect(result?.cfg.channels?.slack).toMatchObject({
|
|
enabled: true,
|
|
userToken: "test-user-token",
|
|
appToken: "test-app-token",
|
|
});
|
|
expect(result?.cfg.channels?.slack).not.toHaveProperty("postAs");
|
|
});
|
|
|
|
it("does not print the manifest after Slack credentials are configured", async () => {
|
|
const queued = createQueuedWizardPrompter();
|
|
|
|
await runSetupWizardPrepare({
|
|
prepare: slackSetupWizard.prepare,
|
|
cfg: baseCfg,
|
|
prompter: queued.prompter,
|
|
});
|
|
|
|
expect(queued.select).not.toHaveBeenCalled();
|
|
expect(queued.plain).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("slackSetupWizard.dmPolicy", () => {
|
|
installChannelDmPolicyContractSuite({
|
|
dmPolicy: slackSetupWizard.dmPolicy!,
|
|
cases: [
|
|
{
|
|
name: "Slack named accounts",
|
|
channel: "slack",
|
|
accountId: "alerts",
|
|
accountConfig: { botToken: "xoxb-alerts", appToken: "xapp-alerts" },
|
|
inheritedAllowFrom: ["U123"],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
describe("slackSetupWizard.status", () => {
|
|
it("defers identity-specific setup instructions until after identity selection", () => {
|
|
expect("introNote" in slackSetupWizard).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "Socket Mode",
|
|
slack: {
|
|
postAs: "user" as const,
|
|
userToken: "test-user-token",
|
|
appToken: "test-app-token",
|
|
},
|
|
},
|
|
{
|
|
name: "HTTP mode",
|
|
slack: {
|
|
postAs: "user" as const,
|
|
mode: "http" as const,
|
|
userToken: "test-user-token",
|
|
signingSecret: "test-signing-secret",
|
|
},
|
|
},
|
|
])("treats a complete user-identity $name account as configured", async ({ slack }) => {
|
|
expect(
|
|
await slackSetupWizard.status.resolveConfigured({
|
|
cfg: { channels: { slack } } as OpenClawConfig,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("uses configured defaultAccount for omitted setup configured state", async () => {
|
|
const configured = await slackSetupWizard.status.resolveConfigured({
|
|
cfg: {
|
|
channels: {
|
|
slack: {
|
|
defaultAccount: "work",
|
|
botToken: "xoxb-root",
|
|
appToken: "xapp-root",
|
|
accounts: {
|
|
alerts: {
|
|
botToken: "xoxb-alerts",
|
|
appToken: "xapp-alerts",
|
|
},
|
|
work: {
|
|
botToken: "",
|
|
appToken: "",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
});
|
|
|
|
expect(configured).toBe(false);
|
|
});
|
|
});
|