From 7cc78869a4b069bd03bb45f83df64a2bc276dd09 Mon Sep 17 00:00:00 2001 From: Muhammed Mukhthar CM Date: Mon, 2 Mar 2026 11:53:21 +0000 Subject: [PATCH] Mattermost: add slash command coverage and docs --- .github/workflows/labeler.yml | 7 +- CHANGELOG.md | 1 + docs/channels/mattermost.md | 29 ++++ docs/gateway/configuration-reference.md | 7 + .../src/mattermost/slash-commands.test.ts | 113 +++++++++++++++ .../src/mattermost/slash-http.test.ts | 129 ++++++++++++++++++ 6 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 extensions/mattermost/src/mattermost/slash-commands.test.ts create mode 100644 extensions/mattermost/src/mattermost/slash-http.test.ts diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index eb0cea9ac09..ed86b4c67bb 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -284,12 +284,7 @@ jobs: }); isMaintainer = membership?.data?.state === "active"; } catch (error) { - // GITHUB_TOKEN may not have org/team read perms; treat permission errors as non-fatal. - if (error?.status === 404) { - // ignore - } else if (error?.status === 403) { - core.warning(`Skipping team membership check for ${login}; missing permissions.`); - } else { + if (error?.status !== 404) { throw error; } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a11279eac..f1d9384d840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1252,6 +1252,7 @@ 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. - 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..9ee21ba5597 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -55,6 +55,35 @@ 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. + ## 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..b6ab61fb2d5 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", }, 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..bd127076fcf --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-commands.test.ts @@ -0,0 +1,113 @@ +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", + 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", + 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); + }); +}); 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..ede51a06ea6 --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-http.test.ts @@ -0,0 +1,129 @@ +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() as IncomingMessage; + req.method = params.method ?? "POST"; + req.headers = { + "content-type": params.contentType ?? "application/x-www-form-urlencoded", + }; + process.nextTick(() => { + if (params.body) { + req.write(params.body); + } + req.end(); + }); + return req; +} + +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."); + }); +});