diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts index bda3f7b95..241e571c2 100644 --- a/src/slack/monitor/events/interactions.test.ts +++ b/src/slack/monitor/events/interactions.test.ts @@ -220,6 +220,55 @@ describe("registerSlackInteractionEvents", () => { ); }); + it("escapes mrkdwn characters in confirmation labels", async () => { + enqueueSystemEventMock.mockReset(); + const { ctx, app, getHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const handler = getHandler(); + expect(handler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + await handler!({ + ack, + body: { + user: { id: "U556" }, + channel: { id: "C1" }, + message: { + ts: "111.223", + blocks: [{ type: "actions", block_id: "select_block", elements: [] }], + }, + }, + action: { + type: "static_select", + action_id: "openclaw:pick", + block_id: "select_block", + selected_option: { + text: { type: "plain_text", text: "Canary_*`~<&>" }, + value: "canary", + }, + }, + }); + + expect(ack).toHaveBeenCalled(); + expect(app.client.chat.update).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "C1", + ts: "111.223", + blocks: [ + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: ":white_check_mark: *Canary\\_\\*\\`\\~<&>* selected by <@U556>", + }, + ], + }, + ], + }), + ); + }); + it("falls back to container channel and message timestamps", async () => { enqueueSystemEventMock.mockReset(); const { ctx, app, getHandler, resolveSessionKey } = createContext(); diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index a6e59aaa5..257e0d4fe 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -109,6 +109,15 @@ function uniqueNonEmptyStrings(values: string[]): string[] { return unique; } +function escapeSlackMrkdwn(value: string): string { + return value + .replaceAll("\\", "\\\\") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replace(/([*_`~])/g, "\\$1"); +} + function collectRichTextFragments(value: unknown, out: string[]): void { if (!value || typeof value !== "object") { return; @@ -289,7 +298,7 @@ function formatInteractionConfirmationText(params: { userId?: string; }): string { const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : ""; - return `:white_check_mark: *${params.selectedLabel}* selected${actor}`; + return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`; } function summarizeViewState(values: unknown): ModalInputSummary[] { diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts index 9ed16ae2b..b2c77507a 100644 --- a/src/slack/monitor/slash.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -6,6 +6,7 @@ vi.mock("../../auto-reply/commands-registry.js", () => { const reportCommand = { key: "report", nativeName: "report" }; const reportCompactCommand = { key: "reportcompact", nativeName: "reportcompact" }; const reportLongCommand = { key: "reportlong", nativeName: "reportlong" }; + const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" }; return { buildCommandTextFromArgs: ( @@ -38,6 +39,9 @@ vi.mock("../../auto-reply/commands-registry.js", () => { if (normalized === "reportlong") { return reportLongCommand; } + if (normalized === "unsafeconfirm") { + return unsafeConfirmCommand; + } return undefined; }, listNativeCommandSpecsForConfig: () => [ @@ -65,6 +69,12 @@ vi.mock("../../auto-reply/commands-registry.js", () => { acceptsArgs: true, args: [], }, + { + name: "unsafeconfirm", + description: "UnsafeConfirm", + acceptsArgs: true, + args: [], + }, ], parseCommandArgs: () => ({ values: {} }), resolveCommandArgMenu: (params: { @@ -120,6 +130,15 @@ vi.mock("../../auto-reply/commands-registry.js", () => { ], }; } + if (params.command?.key === "unsafeconfirm") { + return { + arg: { name: "mode_*`~<&>", description: "mode" }, + choices: [ + { value: "on", label: "on" }, + { value: "off", label: "off" }, + ], + }; + } if (params.command?.key !== "usage") { return null; } @@ -230,6 +249,7 @@ describe("Slack native command argument menus", () => { let reportHandler: (args: unknown) => Promise; let reportCompactHandler: (args: unknown) => Promise; let reportLongHandler: (args: unknown) => Promise; + let unsafeConfirmHandler: (args: unknown) => Promise; let argMenuHandler: (args: unknown) => Promise; beforeAll(async () => { @@ -256,6 +276,11 @@ describe("Slack native command argument menus", () => { throw new Error("Missing /reportlong handler"); } reportLongHandler = reportLong; + const unsafeConfirm = harness.commands.get("/unsafeconfirm"); + if (!unsafeConfirm) { + throw new Error("Missing /unsafeconfirm handler"); + } + unsafeConfirmHandler = unsafeConfirm; const argMenu = harness.actions.get("openclaw_cmdarg"); if (!argMenu) { @@ -376,6 +401,34 @@ describe("Slack native command argument menus", () => { expect(element?.confirm).toBeTruthy(); }); + it("escapes mrkdwn characters in confirm dialog text", async () => { + const respond = vi.fn().mockResolvedValue(undefined); + const ack = vi.fn().mockResolvedValue(undefined); + + await unsafeConfirmHandler({ + 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 }> }; + const actions = findFirstActionsBlock(payload); + const element = actions?.elements?.[0] as + | { confirm?: { text?: { text?: string } } } + | undefined; + expect(element?.confirm?.text?.text).toContain( + "Run */unsafeconfirm* with *mode\\_\\*\\`\\~<&>* set to this value?", + ); + }); + it("dispatches the command when a menu button is clicked", async () => { const respond = vi.fn().mockResolvedValue(undefined); await argMenuHandler({ diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index b512539b0..cc59684e0 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -47,12 +47,23 @@ function truncatePlainText(value: string, max: number): string { return `${trimmed.slice(0, max - 1)}…`; } +function escapeSlackMrkdwn(value: string): string { + return value + .replaceAll("\\", "\\\\") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replace(/([*_`~])/g, "\\$1"); +} + function buildSlackArgMenuConfirm(params: { command: string; arg: string }) { + const command = escapeSlackMrkdwn(params.command); + const arg = escapeSlackMrkdwn(params.arg); return { title: { type: "plain_text", text: "Confirm selection" }, text: { type: "mrkdwn", - text: `Run */${params.command}* with *${params.arg}* set to this value?`, + text: `Run */${command}* with *${arg}* set to this value?`, }, confirm: { type: "plain_text", text: "Run command" }, deny: { type: "plain_text", text: "Cancel" },