mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
committed by
GitHub
parent
5341b5c71c
commit
b1b41eb443
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
156
extensions/mattermost/src/mattermost/slash-commands.test.ts
Normal file
156
extensions/mattermost/src/mattermost/slash-commands.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
565
extensions/mattermost/src/mattermost/slash-commands.ts
Normal file
565
extensions/mattermost/src/mattermost/slash-commands.ts
Normal 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}`;
|
||||
}
|
||||
130
extensions/mattermost/src/mattermost/slash-http.test.ts
Normal file
130
extensions/mattermost/src/mattermost/slash-http.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
657
extensions/mattermost/src/mattermost/slash-http.ts
Normal file
657
extensions/mattermost/src/mattermost/slash-http.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
42
extensions/mattermost/src/mattermost/slash-state.test.ts
Normal file
42
extensions/mattermost/src/mattermost/slash-state.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
313
extensions/mattermost/src/mattermost/slash-state.ts
Normal file
313
extensions/mattermost/src/mattermost/slash-state.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user