mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-27 12:30:40 +00:00
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:
@@ -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````");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
172
src/infra/exec-approval-reply.ts
Normal file
172
src/infra/exec-approval-reply.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
77
src/infra/exec-approval-surface.ts
Normal file
77
src/infra/exec-approval-surface.ts
Normal 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;
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user