mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 11:07:41 +00:00
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 () => {
|
it("falls back to container channel and message timestamps", async () => {
|
||||||
enqueueSystemEventMock.mockReset();
|
enqueueSystemEventMock.mockReset();
|
||||||
const { ctx, app, getHandler, resolveSessionKey } = createContext();
|
const { ctx, app, getHandler, resolveSessionKey } = createContext();
|
||||||
|
|||||||
@@ -109,6 +109,15 @@ function uniqueNonEmptyStrings(values: string[]): string[] {
|
|||||||
return unique;
|
return unique;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeSlackMrkdwn(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("\\", "\\\\")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replace(/([*_`~])/g, "\\$1");
|
||||||
|
}
|
||||||
|
|
||||||
function collectRichTextFragments(value: unknown, out: string[]): void {
|
function collectRichTextFragments(value: unknown, out: string[]): void {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return;
|
return;
|
||||||
@@ -289,7 +298,7 @@ function formatInteractionConfirmationText(params: {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : "";
|
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[] {
|
function summarizeViewState(values: unknown): ModalInputSummary[] {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
|
|||||||
const reportCommand = { key: "report", nativeName: "report" };
|
const reportCommand = { key: "report", nativeName: "report" };
|
||||||
const reportCompactCommand = { key: "reportcompact", nativeName: "reportcompact" };
|
const reportCompactCommand = { key: "reportcompact", nativeName: "reportcompact" };
|
||||||
const reportLongCommand = { key: "reportlong", nativeName: "reportlong" };
|
const reportLongCommand = { key: "reportlong", nativeName: "reportlong" };
|
||||||
|
const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildCommandTextFromArgs: (
|
buildCommandTextFromArgs: (
|
||||||
@@ -38,6 +39,9 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
|
|||||||
if (normalized === "reportlong") {
|
if (normalized === "reportlong") {
|
||||||
return reportLongCommand;
|
return reportLongCommand;
|
||||||
}
|
}
|
||||||
|
if (normalized === "unsafeconfirm") {
|
||||||
|
return unsafeConfirmCommand;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
listNativeCommandSpecsForConfig: () => [
|
listNativeCommandSpecsForConfig: () => [
|
||||||
@@ -65,6 +69,12 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
|
|||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
args: [],
|
args: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "unsafeconfirm",
|
||||||
|
description: "UnsafeConfirm",
|
||||||
|
acceptsArgs: true,
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
parseCommandArgs: () => ({ values: {} }),
|
parseCommandArgs: () => ({ values: {} }),
|
||||||
resolveCommandArgMenu: (params: {
|
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") {
|
if (params.command?.key !== "usage") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -230,6 +249,7 @@ describe("Slack native command argument menus", () => {
|
|||||||
let reportHandler: (args: unknown) => Promise<void>;
|
let reportHandler: (args: unknown) => Promise<void>;
|
||||||
let reportCompactHandler: (args: unknown) => Promise<void>;
|
let reportCompactHandler: (args: unknown) => Promise<void>;
|
||||||
let reportLongHandler: (args: unknown) => Promise<void>;
|
let reportLongHandler: (args: unknown) => Promise<void>;
|
||||||
|
let unsafeConfirmHandler: (args: unknown) => Promise<void>;
|
||||||
let argMenuHandler: (args: unknown) => Promise<void>;
|
let argMenuHandler: (args: unknown) => Promise<void>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -256,6 +276,11 @@ describe("Slack native command argument menus", () => {
|
|||||||
throw new Error("Missing /reportlong handler");
|
throw new Error("Missing /reportlong handler");
|
||||||
}
|
}
|
||||||
reportLongHandler = reportLong;
|
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");
|
const argMenu = harness.actions.get("openclaw_cmdarg");
|
||||||
if (!argMenu) {
|
if (!argMenu) {
|
||||||
@@ -376,6 +401,34 @@ describe("Slack native command argument menus", () => {
|
|||||||
expect(element?.confirm).toBeTruthy();
|
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 () => {
|
it("dispatches the command when a menu button is clicked", async () => {
|
||||||
const respond = vi.fn().mockResolvedValue(undefined);
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
await argMenuHandler({
|
await argMenuHandler({
|
||||||
|
|||||||
@@ -47,12 +47,23 @@ function truncatePlainText(value: string, max: number): string {
|
|||||||
return `${trimmed.slice(0, max - 1)}…`;
|
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 }) {
|
function buildSlackArgMenuConfirm(params: { command: string; arg: string }) {
|
||||||
|
const command = escapeSlackMrkdwn(params.command);
|
||||||
|
const arg = escapeSlackMrkdwn(params.arg);
|
||||||
return {
|
return {
|
||||||
title: { type: "plain_text", text: "Confirm selection" },
|
title: { type: "plain_text", text: "Confirm selection" },
|
||||||
text: {
|
text: {
|
||||||
type: "mrkdwn",
|
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" },
|
confirm: { type: "plain_text", text: "Run command" },
|
||||||
deny: { type: "plain_text", text: "Cancel" },
|
deny: { type: "plain_text", text: "Cancel" },
|
||||||
|
|||||||
Reference in New Issue
Block a user