From b1b41eb44323ac7eda111eafe28f29e1f1593e3b Mon Sep 17 00:00:00 2001 From: Muhammed Mukhthar CM <56378562+mukhtharcm@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:39:18 +0530 Subject: [PATCH] feat(mattermost): add native slash command support (refresh) (#32467) Merged via squash. Prepared head SHA: 989126574ead75c0eedc185293659eb0d4fc6844 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 | 2 + docs/channels/mattermost.md | 39 ++ docs/gateway/configuration-reference.md | 14 + extensions/mattermost/index.ts | 6 + extensions/mattermost/src/channel.ts | 1 + extensions/mattermost/src/config-schema.ts | 15 + .../mattermost/src/mattermost/accounts.ts | 16 +- .../mattermost/src/mattermost/client.ts | 13 + .../mattermost/src/mattermost/monitor.ts | 188 +++++ .../src/mattermost/slash-commands.test.ts | 156 +++++ .../src/mattermost/slash-commands.ts | 565 +++++++++++++++ .../src/mattermost/slash-http.test.ts | 130 ++++ .../mattermost/src/mattermost/slash-http.ts | 657 ++++++++++++++++++ .../src/mattermost/slash-state.test.ts | 42 ++ .../mattermost/src/mattermost/slash-state.ts | 313 +++++++++ extensions/mattermost/src/types.ts | 11 + src/gateway/server-http.ts | 63 ++ src/gateway/server.plugin-http-auth.test.ts | 88 +++ src/plugin-sdk/index.ts | 2 + src/plugins/discovery.test.ts | 5 +- 20 files changed, 2323 insertions(+), 3 deletions(-) create mode 100644 extensions/mattermost/src/mattermost/slash-commands.test.ts create mode 100644 extensions/mattermost/src/mattermost/slash-commands.ts create mode 100644 extensions/mattermost/src/mattermost/slash-http.test.ts create mode 100644 extensions/mattermost/src/mattermost/slash-http.ts create mode 100644 extensions/mattermost/src/mattermost/slash-state.test.ts create mode 100644 extensions/mattermost/src/mattermost/slash-state.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a11279eac..ac09a3611e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1252,6 +1252,8 @@ Docs: https://docs.openclaw.ai - iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky. - iOS/Gateway: wake disconnected iOS nodes via APNs before `nodes.invoke` and auto-reconnect gateway sessions on silent push wake to reduce invoke failures while the app is backgrounded. (#20332) Thanks @mbelinky. - Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky. +- Mattermost: add opt-in native slash command support with registration lifecycle, callback route/token validation, multi-account token routing, and callback URL/path configuration (`channels.mattermost.commands.*`). (#16515) Thanks @echo931. +- Mattermost: harden native slash callback auth-bypass behavior for configurable callback paths, add callback validation coverage, and clarify callback reachability/allowlist docs. (#32467) Thanks @mukhtharcm and @echo931. - iOS/APNs: add push registration and notification-signing configuration for node delivery. (#20308) Thanks @mbelinky. - Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky. - Security/Audit: add `gateway.http.no_auth` findings when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable, with loopback warning and remote-exposure critical severity, plus regression coverage and docs updates. diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index 702f72cc01f..d5cd044a707 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -55,6 +55,45 @@ Minimal config: } ``` +## Native slash commands + +Native slash commands are opt-in. When enabled, OpenClaw registers `oc_*` slash commands via +the Mattermost API and receives callback POSTs on the gateway HTTP server. + +```json5 +{ + channels: { + mattermost: { + commands: { + native: true, + nativeSkills: true, + callbackPath: "/api/channels/mattermost/command", + // Use when Mattermost cannot reach the gateway directly (reverse proxy/public URL). + callbackUrl: "https://gateway.example.com/api/channels/mattermost/command", + }, + }, + }, +} +``` + +Notes: + +- `native: "auto"` defaults to disabled for Mattermost. Set `native: true` to enable. +- If `callbackUrl` is omitted, OpenClaw derives one from gateway host/port + `callbackPath`. +- For multi-account setups, `commands` can be set at the top level or under + `channels.mattermost.accounts..commands` (account values override top-level fields). +- Command callbacks are validated with per-command tokens and fail closed when token checks fail. +- Reachability requirement: the callback endpoint must be reachable from the Mattermost server. + - Do not set `callbackUrl` to `localhost` unless Mattermost runs on the same host/network namespace as OpenClaw. + - Do not set `callbackUrl` to your Mattermost base URL unless that URL reverse-proxies `/api/channels/mattermost/command` to OpenClaw. + - A quick check is `curl https:///api/channels/mattermost/command`; a GET should return `405 Method Not Allowed` from OpenClaw, not `404`. +- Mattermost egress allowlist requirement: + - If your callback targets private/tailnet/internal addresses, set Mattermost + `ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain. + - Use host/domain entries, not full URLs. + - Good: `gateway.tailnet-name.ts.net` + - Bad: `https://gateway.tailnet-name.ts.net` + ## Environment variables (default account) Set these on the gateway host if you prefer env vars: diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index fde4b395c19..ca0a17f9542 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -443,6 +443,13 @@ Mattermost ships as a plugin: `openclaw plugins install @openclaw/mattermost`. dmPolicy: "pairing", chatmode: "oncall", // oncall | onmessage | onchar oncharPrefixes: [">", "!"], + commands: { + native: true, // opt-in + nativeSkills: true, + callbackPath: "/api/channels/mattermost/command", + // Optional explicit URL for reverse-proxy/public deployments + callbackUrl: "https://gateway.example.com/api/channels/mattermost/command", + }, textChunkLimit: 4000, chunkMode: "length", }, @@ -452,6 +459,13 @@ Mattermost ships as a plugin: `openclaw plugins install @openclaw/mattermost`. Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message), `onchar` (messages starting with trigger prefix). +When Mattermost native commands are enabled: + +- `commands.callbackPath` must be a path (for example `/api/channels/mattermost/command`), not a full URL. +- `commands.callbackUrl` must resolve to the OpenClaw gateway endpoint and be reachable from the Mattermost server. +- For private/tailnet/internal callback hosts, Mattermost may require + `ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain. + Use host/domain values, not full URLs. - `channels.mattermost.configWrites`: allow or deny Mattermost-initiated config writes. - `channels.mattermost.requireMention`: require `@mention` before replying in channels. - Optional `channels.mattermost.defaultAccount` overrides default account selection when it matches a configured account id. diff --git a/extensions/mattermost/index.ts b/extensions/mattermost/index.ts index 276c5d01871..ae32fb61f77 100644 --- a/extensions/mattermost/index.ts +++ b/extensions/mattermost/index.ts @@ -1,6 +1,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { mattermostPlugin } from "./src/channel.js"; +import { getSlashCommandState, registerSlashCommandRoute } from "./src/mattermost/slash-state.js"; import { setMattermostRuntime } from "./src/runtime.js"; const plugin = { @@ -11,6 +12,11 @@ const plugin = { register(api: OpenClawPluginApi) { setMattermostRuntime(api.runtime); api.registerChannel({ plugin: mattermostPlugin }); + + // Register the HTTP route for slash command callbacks. + // The actual command registration with MM happens in the monitor + // after the bot connects and we know the team ID. + registerSlashCommandRoute(api); }, }; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index ea9ad100a9c..0f9ec4c82de 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -172,6 +172,7 @@ export const mattermostPlugin: ChannelPlugin = { reactions: true, threads: true, media: true, + nativeCommands: true, }, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index fbf50387982..837facb5587 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -8,6 +8,20 @@ import { import { z } from "zod"; import { buildSecretInputSchema } from "./secret-input.js"; +const MattermostSlashCommandsSchema = z + .object({ + /** Enable native slash commands. "auto" resolves to false (opt-in). */ + native: z.union([z.boolean(), z.literal("auto")]).optional(), + /** Also register skill-based commands. */ + nativeSkills: z.union([z.boolean(), z.literal("auto")]).optional(), + /** Path for the callback endpoint on the gateway HTTP server. */ + callbackPath: z.string().optional(), + /** Explicit callback URL (e.g. behind reverse proxy). */ + callbackUrl: z.string().optional(), + }) + .strict() + .optional(); + const MattermostAccountSchemaBase = z .object({ name: z.string().optional(), @@ -35,6 +49,7 @@ const MattermostAccountSchemaBase = z reactions: z.boolean().optional(), }) .optional(), + commands: MattermostSlashCommandsSchema, }) .strict(); diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index 9af9074087e..ca120d08c6b 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -83,7 +83,21 @@ function mergeMattermostAccountConfig( defaultAccount?: unknown; }; const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; + + // Shallow merging is fine for most keys, but `commands` should be merged + // so that account-specific overrides (callbackPath/callbackUrl) do not + // accidentally reset global settings like `native: true`. + const mergedCommands = { + ...(base.commands ?? {}), + ...(account.commands ?? {}), + }; + + const merged = { ...base, ...account }; + if (Object.keys(mergedCommands).length > 0) { + merged.commands = mergedCommands; + } + + return merged; } function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined { diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index 826212c9eb8..2f4cc4e9a74 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -190,6 +190,19 @@ export async function createMattermostPost( }); } +export type MattermostTeam = { + id: string; + name?: string | null; + display_name?: string | null; +}; + +export async function fetchMattermostUserTeams( + client: MattermostClient, + userId: string, +): Promise { + return await client.request(`/users/${userId}/teams`); +} + export async function uploadMattermostFile( client: MattermostClient, params: { diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 9d16dfedacb..6ad677cf131 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -25,6 +25,7 @@ import { resolveDefaultGroupPolicy, resolveChannelMediaMaxBytes, warnMissingProviderGroupPolicyFallbackOnce, + listSkillCommandsForAgents, type HistoryEntry, } from "openclaw/plugin-sdk"; import { getMattermostRuntime } from "../runtime.js"; @@ -34,6 +35,7 @@ import { fetchMattermostChannel, fetchMattermostMe, fetchMattermostUser, + fetchMattermostUserTeams, normalizeMattermostBaseUrl, sendMattermostTyping, type MattermostChannel, @@ -54,6 +56,19 @@ import { } from "./monitor-websocket.js"; import { runWithReconnect } from "./reconnect.js"; import { sendMessageMattermost } from "./send.js"; +import { + DEFAULT_COMMAND_SPECS, + cleanupSlashCommands, + isSlashCommandsEnabled, + registerSlashCommands, + resolveCallbackUrl, + resolveSlashCommandConfig, +} from "./slash-commands.js"; +import { + activateSlashCommands, + deactivateSlashCommands, + getSlashCommandState, +} from "./slash-state.js"; export type MonitorMattermostOpts = { botToken?: string; @@ -204,6 +219,144 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const botUsername = botUser.username?.trim() || undefined; runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`); + // ─── Slash command registration ────────────────────────────────────────── + const commandsRaw = account.config.commands as + | Partial + | undefined; + const slashConfig = resolveSlashCommandConfig(commandsRaw); + const slashEnabled = isSlashCommandsEnabled(slashConfig); + + if (slashEnabled) { + try { + const teams = await fetchMattermostUserTeams(client, botUserId); + + // Use the *runtime* listener port when available (e.g. `openclaw gateway run --port `). + // The gateway sets OPENCLAW_GATEWAY_PORT when it boots, but the config file may still contain + // a different port. + const envPortRaw = process.env.OPENCLAW_GATEWAY_PORT?.trim(); + const envPort = envPortRaw ? Number.parseInt(envPortRaw, 10) : NaN; + const gatewayPort = + Number.isFinite(envPort) && envPort > 0 ? envPort : (cfg.gateway?.port ?? 18789); + + const callbackUrl = resolveCallbackUrl({ + config: slashConfig, + gatewayPort, + gatewayHost: cfg.gateway?.customBindHost ?? undefined, + }); + + const isLoopbackHost = (hostname: string) => + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + + try { + const mmHost = new URL(baseUrl).hostname; + const callbackHost = new URL(callbackUrl).hostname; + + // NOTE: We cannot infer network reachability from hostnames alone. + // Mattermost might be accessed via a public domain while still running on the same + // machine as the gateway (where http://localhost: is valid). + // 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).`, + ); + } + } catch { + // URL parse failed; ignore and continue (we'll fail naturally if registration requests break). + } + + const commandsToRegister: import("./slash-commands.js").MattermostCommandSpec[] = [ + ...DEFAULT_COMMAND_SPECS, + ]; + + if (slashConfig.nativeSkills === true) { + try { + const skillCommands = listSkillCommandsForAgents({ cfg: cfg as any }); + for (const spec of skillCommands) { + const name = typeof spec.name === "string" ? spec.name.trim() : ""; + if (!name) continue; + const trigger = name.startsWith("oc_") ? name : `oc_${name}`; + commandsToRegister.push({ + trigger, + description: spec.description || `Run skill ${name}`, + autoComplete: true, + autoCompleteHint: "[args]", + originalName: name, + }); + } + } catch (err) { + runtime.error?.(`mattermost: failed to list skill commands: ${String(err)}`); + } + } + + // Deduplicate by trigger + const seen = new Set(); + const dedupedCommands = commandsToRegister.filter((cmd) => { + const key = cmd.trigger.trim(); + if (!key) return false; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + const allRegistered: import("./slash-commands.js").MattermostRegisteredCommand[] = []; + let teamRegistrationFailures = 0; + + for (const team of teams) { + try { + const registered = await registerSlashCommands({ + client, + teamId: team.id, + creatorUserId: botUserId, + callbackUrl, + commands: dedupedCommands, + log: (msg) => runtime.log?.(msg), + }); + allRegistered.push(...registered); + } catch (err) { + teamRegistrationFailures += 1; + runtime.error?.( + `mattermost: failed to register slash commands for team ${team.id}: ${String(err)}`, + ); + } + } + + if (allRegistered.length === 0) { + runtime.error?.( + "mattermost: native slash commands enabled but no commands could be registered; keeping slash callbacks inactive", + ); + } else { + if (teamRegistrationFailures > 0) { + runtime.error?.( + `mattermost: slash command registration completed with ${teamRegistrationFailures} team error(s)`, + ); + } + + // Build trigger→originalName map for accurate command name resolution + const triggerMap = new Map(); + for (const cmd of dedupedCommands) { + if (cmd.originalName) { + triggerMap.set(cmd.trigger, cmd.originalName); + } + } + + activateSlashCommands({ + account, + commandTokens: allRegistered.map((cmd) => cmd.token).filter(Boolean), + registeredCommands: allRegistered, + triggerMap, + api: { cfg, runtime }, + log: (msg) => runtime.log?.(msg), + }); + + runtime.log?.( + `mattermost: slash commands registered (${allRegistered.length} commands across ${teams.length} teams, callback=${callbackUrl})`, + ); + } + } catch (err) { + runtime.error?.(`mattermost: failed to register slash commands: ${String(err)}`); + } + } + const channelCache = new Map(); const userCache = new Map(); const logger = core.logging.getChildLogger({ module: "mattermost" }); @@ -1010,6 +1163,37 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }, }); + let slashShutdownCleanup: Promise | null = null; + + // Clean up slash commands on shutdown + if (slashEnabled) { + const runAbortCleanup = () => { + if (slashShutdownCleanup) { + return; + } + // Snapshot registered commands before deactivating state. + // This listener may run concurrently with startup in a new process, so we keep + // monitor shutdown alive until the remote cleanup completes. + const commands = getSlashCommandState(account.accountId)?.registeredCommands ?? []; + // Deactivate state immediately to prevent new local dispatches during teardown. + deactivateSlashCommands(account.accountId); + + slashShutdownCleanup = cleanupSlashCommands({ + client, + commands, + log: (msg) => runtime.log?.(msg), + }).catch((err) => { + runtime.error?.(`mattermost: slash cleanup failed: ${String(err)}`); + }); + }; + + if (opts.abortSignal?.aborted) { + runAbortCleanup(); + } else { + opts.abortSignal?.addEventListener("abort", runAbortCleanup, { once: true }); + } + } + await runWithReconnect(connectOnce, { abortSignal: opts.abortSignal, jitterRatio: 0.2, @@ -1021,4 +1205,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`); }, }); + + if (slashShutdownCleanup) { + await slashShutdownCleanup; + } } diff --git a/extensions/mattermost/src/mattermost/slash-commands.test.ts b/extensions/mattermost/src/mattermost/slash-commands.test.ts new file mode 100644 index 00000000000..39e4c1670d6 --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-commands.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MattermostClient } from "./client.js"; +import { + parseSlashCommandPayload, + registerSlashCommands, + resolveCallbackUrl, + resolveCommandText, + resolveSlashCommandConfig, +} from "./slash-commands.js"; + +describe("slash-commands", () => { + it("parses application/x-www-form-urlencoded payloads", () => { + const payload = parseSlashCommandPayload( + "token=t1&team_id=team&channel_id=ch1&user_id=u1&command=%2Foc_status&text=now", + "application/x-www-form-urlencoded", + ); + expect(payload).toMatchObject({ + token: "t1", + team_id: "team", + channel_id: "ch1", + user_id: "u1", + command: "/oc_status", + text: "now", + }); + }); + + it("parses application/json payloads", () => { + const payload = parseSlashCommandPayload( + JSON.stringify({ + token: "t2", + team_id: "team", + channel_id: "ch2", + user_id: "u2", + command: "/oc_model", + text: "gpt-5", + }), + "application/json; charset=utf-8", + ); + expect(payload).toMatchObject({ + token: "t2", + command: "/oc_model", + text: "gpt-5", + }); + }); + + it("returns null for malformed payloads missing required fields", () => { + const payload = parseSlashCommandPayload( + JSON.stringify({ token: "t3", command: "/oc_help" }), + "application/json", + ); + expect(payload).toBeNull(); + }); + + it("resolves command text with trigger map fallback", () => { + 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_help", "", undefined)).toBe("/help"); + }); + + it("normalizes callback path in slash config", () => { + const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" }); + expect(config.callbackPath).toBe("/api/channels/mattermost/command"); + }); + + it("falls back to localhost callback URL for wildcard bind hosts", () => { + const config = resolveSlashCommandConfig({ callbackPath: "/api/channels/mattermost/command" }); + const callbackUrl = resolveCallbackUrl({ + config, + gatewayPort: 18789, + gatewayHost: "0.0.0.0", + }); + expect(callbackUrl).toBe("http://localhost:18789/api/channels/mattermost/command"); + }); + + it("reuses existing command when trigger already points to callback URL", async () => { + const request = vi.fn(async (path: string) => { + if (path.startsWith("/commands?team_id=")) { + return [ + { + id: "cmd-1", + token: "tok-1", + team_id: "team-1", + creator_id: "bot-user", + trigger: "oc_status", + method: "P", + url: "http://gateway/callback", + auto_complete: true, + }, + ]; + } + throw new Error(`unexpected request path: ${path}`); + }); + const client = { request } as unknown as MattermostClient; + + const result = await registerSlashCommands({ + client, + teamId: "team-1", + creatorUserId: "bot-user", + callbackUrl: "http://gateway/callback", + commands: [ + { + trigger: "oc_status", + description: "status", + autoComplete: true, + }, + ], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.managed).toBe(false); + expect(result[0]?.id).toBe("cmd-1"); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("skips foreign command trigger collisions instead of mutating non-owned commands", async () => { + const request = vi.fn(async (path: string, init?: { method?: string }) => { + if (path.startsWith("/commands?team_id=")) { + return [ + { + id: "cmd-foreign-1", + token: "tok-foreign-1", + team_id: "team-1", + creator_id: "another-bot-user", + trigger: "oc_status", + method: "P", + url: "http://foreign/callback", + auto_complete: true, + }, + ]; + } + if (init?.method === "POST" || init?.method === "PUT" || init?.method === "DELETE") { + throw new Error("should not mutate foreign commands"); + } + throw new Error(`unexpected request path: ${path}`); + }); + const client = { request } as unknown as MattermostClient; + + const result = await registerSlashCommands({ + client, + teamId: "team-1", + creatorUserId: "bot-user", + callbackUrl: "http://gateway/callback", + commands: [ + { + trigger: "oc_status", + description: "status", + autoComplete: true, + }, + ], + }); + + expect(result).toHaveLength(0); + expect(request).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/mattermost/src/mattermost/slash-commands.ts b/extensions/mattermost/src/mattermost/slash-commands.ts new file mode 100644 index 00000000000..89878289a6c --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-commands.ts @@ -0,0 +1,565 @@ +/** + * Mattermost native slash command support. + * + * Registers custom slash commands via the Mattermost REST API and handles + * incoming command callbacks via an HTTP endpoint on the gateway. + * + * Architecture: + * - On startup, registers commands with MM via POST /api/v4/commands + * - MM sends HTTP POST to callbackUrl when a user invokes a command + * - The callback handler reconstructs the text as `/ ` and + * routes it through the standard inbound reply pipeline + * - On shutdown, cleans up registered commands via DELETE /api/v4/commands/{id} + */ + +import type { MattermostClient } from "./client.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type MattermostSlashCommandConfig = { + /** Enable native slash commands. "auto" resolves to false for now (opt-in). */ + native: boolean | "auto"; + /** Also register skill-based commands. */ + nativeSkills: boolean | "auto"; + /** Path for the callback endpoint on the gateway HTTP server. */ + callbackPath: string; + /** + * Explicit callback URL override (e.g. behind a reverse proxy). + * If not set, auto-derived from baseUrl + gateway port + callbackPath. + */ + callbackUrl?: string; +}; + +export type MattermostCommandSpec = { + trigger: string; + description: string; + autoComplete: boolean; + autoCompleteHint?: string; + /** Original command name (for skill commands that start with oc_) */ + originalName?: string; +}; + +export type MattermostRegisteredCommand = { + id: string; + trigger: string; + teamId: string; + token: string; + /** True when this process created the command and should delete it on shutdown. */ + managed: boolean; +}; + +/** + * Payload sent by Mattermost when a slash command is invoked. + * Can arrive as application/x-www-form-urlencoded or application/json. + */ +export type MattermostSlashCommandPayload = { + token: string; + team_id: string; + team_domain?: string; + channel_id: string; + channel_name?: string; + user_id: string; + user_name?: string; + command: string; // e.g. "/status" + text: string; // args after the trigger word + trigger_id?: string; + response_url?: string; +}; + +/** + * Response format for Mattermost slash command callbacks. + */ +export type MattermostSlashCommandResponse = { + response_type?: "ephemeral" | "in_channel"; + text: string; + username?: string; + icon_url?: string; + goto_location?: string; + attachments?: unknown[]; +}; + +// ─── MM API types ──────────────────────────────────────────────────────────── + +type MattermostCommandCreate = { + team_id: string; + trigger: string; + method: "P" | "G"; + url: string; + description?: string; + auto_complete: boolean; + auto_complete_desc?: string; + auto_complete_hint?: string; + token?: string; + creator_id?: string; +}; + +type MattermostCommandUpdate = { + id: string; + team_id: string; + trigger: string; + method: "P" | "G"; + url: string; + description?: string; + auto_complete: boolean; + auto_complete_desc?: string; + auto_complete_hint?: string; +}; + +type MattermostCommandResponse = { + id: string; + token: string; + team_id: string; + trigger: string; + method: string; + url: string; + auto_complete: boolean; + auto_complete_desc?: string; + auto_complete_hint?: string; + creator_id?: string; + create_at?: number; + update_at?: number; + delete_at?: number; +}; + +// ─── Default commands ──────────────────────────────────────────────────────── + +/** + * Built-in OpenClaw commands to register as native slash commands. + * These mirror the text-based commands already handled by the gateway. + */ +export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [ + { + trigger: "oc_status", + originalName: "status", + description: "Show session status (model, usage, uptime)", + autoComplete: true, + }, + { + trigger: "oc_model", + originalName: "model", + description: "View or change the current model", + autoComplete: true, + autoCompleteHint: "[model-name]", + }, + { + trigger: "oc_new", + originalName: "new", + description: "Start a new conversation session", + autoComplete: true, + }, + { + trigger: "oc_help", + originalName: "help", + description: "Show available commands", + autoComplete: true, + }, + { + trigger: "oc_think", + originalName: "think", + description: "Set thinking/reasoning level", + autoComplete: true, + autoCompleteHint: "[off|low|medium|high]", + }, + { + trigger: "oc_reasoning", + originalName: "reasoning", + description: "Toggle reasoning mode", + autoComplete: true, + autoCompleteHint: "[on|off]", + }, + { + trigger: "oc_verbose", + originalName: "verbose", + description: "Toggle verbose mode", + autoComplete: true, + autoCompleteHint: "[on|off]", + }, +]; + +// ─── Command registration ──────────────────────────────────────────────────── + +/** + * List existing custom slash commands for a team. + */ +export async function listMattermostCommands( + client: MattermostClient, + teamId: string, +): Promise { + return await client.request( + `/commands?team_id=${encodeURIComponent(teamId)}&custom_only=true`, + ); +} + +/** + * Create a custom slash command on a Mattermost team. + */ +export async function createMattermostCommand( + client: MattermostClient, + params: MattermostCommandCreate, +): Promise { + return await client.request("/commands", { + method: "POST", + body: JSON.stringify(params), + }); +} + +/** + * Delete a custom slash command. + */ +export async function deleteMattermostCommand( + client: MattermostClient, + commandId: string, +): Promise { + await client.request>(`/commands/${encodeURIComponent(commandId)}`, { + method: "DELETE", + }); +} + +/** + * Update an existing custom slash command. + */ +export async function updateMattermostCommand( + client: MattermostClient, + params: MattermostCommandUpdate, +): Promise { + return await client.request( + `/commands/${encodeURIComponent(params.id)}`, + { + method: "PUT", + body: JSON.stringify(params), + }, + ); +} + +/** + * Register all OpenClaw slash commands for a given team. + * Skips commands that are already registered with the same trigger + callback URL. + * Returns the list of newly created command IDs. + */ +export async function registerSlashCommands(params: { + client: MattermostClient; + teamId: string; + creatorUserId: string; + callbackUrl: string; + commands: MattermostCommandSpec[]; + log?: (msg: string) => void; +}): Promise { + const { client, teamId, creatorUserId, callbackUrl, commands, log } = params; + const normalizedCreatorUserId = creatorUserId.trim(); + if (!normalizedCreatorUserId) { + throw new Error("creatorUserId is required for slash command reconciliation"); + } + + // Fetch existing commands to avoid duplicates + let existing: MattermostCommandResponse[] = []; + try { + existing = await listMattermostCommands(client, teamId); + } catch (err) { + log?.(`mattermost: failed to list existing commands: ${String(err)}`); + // Fail closed: if we can't list existing commands, we should not attempt to + // create/update anything because we may create duplicates and end up with an + // empty/partial token set (causing callbacks to be rejected until restart). + throw err; + } + + const existingByTrigger = new Map(); + for (const cmd of existing) { + const list = existingByTrigger.get(cmd.trigger) ?? []; + list.push(cmd); + existingByTrigger.set(cmd.trigger, list); + } + + const registered: MattermostRegisteredCommand[] = []; + + for (const spec of commands) { + const existingForTrigger = existingByTrigger.get(spec.trigger) ?? []; + const ownedCommands = existingForTrigger.filter( + (cmd) => cmd.creator_id?.trim() === normalizedCreatorUserId, + ); + const foreignCommands = existingForTrigger.filter( + (cmd) => cmd.creator_id?.trim() !== normalizedCreatorUserId, + ); + + if (ownedCommands.length === 0 && foreignCommands.length > 0) { + log?.( + `mattermost: trigger /${spec.trigger} already used by non-OpenClaw command(s); skipping to avoid mutating external integrations`, + ); + continue; + } + + if (ownedCommands.length > 1) { + log?.( + `mattermost: multiple owned commands found for /${spec.trigger}; using the first and leaving extras untouched`, + ); + } + + const existingCmd = ownedCommands[0]; + + // Already registered with the correct callback URL + if (existingCmd && existingCmd.url === callbackUrl) { + log?.(`mattermost: command /${spec.trigger} already registered (id=${existingCmd.id})`); + registered.push({ + id: existingCmd.id, + trigger: spec.trigger, + teamId, + token: existingCmd.token, + managed: false, + }); + continue; + } + + // Exists but points to a different URL: attempt to reconcile by updating + // (useful during callback URL migrations). + if (existingCmd && existingCmd.url !== callbackUrl) { + log?.( + `mattermost: command /${spec.trigger} exists with different callback URL; updating (id=${existingCmd.id})`, + ); + try { + const updated = await updateMattermostCommand(client, { + id: existingCmd.id, + team_id: teamId, + trigger: spec.trigger, + method: "P", + url: callbackUrl, + description: spec.description, + auto_complete: spec.autoComplete, + auto_complete_desc: spec.description, + auto_complete_hint: spec.autoCompleteHint, + }); + registered.push({ + id: updated.id, + trigger: spec.trigger, + teamId, + token: updated.token, + managed: false, + }); + continue; + } catch (err) { + log?.( + `mattermost: failed to update command /${spec.trigger} (id=${existingCmd.id}): ${String(err)}`, + ); + // Fallback: try delete+recreate for commands owned by this bot user. + try { + await deleteMattermostCommand(client, existingCmd.id); + log?.(`mattermost: deleted stale command /${spec.trigger} (id=${existingCmd.id})`); + } catch (deleteErr) { + log?.( + `mattermost: failed to delete stale command /${spec.trigger} (id=${existingCmd.id}): ${String(deleteErr)}`, + ); + // Can't reconcile; skip this command. + continue; + } + // Continue on to create below. + } + } + + try { + const created = await createMattermostCommand(client, { + team_id: teamId, + trigger: spec.trigger, + method: "P", + url: callbackUrl, + description: spec.description, + auto_complete: spec.autoComplete, + auto_complete_desc: spec.description, + auto_complete_hint: spec.autoCompleteHint, + }); + log?.(`mattermost: registered command /${spec.trigger} (id=${created.id})`); + registered.push({ + id: created.id, + trigger: spec.trigger, + teamId, + token: created.token, + managed: true, + }); + } catch (err) { + log?.(`mattermost: failed to register command /${spec.trigger}: ${String(err)}`); + } + } + + return registered; +} + +/** + * Clean up all registered slash commands. + */ +export async function cleanupSlashCommands(params: { + client: MattermostClient; + commands: MattermostRegisteredCommand[]; + log?: (msg: string) => void; +}): Promise { + const { client, commands, log } = params; + for (const cmd of commands) { + if (!cmd.managed) { + continue; + } + try { + await deleteMattermostCommand(client, cmd.id); + log?.(`mattermost: deleted command /${cmd.trigger} (id=${cmd.id})`); + } catch (err) { + log?.(`mattermost: failed to delete command /${cmd.trigger}: ${String(err)}`); + } + } +} + +// ─── Callback parsing ──────────────────────────────────────────────────────── + +/** + * Parse a Mattermost slash command callback payload from a URL-encoded or JSON body. + */ +export function parseSlashCommandPayload( + body: string, + contentType?: string, +): MattermostSlashCommandPayload | null { + if (!body) { + return null; + } + + try { + if (contentType?.includes("application/json")) { + const parsed = JSON.parse(body) as Record; + + // Validate required fields (same checks as the form-encoded branch) + const token = typeof parsed.token === "string" ? parsed.token : ""; + const teamId = typeof parsed.team_id === "string" ? parsed.team_id : ""; + const channelId = typeof parsed.channel_id === "string" ? parsed.channel_id : ""; + const userId = typeof parsed.user_id === "string" ? parsed.user_id : ""; + const command = typeof parsed.command === "string" ? parsed.command : ""; + + if (!token || !teamId || !channelId || !userId || !command) { + return null; + } + + return { + token, + team_id: teamId, + team_domain: typeof parsed.team_domain === "string" ? parsed.team_domain : undefined, + channel_id: channelId, + channel_name: typeof parsed.channel_name === "string" ? parsed.channel_name : undefined, + user_id: userId, + user_name: typeof parsed.user_name === "string" ? parsed.user_name : undefined, + command, + text: typeof parsed.text === "string" ? parsed.text : "", + trigger_id: typeof parsed.trigger_id === "string" ? parsed.trigger_id : undefined, + response_url: typeof parsed.response_url === "string" ? parsed.response_url : undefined, + }; + } + + // Default: application/x-www-form-urlencoded + const params = new URLSearchParams(body); + const token = params.get("token"); + const teamId = params.get("team_id"); + const channelId = params.get("channel_id"); + const userId = params.get("user_id"); + const command = params.get("command"); + + if (!token || !teamId || !channelId || !userId || !command) { + return null; + } + + return { + token, + team_id: teamId, + team_domain: params.get("team_domain") ?? undefined, + channel_id: channelId, + channel_name: params.get("channel_name") ?? undefined, + user_id: userId, + user_name: params.get("user_name") ?? undefined, + command, + text: params.get("text") ?? "", + trigger_id: params.get("trigger_id") ?? undefined, + response_url: params.get("response_url") ?? undefined, + }; + } catch { + return null; + } +} + +/** + * Map the trigger word back to the original OpenClaw command name. + * e.g. "oc_status" -> "/status", "oc_model" -> "/model" + */ +export function resolveCommandText( + trigger: string, + text: string, + triggerMap?: ReadonlyMap, +): string { + // Use the trigger map if available for accurate name resolution + const commandName = + triggerMap?.get(trigger) ?? (trigger.startsWith("oc_") ? trigger.slice(3) : trigger); + const args = text.trim(); + return args ? `/${commandName} ${args}` : `/${commandName}`; +} + +// ─── Config resolution ─────────────────────────────────────────────────────── + +const DEFAULT_CALLBACK_PATH = "/api/channels/mattermost/command"; + +/** + * Ensure the callback path starts with a leading `/` to prevent + * malformed URLs like `http://host:portapi/...`. + */ +function normalizeCallbackPath(path: string): string { + const trimmed = path.trim(); + if (!trimmed) return DEFAULT_CALLBACK_PATH; + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; +} + +export function resolveSlashCommandConfig( + raw?: Partial, +): MattermostSlashCommandConfig { + return { + native: raw?.native ?? "auto", + nativeSkills: raw?.nativeSkills ?? "auto", + callbackPath: normalizeCallbackPath(raw?.callbackPath ?? DEFAULT_CALLBACK_PATH), + callbackUrl: raw?.callbackUrl?.trim() || undefined, + }; +} + +export function isSlashCommandsEnabled(config: MattermostSlashCommandConfig): boolean { + if (config.native === true) { + return true; + } + if (config.native === false) { + return false; + } + // "auto" defaults to false for mattermost (opt-in) + return false; +} + +/** + * Build the callback URL that Mattermost will POST to when a command is invoked. + */ +export function resolveCallbackUrl(params: { + config: MattermostSlashCommandConfig; + gatewayPort: number; + gatewayHost?: string; +}): string { + if (params.config.callbackUrl) { + return params.config.callbackUrl; + } + + const isWildcardBindHost = (rawHost: string): boolean => { + const trimmed = rawHost.trim(); + if (!trimmed) return false; + const host = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed; + + // NOTE: Wildcard listen hosts are valid bind addresses but are not routable callback + // destinations. Don't emit callback URLs like http://0.0.0.0:3015/... or http://[::]:3015/... + // when an operator sets gateway.customBindHost. + return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0" || host === "::0"; + }; + + let host = + params.gatewayHost && !isWildcardBindHost(params.gatewayHost) + ? params.gatewayHost + : "localhost"; + const path = normalizeCallbackPath(params.config.callbackPath); + + // Bracket IPv6 literals so the URL is valid: http://[::1]:3015/... + if (host.includes(":") && !(host.startsWith("[") && host.endsWith("]"))) { + host = `[${host}]`; + } + + return `http://${host}:${params.gatewayPort}${path}`; +} diff --git a/extensions/mattermost/src/mattermost/slash-http.test.ts b/extensions/mattermost/src/mattermost/slash-http.test.ts new file mode 100644 index 00000000000..c4469b9cad9 --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-http.test.ts @@ -0,0 +1,130 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { PassThrough } from "node:stream"; +import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk"; +import { describe, expect, it } from "vitest"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import { createSlashCommandHttpHandler } from "./slash-http.js"; + +function createRequest(params: { + method?: string; + body?: string; + contentType?: string; +}): IncomingMessage { + const req = new PassThrough(); + const incoming = req as unknown as IncomingMessage; + incoming.method = params.method ?? "POST"; + incoming.headers = { + "content-type": params.contentType ?? "application/x-www-form-urlencoded", + }; + process.nextTick(() => { + if (params.body) { + req.write(params.body); + } + req.end(); + }); + return incoming; +} + +function createResponse(): { + res: ServerResponse; + getBody: () => string; + getHeaders: () => Map; +} { + let body = ""; + const headers = new Map(); + const res = { + statusCode: 200, + setHeader(name: string, value: string) { + headers.set(name.toLowerCase(), value); + }, + end(chunk?: string | Buffer) { + body = chunk ? String(chunk) : ""; + }, + } as unknown as ServerResponse; + return { + res, + getBody: () => body, + getHeaders: () => headers, + }; +} + +const accountFixture: ResolvedMattermostAccount = { + accountId: "default", + enabled: true, + botToken: "bot-token", + baseUrl: "https://chat.example.com", + botTokenSource: "config", + baseUrlSource: "config", + config: {}, +}; + +describe("slash-http", () => { + it("rejects non-POST methods", async () => { + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + commandTokens: new Set(["valid-token"]), + }); + const req = createRequest({ method: "GET", body: "" }); + const response = createResponse(); + + await handler(req, response.res); + + expect(response.res.statusCode).toBe(405); + expect(response.getBody()).toBe("Method Not Allowed"); + expect(response.getHeaders().get("allow")).toBe("POST"); + }); + + it("rejects malformed payloads", async () => { + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + commandTokens: new Set(["valid-token"]), + }); + const req = createRequest({ body: "token=abc&command=%2Foc_status" }); + const response = createResponse(); + + await handler(req, response.res); + + expect(response.res.statusCode).toBe(400); + expect(response.getBody()).toContain("Invalid slash command payload"); + }); + + it("fails closed when no command tokens are registered", async () => { + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + commandTokens: new Set(), + }); + const req = createRequest({ + body: "token=tok1&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=", + }); + const response = createResponse(); + + await handler(req, response.res); + + expect(response.res.statusCode).toBe(401); + expect(response.getBody()).toContain("Unauthorized: invalid command token."); + }); + + it("rejects unknown command tokens", async () => { + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + commandTokens: new Set(["known-token"]), + }); + const req = createRequest({ + body: "token=unknown&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=", + }); + const response = createResponse(); + + await handler(req, response.res); + + expect(response.res.statusCode).toBe(401); + expect(response.getBody()).toContain("Unauthorized: invalid command token."); + }); +}); diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts new file mode 100644 index 00000000000..a454b5c670a --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -0,0 +1,657 @@ +/** + * HTTP callback handler for Mattermost slash commands. + * + * Receives POST requests from Mattermost when a slash command is invoked, + * validates the token, and routes the command through the standard inbound pipeline. + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawConfig, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk"; +import { + createReplyPrefixOptions, + createTypingCallbacks, + isDangerousNameMatchingEnabled, + logTypingFailure, + resolveControlCommandGate, +} from "openclaw/plugin-sdk"; +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, + normalizeMattermostAllowList, + resolveMattermostEffectiveAllowFromLists, +} from "./monitor-auth.js"; +import { sendMessageMattermost } from "./send.js"; +import { + parseSlashCommandPayload, + resolveCommandText, + type MattermostSlashCommandResponse, +} from "./slash-commands.js"; + +type SlashHttpHandlerParams = { + account: ResolvedMattermostAccount; + cfg: OpenClawConfig; + runtime: RuntimeEnv; + /** Expected token from registered commands (for validation). */ + commandTokens: Set; + /** Map from trigger to original command name (for skill commands that start with oc_). */ + triggerMap?: ReadonlyMap; + log?: (msg: string) => void; +}; + +/** + * Read the full request body as a string. + */ +function readBody(req: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > maxBytes) { + req.destroy(); + reject(new Error("Request body too large")); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function sendJsonResponse( + res: ServerResponse, + status: number, + body: MattermostSlashCommandResponse, +) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +type SlashInvocationAuth = { + ok: boolean; + denyResponse?: MattermostSlashCommandResponse; + commandAuthorized: boolean; + channelInfo: MattermostChannel | null; + kind: "direct" | "group" | "channel"; + chatType: "direct" | "group" | "channel"; + channelName: string; + channelDisplay: string; + roomLabel: string; +}; + +async function authorizeSlashInvocation(params: { + account: ResolvedMattermostAccount; + cfg: OpenClawConfig; + client: ReturnType; + commandText: string; + channelId: string; + senderId: string; + senderName: string; + log?: (msg: string) => void; +}): Promise { + const { account, cfg, client, commandText, channelId, senderId, senderName, log } = params; + const core = getMattermostRuntime(); + + // Resolve channel info so we can enforce DM vs group/channel policies. + let channelInfo: MattermostChannel | null = null; + try { + channelInfo = await fetchMattermostChannel(client, channelId); + } catch (err) { + log?.(`mattermost: slash channel lookup failed for ${channelId}: ${String(err)}`); + } + + if (!channelInfo) { + return { + ok: false, + denyResponse: { + response_type: "ephemeral", + text: "Temporary error: unable to determine channel type. Please try again.", + }, + commandAuthorized: false, + channelInfo: null, + kind: "channel", + chatType: "channel", + channelName: "", + channelDisplay: "", + roomLabel: `#${channelId}`, + }; + } + + 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 storeAllowFrom = normalizeMattermostAllowList( + await core.channel.pairing + .readAllowFromStore({ + channel: "mattermost", + accountId: account.accountId, + }) + .catch(() => []), + ); + const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({ + allowFrom: configAllowFrom, + groupAllowFrom: configGroupAllowFrom, + storeAllowFrom, + dmPolicy, + }); + + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + 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, + }, + ], + allowTextCommands, + hasControlCommand, + }); + + const commandAuthorized = + kind === "direct" + ? dmPolicy === "open" || senderAllowedForCommands + : commandGate.commandAuthorized; + + // DM policy enforcement + if (kind === "direct") { + if (dmPolicy === "disabled") { + return { + ok: false, + denyResponse: { + response_type: "ephemeral", + text: "This bot is not accepting direct messages.", + }, + 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, + }; + } + } + + return { + ok: true, + commandAuthorized, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; +} + +/** + * Create the HTTP request handler for Mattermost slash command callbacks. + * + * This handler is registered as a plugin HTTP route and receives POSTs + * from the Mattermost server when a user invokes a registered slash command. + */ +export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) { + const { account, cfg, runtime, commandTokens, triggerMap, log } = params; + + const MAX_BODY_BYTES = 64 * 1024; // 64KB + + return async (req: IncomingMessage, res: ServerResponse): Promise => { + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.end("Method Not Allowed"); + return; + } + + let body: string; + try { + body = await readBody(req, MAX_BODY_BYTES); + } catch { + res.statusCode = 413; + res.end("Payload Too Large"); + return; + } + + const contentType = req.headers["content-type"] ?? ""; + const payload = parseSlashCommandPayload(body, contentType); + if (!payload) { + sendJsonResponse(res, 400, { + response_type: "ephemeral", + text: "Invalid slash command payload.", + }); + return; + } + + // Validate token — fail closed: reject when no tokens are registered + // (e.g. registration failed or startup was partial) + if (commandTokens.size === 0 || !commandTokens.has(payload.token)) { + sendJsonResponse(res, 401, { + response_type: "ephemeral", + text: "Unauthorized: invalid command token.", + }); + return; + } + + // Extract command info + const trigger = payload.command.replace(/^\//, "").trim(); + const commandText = resolveCommandText(trigger, payload.text, triggerMap); + const channelId = payload.channel_id; + const senderId = payload.user_id; + const senderName = payload.user_name ?? senderId; + + const client = createMattermostClient({ + baseUrl: account.baseUrl ?? "", + botToken: account.botToken ?? "", + }); + + const auth = await authorizeSlashInvocation({ + account, + cfg, + client, + commandText, + channelId, + senderId, + senderName, + log, + }); + + if (!auth.ok) { + sendJsonResponse( + res, + 200, + auth.denyResponse ?? { response_type: "ephemeral", text: "Unauthorized." }, + ); + return; + } + + log?.(`mattermost: slash command /${trigger} from ${senderName} in ${channelId}`); + + // Acknowledge immediately — we'll send the actual reply asynchronously + sendJsonResponse(res, 200, { + response_type: "ephemeral", + text: "Processing...", + }); + + // Now handle the command asynchronously (post reply as a message) + try { + await handleSlashCommandAsync({ + account, + cfg, + runtime, + client, + commandText, + channelId, + senderId, + senderName, + teamId: payload.team_id, + triggerId: payload.trigger_id, + kind: auth.kind, + chatType: auth.chatType, + channelName: auth.channelName, + channelDisplay: auth.channelDisplay, + roomLabel: auth.roomLabel, + commandAuthorized: auth.commandAuthorized, + log, + }); + } catch (err) { + log?.(`mattermost: slash command handler error: ${String(err)}`); + try { + const to = `channel:${channelId}`; + await sendMessageMattermost(to, "Sorry, something went wrong processing that command.", { + accountId: account.accountId, + }); + } catch { + // best-effort error reply + } + } + }; +} + +async function handleSlashCommandAsync(params: { + account: ResolvedMattermostAccount; + cfg: OpenClawConfig; + runtime: RuntimeEnv; + client: ReturnType; + commandText: string; + channelId: string; + senderId: string; + senderName: string; + teamId: string; + kind: "direct" | "group" | "channel"; + chatType: "direct" | "group" | "channel"; + channelName: string; + channelDisplay: string; + roomLabel: string; + commandAuthorized: boolean; + triggerId?: string; + log?: (msg: string) => void; +}) { + const { + account, + cfg, + runtime, + client, + commandText, + channelId, + senderId, + senderName, + teamId, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + commandAuthorized, + triggerId, + log, + } = params; + const core = getMattermostRuntime(); + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "direct" ? senderId : channelId, + }, + }); + + const fromLabel = + kind === "direct" + ? `Mattermost DM from ${senderName}` + : `Mattermost message in ${roomLabel} from ${senderName}`; + + const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`; + + // Build inbound context — the command text is the body + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: commandText, + BodyForAgent: commandText, + RawBody: commandText, + CommandBody: commandText, + From: + kind === "direct" + ? `mattermost:${senderId}` + : kind === "group" + ? `mattermost:group:${channelId}` + : `mattermost:channel:${channelId}`, + To: to, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: chatType, + ConversationLabel: fromLabel, + GroupSubject: kind !== "direct" ? channelDisplay || roomLabel : undefined, + SenderName: senderName, + SenderId: senderId, + Provider: "mattermost" as const, + Surface: "mattermost" as const, + MessageSid: triggerId ?? `slash-${Date.now()}`, + Timestamp: Date.now(), + WasMentioned: true, + CommandAuthorized: commandAuthorized, + CommandSource: "native" as const, + 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: () => sendMattermostTyping(client, { channelId }), + onStartError: (err) => { + logTypingFailure({ + log: (message) => log?.(message), + channel: "mattermost", + target: 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 slash reply to ${to}`); + }, + onError: (err, info) => { + runtime.error?.(`mattermost slash ${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, + }, + }), + }); +} diff --git a/extensions/mattermost/src/mattermost/slash-state.test.ts b/extensions/mattermost/src/mattermost/slash-state.test.ts new file mode 100644 index 00000000000..e8c13222ffc --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-state.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + activateSlashCommands, + deactivateSlashCommands, + resolveSlashHandlerForToken, +} from "./slash-state.js"; + +describe("slash-state token routing", () => { + it("returns single match when token belongs to one account", () => { + deactivateSlashCommands(); + activateSlashCommands({ + account: { accountId: "a1" } as any, + commandTokens: ["tok-a"], + registeredCommands: [], + api: { cfg: {} as any, runtime: {} as any }, + }); + + const match = resolveSlashHandlerForToken("tok-a"); + expect(match.kind).toBe("single"); + expect(match.accountIds).toEqual(["a1"]); + }); + + it("returns ambiguous when same token exists in multiple accounts", () => { + deactivateSlashCommands(); + activateSlashCommands({ + account: { accountId: "a1" } as any, + commandTokens: ["tok-shared"], + registeredCommands: [], + api: { cfg: {} as any, runtime: {} as any }, + }); + activateSlashCommands({ + account: { accountId: "a2" } as any, + commandTokens: ["tok-shared"], + registeredCommands: [], + api: { cfg: {} as any, runtime: {} as any }, + }); + + const match = resolveSlashHandlerForToken("tok-shared"); + expect(match.kind).toBe("ambiguous"); + expect(match.accountIds?.sort()).toEqual(["a1", "a2"]); + }); +}); diff --git a/extensions/mattermost/src/mattermost/slash-state.ts b/extensions/mattermost/src/mattermost/slash-state.ts new file mode 100644 index 00000000000..26a2ed029c6 --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-state.ts @@ -0,0 +1,313 @@ +/** + * Shared state for Mattermost slash commands. + * + * Bridges the plugin registration phase (HTTP route) with the monitor phase + * (command registration with MM API). The HTTP handler needs to know which + * tokens are valid, and the monitor needs to store registered command IDs. + * + * State is kept per-account so that multi-account deployments don't + * overwrite each other's tokens, registered commands, or handlers. + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js"; +import { createSlashCommandHttpHandler } from "./slash-http.js"; + +// ─── Per-account state ─────────────────────────────────────────────────────── + +export type SlashCommandAccountState = { + /** Tokens from registered commands, used for validation. */ + commandTokens: Set; + /** Registered command IDs for cleanup on shutdown. */ + registeredCommands: MattermostRegisteredCommand[]; + /** Current HTTP handler for this account. */ + handler: ((req: IncomingMessage, res: ServerResponse) => Promise) | null; + /** The account that activated slash commands. */ + account: ResolvedMattermostAccount; + /** Map from trigger to original command name (for skill commands that start with oc_). */ + triggerMap: Map; +}; + +/** Map from accountId → per-account slash command state. */ +const accountStates = new Map(); + +export function resolveSlashHandlerForToken(token: string): { + kind: "none" | "single" | "ambiguous"; + handler?: (req: IncomingMessage, res: ServerResponse) => Promise; + accountIds?: string[]; +} { + const matches: Array<{ + accountId: string; + handler: (req: IncomingMessage, res: ServerResponse) => Promise; + }> = []; + + for (const [accountId, state] of accountStates) { + if (state.commandTokens.has(token) && state.handler) { + matches.push({ accountId, handler: state.handler }); + } + } + + if (matches.length === 0) { + return { kind: "none" }; + } + if (matches.length === 1) { + return { kind: "single", handler: matches[0]!.handler, accountIds: [matches[0]!.accountId] }; + } + + return { + kind: "ambiguous", + accountIds: matches.map((entry) => entry.accountId), + }; +} + +/** + * Get the slash command state for a specific account, or null if not activated. + */ +export function getSlashCommandState(accountId: string): SlashCommandAccountState | null { + return accountStates.get(accountId) ?? null; +} + +/** + * Get all active slash command account states. + */ +export function getAllSlashCommandStates(): ReadonlyMap { + return accountStates; +} + +/** + * Activate slash commands for a specific account. + * Called from the monitor after bot connects. + */ +export function activateSlashCommands(params: { + account: ResolvedMattermostAccount; + commandTokens: string[]; + registeredCommands: MattermostRegisteredCommand[]; + triggerMap?: Map; + api: { + cfg: import("openclaw/plugin-sdk").OpenClawConfig; + runtime: import("openclaw/plugin-sdk").RuntimeEnv; + }; + log?: (msg: string) => void; +}) { + const { account, commandTokens, registeredCommands, triggerMap, api, log } = params; + const accountId = account.accountId; + + const tokenSet = new Set(commandTokens); + + const handler = createSlashCommandHttpHandler({ + account, + cfg: api.cfg, + runtime: api.runtime, + commandTokens: tokenSet, + triggerMap, + log, + }); + + accountStates.set(accountId, { + commandTokens: tokenSet, + registeredCommands, + handler, + account, + triggerMap: triggerMap ?? new Map(), + }); + + log?.( + `mattermost: slash commands activated for account ${accountId} (${registeredCommands.length} commands)`, + ); +} + +/** + * Deactivate slash commands for a specific account (on shutdown/disconnect). + */ +export function deactivateSlashCommands(accountId?: string) { + if (accountId) { + const state = accountStates.get(accountId); + if (state) { + state.commandTokens.clear(); + state.registeredCommands = []; + state.handler = null; + accountStates.delete(accountId); + } + } else { + // Deactivate all accounts (full shutdown) + for (const [, state] of accountStates) { + state.commandTokens.clear(); + state.registeredCommands = []; + state.handler = null; + } + accountStates.clear(); + } +} + +/** + * Register the HTTP route for slash command callbacks. + * Called during plugin registration. + * + * The single HTTP route dispatches to the correct per-account handler + * by matching the inbound token against each account's registered tokens. + */ +export function registerSlashCommandRoute(api: OpenClawPluginApi) { + const mmConfig = api.config.channels?.mattermost as Record | undefined; + + // Collect callback paths from both top-level and per-account config. + // Command registration uses account.config.commands, so the HTTP route + // registration must include any account-specific callbackPath overrides. + // Also extract the pathname from an explicit callbackUrl when it differs + // from callbackPath, so that Mattermost callbacks hit a registered route. + const callbackPaths = new Set(); + + const addCallbackPaths = ( + raw: Partial | undefined, + ) => { + const resolved = resolveSlashCommandConfig(raw); + callbackPaths.add(resolved.callbackPath); + if (resolved.callbackUrl) { + try { + const urlPath = new URL(resolved.callbackUrl).pathname; + if (urlPath && urlPath !== resolved.callbackPath) { + callbackPaths.add(urlPath); + } + } catch { + // Invalid URL — ignore, will be caught during registration + } + } + }; + + const commandsRaw = mmConfig?.commands as + | Partial + | undefined; + addCallbackPaths(commandsRaw); + + const accountsRaw = (mmConfig?.accounts ?? {}) as Record; + for (const accountId of Object.keys(accountsRaw)) { + const accountCfg = accountsRaw[accountId] as Record | undefined; + const accountCommandsRaw = accountCfg?.commands as + | Partial + | undefined; + addCallbackPaths(accountCommandsRaw); + } + + const routeHandler = async (req: IncomingMessage, res: ServerResponse) => { + if (accountStates.size === 0) { + res.statusCode = 503; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end( + JSON.stringify({ + response_type: "ephemeral", + text: "Slash commands are not yet initialized. Please try again in a moment.", + }), + ); + return; + } + + // We need to peek at the token to route to the right account handler. + // Since each account handler also validates the token, we find the + // account whose token set contains the inbound token and delegate. + + // If there's only one active account (common case), route directly. + if (accountStates.size === 1) { + const [, state] = [...accountStates.entries()][0]!; + if (!state.handler) { + res.statusCode = 503; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end( + JSON.stringify({ + response_type: "ephemeral", + text: "Slash commands are not yet initialized. Please try again in a moment.", + }), + ); + return; + } + await state.handler(req, res); + return; + } + + // Multi-account: buffer the body, find the matching account by token, + // then replay the request to the correct handler. + const chunks: Buffer[] = []; + const MAX_BODY = 64 * 1024; + let size = 0; + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > MAX_BODY) { + res.statusCode = 413; + res.end("Payload Too Large"); + return; + } + chunks.push(chunk as Buffer); + } + const bodyStr = Buffer.concat(chunks).toString("utf8"); + + // Parse just the token to find the right account + let token: string | null = null; + const ct = req.headers["content-type"] ?? ""; + try { + if (ct.includes("application/json")) { + token = (JSON.parse(bodyStr) as { token?: string }).token ?? null; + } else { + token = new URLSearchParams(bodyStr).get("token"); + } + } catch { + // parse failed — will be caught by handler + } + + const match = token ? resolveSlashHandlerForToken(token) : { kind: "none" as const }; + + if (match.kind === "none") { + // No matching account — reject + res.statusCode = 401; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end( + JSON.stringify({ + response_type: "ephemeral", + text: "Unauthorized: invalid command token.", + }), + ); + return; + } + + if (match.kind === "ambiguous") { + api.logger.warn?.( + `mattermost: slash callback token matched multiple accounts (${match.accountIds?.join(", ")})`, + ); + res.statusCode = 409; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end( + JSON.stringify({ + response_type: "ephemeral", + text: "Conflict: command token is not unique across accounts.", + }), + ); + return; + } + + const matchedHandler = match.handler!; + + // Replay: create a synthetic readable that re-emits the buffered body + const { Readable } = await import("node:stream"); + const syntheticReq = new Readable({ + read() { + this.push(Buffer.from(bodyStr, "utf8")); + this.push(null); + }, + }) as IncomingMessage; + + // Copy necessary IncomingMessage properties + syntheticReq.method = req.method; + syntheticReq.url = req.url; + syntheticReq.headers = req.headers; + + await matchedHandler(syntheticReq, res); + }; + + for (const callbackPath of callbackPaths) { + api.registerHttpRoute({ + path: callbackPath, + auth: "plugin", + handler: routeHandler, + }); + api.logger.info?.(`mattermost: registered slash command callback at ${callbackPath}`); + } +} diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index acc24c4a88d..f141695ff73 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -59,6 +59,17 @@ export type MattermostAccountConfig = { /** Enable message reaction actions. Default: true. */ reactions?: boolean; }; + /** Native slash command configuration. */ + commands?: { + /** Enable native slash commands. "auto" resolves to false (opt-in). */ + native?: boolean | "auto"; + /** Also register skill-based commands. */ + nativeSkills?: boolean | "auto"; + /** Path for the callback endpoint on the gateway HTTP server. */ + callbackPath?: string; + /** Explicit callback URL (e.g. behind reverse proxy). */ + callbackUrl?: string; + }; }; export type MattermostConfig = { diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index ef0e56dd6d9..fa3383b41c4 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -84,6 +84,63 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map([ ["/ready", "ready"], ["/readyz", "ready"], ]); +const MATTERMOST_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command"; + +function resolveMattermostSlashCallbackPaths( + configSnapshot: ReturnType, +): Set { + const callbackPaths = new Set([MATTERMOST_SLASH_CALLBACK_PATH]); + const isMattermostCommandCallbackPath = (path: string): boolean => + path === MATTERMOST_SLASH_CALLBACK_PATH || path.startsWith("/api/channels/mattermost/"); + + const normalizeCallbackPath = (value: unknown): string => { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) { + return MATTERMOST_SLASH_CALLBACK_PATH; + } + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + }; + + const tryAddCallbackUrlPath = (rawUrl: unknown) => { + if (typeof rawUrl !== "string") { + return; + } + const trimmed = rawUrl.trim(); + if (!trimmed) { + return; + } + try { + const pathname = new URL(trimmed).pathname; + if (pathname && isMattermostCommandCallbackPath(pathname)) { + callbackPaths.add(pathname); + } + } catch { + // Ignore invalid callback URLs in config and keep default path behavior. + } + }; + + const mmRaw = configSnapshot.channels?.mattermost as Record | undefined; + const addMmCommands = (raw: unknown) => { + if (raw == null || typeof raw !== "object") { + return; + } + const commands = raw as Record; + const callbackPath = normalizeCallbackPath(commands.callbackPath); + if (isMattermostCommandCallbackPath(callbackPath)) { + callbackPaths.add(callbackPath); + } + tryAddCallbackUrlPath(commands.callbackUrl); + }; + + addMmCommands(mmRaw?.commands); + const accountsRaw = (mmRaw?.accounts ?? {}) as Record; + for (const accountId of Object.keys(accountsRaw)) { + const accountCfg = accountsRaw[accountId] as Record | undefined; + addMmCommands(accountCfg?.commands); + } + + return callbackPaths; +} function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathContext): boolean { return ( @@ -174,6 +231,7 @@ function buildPluginRequestStages(params: { req: IncomingMessage; res: ServerResponse; requestPath: string; + mattermostSlashCallbackPaths: ReadonlySet; pluginPathContext: PluginRoutePathContext | null; handlePluginRequest?: PluginHttpRequestHandler; shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean; @@ -189,6 +247,9 @@ function buildPluginRequestStages(params: { { name: "plugin-auth", run: async () => { + if (params.mattermostSlashCallbackPaths.has(params.requestPath)) { + return false; + } const pathContext = params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath); if ( @@ -506,6 +567,7 @@ export function createGatewayHttpServer(opts: { req.url = scopedCanvas.rewrittenUrl; } const requestPath = new URL(req.url ?? "/", "http://localhost").pathname; + const mattermostSlashCallbackPaths = resolveMattermostSlashCallbackPaths(configSnapshot); const pluginPathContext = handlePluginRequest ? resolvePluginRoutePathContext(requestPath) : null; @@ -595,6 +657,7 @@ export function createGatewayHttpServer(opts: { req, res, requestPath, + mattermostSlashCallbackPaths, pluginPathContext, handlePluginRequest, shouldEnforcePluginGatewayAuth, diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 46fdcacc57f..3c5afceaa35 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -17,6 +17,7 @@ import { withGatewayServer, withGatewayTempConfig, } from "./server-http.test-harness.js"; +import { withTempConfig } from "./test-temp-config.js"; type PluginRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise; @@ -216,6 +217,93 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); + test("allows unauthenticated Mattermost slash callback routes while keeping other channel routes protected", async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels/mattermost/command") { + res.statusCode = 200; + res.end("ok:mm-callback"); + return true; + } + if (pathname === "/api/channels/nostr/default/profile") { + res.statusCode = 200; + res.end("ok:nostr"); + return true; + } + return false; + }); + + await withTempConfig({ + cfg: { + gateway: { trustedProxies: [] }, + channels: { + mattermost: { + commands: { callbackPath: "/api/channels/mattermost/command" }, + }, + }, + }, + prefix: "openclaw-plugin-http-auth-mm-callback-", + run: async () => { + const server = createTestGatewayServer({ + resolvedAuth: AUTH_TOKEN, + overrides: { handlePluginRequest }, + }); + + const slashCallback = await sendRequest(server, { + path: "/api/channels/mattermost/command", + method: "POST", + }); + expect(slashCallback.res.statusCode).toBe(200); + expect(slashCallback.getBody()).toBe("ok:mm-callback"); + + const otherChannelUnauthed = await sendRequest(server, { + path: "/api/channels/nostr/default/profile", + }); + expect(otherChannelUnauthed.res.statusCode).toBe(401); + expect(otherChannelUnauthed.getBody()).toContain("Unauthorized"); + }, + }); + }); + + test("does not bypass auth when mattermost callbackPath points to non-mattermost channel routes", async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels/nostr/default/profile") { + res.statusCode = 200; + res.end("ok:nostr"); + return true; + } + return false; + }); + + await withTempConfig({ + cfg: { + gateway: { trustedProxies: [] }, + channels: { + mattermost: { + commands: { callbackPath: "/api/channels/nostr/default/profile" }, + }, + }, + }, + prefix: "openclaw-plugin-http-auth-mm-misconfig-", + run: async () => { + const server = createTestGatewayServer({ + resolvedAuth: AUTH_TOKEN, + overrides: { handlePluginRequest }, + }); + + const unauthenticated = await sendRequest(server, { + path: "/api/channels/nostr/default/profile", + method: "POST", + }); + + expect(unauthenticated.res.statusCode).toBe(401); + expect(unauthenticated.getBody()).toContain("Unauthorized"); + expect(handlePluginRequest).not.toHaveBeenCalled(); + }, + }); + }); + test("keeps wildcard plugin handlers ungated when auth enforcement predicate excludes their paths", async () => { const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 3a1e547548c..32d0f3cfd79 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -543,6 +543,8 @@ export type { } from "../infra/diagnostic-events.js"; export { detectMime, extensionForMime, getFileExtension } from "../media/mime.js"; export { extractOriginalFilename } from "../media/store.js"; +export { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; +export type { SkillCommandSpec } from "../agents/skills.js"; // Channel: Discord export { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index e896910268b..5a760161f41 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -343,9 +343,10 @@ describe("discoverOpenClawPlugins", () => { const result = await withStateDir(stateDir, async () => { return discoverOpenClawPlugins({ ownershipUid: actualUid + 1 }); }); - expect(result.candidates).toHaveLength(0); + const shouldBlockForMismatch = actualUid !== 0; + expect(result.candidates).toHaveLength(shouldBlockForMismatch ? 0 : 1); expect(result.diagnostics.some((diag) => diag.message.includes("suspicious ownership"))).toBe( - true, + shouldBlockForMismatch, ); }, );