Telegram: exec approvals for OpenCode/Codex (#37233)

Merged via squash.

Prepared head SHA: f243379094
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
This commit is contained in:
Harold Hunt
2026-03-09 23:04:35 -04:00
committed by GitHub
parent 9432a8bb3f
commit de49a8b72c
78 changed files with 4058 additions and 524 deletions

View File

@@ -1,8 +1,11 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { telegramOutbound } from "../channels/plugins/outbound/telegram.js";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
const baseRequest = {
@@ -18,8 +21,18 @@ const baseRequest = {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
const emptyRegistry = createTestRegistry([]);
const defaultRegistry = createTestRegistry([
{
pluginId: "telegram",
plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }),
source: "test",
},
]);
function getFirstDeliveryText(deliver: ReturnType<typeof vi.fn>): string {
const firstCall = deliver.mock.calls[0]?.[0] as
| { payloads?: Array<{ text?: string }> }
@@ -32,7 +45,7 @@ const TARGETS_CFG = {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
targets: [{ channel: "slack", to: "U123" }],
},
},
} as OpenClawConfig;
@@ -128,6 +141,14 @@ async function expectSessionFilterRequestResult(params: {
}
describe("exec approval forwarder", () => {
beforeEach(() => {
setActivePluginRegistry(defaultRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("forwards to session target and resolves", async () => {
vi.useFakeTimers();
const cfg = {
@@ -159,19 +180,118 @@ describe("exec approval forwarder", () => {
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true);
await Promise.resolve();
expect(deliver).toHaveBeenCalledTimes(1);
await vi.runAllTimersAsync();
expect(deliver).toHaveBeenCalledTimes(2);
});
it("skips telegram forwarding when telegram exec approvals handler is enabled", async () => {
vi.useFakeTimers();
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "session",
},
},
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["123"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "telegram", to: "-100999", threadId: 77 }),
});
await expect(
forwarder.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
turnSourceChannel: "telegram",
turnSourceTo: "-100999",
turnSourceThreadId: "77",
turnSourceAccountId: "default",
},
}),
).resolves.toBe(false);
expect(deliver).not.toHaveBeenCalled();
});
it("attaches explicit telegram buttons in forwarded telegram fallback payloads", async () => {
vi.useFakeTimers();
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({ cfg });
await expect(
forwarder.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
turnSourceChannel: "discord",
turnSourceTo: "channel:123",
},
}),
).resolves.toBe(true);
expect(deliver).toHaveBeenCalledTimes(1);
expect(deliver).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "123",
payloads: [
expect.objectContaining({
channelData: {
execApproval: expect.objectContaining({
approvalId: "req-1",
}),
telegram: {
buttons: [
[
{ text: "Allow Once", callback_data: "/approve req-1 allow-once" },
{ text: "Allow Always", callback_data: "/approve req-1 allow-always" },
],
[{ text: "Deny", callback_data: "/approve req-1 deny" }],
],
},
},
}),
],
}),
);
});
it("formats single-line commands as inline code", async () => {
vi.useFakeTimers();
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true);
await Promise.resolve();
expect(getFirstDeliveryText(deliver)).toContain("Command: `echo hello`");
const text = getFirstDeliveryText(deliver);
expect(text).toContain("🔒 Exec approval required");
expect(text).toContain("Command: `echo hello`");
expect(text).toContain("Expires in: 5s");
expect(text).toContain("Reply with: /approve <id> allow-once|allow-always|deny");
});
it("formats complex commands as fenced code blocks", async () => {
@@ -187,8 +307,9 @@ describe("exec approval forwarder", () => {
},
}),
).resolves.toBe(true);
await Promise.resolve();
expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```");
expect(getFirstDeliveryText(deliver)).toContain("```\necho `uname`\necho done\n```");
});
it("returns false when forwarding is disabled", async () => {
@@ -334,7 +455,8 @@ describe("exec approval forwarder", () => {
},
}),
).resolves.toBe(true);
await Promise.resolve();
expect(getFirstDeliveryText(deliver)).toContain("Command:\n````\necho ```danger```\n````");
expect(getFirstDeliveryText(deliver)).toContain("````\necho ```danger```\n````");
});
});

View File

@@ -1,3 +1,4 @@
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
@@ -8,11 +9,14 @@ import type {
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js";
import { buildTelegramExecApprovalButtons } from "../telegram/approval-buttons.js";
import { sendTypingTelegram } from "../telegram/send.js";
import {
isDeliverableMessageChannel,
normalizeMessageChannel,
type DeliverableMessageChannel,
} from "../utils/message-channel.js";
import { buildExecApprovalPendingReplyPayload } from "./exec-approval-reply.js";
import type {
ExecApprovalDecision,
ExecApprovalRequest,
@@ -65,7 +69,11 @@ function matchSessionFilter(sessionKey: string, patterns: string[]): boolean {
}
function shouldForward(params: {
config?: ExecApprovalForwardingConfig;
config?: {
enabled?: boolean;
agentFilter?: string[];
sessionFilter?: string[];
};
request: ExecApprovalRequest;
}): boolean {
const config = params.config;
@@ -147,6 +155,48 @@ function shouldSkipDiscordForwarding(
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
}
function shouldSkipTelegramForwarding(params: {
target: ExecApprovalForwardTarget;
cfg: OpenClawConfig;
request: ExecApprovalRequest;
}): boolean {
const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel;
if (channel !== "telegram") {
return false;
}
const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel ?? "");
if (requestChannel !== "telegram") {
return false;
}
const telegram = params.cfg.channels?.telegram;
if (!telegram) {
return false;
}
const telegramConfig = telegram as
| {
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
accounts?: Record<
string,
{ execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
>;
}
| undefined;
if (!telegramConfig) {
return false;
}
const accountId =
params.target.accountId?.trim() || params.request.request.turnSourceAccountId?.trim();
const account = accountId
? (resolveChannelAccountConfig<{
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
}>(telegramConfig.accounts, accountId) as
| { execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
| undefined)
: undefined;
const execApprovals = account?.execApprovals ?? telegramConfig.execApprovals;
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
}
function formatApprovalCommand(command: string): { inline: boolean; text: string } {
if (!command.includes("\n") && !command.includes("`")) {
return { inline: true, text: `\`${command}\`` };
@@ -191,6 +241,10 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
}
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
lines.push(`Expires in: ${expiresIn}s`);
lines.push("Mode: foreground (interactive approvals available in this chat).");
lines.push(
"Background mode note: non-interactive runs cannot wait for chat approvals; use pre-approved policy (allow-always or ask=off).",
);
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
return lines.join("\n");
}
@@ -261,7 +315,7 @@ function defaultResolveSessionTarget(params: {
async function deliverToTargets(params: {
cfg: OpenClawConfig;
targets: ForwardTarget[];
text: string;
buildPayload: (target: ForwardTarget) => ReplyPayload;
deliver: typeof deliverOutboundPayloads;
shouldSend?: () => boolean;
}) {
@@ -274,13 +328,33 @@ async function deliverToTargets(params: {
return;
}
try {
const payload = params.buildPayload(target);
if (
channel === "telegram" &&
payload.channelData &&
typeof payload.channelData === "object" &&
!Array.isArray(payload.channelData) &&
payload.channelData.execApproval
) {
const threadId =
typeof target.threadId === "number"
? target.threadId
: typeof target.threadId === "string"
? Number.parseInt(target.threadId, 10)
: undefined;
await sendTypingTelegram(target.to, {
cfg: params.cfg,
accountId: target.accountId,
...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}),
}).catch(() => {});
}
await params.deliver({
cfg: params.cfg,
channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
payloads: [{ text: params.text }],
payloads: [payload],
});
} catch (err) {
log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`);
@@ -289,6 +363,42 @@ async function deliverToTargets(params: {
await Promise.allSettled(deliveries);
}
function buildRequestPayloadForTarget(
_cfg: OpenClawConfig,
request: ExecApprovalRequest,
nowMsValue: number,
target: ForwardTarget,
): ReplyPayload {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (channel === "telegram") {
const payload = buildExecApprovalPendingReplyPayload({
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: request.request.command,
cwd: request.request.cwd ?? undefined,
host: request.request.host === "node" ? "node" : "gateway",
nodeId: request.request.nodeId ?? undefined,
expiresAtMs: request.expiresAtMs,
nowMs: nowMsValue,
});
const buttons = buildTelegramExecApprovalButtons(request.id);
if (!buttons) {
return payload;
}
return {
...payload,
channelData: {
...payload.channelData,
telegram: {
buttons,
},
},
};
}
return { text: buildRequestMessage(request, nowMsValue) };
}
function resolveForwardTargets(params: {
cfg: OpenClawConfig;
config?: ExecApprovalForwardingConfig;
@@ -343,15 +453,20 @@ export function createExecApprovalForwarder(
const handleRequested = async (request: ExecApprovalRequest): Promise<boolean> => {
const cfg = getConfig();
const config = cfg.approvals?.exec;
if (!shouldForward({ config, request })) {
return false;
}
const filteredTargets = resolveForwardTargets({
cfg,
config,
request,
resolveSessionTarget,
}).filter((target) => !shouldSkipDiscordForwarding(target, cfg));
const filteredTargets = [
...(shouldForward({ config, request })
? resolveForwardTargets({
cfg,
config,
request,
resolveSessionTarget,
})
: []),
].filter(
(target) =>
!shouldSkipDiscordForwarding(target, cfg) &&
!shouldSkipTelegramForwarding({ target, cfg, request }),
);
if (filteredTargets.length === 0) {
return false;
@@ -366,7 +481,12 @@ export function createExecApprovalForwarder(
}
pending.delete(request.id);
const expiredText = buildExpiredMessage(request);
await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver });
await deliverToTargets({
cfg,
targets: entry.targets,
buildPayload: () => ({ text: expiredText }),
deliver,
});
})();
}, expiresInMs);
timeoutId.unref?.();
@@ -377,12 +497,10 @@ export function createExecApprovalForwarder(
if (pending.get(request.id) !== pendingEntry) {
return false;
}
const text = buildRequestMessage(request, nowMs());
void deliverToTargets({
cfg,
targets: filteredTargets,
text,
buildPayload: (target) => buildRequestPayloadForTarget(cfg, request, nowMs(), target),
deliver,
shouldSend: () => pending.get(request.id) === pendingEntry,
}).catch((err) => {
@@ -410,20 +528,26 @@ export function createExecApprovalForwarder(
expiresAtMs: resolved.ts,
};
const config = cfg.approvals?.exec;
if (shouldForward({ config, request })) {
targets = resolveForwardTargets({
cfg,
config,
request,
resolveSessionTarget,
}).filter((target) => !shouldSkipDiscordForwarding(target, cfg));
}
targets = [
...(shouldForward({ config, request })
? resolveForwardTargets({
cfg,
config,
request,
resolveSessionTarget,
})
: []),
].filter(
(target) =>
!shouldSkipDiscordForwarding(target, cfg) &&
!shouldSkipTelegramForwarding({ target, cfg, request }),
);
}
if (!targets || targets.length === 0) {
return;
}
const text = buildResolvedMessage(resolved);
await deliverToTargets({ cfg, targets, text, deliver });
await deliverToTargets({ cfg, targets, buildPayload: () => ({ text }), deliver });
};
const stop = () => {

View File

@@ -0,0 +1,172 @@
import type { ReplyPayload } from "../auto-reply/types.js";
import type { ExecHost } from "./exec-approvals.js";
export type ExecApprovalReplyDecision = "allow-once" | "allow-always" | "deny";
export type ExecApprovalUnavailableReason =
| "initiating-platform-disabled"
| "initiating-platform-unsupported"
| "no-approval-route";
export type ExecApprovalReplyMetadata = {
approvalId: string;
approvalSlug: string;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
};
export type ExecApprovalPendingReplyParams = {
warningText?: string;
approvalId: string;
approvalSlug: string;
approvalCommandId?: string;
command: string;
cwd?: string;
host: ExecHost;
nodeId?: string;
expiresAtMs?: number;
nowMs?: number;
};
export type ExecApprovalUnavailableReplyParams = {
warningText?: string;
channelLabel?: string;
reason: ExecApprovalUnavailableReason;
sentApproverDms?: boolean;
};
export function getExecApprovalApproverDmNoticeText(): string {
return "Approval required. I sent the allowed approvers DMs.";
}
function buildFence(text: string, language?: string): string {
let fence = "```";
while (text.includes(fence)) {
fence += "`";
}
const languagePrefix = language ? language : "";
return `${fence}${languagePrefix}\n${text}\n${fence}`;
}
export function getExecApprovalReplyMetadata(
payload: ReplyPayload,
): ExecApprovalReplyMetadata | null {
const channelData = payload.channelData;
if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) {
return null;
}
const execApproval = channelData.execApproval;
if (!execApproval || typeof execApproval !== "object" || Array.isArray(execApproval)) {
return null;
}
const record = execApproval as Record<string, unknown>;
const approvalId = typeof record.approvalId === "string" ? record.approvalId.trim() : "";
const approvalSlug = typeof record.approvalSlug === "string" ? record.approvalSlug.trim() : "";
if (!approvalId || !approvalSlug) {
return null;
}
const allowedDecisions = Array.isArray(record.allowedDecisions)
? record.allowedDecisions.filter(
(value): value is ExecApprovalReplyDecision =>
value === "allow-once" || value === "allow-always" || value === "deny",
)
: undefined;
return {
approvalId,
approvalSlug,
allowedDecisions,
};
}
export function buildExecApprovalPendingReplyPayload(
params: ExecApprovalPendingReplyParams,
): ReplyPayload {
const approvalCommandId = params.approvalCommandId?.trim() || params.approvalSlug;
const lines: string[] = [];
const warningText = params.warningText?.trim();
if (warningText) {
lines.push(warningText, "");
}
lines.push("Approval required.");
lines.push("Run:");
lines.push(buildFence(`/approve ${approvalCommandId} allow-once`, "txt"));
lines.push("Pending command:");
lines.push(buildFence(params.command, "sh"));
lines.push("Other options:");
lines.push(
buildFence(
`/approve ${approvalCommandId} allow-always\n/approve ${approvalCommandId} deny`,
"txt",
),
);
const info: string[] = [];
info.push(`Host: ${params.host}`);
if (params.nodeId) {
info.push(`Node: ${params.nodeId}`);
}
if (params.cwd) {
info.push(`CWD: ${params.cwd}`);
}
if (typeof params.expiresAtMs === "number" && Number.isFinite(params.expiresAtMs)) {
const expiresInSec = Math.max(
0,
Math.round((params.expiresAtMs - (params.nowMs ?? Date.now())) / 1000),
);
info.push(`Expires in: ${expiresInSec}s`);
}
info.push(`Full id: \`${params.approvalId}\``);
lines.push(info.join("\n"));
return {
text: lines.join("\n\n"),
channelData: {
execApproval: {
approvalId: params.approvalId,
approvalSlug: params.approvalSlug,
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
};
}
export function buildExecApprovalUnavailableReplyPayload(
params: ExecApprovalUnavailableReplyParams,
): ReplyPayload {
const lines: string[] = [];
const warningText = params.warningText?.trim();
if (warningText) {
lines.push(warningText, "");
}
if (params.sentApproverDms) {
lines.push(getExecApprovalApproverDmNoticeText());
return {
text: lines.join("\n\n"),
};
}
if (params.reason === "initiating-platform-disabled") {
lines.push(
`Exec approval is required, but chat exec approvals are not enabled on ${params.channelLabel ?? "this platform"}.`,
);
lines.push(
"Approve it from the Web UI or terminal UI, or from Discord or Telegram if those approval clients are enabled.",
);
} else if (params.reason === "initiating-platform-unsupported") {
lines.push(
`Exec approval is required, but ${params.channelLabel ?? "this platform"} does not support chat exec approvals.`,
);
lines.push(
"Approve it from the Web UI or terminal UI, or from Discord or Telegram if those approval clients are enabled.",
);
} else {
lines.push(
"Exec approval is required, but no interactive approval client is currently available.",
);
lines.push(
"Open the Web UI or terminal UI, or enable Discord or Telegram exec approvals, then retry the command.",
);
}
return {
text: lines.join("\n\n"),
};
}

View File

@@ -0,0 +1,77 @@
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { listEnabledDiscordAccounts } from "../discord/accounts.js";
import { isDiscordExecApprovalClientEnabled } from "../discord/exec-approvals.js";
import { listEnabledTelegramAccounts } from "../telegram/accounts.js";
import { isTelegramExecApprovalClientEnabled } from "../telegram/exec-approvals.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../utils/message-channel.js";
export type ExecApprovalInitiatingSurfaceState =
| { kind: "enabled"; channel: string | undefined; channelLabel: string }
| { kind: "disabled"; channel: string; channelLabel: string }
| { kind: "unsupported"; channel: string; channelLabel: string };
function labelForChannel(channel?: string): string {
switch (channel) {
case "discord":
return "Discord";
case "telegram":
return "Telegram";
case "tui":
return "terminal UI";
case INTERNAL_MESSAGE_CHANNEL:
return "Web UI";
default:
return channel ? channel[0]?.toUpperCase() + channel.slice(1) : "this platform";
}
}
export function resolveExecApprovalInitiatingSurfaceState(params: {
channel?: string | null;
accountId?: string | null;
cfg?: OpenClawConfig;
}): ExecApprovalInitiatingSurfaceState {
const channel = normalizeMessageChannel(params.channel);
const channelLabel = labelForChannel(channel);
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL || channel === "tui") {
return { kind: "enabled", channel, channelLabel };
}
const cfg = params.cfg ?? loadConfig();
if (channel === "telegram") {
return isTelegramExecApprovalClientEnabled({ cfg, accountId: params.accountId })
? { kind: "enabled", channel, channelLabel }
: { kind: "disabled", channel, channelLabel };
}
if (channel === "discord") {
return isDiscordExecApprovalClientEnabled({ cfg, accountId: params.accountId })
? { kind: "enabled", channel, channelLabel }
: { kind: "disabled", channel, channelLabel };
}
return { kind: "unsupported", channel, channelLabel };
}
export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
for (const account of listEnabledDiscordAccounts(cfg)) {
const execApprovals = account.config.execApprovals;
if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) {
continue;
}
const target = execApprovals.target ?? "dm";
if (target === "dm" || target === "both") {
return true;
}
}
for (const account of listEnabledTelegramAccounts(cfg)) {
const execApprovals = account.config.execApprovals;
if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) {
continue;
}
const target = execApprovals.target ?? "dm";
if (target === "dm" || target === "both") {
return true;
}
}
return false;
}

