mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 10:01:41 +00:00
refactor: dedupe slack monitor mrkdwn and modal event base
This commit is contained in:
@@ -3,6 +3,7 @@ import type { Block, KnownBlock } from "@slack/web-api";
|
|||||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||||
import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js";
|
import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js";
|
||||||
import type { SlackMonitorContext } from "../context.js";
|
import type { SlackMonitorContext } from "../context.js";
|
||||||
|
import { escapeSlackMrkdwn } from "../mrkdwn.js";
|
||||||
|
|
||||||
// Prefix for OpenClaw-generated action IDs to scope our handler
|
// Prefix for OpenClaw-generated action IDs to scope our handler
|
||||||
const OPENCLAW_ACTION_PREFIX = "openclaw:";
|
const OPENCLAW_ACTION_PREFIX = "openclaw:";
|
||||||
@@ -58,6 +59,45 @@ type ModalInputSummary = InteractionSelectionFields & {
|
|||||||
actionId: string;
|
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 {
|
function readOptionValues(options: unknown): string[] | undefined {
|
||||||
if (!Array.isArray(options)) {
|
if (!Array.isArray(options)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -97,15 +137,6 @@ 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;
|
||||||
@@ -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 }) {
|
export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) {
|
||||||
const { ctx } = params;
|
const { ctx } = params;
|
||||||
if (typeof ctx.app.action !== "function") {
|
if (typeof ctx.app.action !== "function") {
|
||||||
@@ -544,50 +612,18 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
|
|||||||
async ({ ack, body }: { ack: () => Promise<void>; body: unknown }) => {
|
async ({ ack, body }: { ack: () => Promise<void>; body: unknown }) => {
|
||||||
await ack();
|
await ack();
|
||||||
|
|
||||||
const typedBody = body as {
|
const modalBody = body as SlackModalBody;
|
||||||
user?: { id?: string };
|
const { callbackId, userId, viewId, sessionRouting, payload } = resolveSlackModalEventBase({
|
||||||
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({
|
|
||||||
ctx,
|
ctx,
|
||||||
privateMetadata: typedBody.view?.private_metadata,
|
body: modalBody,
|
||||||
});
|
});
|
||||||
const eventPayload = {
|
const eventPayload = {
|
||||||
interactionType: "view_submission",
|
interactionType: "view_submission",
|
||||||
actionId: `view:${callbackId}`,
|
...payload,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.runtime.log?.(
|
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)}`, {
|
enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, {
|
||||||
@@ -617,53 +653,20 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
|
|||||||
async ({ ack, body }: { ack: () => Promise<void>; body: unknown }) => {
|
async ({ ack, body }: { ack: () => Promise<void>; body: unknown }) => {
|
||||||
await ack();
|
await ack();
|
||||||
|
|
||||||
const typedBody = body as {
|
const modalBody = body as SlackModalBody;
|
||||||
user?: { id?: string };
|
const { callbackId, userId, viewId, sessionRouting, payload } = resolveSlackModalEventBase({
|
||||||
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({
|
|
||||||
ctx,
|
ctx,
|
||||||
privateMetadata: typedBody.view?.private_metadata,
|
body: modalBody,
|
||||||
});
|
});
|
||||||
const eventPayload = {
|
const eventPayload = {
|
||||||
interactionType: "view_closed",
|
interactionType: "view_closed",
|
||||||
actionId: `view:${callbackId}`,
|
...payload,
|
||||||
callbackId,
|
isCleared: modalBody.is_cleared === true,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.runtime.log?.(
|
ctx.runtime.log?.(
|
||||||
`slack:interaction view_closed callback=${callbackId} user=${userId} cleared=${
|
`slack:interaction view_closed callback=${callbackId} user=${userId} cleared=${
|
||||||
typedBody.is_cleared === true
|
modalBody.is_cleared === true
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
12
src/slack/monitor/mrkdwn.test.ts
Normal file
12
src/slack/monitor/mrkdwn.test.ts
Normal 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\\_\\*\\`\\~<&>\\\\");
|
||||||
|
});
|
||||||
|
});
|
||||||
8
src/slack/monitor/mrkdwn.ts
Normal file
8
src/slack/monitor/mrkdwn.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function escapeSlackMrkdwn(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("\\", "\\\\")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replace(/([*_`~])/g, "\\$1");
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./ch
|
|||||||
import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js";
|
import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js";
|
||||||
import type { SlackMonitorContext } from "./context.js";
|
import type { SlackMonitorContext } from "./context.js";
|
||||||
import { normalizeSlackChannelType } from "./context.js";
|
import { normalizeSlackChannelType } from "./context.js";
|
||||||
|
import { escapeSlackMrkdwn } from "./mrkdwn.js";
|
||||||
import { isSlackChannelAllowedByPolicy } from "./policy.js";
|
import { isSlackChannelAllowedByPolicy } from "./policy.js";
|
||||||
import { resolveSlackRoomContextHints } from "./room-context.js";
|
import { resolveSlackRoomContextHints } from "./room-context.js";
|
||||||
|
|
||||||
@@ -55,15 +56,6 @@ 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 command = escapeSlackMrkdwn(params.command);
|
||||||
const arg = escapeSlackMrkdwn(params.arg);
|
const arg = escapeSlackMrkdwn(params.arg);
|
||||||
|
|||||||
Reference in New Issue
Block a user