fix(mattermost): pass mediaLocalRoots through reply delivery (#44021)

Merged via squash.

Prepared head SHA: 856f11f129f7d6a4bc8f23e8d13c786ecb871f52
Co-authored-by: LyleLiu666 <31182860+LyleLiu666@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
This commit is contained in:
Lyle
2026-03-12 14:43:51 +00:00
committed by GitHub
parent 797b6fe614
commit c965049dc6
6 changed files with 233 additions and 141 deletions

View File

@@ -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) => {

View File

@@ -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",
});
});
});

View File

@@ -0,0 +1,71 @@
import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "openclaw/plugin-sdk/mattermost";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/mattermost";
type MarkdownTableMode = Parameters<PluginRuntime["channel"]["text"]["convertMarkdownTables"]>[1];
type SendMattermostMessage = (
to: string,
text: string,
opts: {
accountId?: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
replyToId?: string;
},
) => Promise<unknown>;
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<void> {
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,
});
}
}

View File

@@ -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) => {