diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 2561ec73b0f..3d4d4029fe7 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1075,10 +1075,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const onAbortCleanup = () => { void cleanupSlashCommands({ client, - commands: getSlashCommandState().registeredCommands, + commands: getSlashCommandState(account.accountId)?.registeredCommands ?? [], log: (msg) => runtime.log?.(msg), }) - .then(() => deactivateSlashCommands()) + .then(() => deactivateSlashCommands(account.accountId)) .catch((err) => runtime.error?.(`mattermost: slash cleanup failed: ${String(err)}`)); }; opts.abortSignal?.addEventListener("abort", onAbortCleanup, { once: true }); diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 55283c10d73..8188fde7e59 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -108,8 +108,9 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) { return; } - // Validate token - if (commandTokens.size > 0 && !commandTokens.has(payload.token)) { + // 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.", diff --git a/extensions/mattermost/src/mattermost/slash-state.ts b/extensions/mattermost/src/mattermost/slash-state.ts index 2f18ec3acfa..05316e936f9 100644 --- a/extensions/mattermost/src/mattermost/slash-state.ts +++ b/extensions/mattermost/src/mattermost/slash-state.ts @@ -4,6 +4,9 @@ * 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"; @@ -12,28 +15,34 @@ import type { ResolvedMattermostAccount } from "./accounts.js"; import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js"; import { createSlashCommandHttpHandler } from "./slash-http.js"; -// ─── Shared mutable state ──────────────────────────────────────────────────── +// ─── Per-account state ─────────────────────────────────────────────────────── -type SlashCommandState = { +type SlashCommandAccountState = { /** Tokens from registered commands, used for validation. */ commandTokens: Set; /** Registered command IDs for cleanup on shutdown. */ registeredCommands: MattermostRegisteredCommand[]; - /** Current HTTP handler (set when an account activates). */ + /** Current HTTP handler for this account. */ handler: ((req: IncomingMessage, res: ServerResponse) => Promise) | null; /** The account that activated slash commands. */ - activeAccount: ResolvedMattermostAccount | null; + account: ResolvedMattermostAccount; }; -const state: SlashCommandState = { - commandTokens: new Set(), - registeredCommands: [], - handler: null, - activeAccount: null, -}; +/** Map from accountId → per-account slash command state. */ +const accountStates = new Map(); -export function getSlashCommandState(): SlashCommandState { - return state; +/** + * 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 { + return accountStates; } /** @@ -51,35 +60,59 @@ export function activateSlashCommands(params: { log?: (msg: string) => void; }) { const { account, commandTokens, registeredCommands, api, log } = params; + const accountId = account.accountId; - state.commandTokens = new Set(commandTokens); - state.registeredCommands = registeredCommands; - state.activeAccount = account; + const tokenSet = new Set(commandTokens); - state.handler = createSlashCommandHttpHandler({ + const handler = createSlashCommandHttpHandler({ account, cfg: api.cfg, runtime: api.runtime, - commandTokens: state.commandTokens, + commandTokens: tokenSet, log, }); - log?.(`mattermost: slash commands activated (${registeredCommands.length} commands)`); + accountStates.set(accountId, { + commandTokens: tokenSet, + registeredCommands, + handler, + account, + }); + + log?.( + `mattermost: slash commands activated for account ${accountId} (${registeredCommands.length} commands)`, + ); } /** - * Deactivate slash commands (on shutdown/disconnect). + * Deactivate slash commands for a specific account (on shutdown/disconnect). */ -export function deactivateSlashCommands() { - state.commandTokens.clear(); - state.registeredCommands = []; - state.handler = null; - state.activeAccount = null; +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 | undefined; @@ -92,7 +125,7 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) { api.registerHttpRoute({ path: callbackPath, handler: async (req: IncomingMessage, res: ServerResponse) => { - if (!state.handler) { + if (accountStates.size === 0) { res.statusCode = 503; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end( @@ -103,7 +136,103 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) { ); return; } - await state.handler(req, res); + + // 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 none match, we pick the first handler and let its own validation + // reject the request (fail closed). + + // For multi-account routing: the handlers read the body themselves, + // so we can't pre-parse here without buffering. Instead, 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 + } + + // Find the account whose tokens include this one + let matchedHandler: ((req: IncomingMessage, res: ServerResponse) => Promise) | null = + null; + + if (token) { + for (const [, state] of accountStates) { + if (state.commandTokens.has(token) && state.handler) { + matchedHandler = state.handler; + break; + } + } + } + + if (!matchedHandler) { + // 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; + } + + // 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); }, });