mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Mattermost: add slash command coverage and docs
This commit is contained in:
83
.github/workflows/labeler.yml
vendored
83
.github/workflows/labeler.yml
vendored
@@ -23,31 +23,29 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: self-hosted
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
if: ${{ secrets.GH_APP_PRIVATE_KEY != '' }}
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Resolve token
|
||||
id: token
|
||||
shell: bash
|
||||
run: |
|
||||
# Prefer a GitHub App token when available, otherwise fall back to GITHUB_TOKEN.
|
||||
echo "token=${{ steps.app-token.outputs.token || github.token }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
sync-labels: true
|
||||
- name: Apply PR size label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (!pullRequest) {
|
||||
@@ -136,7 +134,7 @@ jobs:
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.pull_request?.user?.login;
|
||||
if (!login) {
|
||||
@@ -158,12 +156,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;
|
||||
}
|
||||
}
|
||||
@@ -214,25 +207,24 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: self-hosted
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
if: ${{ secrets.GH_APP_PRIVATE_KEY != '' }}
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Resolve token
|
||||
id: token
|
||||
shell: bash
|
||||
run: |
|
||||
echo "token=${{ steps.app-token.outputs.token || github.token }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Backfill PR labels
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
@@ -292,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;
|
||||
}
|
||||
}
|
||||
@@ -467,25 +454,24 @@ jobs:
|
||||
label-issues:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: self-hosted
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
if: ${{ secrets.GH_APP_PRIVATE_KEY != '' }}
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Resolve token
|
||||
id: token
|
||||
shell: bash
|
||||
run: |
|
||||
echo "token=${{ steps.app-token.outputs.token || github.token }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.issue?.user?.login;
|
||||
if (!login) {
|
||||
@@ -507,12 +493,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,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.
|
||||
- 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.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -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.<id>.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:
|
||||
|
||||
@@ -353,6 +353,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",
|
||||
},
|
||||
|
||||
113
extensions/mattermost/src/mattermost/slash-commands.test.ts
Normal file
113
extensions/mattermost/src/mattermost/slash-commands.test.ts
Normal file
@@ -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<string, string>([["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);
|
||||
});
|
||||
});
|
||||
129
extensions/mattermost/src/mattermost/slash-http.test.ts
Normal file
129
extensions/mattermost/src/mattermost/slash-http.test.ts
Normal file
@@ -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<string, string>;
|
||||
} {
|
||||
let body = "";
|
||||
const headers = new Map<string, string>();
|
||||
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<string>(),
|
||||
});
|
||||
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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user