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:
@@ -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) => {
|
||||
|
||||
95
extensions/mattermost/src/mattermost/reply-delivery.test.ts
Normal file
95
extensions/mattermost/src/mattermost/reply-delivery.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
71
extensions/mattermost/src/mattermost/reply-delivery.ts
Normal file
71
extensions/mattermost/src/mattermost/reply-delivery.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user