mirror of https://github.com/openclaw/openclaw.git
fix(mattermost): fail closed on ambiguous slash token routing
This commit is contained in:
parent
4f99f0e663
commit
1a2fb8fc20
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>;
|
||||
/** 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<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.
|
||||
*/
|
||||
|
|
@ -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<void>) | 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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue