From 7ca468e365087c802be32675696080a8dd11fae2 Mon Sep 17 00:00:00 2001 From: Muhammed Mukhthar CM Date: Tue, 17 Mar 2026 05:40:13 +0000 Subject: [PATCH] fix: thread cfg through mattermost fallback sends --- CHANGELOG.md | 1 + .../mattermost/src/mattermost/monitor.ts | 2 +- .../src/mattermost/reply-delivery.test.ts | 5 +- .../mattermost/slash-http.send-config.test.ts | 193 ++++++++++++++++++ .../mattermost/src/mattermost/slash-http.ts | 3 + 5 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 extensions/mattermost/src/mattermost/slash-http.send-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0274a359d37..863fbc134b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -549,6 +549,7 @@ Docs: https://docs.openclaw.ai - Agents/edit tool: accept common path/text alias spellings, show current file contents on exact-match failures, and avoid false edit failures after successful writes. (#52516) thanks @mbelinky. - Agents/compaction: reconcile `sessions.json.compactionCount` after a late embedded auto-compaction success so persisted session counts catch up once the handler reports completion. (#45493) Thanks @jackal092927. +- Mattermost/replies: keep pairing replies, slash-command fallback replies, and model-picker messages on the resolved config path so `exec:` SecretRef bot tokens work across all outbound reply branches. (#48347) thanks @mathiasnagler. ## 2026.3.13 diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 05b8de67cc1..367f5adf2ab 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1086,7 +1086,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} idLine: `Your Mattermost user id: ${senderId}`, code, }), - { accountId: account.accountId }, + { cfg, accountId: account.accountId }, ); opts.statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { diff --git a/extensions/mattermost/src/mattermost/reply-delivery.test.ts b/extensions/mattermost/src/mattermost/reply-delivery.test.ts index ed87604d73e..418fe09408c 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.test.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.test.ts @@ -45,6 +45,7 @@ describe("deliverMattermostReplyPayload", () => { "channel:town-square", "caption", expect.objectContaining({ + cfg, accountId: "default", mediaUrl, replyToId: "root-post", @@ -63,6 +64,7 @@ describe("deliverMattermostReplyPayload", () => { it("forwards replyToId for text-only chunked replies", async () => { const sendMessage = vi.fn(async () => undefined); + const cfg = {} satisfies OpenClawConfig; const core = { channel: { text: { @@ -75,7 +77,7 @@ describe("deliverMattermostReplyPayload", () => { await deliverMattermostReplyPayload({ core, - cfg: {} satisfies OpenClawConfig, + cfg, payload: { text: "hello" }, to: "channel:town-square", accountId: "default", @@ -91,6 +93,7 @@ describe("deliverMattermostReplyPayload", () => { "channel:town-square", "hello", expect.objectContaining({ + cfg, accountId: "default", replyToId: "root-post", }), diff --git a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts new file mode 100644 index 00000000000..780ddec2544 --- /dev/null +++ b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts @@ -0,0 +1,193 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { PassThrough } from "node:stream"; +import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/mattermost"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedMattermostAccount } from "./accounts.js"; + +const mockState = vi.hoisted(() => ({ + readRequestBodyWithLimit: vi.fn(async () => "token=valid-token"), + parseSlashCommandPayload: vi.fn(() => ({ + token: "valid-token", + command: "/oc_models", + text: "models", + channel_id: "chan-1", + user_id: "user-1", + user_name: "alice", + team_id: "team-1", + })), + resolveCommandText: vi.fn((_trigger: string, text: string) => text), + buildModelsProviderData: vi.fn(async () => ({ providers: [] })), + resolveMattermostModelPickerEntry: vi.fn(() => ({ kind: "summary" })), + authorizeMattermostCommandInvocation: vi.fn(() => ({ + ok: true, + commandAuthorized: true, + channelInfo: { id: "chan-1", type: "O", name: "town-square", display_name: "Town Square" }, + kind: "channel", + chatType: "channel", + channelName: "town-square", + channelDisplay: "Town Square", + roomLabel: "#town-square", + })), + createMattermostClient: vi.fn(() => ({})), + fetchMattermostChannel: vi.fn(async () => ({ + id: "chan-1", + type: "O", + name: "town-square", + display_name: "Town Square", + })), + sendMessageMattermost: vi.fn(async () => ({ messageId: "post-1", channelId: "chan-1" })), + normalizeMattermostAllowList: vi.fn((value: unknown) => value), +})); + +vi.mock("openclaw/plugin-sdk/mattermost", () => ({ + buildModelsProviderData: mockState.buildModelsProviderData, + createReplyPrefixOptions: vi.fn(() => ({})), + createTypingCallbacks: vi.fn(() => ({ onReplyStart: vi.fn() })), + isRequestBodyLimitError: vi.fn(() => false), + logTypingFailure: vi.fn(), + readRequestBodyWithLimit: mockState.readRequestBodyWithLimit, +})); + +vi.mock("../runtime.js", () => ({ + getMattermostRuntime: () => ({ + channel: { + commands: { + shouldHandleTextCommands: () => true, + }, + text: { + hasControlCommand: () => false, + }, + pairing: { + readAllowFromStore: vi.fn(async () => []), + }, + routing: { + resolveAgentRoute: vi.fn(() => ({ + agentId: "agent-1", + sessionKey: "mattermost:session:1", + accountId: "default", + })), + }, + }, + }), +})); + +vi.mock("./client.js", () => ({ + createMattermostClient: mockState.createMattermostClient, + fetchMattermostChannel: mockState.fetchMattermostChannel, + normalizeMattermostBaseUrl: vi.fn((value: string | undefined) => value?.trim() ?? ""), + sendMattermostTyping: vi.fn(), +})); + +vi.mock("./model-picker.js", () => ({ + renderMattermostModelSummaryView: vi.fn(), + renderMattermostModelsPickerView: vi.fn(), + renderMattermostProviderPickerView: vi.fn(), + resolveMattermostModelPickerCurrentModel: vi.fn(), + resolveMattermostModelPickerEntry: mockState.resolveMattermostModelPickerEntry, +})); + +vi.mock("./monitor-auth.js", () => ({ + authorizeMattermostCommandInvocation: mockState.authorizeMattermostCommandInvocation, + normalizeMattermostAllowList: mockState.normalizeMattermostAllowList, +})); + +vi.mock("./reply-delivery.js", () => ({ + deliverMattermostReplyPayload: vi.fn(), +})); + +vi.mock("./send.js", () => ({ + sendMessageMattermost: mockState.sendMessageMattermost, +})); + +vi.mock("./slash-commands.js", () => ({ + parseSlashCommandPayload: mockState.parseSlashCommandPayload, + resolveCommandText: mockState.resolveCommandText, +})); + +import { createSlashCommandHttpHandler } from "./slash-http.js"; + +function createRequest(body = "token=valid-token"): IncomingMessage { + const req = new PassThrough(); + const incoming = req as unknown as IncomingMessage; + incoming.method = "POST"; + incoming.headers = { + "content-type": "application/x-www-form-urlencoded", + }; + process.nextTick(() => { + req.end(body); + }); + return incoming; +} + +function createResponse(): { + res: ServerResponse; + getBody: () => string; +} { + let body = ""; + const res = { + statusCode: 200, + setHeader() {}, + end(chunk?: string | Buffer) { + body = chunk ? String(chunk) : ""; + }, + } as unknown as ServerResponse; + return { + res, + getBody: () => body, + }; +} + +const accountFixture: ResolvedMattermostAccount = { + accountId: "default", + enabled: true, + botToken: "bot-token", + baseUrl: "https://chat.example.com", + botTokenSource: "config", + baseUrlSource: "config", + config: {}, +}; + +describe("slash-http cfg threading", () => { + beforeEach(() => { + mockState.readRequestBodyWithLimit.mockClear(); + mockState.parseSlashCommandPayload.mockClear(); + mockState.resolveCommandText.mockClear(); + mockState.buildModelsProviderData.mockClear(); + mockState.resolveMattermostModelPickerEntry.mockClear(); + mockState.authorizeMattermostCommandInvocation.mockClear(); + mockState.createMattermostClient.mockClear(); + mockState.fetchMattermostChannel.mockClear(); + mockState.sendMessageMattermost.mockClear(); + mockState.normalizeMattermostAllowList.mockClear(); + }); + + it("passes cfg through the no-models slash reply send path", async () => { + const cfg = { + channels: { + mattermost: { + botToken: "exec:secret-ref", + }, + }, + } as OpenClawConfig; + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg, + runtime: {} as RuntimeEnv, + commandTokens: new Set(["valid-token"]), + }); + const response = createResponse(); + + await handler(createRequest(), response.res); + + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toContain("Processing"); + expect(mockState.sendMessageMattermost).toHaveBeenCalledWith( + "channel:chan-1", + "No models available.", + expect.objectContaining({ + cfg, + accountId: "default", + }), + ); + }); +}); diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 374af5da044..fd4ad0ed397 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -316,6 +316,7 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) { try { const to = `channel:${channelId}`; await sendMessageMattermost(to, "Sorry, something went wrong processing that command.", { + cfg, accountId: account.accountId, }); } catch { @@ -387,6 +388,7 @@ async function handleSlashCommandAsync(params: { const data = await buildModelsProviderData(cfg, route.agentId); if (data.providers.length === 0) { await sendMessageMattermost(to, "No models available.", { + cfg, accountId: account.accountId, }); return; @@ -418,6 +420,7 @@ async function handleSlashCommandAsync(params: { }); await sendMessageMattermost(to, view.text, { + cfg, accountId: account.accountId, buttons: view.buttons, });