From 136ca87f7bb2f3b764548d8a897fa35c3debc81d Mon Sep 17 00:00:00 2001 From: Tony Dehnke <36720180+tonydehnke@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:44:57 +0700 Subject: [PATCH] feat(mattermost): add interactive buttons support (#19957) Merged via squash. Prepared head SHA: 8a25e608729d0b9fd07bb0ee4219d199d9796dbe Co-authored-by: tonydehnke <36720180+tonydehnke@users.noreply.github.com> Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com> Reviewed-by: @mukhtharcm --- CHANGELOG.md | 1 + docs/channels/mattermost.md | 152 +++++++ extensions/mattermost/src/channel.test.ts | 5 +- extensions/mattermost/src/channel.ts | 222 ++++++--- extensions/mattermost/src/config-schema.ts | 5 + .../mattermost/src/mattermost/client.test.ts | 289 +++++++++++- .../mattermost/src/mattermost/client.ts | 39 +- .../mattermost/src/mattermost/directory.ts | 172 +++++++ .../src/mattermost/interactions.test.ts | 335 ++++++++++++++ .../mattermost/src/mattermost/interactions.ts | 429 ++++++++++++++++++ .../mattermost/src/mattermost/monitor.ts | 226 ++++++++- .../mattermost/src/mattermost/send.test.ts | 94 +++- extensions/mattermost/src/mattermost/send.ts | 68 ++- extensions/mattermost/src/normalize.test.ts | 96 ++++ extensions/mattermost/src/normalize.ts | 16 +- extensions/mattermost/src/types.ts | 4 + src/plugin-sdk/mattermost.ts | 2 + 17 files changed, 2064 insertions(+), 91 deletions(-) create mode 100644 extensions/mattermost/src/mattermost/directory.ts create mode 100644 extensions/mattermost/src/mattermost/interactions.test.ts create mode 100644 extensions/mattermost/src/mattermost/interactions.ts create mode 100644 extensions/mattermost/src/normalize.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 05cef55abea..c96b70c5805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai - LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman. - LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr. - LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann. +- Mattermost/interactive buttons: add interactive button send/callback support with directory-based channel/user target resolution, and harden callbacks via account-scoped HMAC verification plus sender-scoped DM routing. (#19957) thanks @tonydehnke. ## 2026.3.2 diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index d5cd044a707..fdfd48a4dbf 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -175,6 +175,151 @@ Config: - `channels.mattermost.actions.reactions`: enable/disable reaction actions (default true). - Per-account override: `channels.mattermost.accounts..actions.reactions`. +## Interactive buttons (message tool) + +Send messages with clickable buttons. When a user clicks a button, the agent receives the +selection and can respond. + +Enable buttons by adding `inlineButtons` to the channel capabilities: + +```json5 +{ + channels: { + mattermost: { + capabilities: ["inlineButtons"], + }, + }, +} +``` + +Use `message action=send` with a `buttons` parameter. Buttons are a 2D array (rows of buttons): + +``` +message action=send channel=mattermost target=channel: buttons=[[{"text":"Yes","callback_data":"yes"},{"text":"No","callback_data":"no"}]] +``` + +Button fields: + +- `text` (required): display label. +- `callback_data` (required): value sent back on click (used as the action ID). +- `style` (optional): `"default"`, `"primary"`, or `"danger"`. + +When a user clicks a button: + +1. All buttons are replaced with a confirmation line (e.g., "✓ **Yes** selected by @user"). +2. The agent receives the selection as an inbound message and responds. + +Notes: + +- Button callbacks use HMAC-SHA256 verification (automatic, no config needed). +- Mattermost strips callback data from its API responses (security feature), so all buttons + are removed on click — partial removal is not possible. +- Action IDs containing hyphens or underscores are sanitized automatically + (Mattermost routing limitation). + +Config: + +- `channels.mattermost.capabilities`: array of capability strings. Add `"inlineButtons"` to + enable the buttons tool description in the agent system prompt. + +### Direct API integration (external scripts) + +External scripts and webhooks can post buttons directly via the Mattermost REST API +instead of going through the agent's `message` tool. Use `buildButtonAttachments()` from +the extension when possible; if posting raw JSON, follow these rules: + +**Payload structure:** + +```json5 +{ + channel_id: "", + message: "Choose an option:", + props: { + attachments: [ + { + actions: [ + { + id: "mybutton01", // alphanumeric only — see below + type: "button", // required, or clicks are silently ignored + name: "Approve", // display label + style: "primary", // optional: "default", "primary", "danger" + integration: { + url: "http://localhost:18789/mattermost/interactions/default", + context: { + action_id: "mybutton01", // must match button id (for name lookup) + action: "approve", + // ... any custom fields ... + _token: "", // see HMAC section below + }, + }, + }, + ], + }, + ], + }, +} +``` + +**Critical rules:** + +1. Attachments go in `props.attachments`, not top-level `attachments` (silently ignored). +2. Every action needs `type: "button"` — without it, clicks are swallowed silently. +3. Every action needs an `id` field — Mattermost ignores actions without IDs. +4. Action `id` must be **alphanumeric only** (`[a-zA-Z0-9]`). Hyphens and underscores break + Mattermost's server-side action routing (returns 404). Strip them before use. +5. `context.action_id` must match the button's `id` so the confirmation message shows the + button name (e.g., "Approve") instead of a raw ID. +6. `context.action_id` is required — the interaction handler returns 400 without it. + +**HMAC token generation:** + +The gateway verifies button clicks with HMAC-SHA256. External scripts must generate tokens +that match the gateway's verification logic: + +1. Derive the secret from the bot token: + `HMAC-SHA256(key="openclaw-mattermost-interactions", data=botToken)` +2. Build the context object with all fields **except** `_token`. +3. Serialize with **sorted keys** and **no spaces** (the gateway uses `JSON.stringify` + with sorted keys, which produces compact output). +4. Sign: `HMAC-SHA256(key=secret, data=serializedContext)` +5. Add the resulting hex digest as `_token` in the context. + +Python example: + +```python +import hmac, hashlib, json + +secret = hmac.new( + b"openclaw-mattermost-interactions", + bot_token.encode(), hashlib.sha256 +).hexdigest() + +ctx = {"action_id": "mybutton01", "action": "approve"} +payload = json.dumps(ctx, sort_keys=True, separators=(",", ":")) +token = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest() + +context = {**ctx, "_token": token} +``` + +Common HMAC pitfalls: + +- Python's `json.dumps` adds spaces by default (`{"key": "val"}`). Use + `separators=(",", ":")` to match JavaScript's compact output (`{"key":"val"}`). +- Always sign **all** context fields (minus `_token`). The gateway strips `_token` then + signs everything remaining. Signing a subset causes silent verification failure. +- Use `sort_keys=True` — the gateway sorts keys before signing, and Mattermost may + reorder context fields when storing the payload. +- Derive the secret from the bot token (deterministic), not random bytes. The secret + must be the same across the process that creates buttons and the gateway that verifies. + +## Directory adapter + +The Mattermost plugin includes a directory adapter that resolves channel and user names +via the Mattermost API. This enables `#channel-name` and `@username` targets in +`openclaw message send` and cron/webhook deliveries. + +No configuration is needed — the adapter uses the bot token from the account config. + ## Multi-account Mattermost supports multiple accounts under `channels.mattermost.accounts`: @@ -197,3 +342,10 @@ Mattermost supports multiple accounts under `channels.mattermost.accounts`: - No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`. - Auth errors: check the bot token, base URL, and whether the account is enabled. - Multi-account issues: env vars only apply to the `default` account. +- Buttons appear as white boxes: the agent may be sending malformed button data. Check that each button has both `text` and `callback_data` fields. +- Buttons render but clicks do nothing: verify `AllowedUntrustedInternalConnections` in Mattermost server config includes `127.0.0.1 localhost`, and that `EnablePostActionIntegration` is `true` in ServiceSettings. +- Buttons return 404 on click: the button `id` likely contains hyphens or underscores. Mattermost's action router breaks on non-alphanumeric IDs. Use `[a-zA-Z0-9]` only. +- Gateway logs `invalid _token`: HMAC mismatch. Check that you sign all context fields (not a subset), use sorted keys, and use compact JSON (no spaces). See the HMAC section above. +- Gateway logs `missing _token in context`: the `_token` field is not in the button's context. Ensure it is included when building the integration payload. +- Confirmation shows raw ID instead of button name: `context.action_id` does not match the button's `id`. Set both to the same sanitized value. +- Agent doesn't know about buttons: add `capabilities: ["inlineButtons"]` to the Mattermost channel config. diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index e8f1480565c..97314f5e13b 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -102,8 +102,9 @@ describe("mattermostPlugin", () => { const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; expect(actions).toContain("react"); - expect(actions).not.toContain("send"); + expect(actions).toContain("send"); expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true); + expect(mattermostPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true); }); it("hides react when mattermost is not configured", () => { @@ -133,7 +134,7 @@ describe("mattermostPlugin", () => { const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; expect(actions).not.toContain("react"); - expect(actions).not.toContain("send"); + expect(actions).toContain("send"); }); it("respects per-account actions.reactions in listActions", () => { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 9134af26704..5897c11277a 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -22,6 +22,15 @@ import { type ResolvedMattermostAccount, } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; +import { + listMattermostDirectoryGroups, + listMattermostDirectoryPeers, +} from "./mattermost/directory.js"; +import { + buildButtonAttachments, + resolveInteractionCallbackUrl, + setInteractionSecret, +} from "./mattermost/interactions.js"; import { monitorMattermostProvider } from "./mattermost/monitor.js"; import { probeMattermost } from "./mattermost/probe.js"; import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js"; @@ -32,62 +41,91 @@ import { getMattermostRuntime } from "./runtime.js"; const mattermostMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { - const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined; - const baseReactions = actionsConfig?.reactions; - const hasReactionCapableAccount = listMattermostAccountIds(cfg) + const enabledAccounts = listMattermostAccountIds(cfg) .map((accountId) => resolveMattermostAccount({ cfg, accountId })) .filter((account) => account.enabled) - .filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim())) - .some((account) => { - const accountActions = account.config.actions as { reactions?: boolean } | undefined; - return (accountActions?.reactions ?? baseReactions ?? true) !== false; - }); + .filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim())); - if (!hasReactionCapableAccount) { - return []; + const actions: ChannelMessageActionName[] = []; + + // Send (buttons) is available whenever there's at least one enabled account + if (enabledAccounts.length > 0) { + actions.push("send"); } - return ["react"]; + // React requires per-account reactions config check + const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined; + const baseReactions = actionsConfig?.reactions; + const hasReactionCapableAccount = enabledAccounts.some((account) => { + const accountActions = account.config.actions as { reactions?: boolean } | undefined; + return (accountActions?.reactions ?? baseReactions ?? true) !== false; + }); + if (hasReactionCapableAccount) { + actions.push("react"); + } + + return actions; }, supportsAction: ({ action }) => { - return action === "react"; + return action === "send" || action === "react"; + }, + supportsButtons: ({ cfg }) => { + const accounts = listMattermostAccountIds(cfg) + .map((id) => resolveMattermostAccount({ cfg, accountId: id })) + .filter((a) => a.enabled && a.botToken?.trim() && a.baseUrl?.trim()); + return accounts.length > 0; }, handleAction: async ({ action, params, cfg, accountId }) => { - if (action !== "react") { - throw new Error(`Mattermost action ${action} not supported`); - } - // Check reactions gate: per-account config takes precedence over base config - const mmBase = cfg?.channels?.mattermost as Record | undefined; - const accounts = mmBase?.accounts as Record> | undefined; - const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg); - const acctConfig = accounts?.[resolvedAccountId]; - const acctActions = acctConfig?.actions as { reactions?: boolean } | undefined; - const baseActions = mmBase?.actions as { reactions?: boolean } | undefined; - const reactionsEnabled = acctActions?.reactions ?? baseActions?.reactions ?? true; - if (!reactionsEnabled) { - throw new Error("Mattermost reactions are disabled in config"); - } + if (action === "react") { + // Check reactions gate: per-account config takes precedence over base config + const mmBase = cfg?.channels?.mattermost as Record | undefined; + const accounts = mmBase?.accounts as Record> | undefined; + const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg); + const acctConfig = accounts?.[resolvedAccountId]; + const acctActions = acctConfig?.actions as { reactions?: boolean } | undefined; + const baseActions = mmBase?.actions as { reactions?: boolean } | undefined; + const reactionsEnabled = acctActions?.reactions ?? baseActions?.reactions ?? true; + if (!reactionsEnabled) { + throw new Error("Mattermost reactions are disabled in config"); + } - const postIdRaw = - typeof (params as any)?.messageId === "string" - ? (params as any).messageId - : typeof (params as any)?.postId === "string" - ? (params as any).postId - : ""; - const postId = postIdRaw.trim(); - if (!postId) { - throw new Error("Mattermost react requires messageId (post id)"); - } + const postIdRaw = + typeof (params as any)?.messageId === "string" + ? (params as any).messageId + : typeof (params as any)?.postId === "string" + ? (params as any).postId + : ""; + const postId = postIdRaw.trim(); + if (!postId) { + throw new Error("Mattermost react requires messageId (post id)"); + } - const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : ""; - const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, ""); - if (!emojiName) { - throw new Error("Mattermost react requires emoji"); - } + const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : ""; + const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, ""); + if (!emojiName) { + throw new Error("Mattermost react requires emoji"); + } - const remove = (params as any)?.remove === true; - if (remove) { - const result = await removeMattermostReaction({ + const remove = (params as any)?.remove === true; + if (remove) { + const result = await removeMattermostReaction({ + cfg, + postId, + emojiName, + accountId: resolvedAccountId, + }); + if (!result.ok) { + throw new Error(result.error); + } + return { + content: [ + { type: "text" as const, text: `Removed reaction :${emojiName}: from ${postId}` }, + ], + details: {}, + }; + } + + const result = await addMattermostReaction({ cfg, postId, emojiName, @@ -96,26 +134,92 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { if (!result.ok) { throw new Error(result.error); } + return { - content: [ - { type: "text" as const, text: `Removed reaction :${emojiName}: from ${postId}` }, - ], + content: [{ type: "text" as const, text: `Reacted with :${emojiName}: on ${postId}` }], details: {}, }; } - const result = await addMattermostReaction({ - cfg, - postId, - emojiName, - accountId: resolvedAccountId, - }); - if (!result.ok) { - throw new Error(result.error); + if (action !== "send") { + throw new Error(`Unsupported Mattermost action: ${action}`); } + // Send action with optional interactive buttons + const to = + typeof params.to === "string" + ? params.to.trim() + : typeof params.target === "string" + ? params.target.trim() + : ""; + if (!to) { + throw new Error("Mattermost send requires a target (to)."); + } + + const message = typeof params.message === "string" ? params.message : ""; + const replyToId = typeof params.replyToId === "string" ? params.replyToId : undefined; + const resolvedAccountId = accountId || undefined; + + // Build props with button attachments if buttons are provided + let props: Record | undefined; + if (params.buttons && Array.isArray(params.buttons)) { + const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId }); + if (account.botToken) setInteractionSecret(account.accountId, account.botToken); + const callbackUrl = resolveInteractionCallbackUrl(account.accountId, cfg); + + // Flatten 2D array (rows of buttons) to 1D — core schema sends Array> + // but Mattermost doesn't have row layout, so we flatten all rows into a single list. + // Also supports 1D arrays for backward compatibility. + const rawButtons = (params.buttons as Array).flatMap((item) => + Array.isArray(item) ? item : [item], + ) as Array>; + + const buttons = rawButtons + .map((btn) => ({ + id: String(btn.id ?? btn.callback_data ?? ""), + name: String(btn.text ?? btn.name ?? btn.label ?? ""), + style: (btn.style as "default" | "primary" | "danger") ?? "default", + context: + typeof btn.context === "object" && btn.context !== null + ? (btn.context as Record) + : undefined, + })) + .filter((btn) => btn.id && btn.name); + + const attachmentText = + typeof params.attachmentText === "string" ? params.attachmentText : undefined; + props = { + attachments: buildButtonAttachments({ + callbackUrl, + accountId: account.accountId, + buttons, + text: attachmentText, + }), + }; + } + + const mediaUrl = + typeof params.media === "string" ? params.media.trim() || undefined : undefined; + + const result = await sendMessageMattermost(to, message, { + accountId: resolvedAccountId, + replyToId, + props, + mediaUrl, + }); + return { - content: [{ type: "text" as const, text: `Reacted with :${emojiName}: on ${postId}` }], + content: [ + { + type: "text" as const, + text: JSON.stringify({ + ok: true, + channel: "mattermost", + messageId: result.messageId, + channelId: result.channelId, + }), + }, + ], details: {}, }; }, @@ -249,6 +353,12 @@ export const mattermostPlugin: ChannelPlugin = { resolveRequireMention: resolveMattermostGroupRequireMention, }, actions: mattermostMessageActions, + directory: { + listGroups: async (params) => listMattermostDirectoryGroups(params), + listGroupsLive: async (params) => listMattermostDirectoryGroups(params), + listPeers: async (params) => listMattermostDirectoryPeers(params), + listPeersLive: async (params) => listMattermostDirectoryPeers(params), + }, messaging: { normalizeTarget: normalizeMattermostMessagingTarget, targetResolver: { diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 0bc43f22164..12acabf5b7d 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -50,6 +50,11 @@ const MattermostAccountSchemaBase = z }) .optional(), commands: MattermostSlashCommandsSchema, + interactions: z + .object({ + callbackBaseUrl: z.string().optional(), + }) + .optional(), }) .strict(); diff --git a/extensions/mattermost/src/mattermost/client.test.ts b/extensions/mattermost/src/mattermost/client.test.ts index 2bdb1747ee6..3d325dda527 100644 --- a/extensions/mattermost/src/mattermost/client.test.ts +++ b/extensions/mattermost/src/mattermost/client.test.ts @@ -1,19 +1,298 @@ import { describe, expect, it, vi } from "vitest"; -import { createMattermostClient } from "./client.js"; +import { + createMattermostClient, + createMattermostPost, + normalizeMattermostBaseUrl, + updateMattermostPost, +} from "./client.js"; -describe("mattermost client", () => { - it("request returns undefined on 204 responses", async () => { +// ── Helper: mock fetch that captures requests ──────────────────────── + +function createMockFetch(response?: { status?: number; body?: unknown; contentType?: string }) { + const status = response?.status ?? 200; + const body = response?.body ?? {}; + const contentType = response?.contentType ?? "application/json"; + + const calls: Array<{ url: string; init?: RequestInit }> = []; + + const mockFetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + calls.push({ url: urlStr, init }); + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": contentType }, + }); + }); + + return { mockFetch: mockFetch as unknown as typeof fetch, calls }; +} + +// ── normalizeMattermostBaseUrl ──────────────────────────────────────── + +describe("normalizeMattermostBaseUrl", () => { + it("strips trailing slashes", () => { + expect(normalizeMattermostBaseUrl("http://localhost:8065/")).toBe("http://localhost:8065"); + }); + + it("strips /api/v4 suffix", () => { + expect(normalizeMattermostBaseUrl("http://localhost:8065/api/v4")).toBe( + "http://localhost:8065", + ); + }); + + it("returns undefined for empty input", () => { + expect(normalizeMattermostBaseUrl("")).toBeUndefined(); + expect(normalizeMattermostBaseUrl(null)).toBeUndefined(); + expect(normalizeMattermostBaseUrl(undefined)).toBeUndefined(); + }); + + it("preserves valid base URL", () => { + expect(normalizeMattermostBaseUrl("http://mm.example.com")).toBe("http://mm.example.com"); + }); +}); + +// ── createMattermostClient ─────────────────────────────────────────── + +describe("createMattermostClient", () => { + it("creates a client with normalized baseUrl", () => { + const { mockFetch } = createMockFetch(); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065/", + botToken: "tok", + fetchImpl: mockFetch, + }); + expect(client.baseUrl).toBe("http://localhost:8065"); + expect(client.apiBaseUrl).toBe("http://localhost:8065/api/v4"); + }); + + it("throws on empty baseUrl", () => { + expect(() => createMattermostClient({ baseUrl: "", botToken: "tok" })).toThrow( + "baseUrl is required", + ); + }); + + it("sends Authorization header with Bearer token", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "u1" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "my-secret-token", + fetchImpl: mockFetch, + }); + await client.request("/users/me"); + const headers = new Headers(calls[0].init?.headers); + expect(headers.get("Authorization")).toBe("Bearer my-secret-token"); + }); + + it("sets Content-Type for string bodies", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "p1" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + await client.request("/posts", { method: "POST", body: JSON.stringify({ message: "hi" }) }); + const headers = new Headers(calls[0].init?.headers); + expect(headers.get("Content-Type")).toBe("application/json"); + }); + + it("throws on non-ok responses", async () => { + const { mockFetch } = createMockFetch({ + status: 404, + body: { message: "Not Found" }, + }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + await expect(client.request("/missing")).rejects.toThrow("Mattermost API 404"); + }); + + it("returns undefined on 204 responses", async () => { const fetchImpl = vi.fn(async () => { return new Response(null, { status: 204 }); }); - const client = createMattermostClient({ baseUrl: "https://chat.example.com", botToken: "test-token", fetchImpl: fetchImpl as any, }); - const result = await client.request("/anything", { method: "DELETE" }); expect(result).toBeUndefined(); }); }); + +// ── createMattermostPost ───────────────────────────────────────────── + +describe("createMattermostPost", () => { + it("sends channel_id and message", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await createMattermostPost(client, { + channelId: "ch123", + message: "Hello world", + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.channel_id).toBe("ch123"); + expect(body.message).toBe("Hello world"); + }); + + it("includes rootId when provided", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post2" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await createMattermostPost(client, { + channelId: "ch123", + message: "Reply", + rootId: "root456", + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.root_id).toBe("root456"); + }); + + it("includes fileIds when provided", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post3" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await createMattermostPost(client, { + channelId: "ch123", + message: "With file", + fileIds: ["file1", "file2"], + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.file_ids).toEqual(["file1", "file2"]); + }); + + it("includes props when provided (for interactive buttons)", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post4" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + const props = { + attachments: [ + { + text: "Choose:", + actions: [{ id: "btn1", type: "button", name: "Click" }], + }, + ], + }; + + await createMattermostPost(client, { + channelId: "ch123", + message: "Pick an option", + props, + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.props).toEqual(props); + expect(body.props.attachments[0].actions[0].type).toBe("button"); + }); + + it("omits props when not provided", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post5" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await createMattermostPost(client, { + channelId: "ch123", + message: "No props", + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.props).toBeUndefined(); + }); +}); + +// ── updateMattermostPost ───────────────────────────────────────────── + +describe("updateMattermostPost", () => { + it("sends PUT to /posts/{id}", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await updateMattermostPost(client, "post1", { message: "Updated" }); + + expect(calls[0].url).toContain("/posts/post1"); + expect(calls[0].init?.method).toBe("PUT"); + }); + + it("includes post id in the body", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await updateMattermostPost(client, "post1", { message: "Updated" }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.id).toBe("post1"); + expect(body.message).toBe("Updated"); + }); + + it("includes props for button completion updates", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await updateMattermostPost(client, "post1", { + message: "Original message", + props: { + attachments: [{ text: "✓ **do_now** selected by @tony" }], + }, + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.message).toBe("Original message"); + expect(body.props.attachments[0].text).toContain("✓"); + expect(body.props.attachments[0].text).toContain("do_now"); + }); + + it("omits message when not provided", async () => { + const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } }); + const client = createMattermostClient({ + baseUrl: "http://localhost:8065", + botToken: "tok", + fetchImpl: mockFetch, + }); + + await updateMattermostPost(client, "post1", { + props: { attachments: [] }, + }); + + const body = JSON.parse(calls[0].init?.body as string); + expect(body.id).toBe("post1"); + expect(body.message).toBeUndefined(); + expect(body.props).toEqual({ attachments: [] }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index 2f4cc4e9a74..1a8219340b9 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -138,6 +138,16 @@ export async function fetchMattermostChannel( return await client.request(`/channels/${channelId}`); } +export async function fetchMattermostChannelByName( + client: MattermostClient, + teamId: string, + channelName: string, +): Promise { + return await client.request( + `/teams/${teamId}/channels/name/${encodeURIComponent(channelName)}`, + ); +} + export async function sendMattermostTyping( client: MattermostClient, params: { channelId: string; parentId?: string }, @@ -172,9 +182,10 @@ export async function createMattermostPost( message: string; rootId?: string; fileIds?: string[]; + props?: Record; }, ): Promise { - const payload: Record = { + const payload: Record = { channel_id: params.channelId, message: params.message, }; @@ -182,7 +193,10 @@ export async function createMattermostPost( payload.root_id = params.rootId; } if (params.fileIds?.length) { - (payload as Record).file_ids = params.fileIds; + payload.file_ids = params.fileIds; + } + if (params.props) { + payload.props = params.props; } return await client.request("/posts", { method: "POST", @@ -203,6 +217,27 @@ export async function fetchMattermostUserTeams( return await client.request(`/users/${userId}/teams`); } +export async function updateMattermostPost( + client: MattermostClient, + postId: string, + params: { + message?: string; + props?: Record; + }, +): Promise { + const payload: Record = { id: postId }; + if (params.message !== undefined) { + payload.message = params.message; + } + if (params.props !== undefined) { + payload.props = params.props; + } + return await client.request(`/posts/${postId}`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} + export async function uploadMattermostFile( client: MattermostClient, params: { diff --git a/extensions/mattermost/src/mattermost/directory.ts b/extensions/mattermost/src/mattermost/directory.ts new file mode 100644 index 00000000000..1b9d3e91e86 --- /dev/null +++ b/extensions/mattermost/src/mattermost/directory.ts @@ -0,0 +1,172 @@ +import type { + ChannelDirectoryEntry, + OpenClawConfig, + RuntimeEnv, +} from "openclaw/plugin-sdk/mattermost"; +import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js"; +import { + createMattermostClient, + fetchMattermostMe, + type MattermostChannel, + type MattermostClient, + type MattermostUser, +} from "./client.js"; + +export type MattermostDirectoryParams = { + cfg: OpenClawConfig; + accountId?: string | null; + query?: string | null; + limit?: number | null; + runtime: RuntimeEnv; +}; + +function buildClient(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): MattermostClient | null { + const account = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.enabled || !account.botToken || !account.baseUrl) { + return null; + } + return createMattermostClient({ baseUrl: account.baseUrl, botToken: account.botToken }); +} + +/** + * Build clients from ALL enabled accounts (deduplicated by token). + * + * We always scan every account because: + * - Private channels are only visible to bots that are members + * - The requesting agent's account may have an expired/invalid token + * + * This means a single healthy bot token is enough for directory discovery. + */ +function buildClients(params: MattermostDirectoryParams): MattermostClient[] { + const accountIds = listMattermostAccountIds(params.cfg); + const seen = new Set(); + const clients: MattermostClient[] = []; + for (const id of accountIds) { + const client = buildClient({ cfg: params.cfg, accountId: id }); + if (client && !seen.has(client.token)) { + seen.add(client.token); + clients.push(client); + } + } + return clients; +} + +/** + * List channels (public + private) visible to any configured bot account. + * + * NOTE: Uses per_page=200 which covers most instances. Mattermost does not + * return a "has more" indicator, so very large instances (200+ channels per bot) + * may see incomplete results. Pagination can be added if needed. + */ +export async function listMattermostDirectoryGroups( + params: MattermostDirectoryParams, +): Promise { + const clients = buildClients(params); + if (!clients.length) { + return []; + } + const q = params.query?.trim().toLowerCase() || ""; + const seenIds = new Set(); + const entries: ChannelDirectoryEntry[] = []; + + for (const client of clients) { + try { + const me = await fetchMattermostMe(client); + const channels = await client.request( + `/users/${me.id}/channels?per_page=200`, + ); + for (const ch of channels) { + if (ch.type !== "O" && ch.type !== "P") continue; + if (seenIds.has(ch.id)) continue; + if (q) { + const name = (ch.name ?? "").toLowerCase(); + const display = (ch.display_name ?? "").toLowerCase(); + if (!name.includes(q) && !display.includes(q)) continue; + } + seenIds.add(ch.id); + entries.push({ + kind: "group" as const, + id: `channel:${ch.id}`, + name: ch.name ?? undefined, + handle: ch.display_name ?? undefined, + }); + } + } catch (err) { + // Token may be expired/revoked — skip this account and try others + console.debug?.( + "[mattermost-directory] listGroups: skipping account:", + (err as Error)?.message, + ); + continue; + } + } + return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries; +} + +/** + * List team members as peer directory entries. + * + * Uses only the first available client since all bots in a team see the same + * user list (unlike channels where membership varies). Uses the first team + * returned — multi-team setups will only see members from that team. + * + * NOTE: per_page=200 for member listing; same pagination caveat as groups. + */ +export async function listMattermostDirectoryPeers( + params: MattermostDirectoryParams, +): Promise { + const clients = buildClients(params); + if (!clients.length) { + return []; + } + // All bots see the same user list, so one client suffices (unlike channels + // where private channel membership varies per bot). + const client = clients[0]; + try { + const me = await fetchMattermostMe(client); + const teams = await client.request<{ id: string }[]>("/users/me/teams"); + if (!teams.length) { + return []; + } + // Uses first team — multi-team setups may need iteration in the future + const teamId = teams[0].id; + const q = params.query?.trim().toLowerCase() || ""; + + let users: MattermostUser[]; + if (q) { + users = await client.request("/users/search", { + method: "POST", + body: JSON.stringify({ term: q, team_id: teamId }), + }); + } else { + const members = await client.request<{ user_id: string }[]>( + `/teams/${teamId}/members?per_page=200`, + ); + const userIds = members.map((m) => m.user_id).filter((id) => id !== me.id); + if (!userIds.length) { + return []; + } + users = await client.request("/users/ids", { + method: "POST", + body: JSON.stringify(userIds), + }); + } + + const entries = users + .filter((u) => u.id !== me.id) + .map((u) => ({ + kind: "user" as const, + id: `user:${u.id}`, + name: u.username ?? undefined, + handle: + [u.first_name, u.last_name].filter(Boolean).join(" ").trim() || u.nickname || undefined, + })); + return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries; + } catch (err) { + console.debug?.("[mattermost-directory] listPeers failed:", (err as Error)?.message); + return []; + } +} diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts new file mode 100644 index 00000000000..0e24ae4a4ee --- /dev/null +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -0,0 +1,335 @@ +import { type IncomingMessage } from "node:http"; +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { + buildButtonAttachments, + generateInteractionToken, + getInteractionCallbackUrl, + getInteractionSecret, + isLocalhostRequest, + resolveInteractionCallbackUrl, + setInteractionCallbackUrl, + setInteractionSecret, + verifyInteractionToken, +} from "./interactions.js"; + +// ── HMAC token management ──────────────────────────────────────────── + +describe("setInteractionSecret / getInteractionSecret", () => { + beforeEach(() => { + setInteractionSecret("test-bot-token"); + }); + + it("derives a deterministic secret from the bot token", () => { + setInteractionSecret("token-a"); + const secretA = getInteractionSecret(); + setInteractionSecret("token-a"); + const secretA2 = getInteractionSecret(); + expect(secretA).toBe(secretA2); + }); + + it("produces different secrets for different tokens", () => { + setInteractionSecret("token-a"); + const secretA = getInteractionSecret(); + setInteractionSecret("token-b"); + const secretB = getInteractionSecret(); + expect(secretA).not.toBe(secretB); + }); + + it("returns a hex string", () => { + expect(getInteractionSecret()).toMatch(/^[0-9a-f]+$/); + }); +}); + +// ── Token generation / verification ────────────────────────────────── + +describe("generateInteractionToken / verifyInteractionToken", () => { + beforeEach(() => { + setInteractionSecret("test-bot-token"); + }); + + it("generates a hex token", () => { + const token = generateInteractionToken({ action_id: "click" }); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + it("verifies a valid token", () => { + const context = { action_id: "do_now", item_id: "123" }; + const token = generateInteractionToken(context); + expect(verifyInteractionToken(context, token)).toBe(true); + }); + + it("rejects a tampered token", () => { + const context = { action_id: "do_now" }; + const token = generateInteractionToken(context); + const tampered = token.replace(/.$/, token.endsWith("0") ? "1" : "0"); + expect(verifyInteractionToken(context, tampered)).toBe(false); + }); + + it("rejects a token generated with different context", () => { + const token = generateInteractionToken({ action_id: "a" }); + expect(verifyInteractionToken({ action_id: "b" }, token)).toBe(false); + }); + + it("rejects tokens with wrong length", () => { + const context = { action_id: "test" }; + expect(verifyInteractionToken(context, "short")).toBe(false); + }); + + it("is deterministic for the same context", () => { + const context = { action_id: "test", x: 1 }; + const t1 = generateInteractionToken(context); + const t2 = generateInteractionToken(context); + expect(t1).toBe(t2); + }); + + it("produces the same token regardless of key order", () => { + const contextA = { action_id: "do_now", tweet_id: "123", action: "do" }; + const contextB = { action: "do", action_id: "do_now", tweet_id: "123" }; + const contextC = { tweet_id: "123", action: "do", action_id: "do_now" }; + const tokenA = generateInteractionToken(contextA); + const tokenB = generateInteractionToken(contextB); + const tokenC = generateInteractionToken(contextC); + expect(tokenA).toBe(tokenB); + expect(tokenB).toBe(tokenC); + }); + + it("verifies a token when Mattermost reorders context keys", () => { + // Simulate: token generated with keys in one order, verified with keys in another + // (Mattermost reorders context keys when storing/returning interactive message payloads) + const originalContext = { action_id: "bm_do", tweet_id: "999", action: "do" }; + const token = generateInteractionToken(originalContext); + + // Mattermost returns keys in alphabetical order (or any arbitrary order) + const reorderedContext = { action: "do", action_id: "bm_do", tweet_id: "999" }; + expect(verifyInteractionToken(reorderedContext, token)).toBe(true); + }); + + it("scopes tokens per account when account secrets differ", () => { + setInteractionSecret("acct-a", "bot-token-a"); + setInteractionSecret("acct-b", "bot-token-b"); + const context = { action_id: "do_now", item_id: "123" }; + const tokenA = generateInteractionToken(context, "acct-a"); + + expect(verifyInteractionToken(context, tokenA, "acct-a")).toBe(true); + expect(verifyInteractionToken(context, tokenA, "acct-b")).toBe(false); + }); +}); + +// ── Callback URL registry ──────────────────────────────────────────── + +describe("callback URL registry", () => { + it("stores and retrieves callback URLs", () => { + setInteractionCallbackUrl("acct1", "http://localhost:18789/mattermost/interactions/acct1"); + expect(getInteractionCallbackUrl("acct1")).toBe( + "http://localhost:18789/mattermost/interactions/acct1", + ); + }); + + it("returns undefined for unknown account", () => { + expect(getInteractionCallbackUrl("nonexistent-account-id")).toBeUndefined(); + }); +}); + +describe("resolveInteractionCallbackUrl", () => { + afterEach(() => { + setInteractionCallbackUrl("resolve-test", ""); + }); + + it("prefers cached URL from registry", () => { + setInteractionCallbackUrl("cached", "http://cached:1234/path"); + expect(resolveInteractionCallbackUrl("cached")).toBe("http://cached:1234/path"); + }); + + it("falls back to computed URL from gateway port config", () => { + const url = resolveInteractionCallbackUrl("default", { gateway: { port: 9999 } }); + expect(url).toBe("http://localhost:9999/mattermost/interactions/default"); + }); + + it("uses default port 18789 when no config provided", () => { + const url = resolveInteractionCallbackUrl("myaccount"); + expect(url).toBe("http://localhost:18789/mattermost/interactions/myaccount"); + }); + + it("uses default port when gateway config has no port", () => { + const url = resolveInteractionCallbackUrl("acct", { gateway: {} }); + expect(url).toBe("http://localhost:18789/mattermost/interactions/acct"); + }); +}); + +// ── buildButtonAttachments ─────────────────────────────────────────── + +describe("buildButtonAttachments", () => { + beforeEach(() => { + setInteractionSecret("test-bot-token"); + }); + + it("returns an array with one attachment containing all buttons", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost:18789/mattermost/interactions/default", + buttons: [ + { id: "btn1", name: "Click Me" }, + { id: "btn2", name: "Skip", style: "danger" }, + ], + }); + + expect(result).toHaveLength(1); + expect(result[0].actions).toHaveLength(2); + }); + + it("sets type to 'button' on every action", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost:18789/cb", + buttons: [{ id: "a", name: "A" }], + }); + + expect(result[0].actions![0].type).toBe("button"); + }); + + it("includes HMAC _token in integration context", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost:18789/cb", + buttons: [{ id: "test", name: "Test" }], + }); + + const action = result[0].actions![0]; + expect(action.integration.context._token).toMatch(/^[0-9a-f]{64}$/); + }); + + it("includes sanitized action_id in integration context", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost:18789/cb", + buttons: [{ id: "my_action", name: "Do It" }], + }); + + const action = result[0].actions![0]; + // sanitizeActionId strips hyphens and underscores (Mattermost routing bug #25747) + expect(action.integration.context.action_id).toBe("myaction"); + expect(action.id).toBe("myaction"); + }); + + it("merges custom context into integration context", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost:18789/cb", + buttons: [{ id: "btn", name: "Go", context: { tweet_id: "123", batch: true } }], + }); + + const ctx = result[0].actions![0].integration.context; + expect(ctx.tweet_id).toBe("123"); + expect(ctx.batch).toBe(true); + expect(ctx.action_id).toBe("btn"); + expect(ctx._token).toBeDefined(); + }); + + it("passes callback URL to each button integration", () => { + const url = "http://localhost:18789/mattermost/interactions/default"; + const result = buildButtonAttachments({ + callbackUrl: url, + buttons: [ + { id: "a", name: "A" }, + { id: "b", name: "B" }, + ], + }); + + for (const action of result[0].actions!) { + expect(action.integration.url).toBe(url); + } + }); + + it("preserves button style", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [ + { id: "ok", name: "OK", style: "primary" }, + { id: "no", name: "No", style: "danger" }, + ], + }); + + expect(result[0].actions![0].style).toBe("primary"); + expect(result[0].actions![1].style).toBe("danger"); + }); + + it("uses provided text for the attachment", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [{ id: "x", name: "X" }], + text: "Choose an action:", + }); + + expect(result[0].text).toBe("Choose an action:"); + }); + + it("defaults to empty string text when not provided", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [{ id: "x", name: "X" }], + }); + + expect(result[0].text).toBe(""); + }); + + it("generates verifiable tokens", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [{ id: "verify_me", name: "V", context: { extra: "data" } }], + }); + + const ctx = result[0].actions![0].integration.context; + const token = ctx._token as string; + const { _token, ...contextWithoutToken } = ctx; + expect(verifyInteractionToken(contextWithoutToken, token)).toBe(true); + }); + + it("generates tokens that verify even when Mattermost reorders context keys", () => { + const result = buildButtonAttachments({ + callbackUrl: "http://localhost/cb", + buttons: [{ id: "do_action", name: "Do", context: { tweet_id: "42", category: "ai" } }], + }); + + const ctx = result[0].actions![0].integration.context; + const token = ctx._token as string; + + // Simulate Mattermost returning context with keys in a different order + const reordered: Record = {}; + const keys = Object.keys(ctx).filter((k) => k !== "_token"); + // Reverse the key order to simulate reordering + for (const key of keys.reverse()) { + reordered[key] = ctx[key]; + } + expect(verifyInteractionToken(reordered, token)).toBe(true); + }); +}); + +// ── isLocalhostRequest ─────────────────────────────────────────────── + +describe("isLocalhostRequest", () => { + function fakeReq(remoteAddress?: string): IncomingMessage { + return { + socket: { remoteAddress }, + } as unknown as IncomingMessage; + } + + it("accepts 127.0.0.1", () => { + expect(isLocalhostRequest(fakeReq("127.0.0.1"))).toBe(true); + }); + + it("accepts ::1", () => { + expect(isLocalhostRequest(fakeReq("::1"))).toBe(true); + }); + + it("accepts ::ffff:127.0.0.1", () => { + expect(isLocalhostRequest(fakeReq("::ffff:127.0.0.1"))).toBe(true); + }); + + it("rejects external addresses", () => { + expect(isLocalhostRequest(fakeReq("10.0.0.1"))).toBe(false); + expect(isLocalhostRequest(fakeReq("192.168.1.1"))).toBe(false); + }); + + it("rejects when socket has no remote address", () => { + expect(isLocalhostRequest(fakeReq(undefined))).toBe(false); + }); + + it("rejects when socket is missing", () => { + expect(isLocalhostRequest({} as IncomingMessage)).toBe(false); + }); +}); diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts new file mode 100644 index 00000000000..be305db4ba3 --- /dev/null +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -0,0 +1,429 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { getMattermostRuntime } from "../runtime.js"; +import { updateMattermostPost, type MattermostClient } from "./client.js"; + +const INTERACTION_MAX_BODY_BYTES = 64 * 1024; +const INTERACTION_BODY_TIMEOUT_MS = 10_000; + +/** + * Mattermost interactive message callback payload. + * Sent by Mattermost when a user clicks an action button. + * See: https://developers.mattermost.com/integrate/plugins/interactive-messages/ + */ +export type MattermostInteractionPayload = { + user_id: string; + user_name?: string; + channel_id: string; + team_id?: string; + post_id: string; + trigger_id?: string; + type?: string; + data_source?: string; + context?: Record; +}; + +export type MattermostInteractionResponse = { + update?: { + message: string; + props?: Record; + }; + ephemeral_text?: string; +}; + +// ── Callback URL registry ────────────────────────────────────────────── + +const callbackUrls = new Map(); + +export function setInteractionCallbackUrl(accountId: string, url: string): void { + callbackUrls.set(accountId, url); +} + +export function getInteractionCallbackUrl(accountId: string): string | undefined { + return callbackUrls.get(accountId); +} + +/** + * Resolve the interaction callback URL for an account. + * Prefers the in-memory registered URL (set by the gateway monitor). + * Falls back to computing it from the gateway port in config (for CLI callers). + */ +export function resolveInteractionCallbackUrl( + accountId: string, + cfg?: { gateway?: { port?: number } }, +): string { + const cached = callbackUrls.get(accountId); + if (cached) { + return cached; + } + const port = typeof cfg?.gateway?.port === "number" ? cfg.gateway.port : 18789; + return `http://localhost:${port}/mattermost/interactions/${accountId}`; +} + +// ── HMAC token management ────────────────────────────────────────────── +// Secret is derived from the bot token so it's stable across CLI and gateway processes. + +const interactionSecrets = new Map(); +let defaultInteractionSecret: string | undefined; + +function deriveInteractionSecret(botToken: string): string { + return createHmac("sha256", "openclaw-mattermost-interactions").update(botToken).digest("hex"); +} + +export function setInteractionSecret(accountIdOrBotToken: string, botToken?: string): void { + if (typeof botToken === "string") { + interactionSecrets.set(accountIdOrBotToken, deriveInteractionSecret(botToken)); + return; + } + // Backward-compatible fallback for call sites/tests that only pass botToken. + defaultInteractionSecret = deriveInteractionSecret(accountIdOrBotToken); +} + +export function getInteractionSecret(accountId?: string): string { + const scoped = accountId ? interactionSecrets.get(accountId) : undefined; + if (scoped) { + return scoped; + } + if (defaultInteractionSecret) { + return defaultInteractionSecret; + } + // Fallback for single-account runtimes that only registered scoped secrets. + if (interactionSecrets.size === 1) { + const first = interactionSecrets.values().next().value; + if (typeof first === "string") { + return first; + } + } + throw new Error( + "Interaction secret not initialized — call setInteractionSecret(accountId, botToken) first", + ); +} + +export function generateInteractionToken( + context: Record, + accountId?: string, +): string { + const secret = getInteractionSecret(accountId); + // Sort keys for stable serialization — Mattermost may reorder context keys + const payload = JSON.stringify(context, Object.keys(context).sort()); + return createHmac("sha256", secret).update(payload).digest("hex"); +} + +export function verifyInteractionToken( + context: Record, + token: string, + accountId?: string, +): boolean { + const expected = generateInteractionToken(context, accountId); + if (expected.length !== token.length) { + return false; + } + return timingSafeEqual(Buffer.from(expected), Buffer.from(token)); +} + +// ── Button builder helpers ───────────────────────────────────────────── + +export type MattermostButton = { + id: string; + type: "button" | "select"; + name: string; + style?: "default" | "primary" | "danger"; + integration: { + url: string; + context: Record; + }; +}; + +export type MattermostAttachment = { + text?: string; + actions?: MattermostButton[]; + [key: string]: unknown; +}; + +/** + * Build Mattermost `props.attachments` with interactive buttons. + * + * Each button includes an HMAC token in its integration context so the + * callback handler can verify the request originated from a legitimate + * button click (Mattermost's recommended security pattern). + */ +/** + * Sanitize a button ID so Mattermost's action router can match it. + * Mattermost uses the action ID in the URL path `/api/v4/posts/{id}/actions/{actionId}` + * and IDs containing hyphens or underscores break the server-side routing. + * See: https://github.com/mattermost/mattermost/issues/25747 + */ +function sanitizeActionId(id: string): string { + return id.replace(/[-_]/g, ""); +} + +export function buildButtonAttachments(params: { + callbackUrl: string; + accountId?: string; + buttons: Array<{ + id: string; + name: string; + style?: "default" | "primary" | "danger"; + context?: Record; + }>; + text?: string; +}): MattermostAttachment[] { + const actions: MattermostButton[] = params.buttons.map((btn) => { + const safeId = sanitizeActionId(btn.id); + const context: Record = { + action_id: safeId, + ...btn.context, + }; + const token = generateInteractionToken(context, params.accountId); + return { + id: safeId, + type: "button" as const, + name: btn.name, + style: btn.style, + integration: { + url: params.callbackUrl, + context: { + ...context, + _token: token, + }, + }, + }; + }); + + return [ + { + text: params.text ?? "", + actions, + }, + ]; +} + +// ── Localhost validation ─────────────────────────────────────────────── + +const LOCALHOST_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]); + +export function isLocalhostRequest(req: IncomingMessage): boolean { + const addr = req.socket?.remoteAddress; + if (!addr) { + return false; + } + return LOCALHOST_ADDRESSES.has(addr); +} + +// ── Request body reader ──────────────────────────────────────────────── + +function readInteractionBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + + const timer = setTimeout(() => { + req.destroy(); + reject(new Error("Request body read timeout")); + }, INTERACTION_BODY_TIMEOUT_MS); + + req.on("data", (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > INTERACTION_MAX_BODY_BYTES) { + req.destroy(); + clearTimeout(timer); + reject(new Error("Request body too large")); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + clearTimeout(timer); + resolve(Buffer.concat(chunks).toString("utf8")); + }); + + req.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +// ── HTTP handler ─────────────────────────────────────────────────────── + +export function createMattermostInteractionHandler(params: { + client: MattermostClient; + botUserId: string; + accountId: string; + callbackUrl: string; + resolveSessionKey?: (channelId: string, userId: string) => Promise; + dispatchButtonClick?: (opts: { + channelId: string; + userId: string; + userName: string; + actionId: string; + actionName: string; + postId: string; + }) => Promise; + log?: (message: string) => void; +}): (req: IncomingMessage, res: ServerResponse) => Promise { + const { client, accountId, log } = params; + const core = getMattermostRuntime(); + + return async (req: IncomingMessage, res: ServerResponse) => { + // Only accept POST + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Method Not Allowed" })); + return; + } + + // Verify request is from localhost + if (!isLocalhostRequest(req)) { + log?.( + `mattermost interaction: rejected non-localhost request from ${req.socket?.remoteAddress}`, + ); + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Forbidden" })); + return; + } + + let payload: MattermostInteractionPayload; + try { + const raw = await readInteractionBody(req); + payload = JSON.parse(raw) as MattermostInteractionPayload; + } catch (err) { + log?.(`mattermost interaction: failed to parse body: ${String(err)}`); + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Invalid request body" })); + return; + } + + const context = payload.context; + if (!context) { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Missing context" })); + return; + } + + // Verify HMAC token + const token = context._token; + if (typeof token !== "string") { + log?.("mattermost interaction: missing _token in context"); + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Missing token" })); + return; + } + + // Strip _token before verification (it wasn't in the original context) + const { _token, ...contextWithoutToken } = context; + if (!verifyInteractionToken(contextWithoutToken, token, accountId)) { + log?.("mattermost interaction: invalid _token"); + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Invalid token" })); + return; + } + + const actionId = context.action_id; + if (typeof actionId !== "string") { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Missing action_id in context" })); + return; + } + + log?.( + `mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` + + `post=${payload.post_id} channel=${payload.channel_id}`, + ); + + // Dispatch as system event so the agent can handle it. + // Wrapped in try/catch — the post update below must still run even if + // system event dispatch fails (e.g. missing sessionKey or channel lookup). + try { + const eventLabel = + `Mattermost button click: action="${actionId}" ` + + `by ${payload.user_name ?? payload.user_id} ` + + `in channel ${payload.channel_id}`; + + const sessionKey = params.resolveSessionKey + ? await params.resolveSessionKey(payload.channel_id, payload.user_id) + : `agent:main:mattermost:${accountId}:${payload.channel_id}`; + + core.system.enqueueSystemEvent(eventLabel, { + sessionKey, + contextKey: `mattermost:interaction:${payload.post_id}:${actionId}`, + }); + } catch (err) { + log?.(`mattermost interaction: system event dispatch failed: ${String(err)}`); + } + + // Fetch the original post to preserve its message and find the clicked button name. + const userName = payload.user_name ?? payload.user_id; + let originalMessage = ""; + let clickedButtonName = actionId; // fallback to action ID if we can't find the name + try { + const originalPost = await client.request<{ + message?: string; + props?: Record; + }>(`/posts/${payload.post_id}`); + originalMessage = originalPost?.message ?? ""; + + // Find the clicked button's display name from the original attachments + const postAttachments = Array.isArray(originalPost?.props?.attachments) + ? (originalPost.props.attachments as Array<{ + actions?: Array<{ id?: string; name?: string }>; + }>) + : []; + for (const att of postAttachments) { + const match = att.actions?.find((a) => a.id === actionId); + if (match?.name) { + clickedButtonName = match.name; + break; + } + } + } catch (err) { + log?.(`mattermost interaction: failed to fetch post ${payload.post_id}: ${String(err)}`); + } + + // Update the post via API to replace buttons with a completion indicator. + try { + await updateMattermostPost(client, payload.post_id, { + message: originalMessage, + props: { + attachments: [ + { + text: `✓ **${clickedButtonName}** selected by @${userName}`, + }, + ], + }, + }); + } catch (err) { + log?.(`mattermost interaction: failed to update post ${payload.post_id}: ${String(err)}`); + } + + // Respond with empty JSON — the post update is handled above + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end("{}"); + + // Dispatch a synthetic inbound message so the agent responds to the button click. + if (params.dispatchButtonClick) { + try { + await params.dispatchButtonClick({ + channelId: payload.channel_id, + userId: payload.user_id, + userName, + actionId, + actionName: clickedButtonName, + postId: payload.post_id, + }); + } catch (err) { + log?.(`mattermost interaction: dispatchButtonClick failed: ${String(err)}`); + } + } + }; +} diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 3a0241c84f8..13864a33f44 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -18,6 +18,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, isDangerousNameMatchingEnabled, + registerPluginHttpRoute, resolveControlCommandGate, readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, @@ -42,6 +43,11 @@ import { type MattermostPost, type MattermostUser, } from "./client.js"; +import { + createMattermostInteractionHandler, + setInteractionCallbackUrl, + setInteractionSecret, +} from "./interactions.js"; import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js"; import { createDedupeCache, @@ -318,12 +324,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} // a different port. const envPortRaw = process.env.OPENCLAW_GATEWAY_PORT?.trim(); const envPort = envPortRaw ? Number.parseInt(envPortRaw, 10) : NaN; - const gatewayPort = + const slashGatewayPort = Number.isFinite(envPort) && envPort > 0 ? envPort : (cfg.gateway?.port ?? 18789); - const callbackUrl = resolveCallbackUrl({ + const slashCallbackUrl = resolveCallbackUrl({ config: slashConfig, - gatewayPort, + gatewayPort: slashGatewayPort, gatewayHost: cfg.gateway?.customBindHost ?? undefined, }); @@ -332,7 +338,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} try { const mmHost = new URL(baseUrl).hostname; - const callbackHost = new URL(callbackUrl).hostname; + const callbackHost = new URL(slashCallbackUrl).hostname; // NOTE: We cannot infer network reachability from hostnames alone. // Mattermost might be accessed via a public domain while still running on the same @@ -340,7 +346,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} // So treat loopback callback URLs as an advisory warning only. if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) { runtime.error?.( - `mattermost: slash commands callbackUrl resolved to ${callbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If native slash commands don't work, set channels.mattermost.commands.callbackUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`, + `mattermost: slash commands callbackUrl resolved to ${slashCallbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If native slash commands don't work, set channels.mattermost.commands.callbackUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`, ); } } catch { @@ -390,7 +396,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} client, teamId: team.id, creatorUserId: botUserId, - callbackUrl, + callbackUrl: slashCallbackUrl, commands: dedupedCommands, log: (msg) => runtime.log?.(msg), }); @@ -432,7 +438,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }); runtime.log?.( - `mattermost: slash commands registered (${allRegistered.length} commands across ${teams.length} teams, callback=${callbackUrl})`, + `mattermost: slash commands registered (${allRegistered.length} commands across ${teams.length} teams, callback=${slashCallbackUrl})`, ); } } catch (err) { @@ -440,6 +446,182 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } } + // ─── Interactive buttons registration ────────────────────────────────────── + // Derive a stable HMAC secret from the bot token so CLI and gateway share it. + setInteractionSecret(account.accountId, botToken); + + // Register HTTP callback endpoint for interactive button clicks. + // Mattermost POSTs to this URL when a user clicks a button action. + const gatewayPort = typeof cfg.gateway?.port === "number" ? cfg.gateway.port : 18789; + const interactionPath = `/mattermost/interactions/${account.accountId}`; + const callbackUrl = `http://localhost:${gatewayPort}${interactionPath}`; + setInteractionCallbackUrl(account.accountId, callbackUrl); + const unregisterInteractions = registerPluginHttpRoute({ + path: interactionPath, + fallbackPath: "/mattermost/interactions/default", + auth: "plugin", + handler: createMattermostInteractionHandler({ + client, + botUserId, + accountId: account.accountId, + callbackUrl, + resolveSessionKey: async (channelId: string, userId: string) => { + const channelInfo = await resolveChannelInfo(channelId); + const kind = mapMattermostChannelTypeToChatType(channelInfo?.type); + const teamId = channelInfo?.team_id ?? undefined; + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "direct" ? userId : channelId, + }, + }); + return route.sessionKey; + }, + dispatchButtonClick: async (opts) => { + const channelInfo = await resolveChannelInfo(opts.channelId); + const kind = mapMattermostChannelTypeToChatType(channelInfo?.type); + const chatType = channelChatType(kind); + const teamId = channelInfo?.team_id ?? undefined; + const channelName = channelInfo?.name ?? undefined; + const channelDisplay = channelInfo?.display_name ?? channelName ?? opts.channelId; + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "direct" ? opts.userId : opts.channelId, + }, + }); + const to = kind === "direct" ? `user:${opts.userId}` : `channel:${opts.channelId}`; + const bodyText = `[Button click: user @${opts.userName} selected "${opts.actionName}"]`; + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: bodyText, + BodyForAgent: bodyText, + RawBody: bodyText, + CommandBody: bodyText, + From: + kind === "direct" + ? `mattermost:${opts.userId}` + : kind === "group" + ? `mattermost:group:${opts.channelId}` + : `mattermost:channel:${opts.channelId}`, + To: to, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: chatType, + ConversationLabel: `mattermost:${opts.userName}`, + GroupSubject: kind !== "direct" ? channelDisplay : undefined, + GroupChannel: channelName ? `#${channelName}` : undefined, + GroupSpace: teamId, + SenderName: opts.userName, + SenderId: opts.userId, + Provider: "mattermost" as const, + Surface: "mattermost" as const, + MessageSid: `interaction:${opts.postId}:${opts.actionId}`, + WasMentioned: true, + CommandAuthorized: true, + OriginatingChannel: "mattermost" as const, + OriginatingTo: to, + }); + + const textLimit = core.channel.text.resolveTextChunkLimit( + cfg, + "mattermost", + account.accountId, + { fallbackLimit: account.textChunkLimit ?? 4000 }, + ); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "mattermost", + accountId: account.accountId, + }); + const typingCallbacks = createTypingCallbacks({ + start: () => sendTypingIndicator(opts.channelId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: opts.channelId, + error: err, + }); + }, + }); + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + ...prefixOptions, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload: ReplyPayload) => { + const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + if (mediaUrls.length === 0) { + const chunkMode = core.channel.text.resolveChunkMode( + cfg, + "mattermost", + account.accountId, + ); + const chunks = core.channel.text.chunkMarkdownTextWithMode( + text, + textLimit, + chunkMode, + ); + for (const chunk of chunks.length > 0 ? chunks : [text]) { + if (!chunk) continue; + await sendMessageMattermost(to, chunk, { + accountId: account.accountId, + }); + } + } else { + let first = true; + for (const mediaUrl of mediaUrls) { + const caption = first ? text : ""; + first = false; + await sendMessageMattermost(to, caption, { + accountId: account.accountId, + mediaUrl, + }); + } + } + runtime.log?.(`delivered button-click reply to ${to}`); + }, + onError: (err, info) => { + runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks.onReplyStart, + }); + + await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + disableBlockStreaming: + typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, + onModelSelected, + }, + }); + markDispatchIdle(); + }, + log: (msg) => runtime.log?.(msg), + }), + pluginId: "mattermost", + source: "mattermost-interactions", + accountId: account.accountId, + log: (msg: string) => runtime.log?.(msg), + }); + const channelCache = new Map(); const userCache = new Map(); const logger = core.logging.getChildLogger({ module: "mattermost" }); @@ -493,6 +675,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }, filePathHint: fileId, maxBytes: mediaMaxBytes, + // Allow fetching from the Mattermost server host (may be localhost or + // a private IP). Without this, SSRF guards block media downloads. + // Credit: #22594 (@webclerk) + ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] }, }); const saved = await core.channel.media.saveMediaBuffer( fetched.buffer, @@ -1296,17 +1482,21 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } } - await runWithReconnect(connectOnce, { - abortSignal: opts.abortSignal, - jitterRatio: 0.2, - onError: (err) => { - runtime.error?.(`mattermost connection failed: ${String(err)}`); - opts.statusSink?.({ lastError: String(err), connected: false }); - }, - onReconnect: (delayMs) => { - runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`); - }, - }); + try { + await runWithReconnect(connectOnce, { + abortSignal: opts.abortSignal, + jitterRatio: 0.2, + onError: (err) => { + runtime.error?.(`mattermost connection failed: ${String(err)}`); + opts.statusSink?.({ lastError: String(err), connected: false }); + }, + onReconnect: (delayMs) => { + runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`); + }, + }); + } finally { + unregisterInteractions?.(); + } if (slashShutdownCleanup) { await slashShutdownCleanup; diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index a4a710a41b4..364a4c91744 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { sendMessageMattermost } from "./send.js"; +import { parseMattermostTarget, sendMessageMattermost } from "./send.js"; const mockState = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({})), @@ -12,7 +12,9 @@ const mockState = vi.hoisted(() => ({ createMattermostClient: vi.fn(), createMattermostDirectChannel: vi.fn(), createMattermostPost: vi.fn(), + fetchMattermostChannelByName: vi.fn(), fetchMattermostMe: vi.fn(), + fetchMattermostUserTeams: vi.fn(), fetchMattermostUserByUsername: vi.fn(), normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""), uploadMattermostFile: vi.fn(), @@ -30,7 +32,9 @@ vi.mock("./client.js", () => ({ createMattermostClient: mockState.createMattermostClient, createMattermostDirectChannel: mockState.createMattermostDirectChannel, createMattermostPost: mockState.createMattermostPost, + fetchMattermostChannelByName: mockState.fetchMattermostChannelByName, fetchMattermostMe: mockState.fetchMattermostMe, + fetchMattermostUserTeams: mockState.fetchMattermostUserTeams, fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername, normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl, uploadMattermostFile: mockState.uploadMattermostFile, @@ -71,11 +75,16 @@ describe("sendMessageMattermost", () => { mockState.createMattermostClient.mockReset(); mockState.createMattermostDirectChannel.mockReset(); mockState.createMattermostPost.mockReset(); + mockState.fetchMattermostChannelByName.mockReset(); mockState.fetchMattermostMe.mockReset(); + mockState.fetchMattermostUserTeams.mockReset(); mockState.fetchMattermostUserByUsername.mockReset(); mockState.uploadMattermostFile.mockReset(); mockState.createMattermostClient.mockReturnValue({}); mockState.createMattermostPost.mockResolvedValue({ id: "post-1" }); + mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-user" }); + mockState.fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }]); + mockState.fetchMattermostChannelByName.mockResolvedValue({ id: "town-square" }); mockState.uploadMattermostFile.mockResolvedValue({ id: "file-1" }); }); @@ -148,3 +157,86 @@ describe("sendMessageMattermost", () => { ); }); }); + +describe("parseMattermostTarget", () => { + it("parses channel: prefix with valid ID as channel id", () => { + const target = parseMattermostTarget("channel:dthcxgoxhifn3pwh65cut3ud3w"); + expect(target).toEqual({ kind: "channel", id: "dthcxgoxhifn3pwh65cut3ud3w" }); + }); + + it("parses channel: prefix with non-ID as channel name", () => { + const target = parseMattermostTarget("channel:abc123"); + expect(target).toEqual({ kind: "channel-name", name: "abc123" }); + }); + + it("parses user: prefix as user id", () => { + const target = parseMattermostTarget("user:usr456"); + expect(target).toEqual({ kind: "user", id: "usr456" }); + }); + + it("parses mattermost: prefix as user id", () => { + const target = parseMattermostTarget("mattermost:usr789"); + expect(target).toEqual({ kind: "user", id: "usr789" }); + }); + + it("parses @ prefix as username", () => { + const target = parseMattermostTarget("@alice"); + expect(target).toEqual({ kind: "user", username: "alice" }); + }); + + it("parses # prefix as channel name", () => { + const target = parseMattermostTarget("#off-topic"); + expect(target).toEqual({ kind: "channel-name", name: "off-topic" }); + }); + + it("parses # prefix with spaces", () => { + const target = parseMattermostTarget(" #general "); + expect(target).toEqual({ kind: "channel-name", name: "general" }); + }); + + it("treats 26-char alphanumeric bare string as channel id", () => { + const target = parseMattermostTarget("dthcxgoxhifn3pwh65cut3ud3w"); + expect(target).toEqual({ kind: "channel", id: "dthcxgoxhifn3pwh65cut3ud3w" }); + }); + + it("treats non-ID bare string as channel name", () => { + const target = parseMattermostTarget("off-topic"); + expect(target).toEqual({ kind: "channel-name", name: "off-topic" }); + }); + + it("treats channel: with non-ID value as channel name", () => { + const target = parseMattermostTarget("channel:off-topic"); + expect(target).toEqual({ kind: "channel-name", name: "off-topic" }); + }); + + it("throws on empty string", () => { + expect(() => parseMattermostTarget("")).toThrow("Recipient is required"); + }); + + it("throws on empty # prefix", () => { + expect(() => parseMattermostTarget("#")).toThrow("Channel name is required"); + }); + + it("throws on empty @ prefix", () => { + expect(() => parseMattermostTarget("@")).toThrow("Username is required"); + }); + + it("parses channel:#name as channel name", () => { + const target = parseMattermostTarget("channel:#off-topic"); + expect(target).toEqual({ kind: "channel-name", name: "off-topic" }); + }); + + it("parses channel:#name with spaces", () => { + const target = parseMattermostTarget(" channel: #general "); + expect(target).toEqual({ kind: "channel-name", name: "general" }); + }); + + it("is case-insensitive for prefixes", () => { + expect(parseMattermostTarget("CHANNEL:dthcxgoxhifn3pwh65cut3ud3w")).toEqual({ + kind: "channel", + id: "dthcxgoxhifn3pwh65cut3ud3w", + }); + expect(parseMattermostTarget("User:XYZ")).toEqual({ kind: "user", id: "XYZ" }); + expect(parseMattermostTarget("Mattermost:QRS")).toEqual({ kind: "user", id: "QRS" }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index 6beb18539bd..9011abbd27e 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -5,8 +5,10 @@ import { createMattermostClient, createMattermostDirectChannel, createMattermostPost, + fetchMattermostChannelByName, fetchMattermostMe, fetchMattermostUserByUsername, + fetchMattermostUserTeams, normalizeMattermostBaseUrl, uploadMattermostFile, type MattermostUser, @@ -20,6 +22,7 @@ export type MattermostSendOpts = { mediaUrl?: string; mediaLocalRoots?: readonly string[]; replyToId?: string; + props?: Record; }; export type MattermostSendResult = { @@ -29,10 +32,12 @@ export type MattermostSendResult = { type MattermostTarget = | { kind: "channel"; id: string } + | { kind: "channel-name"; name: string } | { kind: "user"; id?: string; username?: string }; const botUserCache = new Map(); const userByNameCache = new Map(); +const channelByNameCache = new Map(); const getCore = () => getMattermostRuntime(); @@ -50,7 +55,12 @@ function isHttpUrl(value: string): boolean { return /^https?:\/\//i.test(value); } -function parseMattermostTarget(raw: string): MattermostTarget { +/** Mattermost IDs are 26-character lowercase alphanumeric strings. */ +function isMattermostId(value: string): boolean { + return /^[a-z0-9]{26}$/.test(value); +} + +export function parseMattermostTarget(raw: string): MattermostTarget { const trimmed = raw.trim(); if (!trimmed) { throw new Error("Recipient is required for Mattermost sends"); @@ -61,6 +71,16 @@ function parseMattermostTarget(raw: string): MattermostTarget { if (!id) { throw new Error("Channel id is required for Mattermost sends"); } + if (id.startsWith("#")) { + const name = id.slice(1).trim(); + if (!name) { + throw new Error("Channel name is required for Mattermost sends"); + } + return { kind: "channel-name", name }; + } + if (!isMattermostId(id)) { + return { kind: "channel-name", name: id }; + } return { kind: "channel", id }; } if (lower.startsWith("user:")) { @@ -84,6 +104,16 @@ function parseMattermostTarget(raw: string): MattermostTarget { } return { kind: "user", username }; } + if (trimmed.startsWith("#")) { + const name = trimmed.slice(1).trim(); + if (!name) { + throw new Error("Channel name is required for Mattermost sends"); + } + return { kind: "channel-name", name }; + } + if (!isMattermostId(trimmed)) { + return { kind: "channel-name", name: trimmed }; + } return { kind: "channel", id: trimmed }; } @@ -116,6 +146,34 @@ async function resolveUserIdByUsername(params: { return user.id; } +async function resolveChannelIdByName(params: { + baseUrl: string; + token: string; + name: string; +}): Promise { + const { baseUrl, token, name } = params; + const key = `${cacheKey(baseUrl, token)}::channel::${name.toLowerCase()}`; + const cached = channelByNameCache.get(key); + if (cached) { + return cached; + } + const client = createMattermostClient({ baseUrl, botToken: token }); + const me = await fetchMattermostMe(client); + const teams = await fetchMattermostUserTeams(client, me.id); + for (const team of teams) { + try { + const channel = await fetchMattermostChannelByName(client, team.id, name); + if (channel?.id) { + channelByNameCache.set(key, channel.id); + return channel.id; + } + } catch { + // Channel not found in this team, try next + } + } + throw new Error(`Mattermost channel "#${name}" not found in any team the bot belongs to`); +} + async function resolveTargetChannelId(params: { target: MattermostTarget; baseUrl: string; @@ -124,6 +182,13 @@ async function resolveTargetChannelId(params: { if (params.target.kind === "channel") { return params.target.id; } + if (params.target.kind === "channel-name") { + return await resolveChannelIdByName({ + baseUrl: params.baseUrl, + token: params.token, + name: params.target.name, + }); + } const userId = params.target.id ? params.target.id : await resolveUserIdByUsername({ @@ -221,6 +286,7 @@ export async function sendMessageMattermost( message, rootId: opts.replyToId, fileIds, + props: opts.props, }); core.channel.activity.record({ diff --git a/extensions/mattermost/src/normalize.test.ts b/extensions/mattermost/src/normalize.test.ts new file mode 100644 index 00000000000..11d8acb2f73 --- /dev/null +++ b/extensions/mattermost/src/normalize.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; + +describe("normalizeMattermostMessagingTarget", () => { + it("returns undefined for empty input", () => { + expect(normalizeMattermostMessagingTarget("")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget(" ")).toBeUndefined(); + }); + + it("normalizes channel: prefix", () => { + expect(normalizeMattermostMessagingTarget("channel:abc123")).toBe("channel:abc123"); + expect(normalizeMattermostMessagingTarget("Channel:ABC")).toBe("channel:ABC"); + }); + + it("normalizes group: prefix to channel:", () => { + expect(normalizeMattermostMessagingTarget("group:abc123")).toBe("channel:abc123"); + }); + + it("normalizes user: prefix", () => { + expect(normalizeMattermostMessagingTarget("user:abc123")).toBe("user:abc123"); + }); + + it("normalizes mattermost: prefix to user:", () => { + expect(normalizeMattermostMessagingTarget("mattermost:abc123")).toBe("user:abc123"); + }); + + it("keeps @username targets", () => { + expect(normalizeMattermostMessagingTarget("@alice")).toBe("@alice"); + expect(normalizeMattermostMessagingTarget("@Alice")).toBe("@Alice"); + }); + + it("returns undefined for #channel (triggers directory lookup)", () => { + expect(normalizeMattermostMessagingTarget("#bookmarks")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("#off-topic")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("# ")).toBeUndefined(); + }); + + it("returns undefined for bare names (triggers directory lookup)", () => { + expect(normalizeMattermostMessagingTarget("bookmarks")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("off-topic")).toBeUndefined(); + }); + + it("returns undefined for empty prefixed values", () => { + expect(normalizeMattermostMessagingTarget("channel:")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("user:")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("@")).toBeUndefined(); + expect(normalizeMattermostMessagingTarget("#")).toBeUndefined(); + }); +}); + +describe("looksLikeMattermostTargetId", () => { + it("returns false for empty input", () => { + expect(looksLikeMattermostTargetId("")).toBe(false); + expect(looksLikeMattermostTargetId(" ")).toBe(false); + }); + + it("recognizes prefixed targets", () => { + expect(looksLikeMattermostTargetId("channel:abc")).toBe(true); + expect(looksLikeMattermostTargetId("Channel:abc")).toBe(true); + expect(looksLikeMattermostTargetId("user:abc")).toBe(true); + expect(looksLikeMattermostTargetId("group:abc")).toBe(true); + expect(looksLikeMattermostTargetId("mattermost:abc")).toBe(true); + }); + + it("recognizes @username", () => { + expect(looksLikeMattermostTargetId("@alice")).toBe(true); + }); + + it("does NOT recognize #channel (should go to directory)", () => { + expect(looksLikeMattermostTargetId("#bookmarks")).toBe(false); + expect(looksLikeMattermostTargetId("#off-topic")).toBe(false); + }); + + it("recognizes 26-char alphanumeric Mattermost IDs", () => { + expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz")).toBe(true); + expect(looksLikeMattermostTargetId("12345678901234567890123456")).toBe(true); + expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true); + }); + + it("recognizes DM channel format (26__26)", () => { + expect( + looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"), + ).toBe(true); + }); + + it("rejects short strings that are not Mattermost IDs", () => { + expect(looksLikeMattermostTargetId("password")).toBe(false); + expect(looksLikeMattermostTargetId("hi")).toBe(false); + expect(looksLikeMattermostTargetId("bookmarks")).toBe(false); + expect(looksLikeMattermostTargetId("off-topic")).toBe(false); + }); + + it("rejects strings longer than 26 chars that are not DM format", () => { + expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false); + }); +}); diff --git a/extensions/mattermost/src/normalize.ts b/extensions/mattermost/src/normalize.ts index d8a8ee967b7..25e3dfcc8b9 100644 --- a/extensions/mattermost/src/normalize.ts +++ b/extensions/mattermost/src/normalize.ts @@ -25,13 +25,16 @@ export function normalizeMattermostMessagingTarget(raw: string): string | undefi return id ? `@${id}` : undefined; } if (trimmed.startsWith("#")) { - const id = trimmed.slice(1).trim(); - return id ? `channel:${id}` : undefined; + // Strip # prefix and fall through to directory lookup (same as bare names). + // The core's resolveMessagingTarget will use the directory adapter to + // resolve the channel name to its Mattermost ID. + return undefined; } - return `channel:${trimmed}`; + // Bare name without prefix — return undefined to allow directory lookup + return undefined; } -export function looksLikeMattermostTargetId(raw: string): boolean { +export function looksLikeMattermostTargetId(raw: string, normalized?: string): boolean { const trimmed = raw.trim(); if (!trimmed) { return false; @@ -39,8 +42,9 @@ export function looksLikeMattermostTargetId(raw: string): boolean { if (/^(user|channel|group|mattermost):/i.test(trimmed)) { return true; } - if (/^[@#]/.test(trimmed)) { + if (trimmed.startsWith("@")) { return true; } - return /^[a-z0-9]{8,}$/i.test(trimmed); + // Mattermost IDs: 26-char alnum, or DM channels like "abc123__xyz789" (53 chars) + return /^[a-z0-9]{26}$/i.test(trimmed) || /^[a-z0-9]{26}__[a-z0-9]{26}$/i.test(trimmed); } diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 5de38e7833c..6cd09934995 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -70,6 +70,10 @@ export type MattermostAccountConfig = { /** Explicit callback URL (e.g. behind reverse proxy). */ callbackUrl?: string; }; + interactions?: { + /** External base URL used for Mattermost interaction callbacks. */ + callbackBaseUrl?: string; + }; }; export type MattermostConfig = { diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 7afe2890d7b..9b3619bc581 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -38,6 +38,7 @@ export type { ChannelMessageActionAdapter, ChannelMessageActionName, } from "../channels/plugins/types.js"; +export type { ChannelDirectoryEntry } from "../channels/plugins/types.core.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; export { createTypingCallbacks } from "../channels/typing.js"; @@ -64,6 +65,7 @@ export { } from "../config/zod-schema.core.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { rawDataToString } from "../infra/ws.js"; +export { registerPluginHttpRoute } from "../plugins/http-registry.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js";