diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index 3517736f620..f9417109a77 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -224,6 +224,8 @@ Config: - `channels.mattermost.interactions.callbackBaseUrl`: optional external base URL for button callbacks (for example `https://gateway.example.com`). Use this when Mattermost cannot reach the gateway at its bind host directly. +- In multi-account setups, you can also set the same field under + `channels.mattermost.accounts..interactions.callbackBaseUrl`. - If `interactions.callbackBaseUrl` is omitted, OpenClaw derives the callback URL from `gateway.customBindHost` + `gateway.port`, then falls back to `http://localhost:`. - Reachability rule: the button callback URL must be reachable from the Mattermost server. diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 5897c11277a..00e4c69e0f7 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -165,7 +165,10 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { if (params.buttons && Array.isArray(params.buttons)) { const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId }); if (account.botToken) setInteractionSecret(account.accountId, account.botToken); - const callbackUrl = resolveInteractionCallbackUrl(account.accountId, cfg); + const callbackUrl = resolveInteractionCallbackUrl(account.accountId, { + gateway: cfg.gateway, + interactions: account.config.interactions, + }); // Flatten 2D array (rows of buttons) to 1D — core schema sends Array> // but Mattermost doesn't have row layout, so we flatten all rows into a single list. diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index 032e3430c6a..2ad979b1a49 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -1,6 +1,7 @@ import { type IncomingMessage, type ServerResponse } from "node:http"; import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { setMattermostRuntime } from "../runtime.js"; +import { resolveMattermostAccount } from "./accounts.js"; import type { MattermostClient } from "./client.js"; import { buildButtonAttachments, @@ -169,6 +170,35 @@ describe("resolveInteractionCallbackUrl", () => { expect(url).toBe("https://gateway.example.com/root/mattermost/interactions/acct"); }); + it("uses merged per-account interactions.callbackBaseUrl", () => { + const cfg = { + gateway: { port: 9999 }, + channels: { + mattermost: { + accounts: { + acct: { + botToken: "bot-token", + baseUrl: "https://chat.example.com", + interactions: { + callbackBaseUrl: "https://gateway.example.com/root", + }, + }, + }, + }, + }, + }; + const account = resolveMattermostAccount({ + cfg, + accountId: "acct", + allowUnresolvedSecretRef: true, + }); + const url = resolveInteractionCallbackUrl(account.accountId, { + gateway: cfg.gateway, + interactions: account.config.interactions, + }); + expect(url).toBe("https://gateway.example.com/root/mattermost/interactions/acct"); + }); + it("falls back to gateway.customBindHost when configured", () => { const url = resolveInteractionCallbackUrl("default", { gateway: { port: 9999, customBindHost: "gateway.internal" }, diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index 79c87197352..6685e194c7d 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -44,7 +44,11 @@ export function getInteractionCallbackUrl(accountId: string): string | undefined return callbackUrls.get(accountId); } -type InteractionCallbackConfig = Pick; +type InteractionCallbackConfig = Pick & { + interactions?: { + callbackBaseUrl?: string; + }; +}; export function resolveInteractionCallbackPath(accountId: string): string { return `/mattermost/interactions/${accountId}`; @@ -75,7 +79,11 @@ export function resolveInteractionCallbackUrl( return cached; } const path = resolveInteractionCallbackPath(accountId); - const callbackBaseUrl = cfg?.channels?.mattermost?.interactions?.callbackBaseUrl?.trim(); + // Prefer merged per-account config when available, but keep the top-level path for + // callers/tests that still pass the root Mattermost config shape directly. + const callbackBaseUrl = + cfg?.interactions?.callbackBaseUrl?.trim() ?? + cfg?.channels?.mattermost?.interactions?.callbackBaseUrl?.trim(); if (callbackBaseUrl) { return `${normalizeCallbackBaseUrl(callbackBaseUrl)}${path}`; } diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 92e8bf71f51..8546f0a0983 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -456,7 +456,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} // Register HTTP callback endpoint for interactive button clicks. // Mattermost POSTs to this URL when a user clicks a button action. const interactionPath = resolveInteractionCallbackPath(account.accountId); - const callbackUrl = resolveInteractionCallbackUrl(account.accountId, cfg); + const callbackUrl = resolveInteractionCallbackUrl(account.accountId, { + gateway: cfg.gateway, + interactions: account.config.interactions, + }); setInteractionCallbackUrl(account.accountId, callbackUrl); try {