View File

@@ -307,6 +307,75 @@ describe("deliverOutboundPayloads", () => {
);
});
it("does not inject telegram approval buttons from plain approval text", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
await deliverTelegramPayload({
sendTelegram,
cfg: {
channels: {
telegram: {
botToken: "tok-1",
execApprovals: {
enabled: true,
approvers: ["123"],
target: "dm",
},
},
},
},
payload: {
text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).",
},
});
const sendOpts = sendTelegram.mock.calls[0]?.[2] as { buttons?: unknown } | undefined;
expect(sendOpts?.buttons).toBeUndefined();
});
it("preserves explicit telegram buttons when sender path provides them", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: OpenClawConfig = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["123"],
target: "dm",
},
},
},
};
await deliverTelegramPayload({
sendTelegram,
cfg,
payload: {
text: "Approval required",
channelData: {
telegram: {
buttons: [
[
{ text: "Allow Once", callback_data: "/approve 117ba06d allow-once" },
{ text: "Allow Always", callback_data: "/approve 117ba06d allow-always" },
],
[{ text: "Deny", callback_data: "/approve 117ba06d deny" }],
],
},
},
},
});
const sendOpts = sendTelegram.mock.calls[0]?.[2] as { buttons?: unknown } | undefined;
expect(sendOpts?.buttons).toEqual([
[
{ text: "Allow Once", callback_data: "/approve 117ba06d allow-once" },
{ text: "Allow Always", callback_data: "/approve 117ba06d allow-always" },
],
[{ text: "Deny", callback_data: "/approve 117ba06d deny" }],
]);
});
it("scopes media local roots to the active agent workspace when agentId is provided", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });

