Mattermost: add slash command coverage and docs

This commit is contained in:
Muhammed Mukhthar CM
2026-03-02 11:53:21 +00:00
parent 86a34f1a74
commit 7cc78869a4
6 changed files with 280 additions and 6 deletions

View 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);
});
});

View 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.");
});
});