Slack: escape mrkdwn in interaction confirmations
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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<void>;
|
||||
let reportCompactHandler: (args: unknown) => Promise<void>;
|
||||
let reportLongHandler: (args: unknown) => Promise<void>;
|
||||
let unsafeConfirmHandler: (args: unknown) => Promise<void>;
|
||||
let argMenuHandler: (args: unknown) => Promise<void>;
|
||||
|
||||
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({
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user