From ac969e602c448e414a2bcdbbfe4337336b600e50 Mon Sep 17 00:00:00 2001 From: Colin Date: Mon, 16 Feb 2026 12:51:57 -0500 Subject: [PATCH] Slack: add modal private metadata utilities --- src/slack/modal-metadata.test.ts | 55 ++++++++++++++++++++++++ src/slack/modal-metadata.ts | 42 ++++++++++++++++++ src/slack/monitor/events/interactions.ts | 33 +------------- 3 files changed, 99 insertions(+), 31 deletions(-) create mode 100644 src/slack/modal-metadata.test.ts create mode 100644 src/slack/modal-metadata.ts diff --git a/src/slack/modal-metadata.test.ts b/src/slack/modal-metadata.test.ts new file mode 100644 index 0000000000..d209c70587 --- /dev/null +++ b/src/slack/modal-metadata.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + encodeSlackModalPrivateMetadata, + parseSlackModalPrivateMetadata, +} from "./modal-metadata.js"; + +describe("parseSlackModalPrivateMetadata", () => { + it("returns empty object for missing or invalid values", () => { + expect(parseSlackModalPrivateMetadata(undefined)).toEqual({}); + expect(parseSlackModalPrivateMetadata("")).toEqual({}); + expect(parseSlackModalPrivateMetadata("{bad-json")).toEqual({}); + }); + + it("parses known metadata fields", () => { + expect( + parseSlackModalPrivateMetadata( + JSON.stringify({ + sessionKey: "agent:main:slack:channel:C1", + channelId: "D123", + channelType: "im", + ignored: "x", + }), + ), + ).toEqual({ + sessionKey: "agent:main:slack:channel:C1", + channelId: "D123", + channelType: "im", + }); + }); +}); + +describe("encodeSlackModalPrivateMetadata", () => { + it("encodes only known non-empty fields", () => { + expect( + JSON.parse( + encodeSlackModalPrivateMetadata({ + sessionKey: "agent:main:slack:channel:C1", + channelId: "", + channelType: "im", + }), + ), + ).toEqual({ + sessionKey: "agent:main:slack:channel:C1", + channelType: "im", + }); + }); + + it("throws when encoded payload exceeds Slack metadata limit", () => { + expect(() => + encodeSlackModalPrivateMetadata({ + sessionKey: `agent:main:${"x".repeat(4000)}`, + }), + ).toThrow(/cannot exceed 3000 chars/i); + }); +}); diff --git a/src/slack/modal-metadata.ts b/src/slack/modal-metadata.ts new file mode 100644 index 0000000000..491fb5d38f --- /dev/null +++ b/src/slack/modal-metadata.ts @@ -0,0 +1,42 @@ +export type SlackModalPrivateMetadata = { + sessionKey?: string; + channelId?: string; + channelType?: string; +}; + +const SLACK_PRIVATE_METADATA_MAX = 3000; + +function normalizeString(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function parseSlackModalPrivateMetadata(raw: unknown): SlackModalPrivateMetadata { + if (typeof raw !== "string" || raw.trim().length === 0) { + return {}; + } + try { + const parsed = JSON.parse(raw) as Record; + return { + sessionKey: normalizeString(parsed.sessionKey), + channelId: normalizeString(parsed.channelId), + channelType: normalizeString(parsed.channelType), + }; + } catch { + return {}; + } +} + +export function encodeSlackModalPrivateMetadata(input: SlackModalPrivateMetadata): string { + const payload: SlackModalPrivateMetadata = { + ...(input.sessionKey ? { sessionKey: input.sessionKey } : {}), + ...(input.channelId ? { channelId: input.channelId } : {}), + ...(input.channelType ? { channelType: input.channelType } : {}), + }; + const encoded = JSON.stringify(payload); + if (encoded.length > SLACK_PRIVATE_METADATA_MAX) { + throw new Error( + `Slack modal private_metadata cannot exceed ${SLACK_PRIVATE_METADATA_MAX} chars`, + ); + } + return encoded; +} diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index 3a428262b0..027d91f713 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -2,6 +2,7 @@ import type { SlackActionMiddlewareArgs } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api"; import type { SlackMonitorContext } from "../context.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js"; // Prefix for OpenClaw-generated action IDs to scope our handler const OPENCLAW_ACTION_PREFIX = "openclaw:"; @@ -47,12 +48,6 @@ type ModalInputSummary = { inputValue?: string; }; -type ModalPrivateMetadata = { - sessionKey?: string; - channelId?: string; - channelType?: string; -}; - function readOptionValues(options: unknown): string[] | undefined { if (!Array.isArray(options)) { return undefined; @@ -152,35 +147,11 @@ function summarizeViewState(values: unknown): ModalInputSummary[] { return entries; } -function parseModalPrivateMetadata(raw: unknown): ModalPrivateMetadata { - if (typeof raw !== "string" || raw.trim().length === 0) { - return {}; - } - try { - const parsed = JSON.parse(raw) as Record; - const sessionKey = - typeof parsed.sessionKey === "string" && parsed.sessionKey.trim().length > 0 - ? parsed.sessionKey - : undefined; - const channelId = - typeof parsed.channelId === "string" && parsed.channelId.trim().length > 0 - ? parsed.channelId - : undefined; - const channelType = - typeof parsed.channelType === "string" && parsed.channelType.trim().length > 0 - ? parsed.channelType - : undefined; - return { sessionKey, channelId, channelType }; - } catch { - return {}; - } -} - function resolveModalSessionRouting(params: { ctx: SlackMonitorContext; privateMetadata: unknown; }): { sessionKey: string; channelId?: string; channelType?: string } { - const metadata = parseModalPrivateMetadata(params.privateMetadata); + const metadata = parseSlackModalPrivateMetadata(params.privateMetadata); if (metadata.sessionKey) { return { sessionKey: metadata.sessionKey }; }