View File

@@ -300,6 +300,9 @@ function normalizePayloadForChannelDelivery(
function normalizePayloadsForChannelDelivery(
payloads: ReplyPayload[],
channel: Exclude<OutboundChannel, "none">,
_cfg: OpenClawConfig,
_to: string,
_accountId?: string,
): ReplyPayload[] {
const normalizedPayloads: ReplyPayload[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
@@ -307,10 +310,13 @@ function normalizePayloadsForChannelDelivery(
// Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.)
// Models occasionally produce <br>, <b>, etc. that render as literal text.
// See https://github.com/openclaw/openclaw/issues/31884
if (isPlainTextSurface(channel) && payload.text) {
if (isPlainTextSurface(channel) && sanitizedPayload.text) {
// Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path.
if (!(channel === "telegram" && payload.channelData)) {
sanitizedPayload = { ...payload, text: sanitizeForPlainText(payload.text) };
if (!(channel === "telegram" && sanitizedPayload.channelData)) {
sanitizedPayload = {
...sanitizedPayload,
text: sanitizeForPlainText(sanitizedPayload.text),
};
}
}
const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel);
@@ -662,7 +668,13 @@ async function deliverOutboundPayloadsCore(
})),
};
};
const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, channel);
const normalizedPayloads = normalizePayloadsForChannelDelivery(
payloads,
channel,
cfg,
to,
accountId,
);
const hookRunner = getGlobalHookRunner();
const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key;
const mirrorIsGroup = params.mirror?.isGroup;