diff --git a/extensions/mattermost/src/mattermost/slash-state.test.ts b/extensions/mattermost/src/mattermost/slash-state.test.ts new file mode 100644 index 00000000000..e8c13222ffc --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-state.test.ts @@ -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"]); + }); +}); diff --git a/extensions/mattermost/src/mattermost/slash-state.ts b/extensions/mattermost/src/mattermost/slash-state.ts index 6c75a8ab4c7..a8590c403f2 100644 --- a/extensions/mattermost/src/mattermost/slash-state.ts +++ b/extensions/mattermost/src/mattermost/slash-state.ts @@ -17,7 +17,7 @@ import { createSlashCommandHttpHandler } from "./slash-http.js"; // ─── Per-account state ─────────────────────────────────────────────────────── -type SlashCommandAccountState = { +export type SlashCommandAccountState = { /** Tokens from registered commands, used for validation. */ commandTokens: Set; /** Registered command IDs for cleanup on shutdown. */ @@ -33,6 +33,35 @@ type SlashCommandAccountState = { /** Map from accountId → per-account slash command state. */ const accountStates = new Map(); +export function resolveSlashHandlerForToken(token: string): { + kind: "none" | "single" | "ambiguous"; + handler?: (req: IncomingMessage, res: ServerResponse) => Promise; + accountIds?: string[]; +} { + const matches: Array<{ + accountId: string; + handler: (req: IncomingMessage, res: ServerResponse) => Promise; + }> = []; + + 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. */ @@ -224,20 +253,9 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) { // parse failed — will be caught by handler } - // Find the account whose tokens include this one - let matchedHandler: ((req: IncomingMessage, res: ServerResponse) => Promise) | null = - null; + const match = token ? resolveSlashHandlerForToken(token) : { kind: "none" as const }; - if (token) { - for (const [, state] of accountStates) { - if (state.commandTokens.has(token) && state.handler) { - matchedHandler = state.handler; - break; - } - } - } - - if (!matchedHandler) { + if (match.kind === "none") { // No matching account — reject res.statusCode = 401; res.setHeader("Content-Type", "application/json; charset=utf-8"); @@ -250,6 +268,23 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) { 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({