mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 08:47:40 +00:00
perf(slack): consolidate slash tests
This commit is contained in:
@@ -227,7 +227,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
const handleSlackMessage = createSlackMessageHandler({ ctx, account });
|
const handleSlackMessage = createSlackMessageHandler({ ctx, account });
|
||||||
|
|
||||||
registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
|
registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
|
||||||
registerSlackMonitorSlashCommands({ ctx, account });
|
await registerSlackMonitorSlashCommands({ ctx, account });
|
||||||
if (slackMode === "http" && slackHttpHandler) {
|
if (slackMode === "http" && slackHttpHandler) {
|
||||||
unregisterHttpHandler = registerSlackHttpHandler({
|
unregisterHttpHandler = registerSlackHttpHandler({
|
||||||
path: slackWebhookPath,
|
path: slackWebhookPath,
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js";
|
|
||||||
|
|
||||||
const { dispatchMock } = getSlackSlashMocks();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resetSlackSlashMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function registerCommands(ctx: unknown, account: unknown) {
|
|
||||||
const { registerSlackMonitorSlashCommands } = await import("./slash.js");
|
|
||||||
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeValue(parts: { command: string; arg: string; value: string; userId: string }) {
|
|
||||||
return [
|
|
||||||
"cmdarg",
|
|
||||||
encodeURIComponent(parts.command),
|
|
||||||
encodeURIComponent(parts.arg),
|
|
||||||
encodeURIComponent(parts.value),
|
|
||||||
encodeURIComponent(parts.userId),
|
|
||||||
].join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHarness() {
|
|
||||||
const commands = new Map<string, (args: unknown) => Promise<void>>();
|
|
||||||
const actions = new Map<string, (args: unknown) => Promise<void>>();
|
|
||||||
|
|
||||||
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
|
|
||||||
const app = {
|
|
||||||
client: { chat: { postEphemeral } },
|
|
||||||
command: (name: string, handler: (args: unknown) => Promise<void>) => {
|
|
||||||
commands.set(name, handler);
|
|
||||||
},
|
|
||||||
action: (id: string, handler: (args: unknown) => Promise<void>) => {
|
|
||||||
actions.set(id, handler);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
cfg: { commands: { native: true } },
|
|
||||||
runtime: {},
|
|
||||||
botToken: "bot-token",
|
|
||||||
botUserId: "bot",
|
|
||||||
teamId: "T1",
|
|
||||||
allowFrom: ["*"],
|
|
||||||
dmEnabled: true,
|
|
||||||
dmPolicy: "open",
|
|
||||||
groupDmEnabled: false,
|
|
||||||
groupDmChannels: [],
|
|
||||||
defaultRequireMention: true,
|
|
||||||
groupPolicy: "open",
|
|
||||||
useAccessGroups: false,
|
|
||||||
channelsConfig: undefined,
|
|
||||||
slashCommand: {
|
|
||||||
enabled: true,
|
|
||||||
name: "openclaw",
|
|
||||||
ephemeral: true,
|
|
||||||
sessionPrefix: "slack:slash",
|
|
||||||
},
|
|
||||||
textLimit: 4000,
|
|
||||||
app,
|
|
||||||
isChannelAllowed: () => true,
|
|
||||||
resolveChannelName: async () => ({ name: "dm", type: "im" }),
|
|
||||||
resolveUserName: async () => ({ name: "Ada" }),
|
|
||||||
} as unknown;
|
|
||||||
|
|
||||||
const account = { accountId: "acct", config: { commands: { native: true } } } as unknown;
|
|
||||||
|
|
||||||
return { commands, actions, postEphemeral, ctx, account };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Slack native command argument menus", () => {
|
|
||||||
it("shows a button menu when required args are omitted", async () => {
|
|
||||||
const { commands, ctx, account } = createHarness();
|
|
||||||
await registerCommands(ctx, account);
|
|
||||||
|
|
||||||
const handler = commands.get("/usage");
|
|
||||||
if (!handler) {
|
|
||||||
throw new Error("Missing /usage handler");
|
|
||||||
}
|
|
||||||
|
|
||||||
const respond = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const ack = vi.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await handler({
|
|
||||||
command: {
|
|
||||||
user_id: "U1",
|
|
||||||
user_name: "Ada",
|
|
||||||
channel_id: "C1",
|
|
||||||
channel_name: "directmessage",
|
|
||||||
text: "",
|
|
||||||
trigger_id: "t1",
|
|
||||||
},
|
|
||||||
ack,
|
|
||||||
respond,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(respond).toHaveBeenCalledTimes(1);
|
|
||||||
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
|
|
||||||
expect(payload.blocks?.[0]?.type).toBe("section");
|
|
||||||
expect(payload.blocks?.[1]?.type).toBe("actions");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dispatches the command when a menu button is clicked", async () => {
|
|
||||||
const { actions, ctx, account } = createHarness();
|
|
||||||
await registerCommands(ctx, account);
|
|
||||||
|
|
||||||
const handler = actions.get("openclaw_cmdarg");
|
|
||||||
if (!handler) {
|
|
||||||
throw new Error("Missing arg-menu action handler");
|
|
||||||
}
|
|
||||||
|
|
||||||
const respond = vi.fn().mockResolvedValue(undefined);
|
|
||||||
await handler({
|
|
||||||
ack: vi.fn().mockResolvedValue(undefined),
|
|
||||||
action: {
|
|
||||||
value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }),
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
user: { id: "U1", name: "Ada" },
|
|
||||||
channel: { id: "C1", name: "directmessage" },
|
|
||||||
trigger_id: "t1",
|
|
||||||
},
|
|
||||||
respond,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
|
||||||
const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } };
|
|
||||||
expect(call.ctx?.Body).toBe("/usage tokens");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects menu clicks from other users", async () => {
|
|
||||||
const { actions, ctx, account } = createHarness();
|
|
||||||
await registerCommands(ctx, account);
|
|
||||||
|
|
||||||
const handler = actions.get("openclaw_cmdarg");
|
|
||||||
if (!handler) {
|
|
||||||
throw new Error("Missing arg-menu action handler");
|
|
||||||
}
|
|
||||||
|
|
||||||
const respond = vi.fn().mockResolvedValue(undefined);
|
|
||||||
await handler({
|
|
||||||
ack: vi.fn().mockResolvedValue(undefined),
|
|
||||||
action: {
|
|
||||||
value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }),
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
user: { id: "U2", name: "Eve" },
|
|
||||||
channel: { id: "C1", name: "directmessage" },
|
|
||||||
trigger_id: "t1",
|
|
||||||
},
|
|
||||||
respond,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dispatchMock).not.toHaveBeenCalled();
|
|
||||||
expect(respond).toHaveBeenCalledWith({
|
|
||||||
text: "That menu is for another user.",
|
|
||||||
response_type: "ephemeral",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to postEphemeral with token when respond is unavailable", async () => {
|
|
||||||
const { actions, postEphemeral, ctx, account } = createHarness();
|
|
||||||
await registerCommands(ctx, account);
|
|
||||||
|
|
||||||
const handler = actions.get("openclaw_cmdarg");
|
|
||||||
if (!handler) {
|
|
||||||
throw new Error("Missing arg-menu action handler");
|
|
||||||
}
|
|
||||||
|
|
||||||
await handler({
|
|
||||||
ack: vi.fn().mockResolvedValue(undefined),
|
|
||||||
action: { value: "garbage" },
|
|
||||||
body: { user: { id: "U1" }, channel: { id: "C1" } },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(postEphemeral).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
token: "bot-token",
|
|
||||||
channel: "C1",
|
|
||||||
user: "U1",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats malformed percent-encoding as an invalid button (no throw)", async () => {
|
|
||||||
const { actions, postEphemeral, ctx, account } = createHarness();
|
|
||||||
await registerCommands(ctx, account);
|
|
||||||
|
|
||||||
const handler = actions.get("openclaw_cmdarg");
|
|
||||||
if (!handler) {
|
|
||||||
throw new Error("Missing arg-menu action handler");
|
|
||||||
}
|
|
||||||
|
|
||||||
await handler({
|
|
||||||
ack: vi.fn().mockResolvedValue(undefined),
|
|
||||||
action: { value: "cmdarg|%E0%A4%A|mode|on|U1" },
|
|
||||||
body: { user: { id: "U1" }, channel: { id: "C1" } },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(postEphemeral).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
token: "bot-token",
|
|
||||||
channel: "C1",
|
|
||||||
user: "U1",
|
|
||||||
text: "Sorry, that button is no longer valid.",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +1,227 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js";
|
import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js";
|
||||||
|
|
||||||
|
type RegisterFn = (params: { ctx: unknown; account: unknown }) => Promise<void>;
|
||||||
|
let registerSlackMonitorSlashCommands: RegisterFn;
|
||||||
|
|
||||||
const { dispatchMock } = getSlackSlashMocks();
|
const { dispatchMock } = getSlackSlashMocks();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ registerSlackMonitorSlashCommands } = (await import("./slash.js")) as unknown as {
|
||||||
|
registerSlackMonitorSlashCommands: RegisterFn;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetSlackSlashMocks();
|
resetSlackSlashMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function registerCommands(ctx: unknown, account: unknown) {
|
async function registerCommands(ctx: unknown, account: unknown) {
|
||||||
const { registerSlackMonitorSlashCommands } = await import("./slash.js");
|
await registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||||
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHarness(overrides?: {
|
function encodeValue(parts: { command: string; arg: string; value: string; userId: string }) {
|
||||||
|
return [
|
||||||
|
"cmdarg",
|
||||||
|
encodeURIComponent(parts.command),
|
||||||
|
encodeURIComponent(parts.arg),
|
||||||
|
encodeURIComponent(parts.value),
|
||||||
|
encodeURIComponent(parts.userId),
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArgMenusHarness() {
|
||||||
|
const commands = new Map<string, (args: unknown) => Promise<void>>();
|
||||||
|
const actions = new Map<string, (args: unknown) => Promise<void>>();
|
||||||
|
|
||||||
|
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
const app = {
|
||||||
|
client: { chat: { postEphemeral } },
|
||||||
|
command: (name: string, handler: (args: unknown) => Promise<void>) => {
|
||||||
|
commands.set(name, handler);
|
||||||
|
},
|
||||||
|
action: (id: string, handler: (args: unknown) => Promise<void>) => {
|
||||||
|
actions.set(id, handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
cfg: { commands: { native: true, nativeSkills: false } },
|
||||||
|
runtime: {},
|
||||||
|
botToken: "bot-token",
|
||||||
|
botUserId: "bot",
|
||||||
|
teamId: "T1",
|
||||||
|
allowFrom: ["*"],
|
||||||
|
dmEnabled: true,
|
||||||
|
dmPolicy: "open",
|
||||||
|
groupDmEnabled: false,
|
||||||
|
groupDmChannels: [],
|
||||||
|
defaultRequireMention: true,
|
||||||
|
groupPolicy: "open",
|
||||||
|
useAccessGroups: false,
|
||||||
|
channelsConfig: undefined,
|
||||||
|
slashCommand: {
|
||||||
|
enabled: true,
|
||||||
|
name: "openclaw",
|
||||||
|
ephemeral: true,
|
||||||
|
sessionPrefix: "slack:slash",
|
||||||
|
},
|
||||||
|
textLimit: 4000,
|
||||||
|
app,
|
||||||
|
isChannelAllowed: () => true,
|
||||||
|
resolveChannelName: async () => ({ name: "dm", type: "im" }),
|
||||||
|
resolveUserName: async () => ({ name: "Ada" }),
|
||||||
|
} as unknown;
|
||||||
|
|
||||||
|
const account = {
|
||||||
|
accountId: "acct",
|
||||||
|
config: { commands: { native: true, nativeSkills: false } },
|
||||||
|
} as unknown;
|
||||||
|
|
||||||
|
return { commands, actions, postEphemeral, ctx, account };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Slack native command argument menus", () => {
|
||||||
|
it("shows a button menu when required args are omitted", async () => {
|
||||||
|
const { commands, ctx, account } = createArgMenusHarness();
|
||||||
|
await registerCommands(ctx, account);
|
||||||
|
|
||||||
|
const handler = commands.get("/usage");
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error("Missing /usage handler");
|
||||||
|
}
|
||||||
|
|
||||||
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const ack = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
command: {
|
||||||
|
user_id: "U1",
|
||||||
|
user_name: "Ada",
|
||||||
|
channel_id: "C1",
|
||||||
|
channel_name: "directmessage",
|
||||||
|
text: "",
|
||||||
|
trigger_id: "t1",
|
||||||
|
},
|
||||||
|
ack,
|
||||||
|
respond,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(respond).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
|
||||||
|
expect(payload.blocks?.[0]?.type).toBe("section");
|
||||||
|
expect(payload.blocks?.[1]?.type).toBe("actions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches the command when a menu button is clicked", async () => {
|
||||||
|
const { actions, ctx, account } = createArgMenusHarness();
|
||||||
|
await registerCommands(ctx, account);
|
||||||
|
|
||||||
|
const handler = actions.get("openclaw_cmdarg");
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error("Missing arg-menu action handler");
|
||||||
|
}
|
||||||
|
|
||||||
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
|
await handler({
|
||||||
|
ack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
action: {
|
||||||
|
value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
user: { id: "U1", name: "Ada" },
|
||||||
|
channel: { id: "C1", name: "directmessage" },
|
||||||
|
trigger_id: "t1",
|
||||||
|
},
|
||||||
|
respond,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } };
|
||||||
|
expect(call.ctx?.Body).toBe("/usage tokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects menu clicks from other users", async () => {
|
||||||
|
const { actions, ctx, account } = createArgMenusHarness();
|
||||||
|
await registerCommands(ctx, account);
|
||||||
|
|
||||||
|
const handler = actions.get("openclaw_cmdarg");
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error("Missing arg-menu action handler");
|
||||||
|
}
|
||||||
|
|
||||||
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
|
await handler({
|
||||||
|
ack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
action: {
|
||||||
|
value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
user: { id: "U2", name: "Eve" },
|
||||||
|
channel: { id: "C1", name: "directmessage" },
|
||||||
|
trigger_id: "t1",
|
||||||
|
},
|
||||||
|
respond,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dispatchMock).not.toHaveBeenCalled();
|
||||||
|
expect(respond).toHaveBeenCalledWith({
|
||||||
|
text: "That menu is for another user.",
|
||||||
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to postEphemeral with token when respond is unavailable", async () => {
|
||||||
|
const { actions, postEphemeral, ctx, account } = createArgMenusHarness();
|
||||||
|
await registerCommands(ctx, account);
|
||||||
|
|
||||||
|
const handler = actions.get("openclaw_cmdarg");
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error("Missing arg-menu action handler");
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
ack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
action: { value: "garbage" },
|
||||||
|
body: { user: { id: "U1" }, channel: { id: "C1" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(postEphemeral).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
token: "bot-token",
|
||||||
|
channel: "C1",
|
||||||
|
user: "U1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats malformed percent-encoding as an invalid button (no throw)", async () => {
|
||||||
|
const { actions, postEphemeral, ctx, account } = createArgMenusHarness();
|
||||||
|
await registerCommands(ctx, account);
|
||||||
|
|
||||||
|
const handler = actions.get("openclaw_cmdarg");
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error("Missing arg-menu action handler");
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
ack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
action: { value: "cmdarg|%E0%A4%A|mode|on|U1" },
|
||||||
|
body: { user: { id: "U1" }, channel: { id: "C1" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(postEphemeral).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
token: "bot-token",
|
||||||
|
channel: "C1",
|
||||||
|
user: "U1",
|
||||||
|
text: "Sorry, that button is no longer valid.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createPolicyHarness(overrides?: {
|
||||||
groupPolicy?: "open" | "allowlist";
|
groupPolicy?: "open" | "allowlist";
|
||||||
channelsConfig?: Record<string, { allow?: boolean; requireMention?: boolean }>;
|
channelsConfig?: Record<string, { allow?: boolean; requireMention?: boolean }>;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
@@ -104,7 +313,7 @@ async function runSlashHandler(params: {
|
|||||||
|
|
||||||
describe("slack slash commands channel policy", () => {
|
describe("slack slash commands channel policy", () => {
|
||||||
it("allows unlisted channels when groupPolicy is open", async () => {
|
it("allows unlisted channels when groupPolicy is open", async () => {
|
||||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
|
||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
channelsConfig: { C_LISTED: { requireMention: true } },
|
channelsConfig: { C_LISTED: { requireMention: true } },
|
||||||
channelId: "C_UNLISTED",
|
channelId: "C_UNLISTED",
|
||||||
@@ -127,7 +336,7 @@ describe("slack slash commands channel policy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("blocks explicitly denied channels when groupPolicy is open", async () => {
|
it("blocks explicitly denied channels when groupPolicy is open", async () => {
|
||||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
|
||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
channelsConfig: { C_DENIED: { allow: false } },
|
channelsConfig: { C_DENIED: { allow: false } },
|
||||||
channelId: "C_DENIED",
|
channelId: "C_DENIED",
|
||||||
@@ -151,7 +360,7 @@ describe("slack slash commands channel policy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("blocks unlisted channels when groupPolicy is allowlist", async () => {
|
it("blocks unlisted channels when groupPolicy is allowlist", async () => {
|
||||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
|
||||||
groupPolicy: "allowlist",
|
groupPolicy: "allowlist",
|
||||||
channelsConfig: { C_LISTED: { requireMention: true } },
|
channelsConfig: { C_LISTED: { requireMention: true } },
|
||||||
channelId: "C_UNLISTED",
|
channelId: "C_UNLISTED",
|
||||||
@@ -177,7 +386,7 @@ describe("slack slash commands channel policy", () => {
|
|||||||
|
|
||||||
describe("slack slash commands access groups", () => {
|
describe("slack slash commands access groups", () => {
|
||||||
it("fails closed when channel type lookup returns empty for channels", async () => {
|
it("fails closed when channel type lookup returns empty for channels", async () => {
|
||||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
channelId: "C_UNKNOWN",
|
channelId: "C_UNKNOWN",
|
||||||
channelName: "unknown",
|
channelName: "unknown",
|
||||||
@@ -201,7 +410,7 @@ describe("slack slash commands access groups", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("still treats D-prefixed channel ids as DMs when lookup fails", async () => {
|
it("still treats D-prefixed channel ids as DMs when lookup fails", async () => {
|
||||||
const { commands, ctx, account } = createHarness({
|
const { commands, ctx, account } = createPolicyHarness({
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
channelId: "D123",
|
channelId: "D123",
|
||||||
channelName: "notdirectmessage",
|
channelName: "notdirectmessage",
|
||||||
@@ -228,7 +437,7 @@ describe("slack slash commands access groups", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("computes CommandAuthorized for DM slash commands when dmPolicy is open", async () => {
|
it("computes CommandAuthorized for DM slash commands when dmPolicy is open", async () => {
|
||||||
const { commands, ctx, account } = createHarness({
|
const { commands, ctx, account } = createPolicyHarness({
|
||||||
allowFrom: ["U_OWNER"],
|
allowFrom: ["U_OWNER"],
|
||||||
channelId: "D999",
|
channelId: "D999",
|
||||||
channelName: "directmessage",
|
channelName: "directmessage",
|
||||||
@@ -254,7 +463,7 @@ describe("slack slash commands access groups", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("enforces access-group gating when lookup fails for private channels", async () => {
|
it("enforces access-group gating when lookup fails for private channels", async () => {
|
||||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
channelId: "G123",
|
channelId: "G123",
|
||||||
channelName: "private",
|
channelName: "private",
|
||||||
@@ -2,30 +2,15 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@sla
|
|||||||
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
|
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
|
||||||
import type { ResolvedSlackAccount } from "../accounts.js";
|
import type { ResolvedSlackAccount } from "../accounts.js";
|
||||||
import type { SlackMonitorContext } from "./context.js";
|
import type { SlackMonitorContext } from "./context.js";
|
||||||
import { resolveChunkMode } from "../../auto-reply/chunk.js";
|
|
||||||
import {
|
|
||||||
buildCommandTextFromArgs,
|
|
||||||
findCommandByNativeName,
|
|
||||||
listNativeCommandSpecsForConfig,
|
|
||||||
parseCommandArgs,
|
|
||||||
resolveCommandArgMenu,
|
|
||||||
} from "../../auto-reply/commands-registry.js";
|
|
||||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
|
||||||
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
|
||||||
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
|
|
||||||
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
|
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
|
||||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||||
import { resolveConversationLabel } from "../../channels/conversation-label.js";
|
|
||||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
|
||||||
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
|
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
|
||||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
|
||||||
import { danger, logVerbose } from "../../globals.js";
|
import { danger, logVerbose } from "../../globals.js";
|
||||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||||
import {
|
import {
|
||||||
readChannelAllowFromStore,
|
readChannelAllowFromStore,
|
||||||
upsertChannelPairingRequest,
|
upsertChannelPairingRequest,
|
||||||
} from "../../pairing/pairing-store.js";
|
} from "../../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
|
||||||
import {
|
import {
|
||||||
normalizeAllowList,
|
normalizeAllowList,
|
||||||
normalizeAllowListLower,
|
normalizeAllowListLower,
|
||||||
@@ -36,7 +21,6 @@ import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./ch
|
|||||||
import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js";
|
import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js";
|
||||||
import { normalizeSlackChannelType } from "./context.js";
|
import { normalizeSlackChannelType } from "./context.js";
|
||||||
import { isSlackChannelAllowedByPolicy } from "./policy.js";
|
import { isSlackChannelAllowedByPolicy } from "./policy.js";
|
||||||
import { deliverSlackSlashReplies } from "./replies.js";
|
|
||||||
import { resolveSlackRoomContextHints } from "./room-context.js";
|
import { resolveSlackRoomContextHints } from "./room-context.js";
|
||||||
|
|
||||||
type SlackBlock = { type: string; [key: string]: unknown };
|
type SlackBlock = { type: string; [key: string]: unknown };
|
||||||
@@ -44,6 +28,15 @@ type SlackBlock = { type: string; [key: string]: unknown };
|
|||||||
const SLACK_COMMAND_ARG_ACTION_ID = "openclaw_cmdarg";
|
const SLACK_COMMAND_ARG_ACTION_ID = "openclaw_cmdarg";
|
||||||
const SLACK_COMMAND_ARG_VALUE_PREFIX = "cmdarg";
|
const SLACK_COMMAND_ARG_VALUE_PREFIX = "cmdarg";
|
||||||
|
|
||||||
|
type CommandsRegistry = typeof import("../../auto-reply/commands-registry.js");
|
||||||
|
let commandsRegistry: CommandsRegistry | undefined;
|
||||||
|
async function getCommandsRegistry(): Promise<CommandsRegistry> {
|
||||||
|
if (!commandsRegistry) {
|
||||||
|
commandsRegistry = await import("../../auto-reply/commands-registry.js");
|
||||||
|
}
|
||||||
|
return commandsRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
function chunkItems<T>(items: T[], size: number): T[][] {
|
function chunkItems<T>(items: T[], size: number): T[][] {
|
||||||
if (size <= 0) {
|
if (size <= 0) {
|
||||||
return [items];
|
return [items];
|
||||||
@@ -139,10 +132,10 @@ function buildSlackCommandArgMenuBlocks(params: {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerSlackMonitorSlashCommands(params: {
|
export async function registerSlackMonitorSlashCommands(params: {
|
||||||
ctx: SlackMonitorContext;
|
ctx: SlackMonitorContext;
|
||||||
account: ResolvedSlackAccount;
|
account: ResolvedSlackAccount;
|
||||||
}) {
|
}): Promise<void> {
|
||||||
const { ctx, account } = params;
|
const { ctx, account } = params;
|
||||||
const cfg = ctx.cfg;
|
const cfg = ctx.cfg;
|
||||||
const runtime = ctx.runtime;
|
const runtime = ctx.runtime;
|
||||||
@@ -349,7 +342,8 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (commandDefinition && supportsInteractiveArgMenus) {
|
if (commandDefinition && supportsInteractiveArgMenus) {
|
||||||
const menu = resolveCommandArgMenu({
|
const reg = await getCommandsRegistry();
|
||||||
|
const menu = reg.resolveCommandArgMenu({
|
||||||
command: commandDefinition,
|
command: commandDefinition,
|
||||||
args: commandArgs,
|
args: commandArgs,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -376,6 +370,17 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
|
|
||||||
const channelName = channelInfo?.name;
|
const channelName = channelInfo?.name;
|
||||||
const roomLabel = channelName ? `#${channelName}` : `#${command.channel_id}`;
|
const roomLabel = channelName ? `#${channelName}` : `#${command.channel_id}`;
|
||||||
|
const [{ resolveAgentRoute }, { finalizeInboundContext }, { dispatchReplyWithDispatcher }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("../../routing/resolve-route.js"),
|
||||||
|
import("../../auto-reply/reply/inbound-context.js"),
|
||||||
|
import("../../auto-reply/reply/provider-dispatcher.js"),
|
||||||
|
]);
|
||||||
|
const [{ resolveConversationLabel }, { createReplyPrefixOptions }] = await Promise.all([
|
||||||
|
import("../../channels/conversation-label.js"),
|
||||||
|
import("../../channels/reply-prefix.js"),
|
||||||
|
]);
|
||||||
|
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "slack",
|
channel: "slack",
|
||||||
@@ -450,6 +455,15 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
...prefixOptions,
|
...prefixOptions,
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
|
const [
|
||||||
|
{ deliverSlackSlashReplies },
|
||||||
|
{ resolveChunkMode },
|
||||||
|
{ resolveMarkdownTableMode },
|
||||||
|
] = await Promise.all([
|
||||||
|
import("./replies.js"),
|
||||||
|
import("../../auto-reply/chunk.js"),
|
||||||
|
import("../../config/markdown-tables.js"),
|
||||||
|
]);
|
||||||
await deliverSlackSlashReplies({
|
await deliverSlackSlashReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
respond,
|
respond,
|
||||||
@@ -473,6 +487,12 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (counts.final + counts.tool + counts.block === 0) {
|
if (counts.final + counts.tool + counts.block === 0) {
|
||||||
|
const [{ deliverSlackSlashReplies }, { resolveChunkMode }, { resolveMarkdownTableMode }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("./replies.js"),
|
||||||
|
import("../../auto-reply/chunk.js"),
|
||||||
|
import("../../config/markdown-tables.js"),
|
||||||
|
]);
|
||||||
await deliverSlackSlashReplies({
|
await deliverSlackSlashReplies({
|
||||||
replies: [],
|
replies: [],
|
||||||
respond,
|
respond,
|
||||||
@@ -505,25 +525,35 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
providerSetting: account.config.commands?.nativeSkills,
|
providerSetting: account.config.commands?.nativeSkills,
|
||||||
globalSetting: cfg.commands?.nativeSkills,
|
globalSetting: cfg.commands?.nativeSkills,
|
||||||
});
|
});
|
||||||
const skillCommands =
|
|
||||||
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
|
let reg: CommandsRegistry | undefined;
|
||||||
const nativeCommands = nativeEnabled
|
let nativeCommands: Array<{ name: string }> = [];
|
||||||
? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "slack" })
|
if (nativeEnabled) {
|
||||||
: [];
|
reg = await getCommandsRegistry();
|
||||||
|
const skillCommands = nativeSkillsEnabled
|
||||||
|
? (await import("../../auto-reply/skill-commands.js")).listSkillCommandsForAgents({ cfg })
|
||||||
|
: [];
|
||||||
|
nativeCommands = reg.listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "slack" });
|
||||||
|
}
|
||||||
|
|
||||||
if (nativeCommands.length > 0) {
|
if (nativeCommands.length > 0) {
|
||||||
|
const registry = reg;
|
||||||
|
if (!registry) {
|
||||||
|
throw new Error("Missing commands registry for native Slack commands.");
|
||||||
|
}
|
||||||
for (const command of nativeCommands) {
|
for (const command of nativeCommands) {
|
||||||
ctx.app.command(
|
ctx.app.command(
|
||||||
`/${command.name}`,
|
`/${command.name}`,
|
||||||
async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => {
|
async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => {
|
||||||
const commandDefinition = findCommandByNativeName(command.name, "slack");
|
const commandDefinition = registry.findCommandByNativeName(command.name, "slack");
|
||||||
const rawText = cmd.text?.trim() ?? "";
|
const rawText = cmd.text?.trim() ?? "";
|
||||||
const commandArgs = commandDefinition
|
const commandArgs = commandDefinition
|
||||||
? parseCommandArgs(commandDefinition, rawText)
|
? registry.parseCommandArgs(commandDefinition, rawText)
|
||||||
: rawText
|
: rawText
|
||||||
? ({ raw: rawText } satisfies CommandArgs)
|
? ({ raw: rawText } satisfies CommandArgs)
|
||||||
: undefined;
|
: undefined;
|
||||||
const prompt = commandDefinition
|
const prompt = commandDefinition
|
||||||
? buildCommandTextFromArgs(commandDefinition, commandArgs)
|
? registry.buildCommandTextFromArgs(commandDefinition, commandArgs)
|
||||||
: rawText
|
: rawText
|
||||||
? `/${command.name} ${rawText}`
|
? `/${command.name} ${rawText}`
|
||||||
: `/${command.name}`;
|
: `/${command.name}`;
|
||||||
@@ -596,12 +626,13 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const commandDefinition = findCommandByNativeName(parsed.command, "slack");
|
const reg = await getCommandsRegistry();
|
||||||
|
const commandDefinition = reg.findCommandByNativeName(parsed.command, "slack");
|
||||||
const commandArgs: CommandArgs = {
|
const commandArgs: CommandArgs = {
|
||||||
values: { [parsed.arg]: parsed.value },
|
values: { [parsed.arg]: parsed.value },
|
||||||
};
|
};
|
||||||
const prompt = commandDefinition
|
const prompt = commandDefinition
|
||||||
? buildCommandTextFromArgs(commandDefinition, commandArgs)
|
? reg.buildCommandTextFromArgs(commandDefinition, commandArgs)
|
||||||
: `/${parsed.command} ${parsed.value}`;
|
: `/${parsed.command} ${parsed.value}`;
|
||||||
const user = body.user;
|
const user = body.user;
|
||||||
const userName =
|
const userName =
|
||||||
|
|||||||
Reference in New Issue
Block a user