Merge branch 'main' into dashboard-v2-views-refactor

This commit is contained in:
Vincent Koc
2026-03-12 10:44:08 -04:00
committed by GitHub
6 changed files with 233 additions and 141 deletions

View File

@@ -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

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

View File

@@ -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";