Skip to content

Commit c965049

Browse files
fix(mattermost): pass mediaLocalRoots through reply delivery (#44021)
Merged via squash. Prepared head SHA: 856f11f Co-authored-by: LyleLiu666 <31182860+LyleLiu666@users.noreply.github.com> Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com> Reviewed-by: @mukhtharcm
1 parent 797b6fe commit c965049

6 files changed

Lines changed: 233 additions & 141 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
3434
- 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.
3535
- Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.
3636
- 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.
37+
- 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.
3738

3839
## 2026.3.11
3940

extensions/mattermost/src/mattermost/monitor.ts

Lines changed: 53 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import {
8080
type MattermostWebSocketFactory,
8181
} from "./monitor-websocket.js";
8282
import { runWithReconnect } from "./reconnect.js";
83+
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
8384
import { sendMessageMattermost } from "./send.js";
8485
import {
8586
DEFAULT_COMMAND_SPECS,
@@ -682,44 +683,21 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
682683
...prefixOptions,
683684
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
684685
deliver: async (payload: ReplyPayload) => {
685-
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
686-
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
687-
if (mediaUrls.length === 0) {
688-
const chunkMode = core.channel.text.resolveChunkMode(
689-
cfg,
690-
"mattermost",
691-
account.accountId,
692-
);
693-
const chunks = core.channel.text.chunkMarkdownTextWithMode(
694-
text,
695-
textLimit,
696-
chunkMode,
697-
);
698-
for (const chunk of chunks.length > 0 ? chunks : [text]) {
699-
if (!chunk) continue;
700-
await sendMessageMattermost(to, chunk, {
701-
accountId: account.accountId,
702-
replyToId: resolveMattermostReplyRootId({
703-
threadRootId: threadContext.effectiveReplyToId,
704-
replyToId: payload.replyToId,
705-
}),
706-
});
707-
}
708-
} else {
709-
let first = true;
710-
for (const mediaUrl of mediaUrls) {
711-
const caption = first ? text : "";
712-
first = false;
713-
await sendMessageMattermost(to, caption, {
714-
accountId: account.accountId,
715-
mediaUrl,
716-
replyToId: resolveMattermostReplyRootId({
717-
threadRootId: threadContext.effectiveReplyToId,
718-
replyToId: payload.replyToId,
719-
}),
720-
});
721-
}
722-
}
686+
await deliverMattermostReplyPayload({
687+
core,
688+
cfg,
689+
payload,
690+
to,
691+
accountId: account.accountId,
692+
agentId: route.agentId,
693+
replyToId: resolveMattermostReplyRootId({
694+
threadRootId: threadContext.effectiveReplyToId,
695+
replyToId: payload.replyToId,
696+
}),
697+
textLimit,
698+
tableMode,
699+
sendMessage: sendMessageMattermost,
700+
});
723701
runtime.log?.(`delivered button-click reply to ${to}`);
724702
},
725703
onError: (err, info) => {
@@ -1000,53 +978,34 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
1000978
...prefixOptions,
1001979
// Picker-triggered confirmations should stay immediate.
1002980
deliver: async (payload: ReplyPayload) => {
1003-
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
1004-
const text = core.channel.text
1005-
.convertMarkdownTables(payload.text ?? "", tableMode)
1006-
.trim();
981+
const trimmedPayload = {
982+
...payload,
983+
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(),
984+
};
1007985

1008986
if (!shouldDeliverReplies) {
1009-
if (text) {
1010-
capturedTexts.push(text);
987+
if (trimmedPayload.text) {
988+
capturedTexts.push(trimmedPayload.text);
1011989
}
1012990
return;
1013991
}
1014992

1015-
if (mediaUrls.length === 0) {
1016-
const chunkMode = core.channel.text.resolveChunkMode(
1017-
cfg,
1018-
"mattermost",
1019-
account.accountId,
1020-
);
1021-
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
1022-
for (const chunk of chunks.length > 0 ? chunks : [text]) {
1023-
if (!chunk) {
1024-
continue;
1025-
}
1026-
await sendMessageMattermost(to, chunk, {
1027-
accountId: account.accountId,
1028-
replyToId: resolveMattermostReplyRootId({
1029-
threadRootId: params.effectiveReplyToId,
1030-
replyToId: payload.replyToId,
1031-
}),
1032-
});
1033-
}
1034-
return;
1035-
}
1036-
1037-
let first = true;
1038-
for (const mediaUrl of mediaUrls) {
1039-
const caption = first ? text : "";
1040-
first = false;
1041-
await sendMessageMattermost(to, caption, {
1042-
accountId: account.accountId,
1043-
mediaUrl,
1044-
replyToId: resolveMattermostReplyRootId({
1045-
threadRootId: params.effectiveReplyToId,
1046-
replyToId: payload.replyToId,
1047-
}),
1048-
});
1049-
}
993+
await deliverMattermostReplyPayload({
994+
core,
995+
cfg,
996+
payload: trimmedPayload,
997+
to,
998+
accountId: account.accountId,
999+
agentId: params.route.agentId,
1000+
replyToId: resolveMattermostReplyRootId({
1001+
threadRootId: params.effectiveReplyToId,
1002+
replyToId: trimmedPayload.replyToId,
1003+
}),
1004+
textLimit,
1005+
// The picker path already converts and trims text before capture/delivery.
1006+
tableMode: "off",
1007+
sendMessage: sendMessageMattermost,
1008+
});
10501009
},
10511010
onError: (err, info) => {
10521011
runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`);
@@ -1743,42 +1702,21 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
17431702
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
17441703
typingCallbacks,
17451704
deliver: async (payload: ReplyPayload) => {
1746-
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
1747-
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
1748-
if (mediaUrls.length === 0) {
1749-
const chunkMode = core.channel.text.resolveChunkMode(
1750-
cfg,
1751-
"mattermost",
1752-
account.accountId,
1753-
);
1754-
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
1755-
for (const chunk of chunks.length > 0 ? chunks : [text]) {
1756-
if (!chunk) {
1757-
continue;
1758-
}
1759-
await sendMessageMattermost(to, chunk, {
1760-
accountId: account.accountId,
1761-
replyToId: resolveMattermostReplyRootId({
1762-
threadRootId: effectiveReplyToId,
1763-
replyToId: payload.replyToId,
1764-
}),
1765-
});
1766-
}
1767-
} else {
1768-
let first = true;
1769-
for (const mediaUrl of mediaUrls) {
1770-
const caption = first ? text : "";
1771-
first = false;
1772-
await sendMessageMattermost(to, caption, {
1773-
accountId: account.accountId,
1774-
mediaUrl,
1775-
replyToId: resolveMattermostReplyRootId({
1776-
threadRootId: effectiveReplyToId,
1777-
replyToId: payload.replyToId,
1778-
}),
1779-
});
1780-
}
1781-
}
1705+
await deliverMattermostReplyPayload({
1706+
core,
1707+
cfg,
1708+
payload,
1709+
to,
1710+
accountId: account.accountId,
1711+
agentId: route.agentId,
1712+
replyToId: resolveMattermostReplyRootId({
1713+
threadRootId: effectiveReplyToId,
1714+
replyToId: payload.replyToId,
1715+
}),
1716+
textLimit,
1717+
tableMode,
1718+
sendMessage: sendMessageMattermost,
1719+
});
17821720
runtime.log?.(`delivered reply to ${to}`);
17831721
},
17841722
onError: (err, info) => {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
5+
import { describe, expect, it, vi } from "vitest";
6+
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
7+
8+
describe("deliverMattermostReplyPayload", () => {
9+
it("passes agent-scoped mediaLocalRoots when sending media paths", async () => {
10+
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
11+
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mm-state-"));
12+
process.env.OPENCLAW_STATE_DIR = stateDir;
13+
14+
try {
15+
const sendMessage = vi.fn(async () => undefined);
16+
const core = {
17+
channel: {
18+
text: {
19+
convertMarkdownTables: vi.fn((text: string) => text),
20+
resolveChunkMode: vi.fn(() => "length"),
21+
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
22+
},
23+
},
24+
} as any;
25+
26+
const agentId = "agent-1";
27+
const mediaUrl = `file://${path.join(stateDir, `workspace-${agentId}`, "photo.png")}`;
28+
const cfg = {} satisfies OpenClawConfig;
29+
30+
await deliverMattermostReplyPayload({
31+
core,
32+
cfg,
33+
payload: { text: "caption", mediaUrl },
34+
to: "channel:town-square",
35+
accountId: "default",
36+
agentId,
37+
replyToId: "root-post",
38+
textLimit: 4000,
39+
tableMode: "off",
40+
sendMessage,
41+
});
42+
43+
expect(sendMessage).toHaveBeenCalledTimes(1);
44+
expect(sendMessage).toHaveBeenCalledWith(
45+
"channel:town-square",
46+
"caption",
47+
expect.objectContaining({
48+
accountId: "default",
49+
mediaUrl,
50+
replyToId: "root-post",
51+
mediaLocalRoots: expect.arrayContaining([path.join(stateDir, `workspace-${agentId}`)]),
52+
}),
53+
);
54+
} finally {
55+
if (previousStateDir === undefined) {
56+
delete process.env.OPENCLAW_STATE_DIR;
57+
} else {
58+
process.env.OPENCLAW_STATE_DIR = previousStateDir;
59+
}
60+
await fs.rm(stateDir, { recursive: true, force: true });
61+
}
62+
});
63+
64+
it("forwards replyToId for text-only chunked replies", async () => {
65+
const sendMessage = vi.fn(async () => undefined);
66+
const core = {
67+
channel: {
68+
text: {
69+
convertMarkdownTables: vi.fn((text: string) => text),
70+
resolveChunkMode: vi.fn(() => "length"),
71+
chunkMarkdownTextWithMode: vi.fn(() => ["hello"]),
72+
},
73+
},
74+
} as any;
75+
76+
await deliverMattermostReplyPayload({
77+
core,
78+
cfg: {} satisfies OpenClawConfig,
79+
payload: { text: "hello" },
80+
to: "channel:town-square",
81+
accountId: "default",
82+
agentId: "agent-1",
83+
replyToId: "root-post",
84+
textLimit: 4000,
85+
tableMode: "off",
86+
sendMessage,
87+
});
88+
89+
expect(sendMessage).toHaveBeenCalledTimes(1);
90+
expect(sendMessage).toHaveBeenCalledWith("channel:town-square", "hello", {
91+
accountId: "default",
92+
replyToId: "root-post",
93+
});
94+
});
95+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "openclaw/plugin-sdk/mattermost";
2+
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/mattermost";
3+
4+
type MarkdownTableMode = Parameters<PluginRuntime["channel"]["text"]["convertMarkdownTables"]>[1];
5+
6+
type SendMattermostMessage = (
7+
to: string,
8+
text: string,
9+
opts: {
10+
accountId?: string;
11+
mediaUrl?: string;
12+
mediaLocalRoots?: readonly string[];
13+
replyToId?: string;
14+
},
15+
) => Promise<unknown>;
16+
17+
export async function deliverMattermostReplyPayload(params: {
18+
core: PluginRuntime;
19+
cfg: OpenClawConfig;
20+
payload: ReplyPayload;
21+
to: string;
22+
accountId: string;
23+
agentId?: string;
24+
replyToId?: string;
25+
textLimit: number;
26+
tableMode: MarkdownTableMode;
27+
sendMessage: SendMattermostMessage;
28+
}): Promise<void> {
29+
const mediaUrls =
30+
params.payload.mediaUrls ?? (params.payload.mediaUrl ? [params.payload.mediaUrl] : []);
31+
const text = params.core.channel.text.convertMarkdownTables(
32+
params.payload.text ?? "",
33+
params.tableMode,
34+
);
35+
36+
if (mediaUrls.length === 0) {
37+
const chunkMode = params.core.channel.text.resolveChunkMode(
38+
params.cfg,
39+
"mattermost",
40+
params.accountId,
41+
);
42+
const chunks = params.core.channel.text.chunkMarkdownTextWithMode(
43+
text,
44+
params.textLimit,
45+
chunkMode,
46+
);
47+
for (const chunk of chunks.length > 0 ? chunks : [text]) {
48+
if (!chunk) {
49+
continue;
50+
}
51+
await params.sendMessage(params.to, chunk, {
52+
accountId: params.accountId,
53+
replyToId: params.replyToId,
54+
});
55+
}
56+
return;
57+
}
58+
59+
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
60+
let first = true;
61+
for (const mediaUrl of mediaUrls) {
62+
const caption = first ? text : "";
63+
first = false;
64+
await params.sendMessage(params.to, caption, {
65+
accountId: params.accountId,
66+
mediaUrl,
67+
mediaLocalRoots,
68+
replyToId: params.replyToId,
69+
});
70+
}
71+
}

0 commit comments

Comments
 (0)