refactor: dedupe slack monitor mrkdwn and modal event base

This commit is contained in:
Peter Steinberger
2026-02-19 14:02:55 +00:00
parent cb6b835a49
commit 672b1c5084
4 changed files with 109 additions and 94 deletions

View File

@@ -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<typeof resolveModalSessionRouting>;
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.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<void>; 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<void>; 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
}`,
);

View File

@@ -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\\_\\*\\`\\~&lt;&amp;&gt;\\\\");
});
});

View File

@@ -0,0 +1,8 @@
export function escapeSlackMrkdwn(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replace(/([*_`~])/g, "\\$1");
}

View File

@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replace(/([*_`~])/g, "\\$1");
}
function buildSlackArgMenuConfirm(params: { command: string; arg: string }) {
const command = escapeSlackMrkdwn(params.command);
const arg = escapeSlackMrkdwn(params.arg);