From 672b1c5084d74d95644993fcdb127071f3194ba9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:02:55 +0000 Subject: [PATCH] refactor: dedupe slack monitor mrkdwn and modal event base --- src/slack/monitor/events/interactions.ts | 173 ++++++++++++----------- src/slack/monitor/mrkdwn.test.ts | 12 ++ src/slack/monitor/mrkdwn.ts | 8 ++ src/slack/monitor/slash.ts | 10 +- 4 files changed, 109 insertions(+), 94 deletions(-) create mode 100644 src/slack/monitor/mrkdwn.test.ts create mode 100644 src/slack/monitor/mrkdwn.ts diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index 958d6b3f5d5..06af384be70 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -3,6 +3,7 @@ import type { Block, KnownBlock } from "@slack/web-api"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js"; import type { SlackMonitorContext } from "../context.js"; +import { escapeSlackMrkdwn } from "../mrkdwn.js"; // Prefix for OpenClaw-generated action IDs to scope our handler const OPENCLAW_ACTION_PREFIX = "openclaw:"; @@ -58,6 +59,45 @@ type ModalInputSummary = InteractionSelectionFields & { actionId: string; }; +type SlackModalBody = { + user?: { id?: string }; + team?: { id?: string }; + view?: { + id?: string; + callback_id?: string; + private_metadata?: string; + root_view_id?: string; + previous_view_id?: string; + external_id?: string; + hash?: string; + state?: { values?: unknown }; + }; + is_cleared?: boolean; +}; + +type SlackModalEventBase = { + callbackId: string; + userId: string; + viewId?: string; + sessionRouting: ReturnType; + payload: { + actionId: string; + callbackId: string; + viewId?: string; + userId: string; + teamId?: string; + rootViewId?: string; + previousViewId?: string; + externalId?: string; + viewHash?: string; + isStackedView?: boolean; + privateMetadata?: string; + routedChannelId?: string; + routedChannelType?: string; + inputs: ModalInputSummary[]; + }; +}; + function readOptionValues(options: unknown): string[] | undefined { if (!Array.isArray(options)) { return undefined; @@ -97,15 +137,6 @@ 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; @@ -374,6 +405,43 @@ function summarizeSlackViewLifecycleContext(view: { }; } +function resolveSlackModalEventBase(params: { + ctx: SlackMonitorContext; + body: SlackModalBody; +}): SlackModalEventBase { + const callbackId = params.body.view?.callback_id ?? "unknown"; + const userId = params.body.user?.id ?? "unknown"; + const viewId = params.body.view?.id; + const inputs = summarizeViewState(params.body.view?.state?.values); + const sessionRouting = resolveModalSessionRouting({ + ctx: params.ctx, + privateMetadata: params.body.view?.private_metadata, + }); + return { + callbackId, + userId, + viewId, + sessionRouting, + payload: { + actionId: `view:${callbackId}`, + callbackId, + viewId, + userId, + teamId: params.body.team?.id, + ...summarizeSlackViewLifecycleContext({ + root_view_id: params.body.view?.root_view_id, + previous_view_id: params.body.view?.previous_view_id, + external_id: params.body.view?.external_id, + hash: params.body.view?.hash, + }), + privateMetadata: params.body.view?.private_metadata, + routedChannelId: sessionRouting.channelId, + routedChannelType: sessionRouting.channelType, + inputs, + }, + }; +} + export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) { const { ctx } = params; if (typeof ctx.app.action !== "function") { @@ -544,50 +612,18 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex async ({ ack, body }: { ack: () => Promise; body: unknown }) => { await ack(); - const typedBody = body as { - user?: { id?: string }; - team?: { id?: string }; - view?: { - id?: string; - callback_id?: string; - private_metadata?: string; - root_view_id?: string; - previous_view_id?: string; - external_id?: string; - hash?: string; - state?: { values?: unknown }; - }; - }; - - const callbackId = typedBody.view?.callback_id ?? "unknown"; - const userId = typedBody.user?.id ?? "unknown"; - const viewId = typedBody.view?.id; - const inputs = summarizeViewState(typedBody.view?.state?.values); - const sessionRouting = resolveModalSessionRouting({ + const modalBody = body as SlackModalBody; + const { callbackId, userId, viewId, sessionRouting, payload } = resolveSlackModalEventBase({ ctx, - privateMetadata: typedBody.view?.private_metadata, + body: modalBody, }); const eventPayload = { interactionType: "view_submission", - actionId: `view:${callbackId}`, - callbackId, - viewId, - userId, - teamId: typedBody.team?.id, - ...summarizeSlackViewLifecycleContext({ - root_view_id: typedBody.view?.root_view_id, - previous_view_id: typedBody.view?.previous_view_id, - external_id: typedBody.view?.external_id, - hash: typedBody.view?.hash, - }), - privateMetadata: typedBody.view?.private_metadata, - routedChannelId: sessionRouting.channelId, - routedChannelType: sessionRouting.channelType, - inputs, + ...payload, }; ctx.runtime.log?.( - `slack:interaction view_submission callback=${callbackId} user=${userId} inputs=${inputs.length}`, + `slack:interaction view_submission callback=${callbackId} user=${userId} inputs=${payload.inputs.length}`, ); enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, { @@ -617,53 +653,20 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex async ({ ack, body }: { ack: () => Promise; body: unknown }) => { await ack(); - const typedBody = body as { - user?: { id?: string }; - team?: { id?: string }; - view?: { - id?: string; - callback_id?: string; - private_metadata?: string; - root_view_id?: string; - previous_view_id?: string; - external_id?: string; - hash?: string; - state?: { values?: unknown }; - }; - is_cleared?: boolean; - }; - - const callbackId = typedBody.view?.callback_id ?? "unknown"; - const userId = typedBody.user?.id ?? "unknown"; - const viewId = typedBody.view?.id; - const inputs = summarizeViewState(typedBody.view?.state?.values); - const sessionRouting = resolveModalSessionRouting({ + const modalBody = body as SlackModalBody; + const { callbackId, userId, viewId, sessionRouting, payload } = resolveSlackModalEventBase({ ctx, - privateMetadata: typedBody.view?.private_metadata, + body: modalBody, }); const eventPayload = { interactionType: "view_closed", - actionId: `view:${callbackId}`, - callbackId, - viewId, - userId, - teamId: typedBody.team?.id, - ...summarizeSlackViewLifecycleContext({ - root_view_id: typedBody.view?.root_view_id, - previous_view_id: typedBody.view?.previous_view_id, - external_id: typedBody.view?.external_id, - hash: typedBody.view?.hash, - }), - isCleared: typedBody.is_cleared === true, - privateMetadata: typedBody.view?.private_metadata, - routedChannelId: sessionRouting.channelId, - routedChannelType: sessionRouting.channelType, - inputs, + ...payload, + isCleared: modalBody.is_cleared === true, }; ctx.runtime.log?.( `slack:interaction view_closed callback=${callbackId} user=${userId} cleared=${ - typedBody.is_cleared === true + modalBody.is_cleared === true }`, ); diff --git a/src/slack/monitor/mrkdwn.test.ts b/src/slack/monitor/mrkdwn.test.ts new file mode 100644 index 00000000000..5efba875a57 --- /dev/null +++ b/src/slack/monitor/mrkdwn.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { escapeSlackMrkdwn } from "./mrkdwn.js"; + +describe("escapeSlackMrkdwn", () => { + it("returns plain text unchanged", () => { + expect(escapeSlackMrkdwn("heartbeat status ok")).toBe("heartbeat status ok"); + }); + + it("escapes slack and mrkdwn control characters", () => { + expect(escapeSlackMrkdwn("mode_*`~<&>\\")).toBe("mode\\_\\*\\`\\~<&>\\\\"); + }); +}); diff --git a/src/slack/monitor/mrkdwn.ts b/src/slack/monitor/mrkdwn.ts new file mode 100644 index 00000000000..aea752da709 --- /dev/null +++ b/src/slack/monitor/mrkdwn.ts @@ -0,0 +1,8 @@ +export function escapeSlackMrkdwn(value: string): string { + return value + .replaceAll("\\", "\\\\") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replace(/([*_`~])/g, "\\$1"); +} diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index d67eb68f9c4..a0651941bf5 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -22,6 +22,7 @@ import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./ch import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js"; import type { SlackMonitorContext } from "./context.js"; import { normalizeSlackChannelType } from "./context.js"; +import { escapeSlackMrkdwn } from "./mrkdwn.js"; import { isSlackChannelAllowedByPolicy } from "./policy.js"; import { resolveSlackRoomContextHints } from "./room-context.js"; @@ -55,15 +56,6 @@ 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);