feat(mattermost): add native slash command support (refresh) (#32467)

Merged via squash.

Prepared head SHA: 989126574e
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
This commit is contained in:
Muhammed Mukhthar CM
2026-03-03 12:39:18 +05:30
committed by GitHub
parent 5341b5c71c
commit b1b41eb443
20 changed files with 2323 additions and 3 deletions

View File

@@ -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);
},
};

View File

@@ -172,6 +172,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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<MattermostTeam[]> {
return await client.request<MattermostTeam[]>(`/users/${userId}/teams`);
}
export async function uploadMattermostFile(
client: MattermostClient,
params: {

View File

@@ -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<import("./slash-commands.js").MattermostSlashCommandConfig>
| 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 <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:<port> 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<string>();
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<string, string>();
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<string, { value: MattermostChannel | null; expiresAt: number }>();
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
const logger = core.logging.getChildLogger({ module: "mattermost" });
@@ -1010,6 +1163,37 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
},
});
let slashShutdownCleanup: Promise<void> | 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;
}
}

View File

@@ -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<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",
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);
});
});

View File

@@ -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 `/<command> <args>` 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<MattermostCommandResponse[]> {
return await client.request<MattermostCommandResponse[]>(
`/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<MattermostCommandResponse> {
return await client.request<MattermostCommandResponse>("/commands", {
method: "POST",
body: JSON.stringify(params),
});
}
/**
* Delete a custom slash command.
*/
export async function deleteMattermostCommand(
client: MattermostClient,
commandId: string,
): Promise<void> {
await client.request<Record<string, unknown>>(`/commands/${encodeURIComponent(commandId)}`, {
method: "DELETE",
});
}
/**
* Update an existing custom slash command.
*/
export async function updateMattermostCommand(
client: MattermostClient,
params: MattermostCommandUpdate,
): Promise<MattermostCommandResponse> {
return await client.request<MattermostCommandResponse>(
`/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<MattermostRegisteredCommand[]> {
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<string, MattermostCommandResponse[]>();
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<void> {
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<string, unknown>;
// 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, string>,
): 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>,
): 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}`;
}

View File

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

View File

@@ -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<string>;
/** Map from trigger to original command name (for skill commands that start with oc_). */
triggerMap?: ReadonlyMap<string, string>;
log?: (msg: string) => void;
};
/**
* Read the full request body as a string.
*/
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
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<typeof createMattermostClient>;
commandText: string;
channelId: string;
senderId: string;
senderName: string;
log?: (msg: string) => void;
}): Promise<SlashInvocationAuth> {
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<void> => {
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<typeof createMattermostClient>;
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,
},
}),
});
}

View File

@@ -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"]);
});
});

View File

@@ -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<string>;
/** Registered command IDs for cleanup on shutdown. */
registeredCommands: MattermostRegisteredCommand[];
/** Current HTTP handler for this account. */
handler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | 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<string, string>;
};
/** Map from accountId → per-account slash command state. */
const accountStates = new Map<string, SlashCommandAccountState>();
export function resolveSlashHandlerForToken(token: string): {
kind: "none" | "single" | "ambiguous";
handler?: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
accountIds?: string[];
} {
const matches: Array<{
accountId: string;
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
}> = [];
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<string, SlashCommandAccountState> {
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<string, string>;
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<string, unknown> | 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<string>();
const addCallbackPaths = (
raw: Partial<import("./slash-commands.js").MattermostSlashCommandConfig> | 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<import("./slash-commands.js").MattermostSlashCommandConfig>
| undefined;
addCallbackPaths(commandsRaw);
const accountsRaw = (mmConfig?.accounts ?? {}) as Record<string, unknown>;
for (const accountId of Object.keys(accountsRaw)) {
const accountCfg = accountsRaw[accountId] as Record<string, unknown> | undefined;
const accountCommandsRaw = accountCfg?.commands as
| Partial<import("./slash-commands.js").MattermostSlashCommandConfig>
| 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}`);
}
}

View File

@@ -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 = {