diff --git a/CHANGELOG.md b/CHANGELOG.md index f69befffc..045115049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman. - Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub. - Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. +- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666. ## 2026.3.11 diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index e655cb68a..16e3bd643 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -80,6 +80,7 @@ import { type MattermostWebSocketFactory, } from "./monitor-websocket.js"; import { runWithReconnect } from "./reconnect.js"; +import { deliverMattermostReplyPayload } from "./reply-delivery.js"; import { sendMessageMattermost } from "./send.js"; import { DEFAULT_COMMAND_SPECS, @@ -682,44 +683,21 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - if (mediaUrls.length === 0) { - const chunkMode = core.channel.text.resolveChunkMode( - cfg, - "mattermost", - account.accountId, - ); - const chunks = core.channel.text.chunkMarkdownTextWithMode( - text, - textLimit, - chunkMode, - ); - for (const chunk of chunks.length > 0 ? chunks : [text]) { - if (!chunk) continue; - await sendMessageMattermost(to, chunk, { - accountId: account.accountId, - replyToId: resolveMattermostReplyRootId({ - threadRootId: threadContext.effectiveReplyToId, - replyToId: payload.replyToId, - }), - }); - } - } else { - let first = true; - for (const mediaUrl of mediaUrls) { - const caption = first ? text : ""; - first = false; - await sendMessageMattermost(to, caption, { - accountId: account.accountId, - mediaUrl, - replyToId: resolveMattermostReplyRootId({ - threadRootId: threadContext.effectiveReplyToId, - replyToId: payload.replyToId, - }), - }); - } - } + await deliverMattermostReplyPayload({ + core, + cfg, + payload, + to, + accountId: account.accountId, + agentId: route.agentId, + replyToId: resolveMattermostReplyRootId({ + threadRootId: threadContext.effectiveReplyToId, + replyToId: payload.replyToId, + }), + textLimit, + tableMode, + sendMessage: sendMessageMattermost, + }); runtime.log?.(`delivered button-click reply to ${to}`); }, onError: (err, info) => { @@ -1000,53 +978,34 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ...prefixOptions, // Picker-triggered confirmations should stay immediate. deliver: async (payload: ReplyPayload) => { - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = core.channel.text - .convertMarkdownTables(payload.text ?? "", tableMode) - .trim(); + const trimmedPayload = { + ...payload, + text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(), + }; if (!shouldDeliverReplies) { - if (text) { - capturedTexts.push(text); + if (trimmedPayload.text) { + capturedTexts.push(trimmedPayload.text); } return; } - if (mediaUrls.length === 0) { - const chunkMode = core.channel.text.resolveChunkMode( - cfg, - "mattermost", - account.accountId, - ); - const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode); - for (const chunk of chunks.length > 0 ? chunks : [text]) { - if (!chunk) { - continue; - } - await sendMessageMattermost(to, chunk, { - accountId: account.accountId, - replyToId: resolveMattermostReplyRootId({ - threadRootId: params.effectiveReplyToId, - replyToId: payload.replyToId, - }), - }); - } - return; - } - - let first = true; - for (const mediaUrl of mediaUrls) { - const caption = first ? text : ""; - first = false; - await sendMessageMattermost(to, caption, { - accountId: account.accountId, - mediaUrl, - replyToId: resolveMattermostReplyRootId({ - threadRootId: params.effectiveReplyToId, - replyToId: payload.replyToId, - }), - }); - } + await deliverMattermostReplyPayload({ + core, + cfg, + payload: trimmedPayload, + to, + accountId: account.accountId, + agentId: params.route.agentId, + replyToId: resolveMattermostReplyRootId({ + threadRootId: params.effectiveReplyToId, + replyToId: trimmedPayload.replyToId, + }), + textLimit, + // The picker path already converts and trims text before capture/delivery. + tableMode: "off", + sendMessage: sendMessageMattermost, + }); }, onError: (err, info) => { runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`); @@ -1743,42 +1702,21 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), typingCallbacks, deliver: async (payload: ReplyPayload) => { - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - if (mediaUrls.length === 0) { - const chunkMode = core.channel.text.resolveChunkMode( - cfg, - "mattermost", - account.accountId, - ); - const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode); - for (const chunk of chunks.length > 0 ? chunks : [text]) { - if (!chunk) { - continue; - } - await sendMessageMattermost(to, chunk, { - accountId: account.accountId, - replyToId: resolveMattermostReplyRootId({ - threadRootId: effectiveReplyToId, - replyToId: payload.replyToId, - }), - }); - } - } else { - let first = true; - for (const mediaUrl of mediaUrls) { - const caption = first ? text : ""; - first = false; - await sendMessageMattermost(to, caption, { - accountId: account.accountId, - mediaUrl, - replyToId: resolveMattermostReplyRootId({ - threadRootId: effectiveReplyToId, - replyToId: payload.replyToId, - }), - }); - } - } + await deliverMattermostReplyPayload({ + core, + cfg, + payload, + to, + accountId: account.accountId, + agentId: route.agentId, + replyToId: resolveMattermostReplyRootId({ + threadRootId: effectiveReplyToId, + replyToId: payload.replyToId, + }), + textLimit, + tableMode, + sendMessage: sendMessageMattermost, + }); runtime.log?.(`delivered reply to ${to}`); }, onError: (err, info) => { diff --git a/extensions/mattermost/src/mattermost/reply-delivery.test.ts b/extensions/mattermost/src/mattermost/reply-delivery.test.ts new file mode 100644 index 000000000..7d48e5fcf --- /dev/null +++ b/extensions/mattermost/src/mattermost/reply-delivery.test.ts @@ -0,0 +1,95 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { describe, expect, it, vi } from "vitest"; +import { deliverMattermostReplyPayload } from "./reply-delivery.js"; + +describe("deliverMattermostReplyPayload", () => { + it("passes agent-scoped mediaLocalRoots when sending media paths", async () => { + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mm-state-")); + process.env.OPENCLAW_STATE_DIR = stateDir; + + try { + const sendMessage = vi.fn(async () => undefined); + const core = { + channel: { + text: { + convertMarkdownTables: vi.fn((text: string) => text), + resolveChunkMode: vi.fn(() => "length"), + chunkMarkdownTextWithMode: vi.fn((text: string) => [text]), + }, + }, + } as any; + + const agentId = "agent-1"; + const mediaUrl = `file://${path.join(stateDir, `workspace-${agentId}`, "photo.png")}`; + const cfg = {} satisfies OpenClawConfig; + + await deliverMattermostReplyPayload({ + core, + cfg, + payload: { text: "caption", mediaUrl }, + to: "channel:town-square", + accountId: "default", + agentId, + replyToId: "root-post", + textLimit: 4000, + tableMode: "off", + sendMessage, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + "channel:town-square", + "caption", + expect.objectContaining({ + accountId: "default", + mediaUrl, + replyToId: "root-post", + mediaLocalRoots: expect.arrayContaining([path.join(stateDir, `workspace-${agentId}`)]), + }), + ); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("forwards replyToId for text-only chunked replies", async () => { + const sendMessage = vi.fn(async () => undefined); + const core = { + channel: { + text: { + convertMarkdownTables: vi.fn((text: string) => text), + resolveChunkMode: vi.fn(() => "length"), + chunkMarkdownTextWithMode: vi.fn(() => ["hello"]), + }, + }, + } as any; + + await deliverMattermostReplyPayload({ + core, + cfg: {} satisfies OpenClawConfig, + payload: { text: "hello" }, + to: "channel:town-square", + accountId: "default", + agentId: "agent-1", + replyToId: "root-post", + textLimit: 4000, + tableMode: "off", + sendMessage, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith("channel:town-square", "hello", { + accountId: "default", + replyToId: "root-post", + }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts new file mode 100644 index 000000000..5c94e5193 --- /dev/null +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -0,0 +1,71 @@ +import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "openclaw/plugin-sdk/mattermost"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/mattermost"; + +type MarkdownTableMode = Parameters[1]; + +type SendMattermostMessage = ( + to: string, + text: string, + opts: { + accountId?: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + replyToId?: string; + }, +) => Promise; + +export async function deliverMattermostReplyPayload(params: { + core: PluginRuntime; + cfg: OpenClawConfig; + payload: ReplyPayload; + to: string; + accountId: string; + agentId?: string; + replyToId?: string; + textLimit: number; + tableMode: MarkdownTableMode; + sendMessage: SendMattermostMessage; +}): Promise { + const mediaUrls = + params.payload.mediaUrls ?? (params.payload.mediaUrl ? [params.payload.mediaUrl] : []); + const text = params.core.channel.text.convertMarkdownTables( + params.payload.text ?? "", + params.tableMode, + ); + + if (mediaUrls.length === 0) { + const chunkMode = params.core.channel.text.resolveChunkMode( + params.cfg, + "mattermost", + params.accountId, + ); + const chunks = params.core.channel.text.chunkMarkdownTextWithMode( + text, + params.textLimit, + chunkMode, + ); + for (const chunk of chunks.length > 0 ? chunks : [text]) { + if (!chunk) { + continue; + } + await params.sendMessage(params.to, chunk, { + accountId: params.accountId, + replyToId: params.replyToId, + }); + } + return; + } + + const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId); + let first = true; + for (const mediaUrl of mediaUrls) { + const caption = first ? text : ""; + first = false; + await params.sendMessage(params.to, caption, { + accountId: params.accountId, + mediaUrl, + mediaLocalRoots, + replyToId: params.replyToId, + }); + } +} diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 3c64b083d..36a5643e3 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -35,6 +35,7 @@ import { authorizeMattermostCommandInvocation, normalizeMattermostAllowList, } from "./monitor-auth.js"; +import { deliverMattermostReplyPayload } from "./reply-delivery.js"; import { sendMessageMattermost } from "./send.js"; import { parseSlashCommandPayload, @@ -492,32 +493,17 @@ async function handleSlashCommandAsync(params: { ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - if (mediaUrls.length === 0) { - const chunkMode = core.channel.text.resolveChunkMode( - cfg, - "mattermost", - account.accountId, - ); - const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode); - for (const chunk of chunks.length > 0 ? chunks : [text]) { - if (!chunk) continue; - await sendMessageMattermost(to, chunk, { - accountId: account.accountId, - }); - } - } else { - let first = true; - for (const mediaUrl of mediaUrls) { - const caption = first ? text : ""; - first = false; - await sendMessageMattermost(to, caption, { - accountId: account.accountId, - mediaUrl, - }); - } - } + await deliverMattermostReplyPayload({ + core, + cfg, + payload, + to, + accountId: account.accountId, + agentId: route.agentId, + textLimit, + tableMode, + sendMessage: sendMessageMattermost, + }); runtime.log?.(`delivered slash reply to ${to}`); }, onError: (err, info) => { diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index ac4c8a9b4..6871a7836 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -101,5 +101,6 @@ export { export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; +export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { createScopedPairingAccess } from "./pairing-access.js";