From 4f08dcccfd7130bb8853072e26284820bd61ff2c Mon Sep 17 00:00:00 2001 From: Muhammed Mukhthar CM <56378562+mukhtharcm@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:45:29 +0530 Subject: [PATCH] Mattermost: add interactive model picker (#38767) Merged via squash. Prepared head SHA: 0883654e887b1176fc9299370b4ef5a351f5ac9d Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com> Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com> Reviewed-by: @mukhtharcm --- CHANGELOG.md | 1 + extensions/mattermost/src/channel.ts | 60 +-- extensions/mattermost/src/config-schema.ts | 1 + .../src/mattermost/interactions.test.ts | 186 +++++++- .../mattermost/src/mattermost/interactions.ts | 154 ++++++- .../src/mattermost/model-picker.test.ts | 155 +++++++ .../mattermost/src/mattermost/model-picker.ts | 383 ++++++++++++++++ .../mattermost/src/mattermost/monitor-auth.ts | 241 ++++++++++ .../src/mattermost/monitor.authz.test.ts | 86 +++- .../mattermost/src/mattermost/monitor.ts | 424 +++++++++++++++++- .../mattermost/src/mattermost/send.test.ts | 26 ++ extensions/mattermost/src/mattermost/send.ts | 31 +- .../src/mattermost/slash-commands.test.ts | 10 + .../src/mattermost/slash-commands.ts | 7 + .../mattermost/src/mattermost/slash-http.ts | 304 ++++--------- extensions/mattermost/src/types.ts | 5 + src/auto-reply/reply/commands-models.ts | 17 +- src/auto-reply/reply/commands.test.ts | 22 + src/discord/monitor/model-picker.test.ts | 6 +- src/discord/monitor/model-picker.ts | 7 +- src/discord/monitor/native-command.ts | 4 +- src/plugin-sdk/mattermost.ts | 8 + src/telegram/bot-handlers.ts | 19 +- 23 files changed, 1867 insertions(+), 290 deletions(-) create mode 100644 extensions/mattermost/src/mattermost/model-picker.test.ts create mode 100644 extensions/mattermost/src/mattermost/model-picker.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 990068f96b3..0180b4fa219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind. - Config/Compaction safeguard tuning: expose `agents.defaults.compaction.recentTurnsPreserve` and quality-guard retry knobs through the validated config surface and embedded-runner wiring, with regression coverage for real config loading and schema metadata. (#25557) thanks @rodrigouroz. - iOS/App Store Connect release prep: align iOS bundle identifiers under `ai.openclaw.client`, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman. +- Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm. ### Breaking diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 16df4f2ebcd..b9f5a3bc85d 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -26,21 +26,14 @@ 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"; -import { resolveMattermostSendChannelId, sendMessageMattermost } from "./mattermost/send.js"; +import { sendMessageMattermost } from "./mattermost/send.js"; import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; import { mattermostOnboardingAdapter } from "./onboarding.js"; import { getMattermostRuntime } from "./runtime.js"; -const SIGNED_CHANNEL_ID_CONTEXT_KEY = "__openclaw_channel_id"; - const mattermostMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { const enabledAccounts = listMattermostAccountIds(cfg) @@ -162,61 +155,14 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { 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 channelId = await resolveMattermostSendChannelId(to, { - cfg, - accountId: account.accountId, - }); - const callbackUrl = resolveInteractionCallbackUrl(account.accountId, { - gateway: cfg.gateway, - interactions: account.config.interactions, - }); - - // 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), - [SIGNED_CHANNEL_ID_CONTEXT_KEY]: channelId, - } - : { [SIGNED_CHANNEL_ID_CONTEXT_KEY]: channelId }, - })) - .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, + buttons: Array.isArray(params.buttons) ? params.buttons : undefined, + attachmentText: typeof params.attachmentText === "string" ? params.attachmentText : undefined, mediaUrl, }); diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 12acabf5b7d..51d9bdbe33a 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -53,6 +53,7 @@ const MattermostAccountSchemaBase = z interactions: z .object({ callbackBaseUrl: z.string().optional(), + allowedSourceIps: z.array(z.string()).optional(), }) .optional(), }) diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index 9da60273d63..40715bf885e 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -1,5 +1,5 @@ import { type IncomingMessage, type ServerResponse } from "node:http"; -import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { setMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; import type { MattermostClient } from "./client.js"; @@ -109,6 +109,53 @@ describe("generateInteractionToken / verifyInteractionToken", () => { expect(verifyInteractionToken(reorderedContext, token)).toBe(true); }); + it("verifies nested context regardless of nested key order", () => { + const originalContext = { + action_id: "nested", + payload: { + model: "gpt-5", + meta: { + provider: "openai", + page: 2, + }, + }, + }; + const token = generateInteractionToken(originalContext); + + const reorderedContext = { + payload: { + meta: { + page: 2, + provider: "openai", + }, + model: "gpt-5", + }, + action_id: "nested", + }; + + expect(verifyInteractionToken(reorderedContext, token)).toBe(true); + }); + + it("rejects nested context tampering", () => { + const originalContext = { + action_id: "nested", + payload: { + provider: "openai", + model: "gpt-5", + }, + }; + const token = generateInteractionToken(originalContext); + const tamperedContext = { + action_id: "nested", + payload: { + provider: "anthropic", + model: "gpt-5", + }, + }; + + expect(verifyInteractionToken(tamperedContext, token)).toBe(false); + }); + it("scopes tokens per account when account secrets differ", () => { setInteractionSecret("acct-a", "bot-token-a"); setInteractionSecret("acct-b", "bot-token-b"); @@ -400,12 +447,14 @@ describe("createMattermostInteractionHandler", () => { method?: string; body?: unknown; remoteAddress?: string; + headers?: Record; }): IncomingMessage { const body = params.body === undefined ? "" : JSON.stringify(params.body); const listeners = new Map void>>(); const req = { method: params.method ?? "POST", + headers: params.headers ?? {}, socket: { remoteAddress: params.remoteAddress ?? "203.0.113.10" }, on(event: string, handler: (...args: unknown[]) => void) { const existing = listeners.get(event) ?? []; @@ -447,7 +496,7 @@ describe("createMattermostInteractionHandler", () => { return res as unknown as ServerResponse & { headers: Record; body: string }; } - it("accepts non-localhost requests when the interaction token is valid", async () => { + it("accepts callback requests from an allowlisted source IP", async () => { const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; const token = generateInteractionToken(context, "acct"); const requestLog: Array<{ path: string; method?: string }> = []; @@ -469,6 +518,7 @@ describe("createMattermostInteractionHandler", () => { } as unknown as MattermostClient, botUserId: "bot", accountId: "acct", + allowedSourceIps: ["198.51.100.8"], }); const req = createReq({ @@ -493,6 +543,80 @@ describe("createMattermostInteractionHandler", () => { ]); }); + it("accepts forwarded Mattermost source IPs from a trusted proxy", async () => { + const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; + const token = generateInteractionToken(context, "acct"); + const handler = createMattermostInteractionHandler({ + client: { + request: async (_path: string, init?: { method?: string }) => { + if (init?.method === "PUT") { + return { id: "post-1" }; + } + return { + channel_id: "chan-1", + message: "Choose", + props: { + attachments: [{ actions: [{ id: "approve", name: "Approve" }] }], + }, + }; + }, + } as unknown as MattermostClient, + botUserId: "bot", + accountId: "acct", + allowedSourceIps: ["198.51.100.8"], + trustedProxies: ["127.0.0.1"], + }); + + const req = createReq({ + remoteAddress: "127.0.0.1", + headers: { "x-forwarded-for": "198.51.100.8" }, + body: { + user_id: "user-1", + user_name: "alice", + channel_id: "chan-1", + post_id: "post-1", + context: { ...context, _token: token }, + }, + }); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toBe("{}"); + }); + + it("rejects callback requests from non-allowlisted source IPs", async () => { + const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; + const token = generateInteractionToken(context, "acct"); + const handler = createMattermostInteractionHandler({ + client: { + request: async () => { + throw new Error("should not fetch post for rejected origins"); + }, + } as unknown as MattermostClient, + botUserId: "bot", + accountId: "acct", + allowedSourceIps: ["127.0.0.1"], + }); + + const req = createReq({ + remoteAddress: "198.51.100.8", + body: { + user_id: "user-1", + channel_id: "chan-1", + post_id: "post-1", + context: { ...context, _token: token }, + }, + }); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toContain("Forbidden origin"); + }); + it("rejects requests with an invalid interaction token", async () => { const handler = createMattermostInteractionHandler({ client: { @@ -610,4 +734,62 @@ describe("createMattermostInteractionHandler", () => { expect(res.statusCode).toBe(403); expect(res.body).toContain("Unknown action"); }); + + it("lets a custom interaction handler short-circuit generic completion updates", async () => { + const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" }; + const token = generateInteractionToken(context, "acct"); + const requestLog: Array<{ path: string; method?: string }> = []; + const handleInteraction = vi.fn().mockResolvedValue({ + ephemeral_text: "Only the original requester can use this picker.", + }); + const dispatchButtonClick = vi.fn(); + const handler = createMattermostInteractionHandler({ + client: { + request: async (path: string, init?: { method?: string }) => { + requestLog.push({ path, method: init?.method }); + return { + channel_id: "chan-1", + message: "Choose", + props: { + attachments: [{ actions: [{ id: "mdlprov", name: "Browse providers" }] }], + }, + }; + }, + } as unknown as MattermostClient, + botUserId: "bot", + accountId: "acct", + handleInteraction, + dispatchButtonClick, + }); + + const req = createReq({ + body: { + user_id: "user-2", + user_name: "alice", + channel_id: "chan-1", + post_id: "post-1", + context: { ...context, _token: token }, + }, + }); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toBe( + JSON.stringify({ + ephemeral_text: "Only the original requester can use this picker.", + }), + ); + expect(requestLog).toEqual([{ path: "/posts/post-1", method: undefined }]); + expect(handleInteraction).toHaveBeenCalledWith( + expect.objectContaining({ + actionId: "mdlprov", + actionName: "Browse providers", + originalMessage: "Choose", + userName: "alice", + }), + ); + expect(dispatchButtonClick).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index 5ca911fbeb6..ee763a12a6d 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -1,6 +1,10 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { + isTrustedProxyAddress, + resolveClientIp, + type OpenClawConfig, +} from "openclaw/plugin-sdk/mattermost"; import { getMattermostRuntime } from "../runtime.js"; import { updateMattermostPost, type MattermostClient } from "./client.js"; @@ -33,6 +37,16 @@ export type MattermostInteractionResponse = { ephemeral_text?: string; }; +export type MattermostInteractiveButtonInput = { + id?: string; + callback_data?: string; + text?: string; + name?: string; + label?: string; + style?: "default" | "primary" | "danger"; + context?: Record; +}; + // ── Callback URL registry ────────────────────────────────────────────── const callbackUrls = new Map(); @@ -66,6 +80,34 @@ function normalizeCallbackBaseUrl(baseUrl: string): string { return baseUrl.trim().replace(/\/+$/, ""); } +function headerValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]?.trim() || undefined; + } + return value?.trim() || undefined; +} + +function isAllowedInteractionSource(params: { + req: IncomingMessage; + allowedSourceIps?: string[]; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; +}): boolean { + const { allowedSourceIps } = params; + if (!allowedSourceIps?.length) { + return true; + } + + const clientIp = resolveClientIp({ + remoteAddr: params.req.socket?.remoteAddress, + forwardedFor: headerValue(params.req.headers["x-forwarded-for"]), + realIp: headerValue(params.req.headers["x-real-ip"]), + trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, + }); + return isTrustedProxyAddress(clientIp, allowedSourceIps); +} + /** * Resolve the interaction callback URL for an account. * Falls back to computing it from interactions.callbackBaseUrl or gateway host config. @@ -152,13 +194,26 @@ export function getInteractionSecret(accountId?: string): string { ); } +function canonicalizeInteractionContext(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => canonicalizeInteractionContext(item)); + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => [key, canonicalizeInteractionContext(entryValue)]); + return Object.fromEntries(entries); + } + return value; +} + 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()); + const payload = JSON.stringify(canonicalizeInteractionContext(context)); return createHmac("sha256", secret).update(payload).digest("hex"); } @@ -251,6 +306,46 @@ export function buildButtonAttachments(params: { ]; } +export function buildButtonProps(params: { + callbackUrl: string; + accountId?: string; + channelId: string; + buttons: Array; + text?: string; +}): Record | undefined { + const rawButtons = params.buttons.flatMap((item) => + Array.isArray(item) ? item : [item], + ) as MattermostInteractiveButtonInput[]; + + const buttons = rawButtons + .map((btn) => ({ + id: String(btn.id ?? btn.callback_data ?? "").trim(), + name: String(btn.text ?? btn.name ?? btn.label ?? "").trim(), + style: btn.style ?? "default", + context: + typeof btn.context === "object" && btn.context !== null + ? { + ...btn.context, + [SIGNED_CHANNEL_ID_CONTEXT_KEY]: params.channelId, + } + : { [SIGNED_CHANNEL_ID_CONTEXT_KEY]: params.channelId }, + })) + .filter((btn) => btn.id && btn.name); + + if (buttons.length === 0) { + return undefined; + } + + return { + attachments: buildButtonAttachments({ + callbackUrl: params.callbackUrl, + accountId: params.accountId, + buttons, + text: params.text, + }), + }; +} + // ── Request body reader ──────────────────────────────────────────────── function readInteractionBody(req: IncomingMessage): Promise { @@ -292,7 +387,18 @@ export function createMattermostInteractionHandler(params: { client: MattermostClient; botUserId: string; accountId: string; + allowedSourceIps?: string[]; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; resolveSessionKey?: (channelId: string, userId: string) => Promise; + handleInteraction?: (opts: { + payload: MattermostInteractionPayload; + userName: string; + actionId: string; + actionName: string; + originalMessage: string; + context: Record; + }) => Promise; dispatchButtonClick?: (opts: { channelId: string; userId: string; @@ -316,6 +422,23 @@ export function createMattermostInteractionHandler(params: { return; } + if ( + !isAllowedInteractionSource({ + req, + allowedSourceIps: params.allowedSourceIps, + trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, + }) + ) { + log?.( + `mattermost interaction: rejected callback source remote=${req.socket?.remoteAddress ?? "?"}`, + ); + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Forbidden origin" })); + return; + } + let payload: MattermostInteractionPayload; try { const raw = await readInteractionBody(req); @@ -432,6 +555,31 @@ export function createMattermostInteractionHandler(params: { `post=${payload.post_id} channel=${payload.channel_id}`, ); + if (params.handleInteraction) { + try { + const response = await params.handleInteraction({ + payload, + userName, + actionId, + actionName: clickedButtonName, + originalMessage, + context: contextWithoutToken, + }); + if (response !== null) { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(response)); + return; + } + } catch (err) { + log?.(`mattermost interaction: custom handler failed: ${String(err)}`); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Interaction handler failed" })); + return; + } + } + // 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). diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts new file mode 100644 index 00000000000..b448339523e --- /dev/null +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -0,0 +1,155 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { buildModelsProviderData } from "openclaw/plugin-sdk/mattermost"; +import { describe, expect, it } from "vitest"; +import { + buildMattermostAllowedModelRefs, + parseMattermostModelPickerContext, + renderMattermostModelSummaryView, + renderMattermostModelsPickerView, + renderMattermostProviderPickerView, + resolveMattermostModelPickerCurrentModel, + resolveMattermostModelPickerEntry, +} from "./model-picker.js"; + +const data = { + byProvider: new Map>([ + ["anthropic", new Set(["claude-opus-4-5", "claude-sonnet-4-5"])], + ["openai", new Set(["gpt-4.1", "gpt-5"])], + ]), + providers: ["anthropic", "openai"], + resolvedDefault: { + provider: "anthropic", + model: "claude-opus-4-5", + }, +}; + +describe("Mattermost model picker", () => { + it("resolves bare /model and /models entry points", () => { + expect(resolveMattermostModelPickerEntry("/model")).toEqual({ kind: "summary" }); + expect(resolveMattermostModelPickerEntry("/models")).toEqual({ kind: "providers" }); + expect(resolveMattermostModelPickerEntry("/models OpenAI")).toEqual({ + kind: "models", + provider: "openai", + }); + expect(resolveMattermostModelPickerEntry("/model openai/gpt-5")).toBeNull(); + }); + + it("builds the allowed model refs set", () => { + expect(buildMattermostAllowedModelRefs(data)).toEqual( + new Set([ + "anthropic/claude-opus-4-5", + "anthropic/claude-sonnet-4-5", + "openai/gpt-4.1", + "openai/gpt-5", + ]), + ); + }); + + it("renders the summary view with a browse button", () => { + const view = renderMattermostModelSummaryView({ + ownerUserId: "user-1", + currentModel: "openai/gpt-5", + }); + + expect(view.text).toContain("Current: openai/gpt-5"); + expect(view.text).toContain("Tap below to browse models"); + expect(view.text).toContain("/oc_model to switch"); + expect(view.buttons[0]?.[0]?.text).toBe("Browse providers"); + }); + + it("renders providers and models with Telegram-style navigation", () => { + const providersView = renderMattermostProviderPickerView({ + ownerUserId: "user-1", + data, + currentModel: "openai/gpt-5", + }); + const providerTexts = providersView.buttons.flat().map((button) => button.text); + expect(providerTexts).toContain("anthropic (2)"); + expect(providerTexts).toContain("openai (2)"); + + const modelsView = renderMattermostModelsPickerView({ + ownerUserId: "user-1", + data, + provider: "openai", + page: 1, + currentModel: "openai/gpt-5", + }); + const modelTexts = modelsView.buttons.flat().map((button) => button.text); + expect(modelsView.text).toContain("Models (openai) - 2 available"); + expect(modelTexts).toContain("gpt-5 [current]"); + expect(modelTexts).toContain("Back to providers"); + }); + + it("renders unique alphanumeric action ids per button", () => { + const modelsView = renderMattermostModelsPickerView({ + ownerUserId: "user-1", + data, + provider: "openai", + page: 1, + currentModel: "openai/gpt-5", + }); + + const ids = modelsView.buttons.flat().map((button) => button.id); + expect(ids.every((id) => typeof id === "string" && /^[a-z0-9]+$/.test(id))).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("parses signed picker contexts", () => { + expect( + parseMattermostModelPickerContext({ + oc_model_picker: true, + action: "select", + ownerUserId: "user-1", + provider: "openai", + page: 2, + model: "gpt-5", + }), + ).toEqual({ + action: "select", + ownerUserId: "user-1", + provider: "openai", + page: 2, + model: "gpt-5", + }); + expect(parseMattermostModelPickerContext({ action: "select" })).toBeNull(); + }); + + it("falls back to the routed agent default model when no override is stored", async () => { + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-model-picker-")); + try { + const cfg: OpenClawConfig = { + session: { + store: path.join(testDir, "{agentId}.json"), + }, + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + }, + list: [ + { + id: "support", + model: "openai/gpt-5", + }, + ], + }, + }; + const providerData = await buildModelsProviderData(cfg, "support"); + + expect( + resolveMattermostModelPickerCurrentModel({ + cfg, + route: { + agentId: "support", + sessionKey: "agent:support:main", + }, + data: providerData, + }), + ).toBe("openai/gpt-5"); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts new file mode 100644 index 00000000000..42462180901 --- /dev/null +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -0,0 +1,383 @@ +import { createHash } from "node:crypto"; +import { + loadSessionStore, + normalizeProviderId, + resolveStorePath, + resolveStoredModelOverride, + type ModelsProviderData, + type OpenClawConfig, +} from "openclaw/plugin-sdk/mattermost"; +import type { MattermostInteractiveButtonInput } from "./interactions.js"; + +const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker"; +const MODELS_PAGE_SIZE = 8; +const ACTION_IDS = { + providers: "mdlprov", + list: "mdllist", + select: "mdlsel", + back: "mdlback", +} as const; + +export type MattermostModelPickerEntry = + | { kind: "summary" } + | { kind: "providers" } + | { kind: "models"; provider: string }; + +export type MattermostModelPickerState = + | { action: "providers"; ownerUserId: string } + | { action: "back"; ownerUserId: string } + | { action: "list"; ownerUserId: string; provider: string; page: number } + | { action: "select"; ownerUserId: string; provider: string; page: number; model: string }; + +export type MattermostModelPickerRenderedView = { + text: string; + buttons: MattermostInteractiveButtonInput[][]; +}; + +function splitModelRef(modelRef?: string | null): { provider: string; model: string } | null { + const trimmed = modelRef?.trim(); + if (!trimmed) { + return null; + } + const slashIndex = trimmed.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) { + return null; + } + const provider = normalizeProviderId(trimmed.slice(0, slashIndex)); + const model = trimmed.slice(slashIndex + 1).trim(); + if (!provider || !model) { + return null; + } + return { provider, model }; +} + +function normalizePage(value: number | undefined): number { + if (!Number.isFinite(value)) { + return 1; + } + return Math.max(1, Math.floor(value as number)); +} + +function paginateItems(items: T[], page?: number, pageSize = MODELS_PAGE_SIZE) { + const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); + const safePage = Math.max(1, Math.min(normalizePage(page), totalPages)); + const start = (safePage - 1) * pageSize; + return { + items: items.slice(start, start + pageSize), + page: safePage, + totalPages, + hasPrev: safePage > 1, + hasNext: safePage < totalPages, + totalItems: items.length, + }; +} + +function buildContext(state: MattermostModelPickerState): Record { + return { + [MATTERMOST_MODEL_PICKER_CONTEXT_KEY]: true, + ...state, + }; +} + +function buildButtonId(state: MattermostModelPickerState): string { + const digest = createHash("sha256").update(JSON.stringify(state)).digest("hex").slice(0, 12); + return `${ACTION_IDS[state.action]}${digest}`; +} + +function buildButton(params: { + action: MattermostModelPickerState["action"]; + ownerUserId: string; + text: string; + provider?: string; + page?: number; + model?: string; + style?: "default" | "primary" | "danger"; +}): MattermostInteractiveButtonInput { + const baseState = + params.action === "providers" || params.action === "back" + ? { + action: params.action, + ownerUserId: params.ownerUserId, + } + : params.action === "list" + ? { + action: "list" as const, + ownerUserId: params.ownerUserId, + provider: normalizeProviderId(params.provider ?? ""), + page: normalizePage(params.page), + } + : { + action: "select" as const, + ownerUserId: params.ownerUserId, + provider: normalizeProviderId(params.provider ?? ""), + page: normalizePage(params.page), + model: String(params.model ?? "").trim(), + }; + + return { + // Mattermost requires action IDs to be unique within a post. + id: buildButtonId(baseState), + text: params.text, + ...(params.style ? { style: params.style } : {}), + context: buildContext(baseState), + }; +} + +function getProviderModels(data: ModelsProviderData, provider: string): string[] { + return [...(data.byProvider.get(normalizeProviderId(provider)) ?? new Set())].toSorted(); +} + +function formatCurrentModelLine(currentModel?: string): string { + const parsed = splitModelRef(currentModel); + if (!parsed) { + return "Current: default"; + } + return `Current: ${parsed.provider}/${parsed.model}`; +} + +export function resolveMattermostModelPickerEntry( + commandText: string, +): MattermostModelPickerEntry | null { + const normalized = commandText.trim().replace(/\s+/g, " "); + if (/^\/model$/i.test(normalized)) { + return { kind: "summary" }; + } + if (/^\/models$/i.test(normalized)) { + return { kind: "providers" }; + } + const providerMatch = normalized.match(/^\/models\s+(\S+)$/i); + if (!providerMatch?.[1]) { + return null; + } + return { + kind: "models", + provider: normalizeProviderId(providerMatch[1]), + }; +} + +export function parseMattermostModelPickerContext( + context: Record, +): MattermostModelPickerState | null { + if (!context || context[MATTERMOST_MODEL_PICKER_CONTEXT_KEY] !== true) { + return null; + } + + const ownerUserId = String(context.ownerUserId ?? "").trim(); + const action = String(context.action ?? "").trim(); + if (!ownerUserId) { + return null; + } + + if (action === "providers" || action === "back") { + return { action, ownerUserId }; + } + + const provider = normalizeProviderId(String(context.provider ?? "")); + const page = Number.parseInt(String(context.page ?? "1"), 10); + if (!provider) { + return null; + } + + if (action === "list") { + return { + action, + ownerUserId, + provider, + page: normalizePage(page), + }; + } + + if (action === "select") { + const model = String(context.model ?? "").trim(); + if (!model) { + return null; + } + return { + action, + ownerUserId, + provider, + page: normalizePage(page), + model, + }; + } + + return null; +} + +export function buildMattermostAllowedModelRefs(data: ModelsProviderData): Set { + const refs = new Set(); + for (const provider of data.providers) { + for (const model of data.byProvider.get(provider) ?? []) { + refs.add(`${provider}/${model}`); + } + } + return refs; +} + +export function resolveMattermostModelPickerCurrentModel(params: { + cfg: OpenClawConfig; + route: { agentId: string; sessionKey: string }; + data: ModelsProviderData; + skipCache?: boolean; +}): string { + const fallback = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`; + try { + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: params.route.agentId, + }); + const sessionStore = params.skipCache + ? loadSessionStore(storePath, { skipCache: true }) + : loadSessionStore(storePath); + const sessionEntry = sessionStore[params.route.sessionKey]; + const override = resolveStoredModelOverride({ + sessionEntry, + sessionStore, + sessionKey: params.route.sessionKey, + }); + if (!override?.model) { + return fallback; + } + const provider = (override.provider || params.data.resolvedDefault.provider).trim(); + return provider ? `${provider}/${override.model}` : fallback; + } catch { + return fallback; + } +} + +export function renderMattermostModelSummaryView(params: { + ownerUserId: string; + currentModel?: string; +}): MattermostModelPickerRenderedView { + return { + text: [ + formatCurrentModelLine(params.currentModel), + "", + "Tap below to browse models, or use:", + "/oc_model to switch", + "/oc_model status for details", + ].join("\n"), + buttons: [ + [ + buildButton({ + action: "providers", + ownerUserId: params.ownerUserId, + text: "Browse providers", + style: "primary", + }), + ], + ], + }; +} + +export function renderMattermostProviderPickerView(params: { + ownerUserId: string; + data: ModelsProviderData; + currentModel?: string; +}): MattermostModelPickerRenderedView { + const currentProvider = splitModelRef(params.currentModel)?.provider; + const rows = params.data.providers.map((provider) => [ + buildButton({ + action: "list", + ownerUserId: params.ownerUserId, + text: `${provider} (${params.data.byProvider.get(provider)?.size ?? 0})`, + provider, + page: 1, + style: provider === currentProvider ? "primary" : "default", + }), + ]); + + return { + text: [formatCurrentModelLine(params.currentModel), "", "Select a provider:"].join("\n"), + buttons: rows, + }; +} + +export function renderMattermostModelsPickerView(params: { + ownerUserId: string; + data: ModelsProviderData; + provider: string; + page?: number; + currentModel?: string; +}): MattermostModelPickerRenderedView { + const provider = normalizeProviderId(params.provider); + const models = getProviderModels(params.data, provider); + const current = splitModelRef(params.currentModel); + + if (models.length === 0) { + return { + text: [formatCurrentModelLine(params.currentModel), "", `Unknown provider: ${provider}`].join( + "\n", + ), + buttons: [ + [ + buildButton({ + action: "back", + ownerUserId: params.ownerUserId, + text: "Back to providers", + }), + ], + ], + }; + } + + const page = paginateItems(models, params.page); + const rows: MattermostInteractiveButtonInput[][] = page.items.map((model) => { + const isCurrent = current?.provider === provider && current.model === model; + return [ + buildButton({ + action: "select", + ownerUserId: params.ownerUserId, + text: isCurrent ? `${model} [current]` : model, + provider, + model, + page: page.page, + style: isCurrent ? "primary" : "default", + }), + ]; + }); + + const navRow: MattermostInteractiveButtonInput[] = []; + if (page.hasPrev) { + navRow.push( + buildButton({ + action: "list", + ownerUserId: params.ownerUserId, + text: "Prev", + provider, + page: page.page - 1, + }), + ); + } + if (page.hasNext) { + navRow.push( + buildButton({ + action: "list", + ownerUserId: params.ownerUserId, + text: "Next", + provider, + page: page.page + 1, + }), + ); + } + if (navRow.length > 0) { + rows.push(navRow); + } + + rows.push([ + buildButton({ + action: "back", + ownerUserId: params.ownerUserId, + text: "Back to providers", + }), + ]); + + return { + text: [ + `Models (${provider}) - ${page.totalItems} available`, + formatCurrentModelLine(params.currentModel), + `Page ${page.page}/${page.totalPages}`, + "Select a model to switch immediately.", + ].join("\n"), + buttons: rows, + }; +} diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts index 1685d4b560a..530502f9101 100644 --- a/extensions/mattermost/src/mattermost/monitor-auth.ts +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -1,7 +1,12 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; import { + isDangerousNameMatchingEnabled, resolveAllowlistMatchSimple, + resolveControlCommandGate, resolveEffectiveAllowFromLists, } from "openclaw/plugin-sdk/mattermost"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import type { MattermostChannel } from "./client.js"; export function normalizeMattermostAllowEntry(entry: string): string { const trimmed = entry.trim(); @@ -59,3 +64,239 @@ export function isMattermostSenderAllowed(params: { }); return match.allowed; } + +function mapMattermostChannelKind(channelType?: string | null): "direct" | "group" | "channel" { + const normalized = channelType?.trim().toUpperCase(); + if (normalized === "D") { + return "direct"; + } + if (normalized === "G" || normalized === "P") { + return "group"; + } + return "channel"; +} + +export type MattermostCommandAuthDecision = + | { + ok: true; + commandAuthorized: boolean; + channelInfo: MattermostChannel; + kind: "direct" | "group" | "channel"; + chatType: "direct" | "group" | "channel"; + channelName: string; + channelDisplay: string; + roomLabel: string; + } + | { + ok: false; + denyReason: + | "unknown-channel" + | "dm-disabled" + | "dm-pairing" + | "unauthorized" + | "channels-disabled" + | "channel-no-allowlist"; + commandAuthorized: false; + channelInfo: MattermostChannel | null; + kind: "direct" | "group" | "channel"; + chatType: "direct" | "group" | "channel"; + channelName: string; + channelDisplay: string; + roomLabel: string; + }; + +export function authorizeMattermostCommandInvocation(params: { + account: ResolvedMattermostAccount; + cfg: OpenClawConfig; + senderId: string; + senderName: string; + channelId: string; + channelInfo: MattermostChannel | null; + storeAllowFrom?: Array | null; + allowTextCommands: boolean; + hasControlCommand: boolean; +}): MattermostCommandAuthDecision { + const { + account, + cfg, + senderId, + senderName, + channelId, + channelInfo, + storeAllowFrom, + allowTextCommands, + hasControlCommand, + } = params; + + if (!channelInfo) { + return { + ok: false, + denyReason: "unknown-channel", + commandAuthorized: false, + channelInfo: null, + kind: "channel", + chatType: "channel", + channelName: "", + channelDisplay: "", + roomLabel: `#${channelId}`, + }; + } + + const kind = mapMattermostChannelKind(channelInfo.type); + const chatType = kind; + const channelName = channelInfo.name ?? ""; + const channelDisplay = channelInfo.display_name ?? channelName; + const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`; + + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); + const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); + const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []); + const normalizedStoreAllowFrom = normalizeMattermostAllowList(storeAllowFrom ?? []); + const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({ + allowFrom: configAllowFrom, + groupAllowFrom: configGroupAllowFrom, + storeAllowFrom: normalizedStoreAllowFrom, + dmPolicy, + }); + + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom; + const commandGroupAllowFrom = + kind === "direct" + ? effectiveGroupAllowFrom + : configGroupAllowFrom.length > 0 + ? configGroupAllowFrom + : configAllowFrom; + + const senderAllowedForCommands = isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom: commandDmAllowFrom, + allowNameMatching, + }); + const groupAllowedForCommands = isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom: commandGroupAllowFrom, + allowNameMatching, + }); + + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { + configured: commandGroupAllowFrom.length > 0, + allowed: groupAllowedForCommands, + }, + ], + allowTextCommands, + hasControlCommand: allowTextCommands && hasControlCommand, + }); + + const commandAuthorized = + kind === "direct" + ? dmPolicy === "open" || senderAllowedForCommands + : commandGate.commandAuthorized; + + if (kind === "direct") { + if (dmPolicy === "disabled") { + return { + ok: false, + denyReason: "dm-disabled", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + + if (dmPolicy !== "open" && !senderAllowedForCommands) { + return { + ok: false, + denyReason: dmPolicy === "pairing" ? "dm-pairing" : "unauthorized", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + } else { + if (groupPolicy === "disabled") { + return { + ok: false, + denyReason: "channels-disabled", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + + if (groupPolicy === "allowlist") { + if (effectiveGroupAllowFrom.length === 0) { + return { + ok: false, + denyReason: "channel-no-allowlist", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + if (!groupAllowedForCommands) { + return { + ok: false, + denyReason: "unauthorized", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + } + + if (commandGate.shouldBlock) { + return { + ok: false, + denyReason: "unauthorized", + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; + } + } + + return { + ok: true, + commandAuthorized, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; +} diff --git a/extensions/mattermost/src/mattermost/monitor.authz.test.ts b/extensions/mattermost/src/mattermost/monitor.authz.test.ts index 065904f373c..92fd0a3c3f4 100644 --- a/extensions/mattermost/src/mattermost/monitor.authz.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.authz.test.ts @@ -1,6 +1,20 @@ import { resolveControlCommandGate } from "openclaw/plugin-sdk/mattermost"; import { describe, expect, it } from "vitest"; -import { resolveMattermostEffectiveAllowFromLists } from "./monitor-auth.js"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import { + authorizeMattermostCommandInvocation, + resolveMattermostEffectiveAllowFromLists, +} from "./monitor-auth.js"; + +const accountFixture: ResolvedMattermostAccount = { + accountId: "default", + enabled: true, + botToken: "bot-token", + baseUrl: "https://chat.example.com", + botTokenSource: "config", + baseUrlSource: "config", + config: {}, +}; describe("mattermost monitor authz", () => { it("keeps DM allowlist merged with pairing-store entries", () => { @@ -56,4 +70,74 @@ describe("mattermost monitor authz", () => { expect(commandGate.commandAuthorized).toBe(false); }); + + it("denies group control commands when the sender is outside the allowlist", () => { + const decision = authorizeMattermostCommandInvocation({ + account: { + ...accountFixture, + config: { + groupPolicy: "allowlist", + allowFrom: ["trusted-user"], + }, + }, + cfg: { + commands: { + useAccessGroups: true, + }, + }, + senderId: "attacker", + senderName: "attacker", + channelId: "chan-1", + channelInfo: { + id: "chan-1", + type: "O", + name: "general", + display_name: "General", + }, + storeAllowFrom: [], + allowTextCommands: true, + hasControlCommand: true, + }); + + expect(decision).toMatchObject({ + ok: false, + denyReason: "unauthorized", + kind: "channel", + }); + }); + + it("authorizes group control commands for allowlisted senders", () => { + const decision = authorizeMattermostCommandInvocation({ + account: { + ...accountFixture, + config: { + groupPolicy: "allowlist", + allowFrom: ["trusted-user"], + }, + }, + cfg: { + commands: { + useAccessGroups: true, + }, + }, + senderId: "trusted-user", + senderName: "trusted-user", + channelId: "chan-1", + channelInfo: { + id: "chan-1", + type: "O", + name: "general", + display_name: "General", + }, + storeAllowFrom: [], + allowTextCommands: true, + hasControlCommand: true, + }); + + expect(decision).toMatchObject({ + ok: true, + commandAuthorized: true, + kind: "channel", + }); + }); }); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index e5a2c91263b..7de24cb03e6 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -7,6 +7,7 @@ import type { } from "openclaw/plugin-sdk/mattermost"; import { buildAgentMediaPayload, + buildModelsProviderData, DM_GROUP_ACCESS_REASON, createScopedPairingAccess, createReplyPrefixOptions, @@ -39,18 +40,32 @@ import { fetchMattermostUserTeams, normalizeMattermostBaseUrl, sendMattermostTyping, + updateMattermostPost, type MattermostChannel, type MattermostPost, type MattermostUser, } from "./client.js"; import { + buildButtonProps, computeInteractionCallbackUrl, createMattermostInteractionHandler, resolveInteractionCallbackPath, setInteractionCallbackUrl, setInteractionSecret, + type MattermostInteractionResponse, } from "./interactions.js"; -import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js"; +import { + buildMattermostAllowedModelRefs, + parseMattermostModelPickerContext, + renderMattermostModelsPickerView, + renderMattermostProviderPickerView, + resolveMattermostModelPickerCurrentModel, +} from "./model-picker.js"; +import { + authorizeMattermostCommandInvocation, + isMattermostSenderAllowed, + normalizeMattermostAllowList, +} from "./monitor-auth.js"; import { createDedupeCache, formatInboundFromLabel, @@ -106,6 +121,10 @@ function isLoopbackHost(hostname: string): boolean { return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; } +function normalizeInteractionSourceIps(values?: string[]): string[] { + return (values ?? []).map((value) => value.trim()).filter(Boolean); +} + const recentInboundMessages = createDedupeCache({ ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS, maxSize: RECENT_MATTERMOST_MESSAGE_MAX, @@ -463,6 +482,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} interactions: account.config.interactions, }); setInteractionCallbackUrl(account.accountId, callbackUrl); + const allowedInteractionSourceIps = normalizeInteractionSourceIps( + account.config.interactions?.allowedSourceIps, + ); try { const mmHost = new URL(baseUrl).hostname; @@ -472,10 +494,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} `mattermost: interactions callbackUrl resolved to ${callbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If button clicks don't work, set channels.mattermost.interactions.callbackBaseUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`, ); } + if (!isLoopbackHost(callbackHost) && allowedInteractionSourceIps.length === 0) { + runtime.error?.( + `mattermost: interactions callbackUrl resolved to ${callbackUrl} without channels.mattermost.interactions.allowedSourceIps. For safety, non-loopback callback sources will be rejected until you allowlist the Mattermost server or trusted ingress IPs.`, + ); + } } catch { // URL parse failed; ignore and continue (we will fail naturally if callbacks cannot be delivered). } + const effectiveInteractionSourceIps = + allowedInteractionSourceIps.length > 0 ? allowedInteractionSourceIps : ["127.0.0.1", "::1"]; + const unregisterInteractions = registerPluginHttpRoute({ path: interactionPath, fallbackPath: "/mattermost/interactions/default", @@ -484,6 +514,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} client, botUserId, accountId: account.accountId, + allowedSourceIps: effectiveInteractionSourceIps, + trustedProxies: cfg.gateway?.trustedProxies, + allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true, + handleInteraction: handleModelPickerInteraction, resolveSessionKey: async (channelId: string, userId: string) => { const channelInfo = await resolveChannelInfo(channelId); const kind = mapMattermostChannelTypeToChatType(channelInfo?.type); @@ -766,6 +800,394 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } }; + const buildModelPickerProps = ( + channelId: string, + buttons: Array, + ): Record | undefined => + buildButtonProps({ + callbackUrl, + accountId: account.accountId, + channelId, + buttons, + }); + + const updateModelPickerPost = async (params: { + channelId: string; + postId: string; + message: string; + buttons?: Array; + }): Promise => { + const props = buildModelPickerProps(params.channelId, params.buttons ?? []) ?? { + attachments: [], + }; + await updateMattermostPost(client, params.postId, { + message: params.message, + props, + }); + return {}; + }; + + const runModelPickerCommand = async (params: { + commandText: string; + commandAuthorized: boolean; + route: ReturnType; + channelId: string; + senderId: string; + senderName: string; + kind: ChatType; + chatType: "direct" | "group" | "channel"; + channelName?: string; + channelDisplay?: string; + roomLabel: string; + teamId?: string; + postId: string; + deliverReplies?: boolean; + }): Promise => { + const to = params.kind === "direct" ? `user:${params.senderId}` : `channel:${params.channelId}`; + const fromLabel = + params.kind === "direct" + ? `Mattermost DM from ${params.senderName}` + : `Mattermost message in ${params.roomLabel} from ${params.senderName}`; + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: params.commandText, + BodyForAgent: params.commandText, + RawBody: params.commandText, + CommandBody: params.commandText, + From: + params.kind === "direct" + ? `mattermost:${params.senderId}` + : params.kind === "group" + ? `mattermost:group:${params.channelId}` + : `mattermost:channel:${params.channelId}`, + To: to, + SessionKey: params.route.sessionKey, + AccountId: params.route.accountId, + ChatType: params.chatType, + ConversationLabel: fromLabel, + GroupSubject: + params.kind !== "direct" ? params.channelDisplay || params.roomLabel : undefined, + GroupChannel: params.channelName ? `#${params.channelName}` : undefined, + GroupSpace: params.teamId, + SenderName: params.senderName, + SenderId: params.senderId, + Provider: "mattermost" as const, + Surface: "mattermost" as const, + MessageSid: `interaction:${params.postId}:${Date.now()}`, + Timestamp: Date.now(), + WasMentioned: true, + CommandAuthorized: params.commandAuthorized, + CommandSource: "native" as const, + OriginatingChannel: "mattermost" as const, + OriginatingTo: to, + }); + + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); + const textLimit = core.channel.text.resolveTextChunkLimit( + cfg, + "mattermost", + account.accountId, + { + fallbackLimit: account.textChunkLimit ?? 4000, + }, + ); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: params.route.agentId, + channel: "mattermost", + accountId: account.accountId, + }); + const shouldDeliverReplies = params.deliverReplies === true; + const capturedTexts: string[] = []; + const typingCallbacks = shouldDeliverReplies + ? createTypingCallbacks({ + start: () => sendTypingIndicator(params.channelId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: params.channelId, + error: err, + }); + }, + }) + : undefined; + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + ...prefixOptions, + // Picker-triggered confirmations should stay immediate. + deliver: async (payload: ReplyPayload) => { + const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = core.channel.text + .convertMarkdownTables(payload.text ?? "", tableMode) + .trim(); + + if (!shouldDeliverReplies) { + if (text) { + capturedTexts.push(text); + } + return; + } + + 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, + }); + } + return; + } + + let first = true; + for (const mediaUrl of mediaUrls) { + const caption = first ? text : ""; + first = false; + await sendMessageMattermost(to, caption, { + accountId: account.accountId, + mediaUrl, + }); + } + }, + onError: (err, info) => { + runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks?.onReplyStart, + }); + + await core.channel.reply.withReplyDispatcher({ + dispatcher, + onSettled: () => { + markDispatchIdle(); + }, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + disableBlockStreaming: + typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, + onModelSelected, + }, + }), + }); + + return capturedTexts.join("\n\n").trim(); + }; + + async function handleModelPickerInteraction(params: { + payload: { + channel_id: string; + post_id: string; + team_id?: string; + user_id: string; + }; + userName: string; + context: Record; + }): Promise { + const pickerState = parseMattermostModelPickerContext(params.context); + if (!pickerState) { + return null; + } + + if (pickerState.ownerUserId !== params.payload.user_id) { + return { + ephemeral_text: "Only the person who opened this picker can use it.", + }; + } + + const channelInfo = await resolveChannelInfo(params.payload.channel_id); + const pickerCommandText = + pickerState.action === "select" + ? `/model ${pickerState.provider}/${pickerState.model}` + : pickerState.action === "list" + ? `/models ${pickerState.provider}` + : "/models"; + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "mattermost", + }); + const hasControlCommand = core.channel.text.hasControlCommand(pickerCommandText, cfg); + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const storeAllowFrom = normalizeMattermostAllowList( + await readStoreAllowFromForDmPolicy({ + provider: "mattermost", + accountId: account.accountId, + dmPolicy, + readStore: pairing.readStoreForDmPolicy, + }), + ); + const auth = authorizeMattermostCommandInvocation({ + account, + cfg, + senderId: params.payload.user_id, + senderName: params.userName, + channelId: params.payload.channel_id, + channelInfo, + storeAllowFrom, + allowTextCommands, + hasControlCommand, + }); + if (!auth.ok) { + if (auth.denyReason === "dm-pairing") { + const { code } = await pairing.upsertPairingRequest({ + id: params.payload.user_id, + meta: { name: params.userName }, + }); + return { + ephemeral_text: core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${params.payload.user_id}`, + code, + }), + }; + } + const denyText = + auth.denyReason === "unknown-channel" + ? "Temporary error: unable to determine channel type. Please try again." + : auth.denyReason === "dm-disabled" + ? "This bot is not accepting direct messages." + : auth.denyReason === "channels-disabled" + ? "Model picker actions are disabled in channels." + : auth.denyReason === "channel-no-allowlist" + ? "Model picker actions are not configured for this channel." + : "Unauthorized."; + return { + ephemeral_text: denyText, + }; + } + const kind = auth.kind; + const chatType = auth.chatType; + const teamId = auth.channelInfo.team_id ?? params.payload.team_id ?? undefined; + const channelName = auth.channelName || undefined; + const channelDisplay = auth.channelDisplay || auth.channelName || params.payload.channel_id; + const roomLabel = auth.roomLabel; + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "direct" ? params.payload.user_id : params.payload.channel_id, + }, + }); + + const data = await buildModelsProviderData(cfg, route.agentId); + if (data.providers.length === 0) { + return await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: "No models available.", + }); + } + + if (pickerState.action === "providers" || pickerState.action === "back") { + const currentModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route, + data, + }); + const view = renderMattermostProviderPickerView({ + ownerUserId: pickerState.ownerUserId, + data, + currentModel, + }); + return await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: view.text, + buttons: view.buttons, + }); + } + + if (pickerState.action === "list") { + const currentModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route, + data, + }); + const view = renderMattermostModelsPickerView({ + ownerUserId: pickerState.ownerUserId, + data, + provider: pickerState.provider, + page: pickerState.page, + currentModel, + }); + return await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: view.text, + buttons: view.buttons, + }); + } + + const targetModelRef = `${pickerState.provider}/${pickerState.model}`; + if (!buildMattermostAllowedModelRefs(data).has(targetModelRef)) { + return { + ephemeral_text: `That model is no longer available: ${targetModelRef}`, + }; + } + + void (async () => { + try { + await runModelPickerCommand({ + commandText: `/model ${targetModelRef}`, + commandAuthorized: auth.commandAuthorized, + route, + channelId: params.payload.channel_id, + senderId: params.payload.user_id, + senderName: params.userName, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + teamId, + postId: params.payload.post_id, + deliverReplies: true, + }); + const updatedModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route, + data, + skipCache: true, + }); + const view = renderMattermostModelsPickerView({ + ownerUserId: pickerState.ownerUserId, + data, + provider: pickerState.provider, + page: pickerState.page, + currentModel: updatedModel, + }); + + await updateModelPickerPost({ + channelId: params.payload.channel_id, + postId: params.payload.post_id, + message: view.text, + buttons: view.buttons, + }); + } catch (err) { + runtime.error?.(`mattermost model picker select failed: ${String(err)}`); + } + })(); + + return {}; + } + const handlePost = async ( post: MattermostPost, payload: MattermostEventPayload, diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 364a4c91744..41ce2dd283a 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -156,6 +156,32 @@ describe("sendMessageMattermost", () => { }), ); }); + + it("builds interactive button props when buttons are provided", async () => { + await sendMessageMattermost("channel:town-square", "Pick a model", { + buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]], + }); + + expect(mockState.createMattermostPost).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + channelId: "town-square", + message: "Pick a model", + props: expect.objectContaining({ + attachments: expect.arrayContaining([ + expect.objectContaining({ + actions: expect.arrayContaining([ + expect.objectContaining({ + id: "mdlprov", + name: "Browse providers", + }), + ]), + }), + ]), + }), + }), + ); + }); }); describe("parseMattermostTarget", () => { diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index b4db4550c86..7af69a65ada 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -13,6 +13,12 @@ import { uploadMattermostFile, type MattermostUser, } from "./client.js"; +import { + buildButtonProps, + resolveInteractionCallbackUrl, + setInteractionSecret, + type MattermostInteractiveButtonInput, +} from "./interactions.js"; export type MattermostSendOpts = { cfg?: OpenClawConfig; @@ -23,6 +29,8 @@ export type MattermostSendOpts = { mediaLocalRoots?: readonly string[]; replyToId?: string; props?: Record; + buttons?: Array; + attachmentText?: string; }; export type MattermostSendResult = { @@ -30,6 +38,10 @@ export type MattermostSendResult = { channelId: string; }; +export type MattermostReplyButtons = Array< + MattermostInteractiveButtonInput | MattermostInteractiveButtonInput[] +>; + type MattermostTarget = | { kind: "channel"; id: string } | { kind: "channel-name"; name: string } @@ -272,6 +284,23 @@ export async function sendMessageMattermost( ); const client = createMattermostClient({ baseUrl, botToken: token }); + let props = opts.props; + if (!props && Array.isArray(opts.buttons) && opts.buttons.length > 0) { + setInteractionSecret(accountId, token); + props = buildButtonProps({ + callbackUrl: resolveInteractionCallbackUrl(accountId, { + gateway: cfg.gateway, + interactions: resolveMattermostAccount({ + cfg, + accountId, + }).config?.interactions, + }), + accountId, + channelId, + buttons: opts.buttons, + text: opts.attachmentText, + }); + } let message = text?.trim() ?? ""; let fileIds: string[] | undefined; let uploadError: Error | undefined; @@ -320,7 +349,7 @@ export async function sendMessageMattermost( message, rootId: opts.replyToId, fileIds, - props: opts.props, + props, }); core.channel.activity.record({ diff --git a/extensions/mattermost/src/mattermost/slash-commands.test.ts b/extensions/mattermost/src/mattermost/slash-commands.test.ts index 39e4c1670d6..4beaea98ca5 100644 --- a/extensions/mattermost/src/mattermost/slash-commands.test.ts +++ b/extensions/mattermost/src/mattermost/slash-commands.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { MattermostClient } from "./client.js"; import { + DEFAULT_COMMAND_SPECS, parseSlashCommandPayload, registerSlashCommands, resolveCallbackUrl, @@ -55,9 +56,18 @@ describe("slash-commands", () => { const triggerMap = new Map([["oc_status", "status"]]); expect(resolveCommandText("oc_status", " ", triggerMap)).toBe("/status"); expect(resolveCommandText("oc_status", " now ", triggerMap)).toBe("/status now"); + expect(resolveCommandText("oc_models", " openai ", undefined)).toBe("/models openai"); expect(resolveCommandText("oc_help", "", undefined)).toBe("/help"); }); + it("registers both public model slash commands", () => { + expect( + DEFAULT_COMMAND_SPECS.filter( + (spec) => spec.trigger === "oc_model" || spec.trigger === "oc_models", + ).map((spec) => spec.trigger), + ).toEqual(["oc_model", "oc_models"]); + }); + it("normalizes callback path in slash config", () => { const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" }); expect(config.callbackPath).toBe("/api/channels/mattermost/command"); diff --git a/extensions/mattermost/src/mattermost/slash-commands.ts b/extensions/mattermost/src/mattermost/slash-commands.ts index 89878289a6c..c7ddd80e7e2 100644 --- a/extensions/mattermost/src/mattermost/slash-commands.ts +++ b/extensions/mattermost/src/mattermost/slash-commands.ts @@ -141,6 +141,13 @@ export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [ autoComplete: true, autoCompleteHint: "[model-name]", }, + { + trigger: "oc_models", + originalName: "models", + description: "Browse available models", + autoComplete: true, + autoCompleteHint: "[provider]", + }, { trigger: "oc_new", originalName: "new", diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 004d8af80d7..3c64b083d3a 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -6,28 +6,34 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/mattermost"; import { + buildModelsProviderData, createReplyPrefixOptions, createTypingCallbacks, - isDangerousNameMatchingEnabled, logTypingFailure, - resolveControlCommandGate, + type OpenClawConfig, + type ReplyPayload, + type RuntimeEnv, } from "openclaw/plugin-sdk/mattermost"; import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { getMattermostRuntime } from "../runtime.js"; import { createMattermostClient, fetchMattermostChannel, - fetchMattermostUser, normalizeMattermostBaseUrl, sendMattermostTyping, type MattermostChannel, } from "./client.js"; import { - isMattermostSenderAllowed, + renderMattermostModelSummaryView, + renderMattermostModelsPickerView, + renderMattermostProviderPickerView, + resolveMattermostModelPickerCurrentModel, + resolveMattermostModelPickerEntry, +} from "./model-picker.js"; +import { + authorizeMattermostCommandInvocation, normalizeMattermostAllowList, - resolveMattermostEffectiveAllowFromLists, } from "./monitor-auth.js"; import { sendMessageMattermost } from "./send.js"; import { @@ -128,29 +134,11 @@ async function authorizeSlashInvocation(params: { }; } - const channelType = channelInfo.type ?? undefined; - const isDirectMessage = channelType?.toUpperCase() === "D"; - const kind: SlashInvocationAuth["kind"] = isDirectMessage - ? "direct" - : channelInfo - ? channelType?.toUpperCase() === "G" - ? "group" - : "channel" - : "channel"; - - const chatType = kind === "direct" ? "direct" : kind === "group" ? "group" : "channel"; - - const channelName = channelInfo?.name ?? ""; - const channelDisplay = channelInfo?.display_name ?? channelName; - const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`; - - const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; - const allowNameMatching = isDangerousNameMatchingEnabled(account.config); - - const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); - const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []); + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "mattermost", + }); + const hasControlCommand = core.channel.text.hasControlCommand(commandText, cfg); const storeAllowFrom = normalizeMattermostAllowList( await core.channel.pairing .readAllowFromStore({ @@ -159,201 +147,61 @@ async function authorizeSlashInvocation(params: { }) .catch(() => []), ); - const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({ - allowFrom: configAllowFrom, - groupAllowFrom: configGroupAllowFrom, - storeAllowFrom, - dmPolicy, - }); - - const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + const decision = authorizeMattermostCommandInvocation({ + account, cfg, - surface: "mattermost", - }); - const hasControlCommand = core.channel.text.hasControlCommand(commandText, cfg); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom; - const commandGroupAllowFrom = - kind === "direct" - ? effectiveGroupAllowFrom - : configGroupAllowFrom.length > 0 - ? configGroupAllowFrom - : configAllowFrom; - - const senderAllowedForCommands = isMattermostSenderAllowed({ senderId, senderName, - allowFrom: commandDmAllowFrom, - allowNameMatching, - }); - const groupAllowedForCommands = isMattermostSenderAllowed({ - senderId, - senderName, - allowFrom: commandGroupAllowFrom, - allowNameMatching, - }); - - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, - { - configured: commandGroupAllowFrom.length > 0, - allowed: groupAllowedForCommands, - }, - ], + channelId, + channelInfo, + storeAllowFrom, allowTextCommands, hasControlCommand, }); - const commandAuthorized = - kind === "direct" - ? dmPolicy === "open" || senderAllowedForCommands - : commandGate.commandAuthorized; - - // DM policy enforcement - if (kind === "direct") { - if (dmPolicy === "disabled") { + if (!decision.ok) { + if (decision.denyReason === "dm-pairing") { + const { code } = await core.channel.pairing.upsertPairingRequest({ + channel: "mattermost", + accountId: account.accountId, + id: senderId, + meta: { name: senderName }, + }); return { - ok: false, + ...decision, denyResponse: { response_type: "ephemeral", - text: "This bot is not accepting direct messages.", + text: core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${senderId}`, + code, + }), }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, }; } - if (dmPolicy !== "open" && !senderAllowedForCommands) { - if (dmPolicy === "pairing") { - const { code } = await core.channel.pairing.upsertPairingRequest({ - channel: "mattermost", - accountId: account.accountId, - id: senderId, - meta: { name: senderName }, - }); - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: core.channel.pairing.buildPairingReply({ - channel: "mattermost", - idLine: `Your Mattermost user id: ${senderId}`, - code, - }), - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: "Unauthorized.", - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - } else { - // Group/channel policy enforcement - if (groupPolicy === "disabled") { - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: "Slash commands are disabled in channels.", - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - - if (groupPolicy === "allowlist") { - if (effectiveGroupAllowFrom.length === 0) { - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: "Slash commands are not configured for this channel (no allowlist).", - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - if (!groupAllowedForCommands) { - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: "Unauthorized.", - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - } - - if (commandGate.shouldBlock) { - return { - ok: false, - denyResponse: { - response_type: "ephemeral", - text: "Unauthorized.", - }, - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } + const denyText = + decision.denyReason === "unknown-channel" + ? "Temporary error: unable to determine channel type. Please try again." + : decision.denyReason === "dm-disabled" + ? "This bot is not accepting direct messages." + : decision.denyReason === "channels-disabled" + ? "Slash commands are disabled in channels." + : decision.denyReason === "channel-no-allowlist" + ? "Slash commands are not configured for this channel (no allowlist)." + : "Unauthorized."; + return { + ...decision, + denyResponse: { + response_type: "ephemeral", + text: denyText, + }, + }; } return { - ok: true, - commandAuthorized, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, + ...decision, + denyResponse: undefined, }; } @@ -537,6 +385,48 @@ async function handleSlashCommandAsync(params: { : `Mattermost message in ${roomLabel} from ${senderName}`; const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`; + const pickerEntry = resolveMattermostModelPickerEntry(commandText); + if (pickerEntry) { + const data = await buildModelsProviderData(cfg, route.agentId); + if (data.providers.length === 0) { + await sendMessageMattermost(to, "No models available.", { + accountId: account.accountId, + }); + return; + } + + const currentModel = resolveMattermostModelPickerCurrentModel({ + cfg, + route, + data, + }); + const view = + pickerEntry.kind === "summary" + ? renderMattermostModelSummaryView({ + ownerUserId: senderId, + currentModel, + }) + : pickerEntry.kind === "providers" + ? renderMattermostProviderPickerView({ + ownerUserId: senderId, + data, + currentModel, + }) + : renderMattermostModelsPickerView({ + ownerUserId: senderId, + data, + provider: pickerEntry.provider, + page: 1, + currentModel, + }); + + await sendMessageMattermost(to, view.text, { + accountId: account.accountId, + buttons: view.buttons, + }); + runtime.log?.(`delivered model picker to ${to}`); + return; + } // Build inbound context — the command text is the body const ctxPayload = core.channel.reply.finalizeInboundContext({ diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 6cd09934995..ba664baa894 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -73,6 +73,11 @@ export type MattermostAccountConfig = { interactions?: { /** External base URL used for Mattermost interaction callbacks. */ callbackBaseUrl?: string; + /** + * IP/CIDR allowlist for callback request sources when Mattermost reaches the gateway + * over a non-loopback path. Keep this narrow to the Mattermost server or trusted ingress. + */ + allowedSourceIps?: string[]; }; }; diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index c4e3bc944c9..c23e6d851b2 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -1,12 +1,11 @@ import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { buildAllowedModelSet, buildModelAliasIndex, normalizeProviderId, - resolveConfiguredModelRef, + resolveDefaultModelForAgent, resolveModelRefFromString, } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -35,11 +34,13 @@ export type ModelsProviderData = { * Build provider/model data from config and catalog. * Exported for reuse by callback handlers. */ -export async function buildModelsProviderData(cfg: OpenClawConfig): Promise { - const resolvedDefault = resolveConfiguredModelRef({ +export async function buildModelsProviderData( + cfg: OpenClawConfig, + agentId?: string, +): Promise { + const resolvedDefault = resolveDefaultModelForAgent({ cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, + agentId, }); const catalog = await loadModelCatalog({ config: cfg }); @@ -220,6 +221,7 @@ export async function resolveModelsCommandReply(params: { commandBodyNormalized: string; surface?: string; currentModel?: string; + agentId?: string; agentDir?: string; sessionEntry?: SessionEntry; }): Promise { @@ -231,7 +233,7 @@ export async function resolveModelsCommandReply(params: { const argText = body.replace(/^\/models\b/i, "").trim(); const { provider, page, pageSize, all } = parseModelsArgs(argText); - const { byProvider, providers } = await buildModelsProviderData(params.cfg); + const { byProvider, providers } = await buildModelsProviderData(params.cfg, params.agentId); const isTelegram = params.surface === "telegram"; // Provider list (no provider specified) @@ -386,6 +388,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma commandBodyNormalized, surface: params.ctx.Surface, currentModel: params.model ? `${params.provider}/${params.model}` : undefined, + agentId: modelsAgentId, agentDir: modelsAgentDir, sessionEntry: params.sessionEntry, }); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index cbf09485721..a83727d2daf 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -907,6 +907,28 @@ describe("/models command", () => { expect(result.reply?.text).toContain("localai/ultra-chat"); expect(result.reply?.text).not.toContain("Unknown provider"); }); + + it("threads the routed agent through /models replies", async () => { + const scopedCfg = { + commands: { text: true }, + agents: { + defaults: { model: { primary: "anthropic/claude-opus-4-5" } }, + list: [{ id: "support", model: "localai/ultra-chat" }], + }, + } as unknown as OpenClawConfig; + const params = buildPolicyParams("/models", scopedCfg, { + Provider: "discord", + Surface: "discord", + }); + + const result = await handleCommands({ + ...params, + agentId: "support", + sessionKey: "agent:support:main", + }); + + expect(result.reply?.text).toContain("localai"); + }); }); describe("handleCommands plugin commands", () => { diff --git a/src/discord/monitor/model-picker.test.ts b/src/discord/monitor/model-picker.test.ts index 29365fb784b..04d5006feb6 100644 --- a/src/discord/monitor/model-picker.test.ts +++ b/src/discord/monitor/model-picker.test.ts @@ -61,15 +61,17 @@ function renderRecentsViewRows( } describe("loadDiscordModelPickerData", () => { - it("reuses buildModelsProviderData as source of truth", async () => { + it("reuses buildModelsProviderData as source of truth with agent scope", async () => { const expected = createModelsProviderData({ openai: ["gpt-4o"] }); + const cfg = {} as OpenClawConfig; const spy = vi .spyOn(modelsCommandModule, "buildModelsProviderData") .mockResolvedValue(expected); - const result = await loadDiscordModelPickerData({} as OpenClawConfig); + const result = await loadDiscordModelPickerData(cfg, "support"); expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(cfg, "support"); expect(result).toBe(expected); }); }); diff --git a/src/discord/monitor/model-picker.ts b/src/discord/monitor/model-picker.ts index 5c686face27..9fa8063cb9a 100644 --- a/src/discord/monitor/model-picker.ts +++ b/src/discord/monitor/model-picker.ts @@ -541,8 +541,11 @@ function buildModelRows(params: { * Source-of-truth data for Discord picker views. This intentionally reuses the * same provider/model resolver used by text and Telegram model commands. */ -export async function loadDiscordModelPickerData(cfg: OpenClawConfig): Promise { - return buildModelsProviderData(cfg); +export async function loadDiscordModelPickerData( + cfg: OpenClawConfig, + agentId?: string, +): Promise { + return buildModelsProviderData(cfg, agentId); } export function buildDiscordModelPickerCustomId(params: { diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 652e6f21214..18960697a40 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -476,13 +476,13 @@ async function replyWithDiscordModelPickerProviders(params: { threadBindings: ThreadBindingManager; preferFollowUp: boolean; }) { - const data = await loadDiscordModelPickerData(params.cfg); const route = await resolveDiscordModelPickerRoute({ interaction: params.interaction, cfg: params.cfg, accountId: params.accountId, threadBindings: params.threadBindings, }); + const data = await loadDiscordModelPickerData(params.cfg, route.agentId); const currentModel = resolveDiscordModelPickerCurrentModel({ cfg: params.cfg, route, @@ -637,13 +637,13 @@ async function handleDiscordModelPickerInteraction( return; } - const pickerData = await loadDiscordModelPickerData(ctx.cfg); const route = await resolveDiscordModelPickerRoute({ interaction, cfg: ctx.cfg, accountId: ctx.accountId, threadBindings: ctx.threadBindings, }); + const pickerData = await loadDiscordModelPickerData(ctx.cfg, route.agentId); const currentModelRef = resolveDiscordModelPickerCurrentModel({ cfg: ctx.cfg, route, diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 9b3619bc581..12c18efea78 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -15,6 +15,12 @@ export type { ChatType } from "../channels/chat-type.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; export { logInboundDrop, logTypingFailure } from "../channels/logging.js"; export { resolveAllowlistMatchSimple } from "../channels/plugins/allowlist-match.js"; +export { normalizeProviderId } from "../agents/model-selection.js"; +export { + buildModelsProviderData, + type ModelsProviderData, +} from "../auto-reply/reply/commands-models.js"; +export { resolveStoredModelOverride } from "../auto-reply/reply/model-selection.js"; export { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, @@ -44,6 +50,7 @@ export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; export { createTypingCallbacks } from "../channels/typing.js"; export type { OpenClawConfig } from "../config/config.js"; export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; +export { loadSessionStore, resolveStorePath } from "../config/sessions.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -65,6 +72,7 @@ export { } from "../config/zod-schema.core.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { rawDataToString } from "../infra/ws.js"; +export { isLoopbackHost, isTrustedProxyAddress, resolveClientIp } from "../gateway/net.js"; export { registerPluginHttpRoute } from "../plugins/http-registry.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index aaea84ecad7..34b8b8de208 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -1179,7 +1179,15 @@ export const registerTelegramHandlers = ({ // Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back) const modelCallback = parseModelCallbackData(data); if (modelCallback) { - const modelData = await buildModelsProviderData(cfg); + const sessionState = resolveTelegramSessionState({ + chatId, + isGroup, + isForum, + messageThreadId, + resolvedThreadId, + senderId, + }); + const modelData = await buildModelsProviderData(cfg, sessionState.agentId); const { byProvider, providers } = modelData; const editMessageWithButtons = async ( @@ -1238,14 +1246,15 @@ export const registerTelegramHandlers = ({ const safePage = Math.max(1, Math.min(page, totalPages)); // Resolve current model from session (prefer overrides) - const sessionState = resolveTelegramSessionState({ + const currentSessionState = resolveTelegramSessionState({ chatId, isGroup, isForum, messageThreadId, resolvedThreadId, + senderId, }); - const currentModel = sessionState.model; + const currentModel = currentSessionState.model; const buttons = buildModelsKeyboard({ provider, @@ -1259,8 +1268,8 @@ export const registerTelegramHandlers = ({ provider, total: models.length, cfg, - agentDir: resolveAgentDir(cfg, sessionState.agentId), - sessionEntry: sessionState.sessionEntry, + agentDir: resolveAgentDir(cfg, currentSessionState.agentId), + sessionEntry: currentSessionState.sessionEntry, }); await editMessageWithButtons(text, buttons); return;