diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index c7411e9b9b3..cdb77314074 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -70,11 +70,26 @@ function sendJsonResponse( res.end(JSON.stringify(body)); } +/** + * Normalize a single allowlist entry, matching the websocket monitor behaviour. + * Strips `mattermost:`, `user:`, and `@` prefixes, and preserves the `*` wildcard. + */ +function normalizeAllowEntry(entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) { + return ""; + } + if (trimmed === "*") { + return "*"; + } + return trimmed + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .toLowerCase(); +} + function normalizeAllowList(entries: Array): string[] { - const normalized = entries - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => entry.toLowerCase()); + const normalized = entries.map((entry) => normalizeAllowEntry(String(entry))).filter(Boolean); return Array.from(new Set(normalized)); } @@ -83,12 +98,16 @@ function isSenderAllowed(params: { senderId: string; senderName: string; allowFr if (allowFrom.length === 0) { return false; } + if (allowFrom.includes("*")) { + return true; + } - const allowed = new Set(allowFrom.map((v) => v.toLowerCase())); - const id = senderId.trim().toLowerCase(); - const name = senderName.trim().toLowerCase(); + const normalizedId = normalizeAllowEntry(senderId); + const normalizedName = senderName ? normalizeAllowEntry(senderName) : ""; - return allowed.has(id) || allowed.has(name); + return allowFrom.some( + (entry) => entry === normalizedId || (normalizedName && entry === normalizedName), + ); } type SlashInvocationAuth = { diff --git a/extensions/mattermost/src/mattermost/slash-state.ts b/extensions/mattermost/src/mattermost/slash-state.ts index b989c77b94a..2baf7669741 100644 --- a/extensions/mattermost/src/mattermost/slash-state.ts +++ b/extensions/mattermost/src/mattermost/slash-state.ts @@ -120,12 +120,31 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) { // 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(); + const addCallbackPaths = ( + raw: Partial | 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 | undefined; - callbackPaths.add(resolveSlashCommandConfig(commandsRaw).callbackPath); + addCallbackPaths(commandsRaw); const accountsRaw = (mmConfig?.accounts ?? {}) as Record; for (const accountId of Object.keys(accountsRaw)) { @@ -133,7 +152,7 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) { const accountCommandsRaw = accountCfg?.commands as | Partial | undefined; - callbackPaths.add(resolveSlashCommandConfig(accountCommandsRaw).callbackPath); + addCallbackPaths(accountCommandsRaw); } const routeHandler = async (req: IncomingMessage, res: ServerResponse) => {