Telegram: add deterministic exec approval prompts and config gating

This commit is contained in:
huntharo
2026-03-05 11:01:24 -05:00
parent e18e4b2009
commit abe0ede9d2
50 changed files with 2198 additions and 97 deletions

View File

@@ -179,6 +179,41 @@ describe("telegramPlugin duplicate token guard", () => {
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" });
});
it("preserves buttons for outbound text payload sends", async () => {
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-2" }));
setTelegramRuntime({
channel: {
telegram: {
sendMessageTelegram,
},
},
} as unknown as PluginRuntime);
const result = await telegramPlugin.outbound!.sendPayload!({
cfg: createCfg(),
to: "12345",
text: "",
payload: {
text: "Approval required",
channelData: {
telegram: {
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
},
},
},
accountId: "ops",
});
expect(sendMessageTelegram).toHaveBeenCalledWith(
"12345",
"Approval required",
expect.objectContaining({
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
}),
);
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" });
});
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {} as never;

View File

@@ -91,6 +91,10 @@ const telegramMessageActions: ChannelMessageActionAdapter = {
},
};
type TelegramInlineButtons = ReadonlyArray<
ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
>;
const telegramConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom,
@@ -317,6 +321,62 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
chunkerMode: "markdown",
textChunkLimit: 4000,
pollMaxOptions: 10,
sendPayload: async ({
cfg,
to,
payload,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
silent,
}) => {
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
const messageThreadId = parseTelegramThreadId(threadId);
const telegramData = payload.channelData?.telegram as
| { buttons?: TelegramInlineButtons; quoteText?: string }
| undefined;
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
const text = payload.text ?? "";
const mediaUrls = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const baseOpts = {
verbose: false,
cfg,
mediaLocalRoots,
messageThreadId,
replyToMessageId,
quoteText,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
};
if (mediaUrls.length === 0) {
const result = await send(to, text, {
...baseOpts,
buttons: telegramData?.buttons,
});
return { channel: "telegram", ...result };
}
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
for (let i = 0; i < mediaUrls.length; i += 1) {
const mediaUrl = mediaUrls[i];
const isFirst = i === 0;
finalResult = await send(to, isFirst ? text : "", {
...baseOpts,
mediaUrl,
...(isFirst ? { buttons: telegramData?.buttons } : {}),
});
}
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);

View File

@@ -321,6 +321,7 @@ export async function processGatewayAllowlist(
host: "gateway",
command: params.command,
cwd: params.workdir,
warningText,
},
},
};

View File

@@ -377,6 +377,7 @@ export async function executeNodeHostCommand(
command: params.command,
cwd: params.workdir,
nodeId,
warningText,
},
};
}

View File

@@ -60,4 +60,5 @@ export type ExecToolDetails =
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
};

View File

@@ -1457,6 +1457,7 @@ export async function runEmbeddedPiAgent(
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
inlineToolResultsAllowed: false,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
});
// Timeout aborts can leave the run without any assistant payloads.
@@ -1479,6 +1480,7 @@ export async function runEmbeddedPiAgent(
systemPromptReport: attempt.systemPromptReport,
},
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,
@@ -1526,6 +1528,7 @@ export async function runEmbeddedPiAgent(
: undefined,
},
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,

View File

@@ -1544,6 +1544,7 @@ export async function runEmbeddedAttempt(
getMessagingToolSentTargets,
getSuccessfulCronAdds,
didSendViaMessagingTool,
didSendDeterministicApprovalPrompt,
getLastToolError,
getUsageTotals,
getCompactionCount,
@@ -2058,6 +2059,7 @@ export async function runEmbeddedAttempt(
lastAssistant,
lastToolError: getLastToolError?.(),
didSendViaMessagingTool: didSendViaMessagingTool(),
didSendDeterministicApprovalPrompt: didSendDeterministicApprovalPrompt(),
messagingToolSentTexts: getMessagingToolSentTexts(),
messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(),
messagingToolSentTargets: getMessagingToolSentTargets(),

View File

@@ -1,5 +1,6 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import type { AgentStreamParams } from "../../../commands/agent/types.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { enqueueCommand } from "../../../process/command-queue.js";
@@ -104,7 +105,7 @@ export type RunEmbeddedPiAgentParams = {
blockReplyChunking?: BlockReplyChunking;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onReasoningEnd?: () => void | Promise<void>;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
lane?: string;
enqueue?: typeof enqueueCommand;

View File

@@ -82,4 +82,13 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
expect(payloads).toHaveLength(0);
});
it("suppresses assistant text when a deterministic exec approval prompt was already delivered", () => {
const payloads = buildPayloads({
assistantTexts: ["Approval is needed. Please run /approve abc allow-once"],
didSendDeterministicApprovalPrompt: true,
});
expect(payloads).toHaveLength(0);
});
});

View File

@@ -102,6 +102,7 @@ export function buildEmbeddedRunPayloads(params: {
suppressToolErrorWarnings?: boolean;
inlineToolResultsAllowed: boolean;
didSendViaMessagingTool?: boolean;
didSendDeterministicApprovalPrompt?: boolean;
}): Array<{
text?: string;
mediaUrl?: string;
@@ -125,14 +126,17 @@ export function buildEmbeddedRunPayloads(params: {
}> = [];
const useMarkdown = params.toolResultFormat === "markdown";
const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true;
const lastAssistantErrored = params.lastAssistant?.stopReason === "error";
const errorText = params.lastAssistant
? formatAssistantErrorText(params.lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
})
? suppressAssistantArtifacts
? undefined
: formatAssistantErrorText(params.lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
})
: undefined;
const rawErrorMessage = lastAssistantErrored
? params.lastAssistant?.errorMessage?.trim() || undefined
@@ -184,8 +188,9 @@ export function buildEmbeddedRunPayloads(params: {
}
}
const reasoningText =
params.lastAssistant && params.reasoningLevel === "on"
const reasoningText = suppressAssistantArtifacts
? ""
: params.lastAssistant && params.reasoningLevel === "on"
? formatReasoningMessage(extractAssistantThinking(params.lastAssistant))
: "";
if (reasoningText) {
@@ -243,13 +248,14 @@ export function buildEmbeddedRunPayloads(params: {
}
return isRawApiErrorPayload(trimmed);
};
const answerTexts = (
params.assistantTexts.length
? params.assistantTexts
: fallbackAnswerText
? [fallbackAnswerText]
: []
).filter((text) => !shouldSuppressRawErrorText(text));
const answerTexts = suppressAssistantArtifacts
? []
: (params.assistantTexts.length
? params.assistantTexts
: fallbackAnswerText
? [fallbackAnswerText]
: []
).filter((text) => !shouldSuppressRawErrorText(text));
let hasUserFacingAssistantReply = false;
for (const text of answerTexts) {

View File

@@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = {
actionFingerprint?: string;
};
didSendViaMessagingTool: boolean;
didSendDeterministicApprovalPrompt?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];

View File

@@ -85,6 +85,9 @@ export function handleMessageUpdate(
}
ctx.noteLastAssistant(msg);
if (ctx.state.deterministicApprovalPromptSent) {
return;
}
const assistantEvent = evt.assistantMessageEvent;
const assistantRecord =
@@ -261,6 +264,9 @@ export function handleMessageEnd(
const assistantMessage = msg;
ctx.noteLastAssistant(assistantMessage);
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
if (ctx.state.deterministicApprovalPromptSent) {
return;
}
promoteThinkingTagsToBlocks(assistantMessage);
const rawText = extractAssistantText(assistantMessage);

View File

@@ -28,6 +28,7 @@ function createMockContext(overrides?: {
messagingToolSentTextsNormalized: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
deterministicApprovalPromptSent: false,
},
log: { debug: vi.fn(), warn: vi.fn() },
shouldEmitToolResult: vi.fn(() => false),

View File

@@ -45,6 +45,7 @@ function createTestContext(): {
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
successfulCronAdds: 0,
deterministicApprovalPromptSent: false,
},
shouldEmitToolResult: () => false,
shouldEmitToolOutput: () => false,
@@ -175,6 +176,50 @@ describe("handleToolExecutionEnd cron.add commitment tracking", () => {
});
});
describe("handleToolExecutionEnd exec approval prompts", () => {
it("emits a deterministic approval payload and marks assistant output suppressed", async () => {
const { ctx } = createTestContext();
const onToolResult = vi.fn();
ctx.params.onToolResult = onToolResult;
await handleToolExecutionEnd(
ctx as never,
{
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-approval",
isError: false,
result: {
details: {
status: "approval-pending",
approvalId: "12345678-1234-1234-1234-123456789012",
approvalSlug: "12345678",
expiresAtMs: 1_800_000_000_000,
host: "gateway",
command: "npm view diver name version description",
cwd: "/tmp/work",
warningText: "Warning: heredoc execution requires explicit approval in allowlist mode.",
},
},
} as never,
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("```txt\n/approve 12345678 allow-once\n```"),
channelData: {
execApproval: {
approvalId: "12345678-1234-1234-1234-123456789012",
approvalSlug: "12345678",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
}),
);
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
});
});
describe("messaging tool media URL tracking", () => {
it("tracks media arg from messaging tool as pending", async () => {
const { ctx } = createTestContext();

View File

@@ -1,5 +1,6 @@
import type { AgentEvent } from "@mariozechner/pi-agent-core";
import { emitAgentEvent } from "../infra/agent-events.js";
import { buildExecApprovalPendingReplyPayload } from "../infra/exec-approval-reply.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
@@ -139,6 +140,46 @@ function collectMessagingMediaUrlsFromToolResult(result: unknown): string[] {
return urls;
}
function readExecApprovalPendingDetails(result: unknown): {
approvalId: string;
approvalSlug: string;
expiresAtMs?: number;
host: "gateway" | "node";
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
} | null {
if (!result || typeof result !== "object") {
return null;
}
const outer = result as Record<string, unknown>;
const details =
outer.details && typeof outer.details === "object" && !Array.isArray(outer.details)
? (outer.details as Record<string, unknown>)
: outer;
if (details.status !== "approval-pending") {
return null;
}
const approvalId = typeof details.approvalId === "string" ? details.approvalId.trim() : "";
const approvalSlug = typeof details.approvalSlug === "string" ? details.approvalSlug.trim() : "";
const command = typeof details.command === "string" ? details.command : "";
const host = details.host === "node" ? "node" : details.host === "gateway" ? "gateway" : null;
if (!approvalId || !approvalSlug || !command || !host) {
return null;
}
return {
approvalId,
approvalSlug,
expiresAtMs: typeof details.expiresAtMs === "number" ? details.expiresAtMs : undefined,
host,
command,
cwd: typeof details.cwd === "string" ? details.cwd : undefined,
nodeId: typeof details.nodeId === "string" ? details.nodeId : undefined,
warningText: typeof details.warningText === "string" ? details.warningText : undefined,
};
}
function emitToolResultOutput(params: {
ctx: ToolHandlerContext;
toolName: string;
@@ -152,6 +193,28 @@ function emitToolResultOutput(params: {
return;
}
const approvalPending = readExecApprovalPendingDetails(result);
if (!isToolError && approvalPending) {
try {
void ctx.params.onToolResult(
buildExecApprovalPendingReplyPayload({
approvalId: approvalPending.approvalId,
approvalSlug: approvalPending.approvalSlug,
command: approvalPending.command,
cwd: approvalPending.cwd,
host: approvalPending.host,
nodeId: approvalPending.nodeId,
expiresAtMs: approvalPending.expiresAtMs,
warningText: approvalPending.warningText,
}),
);
ctx.state.deterministicApprovalPromptSent = true;
} catch {
// ignore delivery failures
}
return;
}
if (ctx.shouldEmitToolOutput()) {
const outputText = extractToolResultText(sanitizedResult);
if (outputText) {

View File

@@ -76,6 +76,7 @@ export type EmbeddedPiSubscribeState = {
pendingMessagingTargets: Map<string, MessagingToolSend>;
successfulCronAdds: number;
pendingMessagingMediaUrls: Map<string, string[]>;
deterministicApprovalPromptSent: boolean;
lastAssistant?: AgentMessage;
};
@@ -155,6 +156,7 @@ export type ToolHandlerState = Pick<
| "messagingToolSentMediaUrls"
| "messagingToolSentTargets"
| "successfulCronAdds"
| "deterministicApprovalPromptSent"
>;
export type ToolHandlerContext = {

View File

@@ -78,6 +78,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
pendingMessagingTargets: new Map(),
successfulCronAdds: 0,
pendingMessagingMediaUrls: new Map(),
deterministicApprovalPromptSent: false,
};
const usageTotals = {
input: 0,
@@ -598,6 +599,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
pendingMessagingTargets.clear();
state.successfulCronAdds = 0;
state.pendingMessagingMediaUrls.clear();
state.deterministicApprovalPromptSent = false;
resetAssistantMessageState(0);
};
@@ -688,6 +690,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
// which is generated AFTER the tool sends the actual answer.
didSendViaMessagingTool: () => messagingToolSentTexts.length > 0,
didSendDeterministicApprovalPrompt: () => state.deterministicApprovalPromptSent,
getLastToolError: () => (state.lastToolError ? { ...state.lastToolError } : undefined),
getUsageTotals,
getCompactionCount: () => compactionCount,

View File

@@ -1,5 +1,6 @@
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HookRunner } from "../plugins/hooks.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
@@ -16,7 +17,7 @@ export type SubscribeEmbeddedPiSessionParams = {
toolResultFormat?: ToolResultFormat;
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
/** Called when a thinking/reasoning block ends (</think> tag processed). */
onReasoningEnd?: () => void | Promise<void>;

View File

@@ -10,6 +10,7 @@ export function createBaseToolHandlerState() {
messagingToolSentTextsNormalized: [] as string[],
messagingToolSentMediaUrls: [] as string[],
messagingToolSentTargets: [] as unknown[],
deterministicApprovalPromptSent: false,
blockBuffer: "",
};
}

View File

@@ -445,8 +445,8 @@ export async function runAgentTurnWithFallback(params: {
}
await params.typingSignals.signalTextDelta(text);
await onToolResult({
...payload,
text,
mediaUrls: payload.mediaUrls,
});
})
.catch((err) => {

View File

@@ -21,7 +21,7 @@ type AgentRunParams = {
onAssistantMessageStart?: () => Promise<void> | void;
onReasoningStream?: (payload: { text?: string }) => Promise<void> | void;
onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
};
@@ -594,6 +594,40 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
});
it("preserves channelData on forwarded tool results", async () => {
const onToolResult = vi.fn();
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
await params.onToolResult?.({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
return { payloads: [{ text: "final" }], meta: {} };
});
const { run } = createMinimalRun({
typingMode: "message",
opts: { onToolResult },
});
await run();
expect(onToolResult).toHaveBeenCalledWith({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
});
it("retries transient HTTP failures once with timer-driven backoff", async () => {
vi.useFakeTimers();
let calls = 0;
@@ -1952,3 +1986,4 @@ describe("runReplyAgent memory flush", () => {
});
});
});
import type { ReplyPayload } from "../types.js";

View File

@@ -1,5 +1,9 @@
import { callGateway } from "../../gateway/call.js";
import { logVerbose } from "../../globals.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalClientEnabled,
} from "../../telegram/exec-approvals.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";
@@ -84,6 +88,29 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
return { shouldContinue: false, reply: { text: parsed.error } };
}
if (params.command.channel === "telegram") {
if (
!isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId })
) {
return {
shouldContinue: false,
reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." },
};
}
if (
!isTelegramExecApprovalApprover({
cfg: params.cfg,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
})
) {
return {
shouldContinue: false,
reply: { text: "❌ You are not authorized to approve exec requests on Telegram." },
};
}
}
const missingScope = requireGatewayClientScopeForInternalChannel(params, {
label: "/approve",
allowedScopes: ["operator.approvals", "operator.admin"],

View File

@@ -293,7 +293,12 @@ describe("/approve command", () => {
it("accepts Telegram command mentions for /approve", async () => {
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["*"] } },
channels: {
telegram: {
allowFrom: ["*"],
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
const params = buildParams("/approve@bot abc12345 allow-once", cfg, {
Provider: "telegram",
@@ -317,7 +322,12 @@ describe("/approve command", () => {
it("surfaces unknown or expired approval id errors", async () => {
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["*"] } },
channels: {
telegram: {
allowFrom: ["*"],
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
const params = buildParams("/approve abc12345 allow-once", cfg, {
Provider: "telegram",
@@ -332,6 +342,45 @@ describe("/approve command", () => {
expect(result.reply?.text).toContain("unknown or expired approval id");
});
it("rejects Telegram /approve when telegram exec approvals are disabled", async () => {
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["*"] } },
} as OpenClawConfig;
const params = buildParams("/approve abc12345 allow-once", cfg, {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
});
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Telegram exec approvals are not enabled");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects Telegram /approve from non-approvers", async () => {
const cfg = {
commands: { text: true },
channels: {
telegram: {
allowFrom: ["*"],
execApprovals: { enabled: true, approvers: ["999"], target: "dm" },
},
},
} as OpenClawConfig;
const params = buildParams("/approve abc12345 allow-once", cfg, {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
});
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("not authorized to approve");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects gateway clients without approvals scope", async () => {
const cfg = {
commands: { text: true },

View File

@@ -543,6 +543,51 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
});
it("delivers deterministic exec approval tool payloads in groups", async () => {
setNoAbort();
const cfg = emptyConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "telegram",
ChatType: "group",
});
const replyResolver = async (
_ctx: MsgContext,
opts?: GetReplyOptions,
_cfg?: OpenClawConfig,
) => {
await opts?.onToolResult?.({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
return { text: "NO_REPLY" } satisfies ReplyPayload;
};
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
expect(firstToolResultPayload(dispatcher)).toEqual(
expect.objectContaining({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
}),
);
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" });
});
it("sends tool results via dispatcher in DM sessions", async () => {
setNoAbort();
const cfg = emptyConfig;
@@ -601,6 +646,50 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
});
it("delivers deterministic exec approval tool payloads for native commands", async () => {
setNoAbort();
const cfg = emptyConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "telegram",
CommandSource: "native",
});
const replyResolver = async (
_ctx: MsgContext,
opts?: GetReplyOptions,
_cfg?: OpenClawConfig,
) => {
await opts?.onToolResult?.({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
return { text: "NO_REPLY" } satisfies ReplyPayload;
};
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
expect(firstToolResultPayload(dispatcher)).toEqual(
expect.objectContaining({
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
}),
);
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" });
});
it("fast-aborts without calling the reply resolver", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: true,

View File

@@ -368,6 +368,15 @@ export async function dispatchReplyFromConfig(params: {
if (shouldSendToolSummaries) {
return payload;
}
const execApproval =
payload.channelData &&
typeof payload.channelData === "object" &&
!Array.isArray(payload.channelData)
? payload.channelData.execApproval
: undefined;
if (execApproval && typeof execApproval === "object" && !Array.isArray(execApproval)) {
return payload;
}
// Group/native flows intentionally suppress tool summary text, but media-only
// tool results (for example TTS audio) must still be delivered.
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;

View File

@@ -115,7 +115,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
quoteText,
mediaLocalRoots,
};
if (mediaUrls.length === 0) {
const result = await send(to, text, {
...payloadOpts,

View File

@@ -522,6 +522,12 @@ const CHANNELS_AGENTS_TARGET_KEYS = [
"channels.telegram",
"channels.telegram.botToken",
"channels.telegram.capabilities.inlineButtons",
"channels.telegram.execApprovals",
"channels.telegram.execApprovals.enabled",
"channels.telegram.execApprovals.approvers",
"channels.telegram.execApprovals.agentFilter",
"channels.telegram.execApprovals.sessionFilter",
"channels.telegram.execApprovals.target",
"channels.whatsapp",
] as const;

View File

@@ -1383,6 +1383,18 @@ export const FIELD_HELP: Record<string, string> = {
"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected.",
"channels.telegram.capabilities.inlineButtons":
"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior.",
"channels.telegram.execApprovals":
"Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.",
"channels.telegram.execApprovals.enabled":
"Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.",
"channels.telegram.execApprovals.approvers":
"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.",
"channels.telegram.execApprovals.agentFilter":
'Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `["main", "ops-agent"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.',
"channels.telegram.execApprovals.sessionFilter":
"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.",
"channels.telegram.execApprovals.target":
'Controls where Telegram approval prompts are sent: "dm" sends to approver DMs (default), "channel" sends to the originating Telegram chat/topic, and "both" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.',
"channels.slack.configWrites":
"Allow Slack to write config in response to channel events/commands (default: true).",
"channels.slack.botToken":

View File

@@ -719,6 +719,12 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
"channels.telegram.execApprovals": "Telegram Exec Approvals",
"channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled",
"channels.telegram.execApprovals.approvers": "Telegram Exec Approval Approvers",
"channels.telegram.execApprovals.agentFilter": "Telegram Exec Approval Agent Filter",
"channels.telegram.execApprovals.sessionFilter": "Telegram Exec Approval Session Filter",
"channels.telegram.execApprovals.target": "Telegram Exec Approval Target",
"channels.telegram.threadBindings.enabled": "Telegram Thread Binding Enabled",
"channels.telegram.threadBindings.idleHours": "Telegram Thread Binding Idle Timeout (hours)",
"channels.telegram.threadBindings.maxAgeHours": "Telegram Thread Binding Max Age (hours)",

View File

@@ -38,6 +38,20 @@ export type TelegramNetworkConfig = {
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
export type TelegramStreamingMode = "off" | "partial" | "block" | "progress";
export type TelegramExecApprovalTarget = "dm" | "channel" | "both";
export type TelegramExecApprovalConfig = {
/** Enable Telegram exec approvals for this account. Default: false. */
enabled?: boolean;
/** Telegram user IDs allowed to approve exec requests. Required if enabled. */
approvers?: Array<string | number>;
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];
/** Only forward approvals matching these session key patterns (substring or regex). */
sessionFilter?: string[];
/** Where to send approval prompts. Default: "dm". */
target?: TelegramExecApprovalTarget;
};
export type TelegramCapabilitiesConfig =
| string[]
@@ -58,6 +72,8 @@ export type TelegramAccountConfig = {
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: TelegramCapabilitiesConfig;
/** Telegram-native exec approval delivery + approver authorization. */
execApprovals?: TelegramExecApprovalConfig;
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Override native command registration for Telegram (bool or "auto"). */

View File

@@ -49,6 +49,7 @@ const DiscordIdSchema = z
const DiscordIdListSchema = z.array(DiscordIdSchema);
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
const TelegramIdListSchema = z.array(z.union([z.string(), z.number()]));
const TelegramCapabilitiesSchema = z.union([
z.array(z.string()),
@@ -153,6 +154,16 @@ export const TelegramAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: TelegramCapabilitiesSchema.optional(),
execApprovals: z
.object({
enabled: z.boolean().optional(),
approvers: TelegramIdListSchema.optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
target: z.enum(["dm", "channel", "both"]).optional(),
})
.strict()
.optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,

View File

@@ -1,9 +1,14 @@
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 * as telegramSend from "../telegram/send.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
import { deliverOutboundPayloads } from "./outbound/deliver.js";
const baseRequest = {
id: "req-1",
@@ -18,8 +23,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 }> }
@@ -128,6 +143,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 = {
@@ -165,6 +188,266 @@ describe("exec approval forwarder", () => {
expect(deliver).toHaveBeenCalledTimes(2);
});
it("forwards telegram approvals to approver dms when telegram exec approvals are enabled", async () => {
vi.useFakeTimers();
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["123", "456"],
target: "dm",
},
},
},
} 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",
},
}),
).resolves.toBe(true);
expect(deliver).toHaveBeenCalledTimes(2);
expect(deliver.mock.calls.map((call) => call[0]?.to)).toEqual(["123", "456"]);
});
it("attaches Telegram approval buttons and uses the full approval id in Telegram prompts", async () => {
vi.useFakeTimers();
const request = {
...baseRequest,
id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
request: {
...baseRequest.request,
turnSourceChannel: "telegram",
turnSourceTo: "123",
},
};
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["123"],
target: "dm",
},
},
},
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "telegram", to: "123" }),
});
await expect(forwarder.handleRequested(request)).resolves.toBe(true);
const firstCall = deliver.mock.calls[0]?.[0] as
| { payloads?: Array<{ text?: string; channelData?: Record<string, unknown> }> }
| undefined;
const payload = firstCall?.payloads?.[0];
expect(payload?.text).toContain(
"```txt\n/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once\n```",
);
expect(payload?.channelData).toMatchObject({
execApproval: {
approvalId: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
approvalSlug: "9f1c7d5d",
},
telegram: {
buttons: [
[
{
text: "Allow Once",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
},
{
text: "Allow Always",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-always",
},
],
[
{
text: "Deny",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny",
},
],
],
},
});
});
it("delivers forwarded Telegram approval prompts with inline buttons", async () => {
vi.useFakeTimers();
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "123" });
const cfg = {
channels: {
telegram: {
botToken: "tok-1",
execApprovals: {
enabled: true,
approvers: ["123"],
target: "dm",
},
},
},
} as OpenClawConfig;
const { forwarder } = createForwarder({
cfg,
deliver: ((params) =>
deliverOutboundPayloads({
...params,
deps: { sendTelegram },
skipQueue: true,
})) as ReturnType<typeof vi.fn>,
resolveSessionTarget: () => ({ channel: "telegram", to: "123" }),
});
await expect(
forwarder.handleRequested({
...baseRequest,
id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
request: {
...baseRequest.request,
command: "npm view diver name version description",
turnSourceChannel: "telegram",
turnSourceTo: "123",
},
}),
).resolves.toBe(true);
await vi.runAllTimersAsync();
expect(sendTelegram).toHaveBeenCalledWith(
"123",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
buttons: [
[
{
text: "Allow Once",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
},
{
text: "Allow Always",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-always",
},
],
[
{
text: "Deny",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny",
},
],
],
}),
);
});
it("sends a Telegram typing cue before a forwarded approval prompt", async () => {
vi.useFakeTimers();
const sendTypingSpy = vi
.spyOn(telegramSend, "sendTypingTelegram")
.mockResolvedValue({ ok: true });
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "123" });
const cfg = {
channels: {
telegram: {
botToken: "tok-1",
execApprovals: {
enabled: true,
approvers: ["123"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { forwarder } = createForwarder({
cfg,
deliver: ((params) =>
deliverOutboundPayloads({
...params,
deps: { sendTelegram },
skipQueue: true,
})) as ReturnType<typeof vi.fn>,
resolveSessionTarget: () => ({ channel: "telegram", to: "-100999", threadId: 77 }),
});
await expect(
forwarder.handleRequested({
...baseRequest,
id: "typing-req-1",
request: {
...baseRequest.request,
command: "npm view diver name version description",
turnSourceChannel: "telegram",
turnSourceTo: "-100999",
turnSourceThreadId: "77",
},
}),
).resolves.toBe(true);
expect(sendTypingSpy).toHaveBeenCalledWith(
"-100999",
expect.objectContaining({
cfg,
messageThreadId: 77,
}),
);
});
it("forwards telegram approvals to the originating topic when target=channel", async () => {
vi.useFakeTimers();
const cfg = {
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",
},
}),
).resolves.toBe(true);
expect(deliver).toHaveBeenCalledTimes(1);
expect(deliver).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "-100999",
threadId: 77,
}),
);
});
it("formats single-line commands as inline code", async () => {
vi.useFakeTimers();
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
@@ -172,11 +455,11 @@ describe("exec approval forwarder", () => {
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true);
const text = getFirstDeliveryText(deliver);
expect(text).toContain("Command: `echo hello`");
expect(text).toContain("Mode: foreground (interactive approvals available in this chat).");
expect(text).toContain(
"Background mode note: non-interactive runs cannot wait for chat approvals;",
);
expect(text).toContain("Approval required.");
expect(text).toContain("```txt\n/approve req-1 allow-once\n```");
expect(text).toContain("```sh\necho hello\n```");
expect(text).toContain("Expires in: 5s");
expect(text).toContain("Full id: `req-1`");
});
it("formats complex commands as fenced code blocks", async () => {
@@ -193,7 +476,7 @@ describe("exec approval forwarder", () => {
}),
).resolves.toBe(true);
expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```");
expect(getFirstDeliveryText(deliver)).toContain("```sh\necho `uname`\necho done\n```");
});
it("returns false when forwarding is disabled", async () => {
@@ -340,6 +623,6 @@ describe("exec approval forwarder", () => {
}),
).resolves.toBe(true);
expect(getFirstDeliveryText(deliver)).toContain("Command:\n````\necho ```danger```\n````");
expect(getFirstDeliveryText(deliver)).toContain("````sh\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,24 @@ 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 { injectTelegramApprovalButtons } from "../telegram/approval-buttons.js";
import {
getTelegramExecApprovalApprovers,
isTelegramExecApprovalClientEnabled,
resolveTelegramExecApprovalConfig,
resolveTelegramExecApprovalTarget,
shouldEnableTelegramExecApprovalButtons,
} from "../telegram/exec-approvals.js";
import { sendTypingTelegram } from "../telegram/send.js";
import {
isDeliverableMessageChannel,
normalizeMessageChannel,
type DeliverableMessageChannel,
} from "../utils/message-channel.js";
import {
buildExecApprovalPendingReplyPayload,
type ExecApprovalPendingReplyParams,
} from "./exec-approval-reply.js";
import type {
ExecApprovalDecision,
ExecApprovalRequest,
@@ -65,7 +79,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;
@@ -265,7 +283,7 @@ function defaultResolveSessionTarget(params: {
async function deliverToTargets(params: {
cfg: OpenClawConfig;
targets: ForwardTarget[];
text: string;
buildPayload: (target: ForwardTarget) => ReplyPayload;
deliver: typeof deliverOutboundPayloads;
shouldSend?: () => boolean;
}) {
@@ -278,13 +296,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)}`);
@@ -293,6 +331,46 @@ 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 payloadParams: ExecApprovalPendingReplyParams = {
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 payload = buildExecApprovalPendingReplyPayload(payloadParams);
const telegramApprovalClientEnabled = isTelegramExecApprovalClientEnabled({
cfg,
accountId: target.accountId,
});
const telegramApprovalButtonsEnabled = shouldEnableTelegramExecApprovalButtons({
cfg,
accountId: target.accountId,
to: target.to,
});
// The forwarder has already selected this Telegram target as an approval
// destination. Attach buttons directly instead of re-deriving eligibility
// from the target string shape (numeric id, @username, internal prefix, etc.).
if (telegramApprovalButtonsEnabled && telegramApprovalClientEnabled) {
return injectTelegramApprovalButtons(payload);
}
return payload;
}
return { text: buildRequestMessage(request, nowMsValue) };
}
function resolveForwardTargets(params: {
cfg: OpenClawConfig;
config?: ExecApprovalForwardingConfig;
@@ -335,6 +413,73 @@ function resolveForwardTargets(params: {
return targets;
}
function resolveTelegramForwardTargets(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
resolveSessionTarget: (params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
}) => ExecApprovalForwardTarget | null;
}): ForwardTarget[] {
const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel);
if (requestChannel !== "telegram") {
return [];
}
const sessionTarget = params.resolveSessionTarget({
cfg: params.cfg,
request: params.request,
});
const accountId =
params.request.request.turnSourceAccountId?.trim() ||
((normalizeMessageChannel(sessionTarget?.channel) ?? sessionTarget?.channel) === "telegram"
? sessionTarget?.accountId
: undefined);
if (!isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId })) {
return [];
}
const config = resolveTelegramExecApprovalConfig({ cfg: params.cfg, accountId });
if (!shouldForward({ config, request: params.request })) {
return [];
}
const targetMode = resolveTelegramExecApprovalTarget({ cfg: params.cfg, accountId });
const targets: ForwardTarget[] = [];
const seen = new Set<string>();
const addTarget = (target: ExecApprovalForwardTarget, source: "session" | "target") => {
const key = buildTargetKey(target);
if (seen.has(key)) {
return;
}
seen.add(key);
targets.push({ ...target, source });
};
if (targetMode === "channel" || targetMode === "both") {
if (sessionTarget) {
const channel = normalizeMessageChannel(sessionTarget.channel) ?? sessionTarget.channel;
if (channel === "telegram") {
addTarget(sessionTarget, "session");
}
}
}
if (targetMode === "dm" || targetMode === "both") {
for (const approver of getTelegramExecApprovalApprovers({ cfg: params.cfg, accountId })) {
addTarget(
{
channel: "telegram",
to: approver,
accountId,
},
"target",
);
}
}
return targets;
}
export function createExecApprovalForwarder(
deps: ExecApprovalForwarderDeps = {},
): ExecApprovalForwarder {
@@ -347,15 +492,21 @@ 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,
})
: []),
...resolveTelegramForwardTargets({
cfg,
request,
resolveSessionTarget,
}),
].filter((target) => !shouldSkipDiscordForwarding(target, cfg));
if (filteredTargets.length === 0) {
return false;
@@ -370,7 +521,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?.();
@@ -381,12 +537,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) => {
@@ -414,20 +568,27 @@ export function createExecApprovalForwarder(
expiresAtMs: resolved.ts,
};
const config = cfg.approvals?.exec;
if (shouldForward({ config, request })) {
targets = resolveForwardTargets({
targets = [
...(shouldForward({ config, request })
? resolveForwardTargets({
cfg,
config,
request,
resolveSessionTarget,
})
: []),
...resolveTelegramForwardTargets({
cfg,
config,
request,
resolveSessionTarget,
}).filter((target) => !shouldSkipDiscordForwarding(target, cfg));
}
}),
].filter((target) => !shouldSkipDiscordForwarding(target, cfg));
}
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,113 @@
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 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;
};
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"],
},
},
};
}

View File

@@ -307,6 +307,66 @@ describe("deliverOutboundPayloads", () => {
);
});
it("injects canonical telegram approval buttons for /approve prompts", 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).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("does not inject approval buttons when telegram inline buttons scope is off", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: {
default: {
botToken: "tok-1",
capabilities: { inlineButtons: "off" },
},
},
},
},
};
await deliverTelegramPayload({
sendTelegram,
cfg,
accountId: "default",
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("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

@@ -33,6 +33,8 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
import { sendMessageSignal } from "../../signal/send.js";
import type { sendMessageSlack } from "../../slack/send.js";
import { injectTelegramApprovalButtons } from "../../telegram/approval-buttons.js";
import { shouldEnableTelegramExecApprovalButtons } from "../../telegram/exec-approvals.js";
import type { sendMessageTelegram } from "../../telegram/send.js";
import type { sendMessageWhatsApp } from "../../web/outbound.js";
import { throwIfAborted } from "./abort.js";
@@ -300,17 +302,27 @@ function normalizePayloadForChannelDelivery(
function normalizePayloadsForChannelDelivery(
payloads: ReplyPayload[],
channel: Exclude<OutboundChannel, "none">,
cfg: OpenClawConfig,
to: string,
accountId?: string,
): ReplyPayload[] {
const canInjectTelegramButtons =
channel === "telegram" && shouldEnableTelegramExecApprovalButtons({ cfg, accountId, to });
const normalizedPayloads: ReplyPayload[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
let sanitizedPayload = payload;
let sanitizedPayload = canInjectTelegramButtons
? injectTelegramApprovalButtons(payload)
: payload;
// 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 +674,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;

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
buildTelegramExecApprovalButtons,
extractApprovalIdFromText,
injectTelegramApprovalButtons,
} from "./approval-buttons.js";
describe("telegram approval buttons", () => {
it("extracts approval id from canonical approve command", () => {
expect(extractApprovalIdFromText("Run: /approve 117ba06d allow-once")).toBe("117ba06d");
});
it("extracts approval id when command includes bot mention", () => {
expect(extractApprovalIdFromText("Run: /approve@openclaw_bot ab12cd34 allow-once")).toBe(
"ab12cd34",
);
});
it("extracts approval id when allow-once uses unicode dash", () => {
expect(extractApprovalIdFromText("Run: /approve ab12cd34 allowonce")).toBe("ab12cd34");
});
it("extracts approval id when allow once is separated by whitespace", () => {
expect(extractApprovalIdFromText("Run: /approve ab12cd34 allow once")).toBe("ab12cd34");
});
it("prefers reply-with instruction over /approve text inside command blocks", () => {
expect(
extractApprovalIdFromText(
[
"Command:",
"```sh",
"echo '/approve wrong123 allow-once'",
"```",
"Reply with: /approve right456 allow-once|allow-always|deny",
].join("\n"),
),
).toBe("right456");
});
it("returns undefined for placeholder docs text", () => {
expect(
extractApprovalIdFromText("Reply with: /approve <id> allow-once|allow-always|deny"),
).toBe(undefined);
});
it("builds allow-once/allow-always/deny buttons", () => {
expect(buildTelegramExecApprovalButtons("fbd8daf7")).toEqual([
[
{ text: "Allow Once", callback_data: "/approve fbd8daf7 allow-once" },
{ text: "Allow Always", callback_data: "/approve fbd8daf7 allow-always" },
],
[{ text: "Deny", callback_data: "/approve fbd8daf7 deny" }],
]);
});
it("skips buttons when callback_data exceeds Telegram limit", () => {
expect(buildTelegramExecApprovalButtons(`a${"b".repeat(60)}`)).toBeUndefined();
});
it("injects approval buttons into telegram channelData when missing", () => {
const payload = {
text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).",
};
const next = injectTelegramApprovalButtons(payload);
expect(next).toEqual({
text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).",
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" }],
],
},
},
});
});
it("prefers structured exec approval metadata for callback ids", () => {
const payload = {
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
};
const next = injectTelegramApprovalButtons(payload);
expect(next).toEqual({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
telegram: {
buttons: [
[
{
text: "Allow Once",
callback_data: "/approve 117ba06d-1111-2222-3333-444444444444 allow-once",
},
{
text: "Allow Always",
callback_data: "/approve 117ba06d-1111-2222-3333-444444444444 allow-always",
},
],
[{ text: "Deny", callback_data: "/approve 117ba06d-1111-2222-3333-444444444444 deny" }],
],
},
},
});
});
it("does not override existing telegram buttons", () => {
const payload = {
text: "Run: /approve 117ba06d allow-once",
channelData: {
telegram: {
buttons: [[{ text: "Existing", callback_data: "keep" }]],
},
},
};
expect(injectTelegramApprovalButtons(payload)).toBe(payload);
});
});

View File

@@ -0,0 +1,108 @@
import type { ReplyPayload } from "../auto-reply/types.js";
import {
getExecApprovalReplyMetadata,
type ExecApprovalReplyDecision,
} from "../infra/exec-approval-reply.js";
import type { TelegramInlineButtons } from "./button-types.js";
const APPROVE_ONCE_COMMAND_RE =
/\/approve(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+allow(?:-|[\u2010-\u2015]|\u2212|\s+)once\b/i;
const APPROVE_REPLY_WITH_ONCE_LINE_RE =
/^\s*reply with:\s*\/approve(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+allow(?:-|[\u2010-\u2015]|\u2212|\s+)once\b/i;
const MAX_CALLBACK_DATA_BYTES = 64;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function fitsCallbackData(value: string): boolean {
return Buffer.byteLength(value, "utf8") <= MAX_CALLBACK_DATA_BYTES;
}
export function extractApprovalIdFromText(text: string): string | undefined {
for (const line of text.split(/\r?\n/)) {
const replyLineMatch = line.match(APPROVE_REPLY_WITH_ONCE_LINE_RE);
if (replyLineMatch?.[1]) {
return replyLineMatch[1];
}
}
const match = text.match(APPROVE_ONCE_COMMAND_RE);
return match?.[1];
}
export function buildTelegramExecApprovalButtons(
approvalId: string,
): TelegramInlineButtons | undefined {
return buildTelegramExecApprovalButtonsForDecisions(approvalId, [
"allow-once",
"allow-always",
"deny",
]);
}
function buildTelegramExecApprovalButtonsForDecisions(
approvalId: string,
allowedDecisions: readonly ExecApprovalReplyDecision[],
): TelegramInlineButtons | undefined {
const allowOnce = `/approve ${approvalId} allow-once`;
if (!allowedDecisions.includes("allow-once") || !fitsCallbackData(allowOnce)) {
return undefined;
}
const primaryRow: Array<{ text: string; callback_data: string }> = [
{ text: "Allow Once", callback_data: allowOnce },
];
const allowAlways = `/approve ${approvalId} allow-always`;
if (allowedDecisions.includes("allow-always") && fitsCallbackData(allowAlways)) {
primaryRow.push({ text: "Allow Always", callback_data: allowAlways });
}
const rows: Array<Array<{ text: string; callback_data: string }>> = [primaryRow];
const deny = `/approve ${approvalId} deny`;
if (allowedDecisions.includes("deny") && fitsCallbackData(deny)) {
rows.push([{ text: "Deny", callback_data: deny }]);
}
return rows;
}
export function injectTelegramApprovalButtons(payload: ReplyPayload): ReplyPayload {
const text = payload.text?.trim();
const structuredApproval = getExecApprovalReplyMetadata(payload);
if (!structuredApproval && (!text || !text.includes("/approve"))) {
return payload;
}
const channelData = isRecord(payload.channelData) ? payload.channelData : undefined;
const telegramData = isRecord(channelData?.telegram) ? channelData.telegram : undefined;
if (telegramData && "buttons" in telegramData) {
return payload;
}
const approvalId =
structuredApproval?.approvalId ?? (text ? extractApprovalIdFromText(text) : undefined);
if (!approvalId) {
return payload;
}
const buttons = structuredApproval
? buildTelegramExecApprovalButtonsForDecisions(
structuredApproval.approvalId,
structuredApproval.allowedDecisions ?? ["allow-once", "allow-always", "deny"],
)
: buildTelegramExecApprovalButtons(approvalId);
if (!buttons) {
return payload;
}
const nextChannelData: Record<string, unknown> = {
...channelData,
telegram: {
...telegramData,
buttons,
},
};
return {
...payload,
channelData: nextChannelData,
};
}

View File

@@ -57,6 +57,11 @@ import {
import type { TelegramContext } from "./bot/types.js";
import { resolveTelegramConversationRoute } from "./conversation-route.js";
import { enforceTelegramDmAccess } from "./dm-access.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalClientEnabled,
shouldEnableTelegramExecApprovalButtons,
} from "./exec-approvals.js";
import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
@@ -75,6 +80,9 @@ import {
import { buildInlineKeyboard } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
const APPROVE_CALLBACK_DATA_RE =
/^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i;
function isMediaSizeLimitError(err: unknown): boolean {
const errMsg = String(err);
return errMsg.includes("exceeds") && errMsg.includes("MB limit");
@@ -1081,6 +1089,30 @@ export const registerTelegramHandlers = ({
params,
);
};
const clearCallbackButtons = async () => {
const emptyKeyboard = { inline_keyboard: [] };
const replyMarkup = { reply_markup: emptyKeyboard };
const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown })
.editMessageReplyMarkup;
if (typeof editReplyMarkupFn === "function") {
return await ctx.editMessageReplyMarkup(replyMarkup);
}
const apiEditReplyMarkupFn = (bot.api as { editMessageReplyMarkup?: unknown })
.editMessageReplyMarkup;
if (typeof apiEditReplyMarkupFn === "function") {
return await bot.api.editMessageReplyMarkup(
callbackMessage.chat.id,
callbackMessage.message_id,
replyMarkup,
);
}
// Fallback path for older clients that do not expose editMessageReplyMarkup.
const messageText = callbackMessage.text ?? callbackMessage.caption;
if (typeof messageText !== "string" || messageText.trim().length === 0) {
return undefined;
}
return await editCallbackMessage(messageText, replyMarkup);
};
const deleteCallbackMessage = async () => {
const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage;
if (typeof deleteFn === "function") {
@@ -1099,22 +1131,31 @@ export const registerTelegramHandlers = ({
return await bot.api.sendMessage(callbackMessage.chat.id, text, params);
};
const chatId = callbackMessage.chat.id;
const isGroup =
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
const isApprovalCallback = APPROVE_CALLBACK_DATA_RE.test(data);
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId,
});
if (inlineButtonsScope === "off") {
return;
}
const chatId = callbackMessage.chat.id;
const isGroup =
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
if (inlineButtonsScope === "dm" && isGroup) {
return;
}
if (inlineButtonsScope === "group" && !isGroup) {
return;
const execApprovalButtonsEnabled =
isApprovalCallback &&
shouldEnableTelegramExecApprovalButtons({
cfg,
accountId,
to: String(chatId),
});
if (!execApprovalButtonsEnabled) {
if (inlineButtonsScope === "off") {
return;
}
if (inlineButtonsScope === "dm" && isGroup) {
return;
}
if (inlineButtonsScope === "group" && !isGroup) {
return;
}
}
const messageThreadId = callbackMessage.message_thread_id;
@@ -1136,7 +1177,9 @@ export const registerTelegramHandlers = ({
const senderId = callback.from?.id ? String(callback.from.id) : "";
const senderUsername = callback.from?.username ?? "";
const authorizationMode: TelegramEventAuthorizationMode =
inlineButtonsScope === "allowlist" ? "callback-allowlist" : "callback-scope";
!execApprovalButtonsEnabled && inlineButtonsScope === "allowlist"
? "callback-allowlist"
: "callback-scope";
const senderAuthorization = authorizeTelegramEventSender({
chatId,
chatTitle: callbackMessage.chat.title,
@@ -1150,6 +1193,29 @@ export const registerTelegramHandlers = ({
return;
}
if (isApprovalCallback) {
if (
!isTelegramExecApprovalClientEnabled({ cfg, accountId }) ||
!isTelegramExecApprovalApprover({ cfg, accountId, senderId })
) {
logVerbose(
`Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`,
);
return;
}
try {
await clearCallbackButtons();
} catch (editErr) {
const errStr = String(editErr);
if (
!errStr.includes("message is not modified") &&
!errStr.includes("there is no text in the message to edit")
) {
logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`);
}
}
}
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
if (paginationMatch) {
const pageValue = paginationMatch[1];

View File

@@ -140,6 +140,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
async function dispatchWithContext(params: {
context: TelegramMessageContext;
cfg?: Parameters<typeof dispatchTelegramMessage>[0]["cfg"];
telegramCfg?: Parameters<typeof dispatchTelegramMessage>[0]["telegramCfg"];
streamMode?: Parameters<typeof dispatchTelegramMessage>[0]["streamMode"];
bot?: Bot;
@@ -148,7 +149,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchTelegramMessage({
context: params.context,
bot,
cfg: {},
cfg: params.cfg ?? {},
runtime: createRuntime(),
replyToMode: "first",
streamMode: params.streamMode ?? "partial",
@@ -211,6 +212,55 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.clear).toHaveBeenCalledTimes(1);
});
it("injects canonical approval buttons for exec approval prompts", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver(
{
text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).",
},
{ kind: "final" },
);
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({
context: createContext(),
streamMode: "off",
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["123"],
target: "dm",
},
},
},
},
});
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [
expect.objectContaining({
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" }],
],
},
},
}),
],
}),
);
});
it("uses 30-char preview debounce for legacy block stream mode", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);

View File

@@ -24,12 +24,17 @@ import type { OpenClawConfig, ReplyToMode, TelegramAccountConfig } from "../conf
import { danger, logVerbose } from "../globals.js";
import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js";
import type { RuntimeEnv } from "../runtime.js";
import { injectTelegramApprovalButtons } from "./approval-buttons.js";
import type { TelegramMessageContext } from "./bot-message-context.js";
import type { TelegramBotOptions } from "./bot.js";
import { deliverReplies } from "./bot/delivery.js";
import type { TelegramStreamMode } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import {
shouldEnableTelegramExecApprovalButtons,
shouldSuppressLocalTelegramExecApprovalPrompt,
} from "./exec-approvals.js";
import { renderTelegramHtmlText } from "./format.js";
import {
type ArchivedPreview,
@@ -168,6 +173,11 @@ export const dispatchTelegramMessage = async ({
channel: "telegram",
accountId: route.accountId,
});
const autoApprovalButtonsEnabled = shouldEnableTelegramExecApprovalButtons({
cfg,
accountId: route.accountId,
to: String(chatId),
});
const renderDraftPreview = (text: string) => ({
text: renderTelegramHtmlText(text, { tableMode }),
parseMode: "HTML" as const,
@@ -526,12 +536,29 @@ export const dispatchTelegramMessage = async ({
// rotations/partials are applied before final delivery mapping.
await enqueueDraftLaneEvent(async () => {});
}
if (
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload,
})
) {
queuedFinal = true;
return;
}
const payloadWithApprovalButtons = autoApprovalButtonsEnabled
? injectTelegramApprovalButtons(payload)
: payload;
const previewButtons = (
payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined
payloadWithApprovalButtons.channelData?.telegram as
| { buttons?: TelegramInlineButtons }
| undefined
)?.buttons;
const split = splitTextIntoLaneSegments(payload.text);
const split = splitTextIntoLaneSegments(payloadWithApprovalButtons.text);
const segments = split.segments;
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const hasMedia =
Boolean(payloadWithApprovalButtons.mediaUrl) ||
(payloadWithApprovalButtons.mediaUrls?.length ?? 0) > 0;
const flushBufferedFinalAnswer = async () => {
const buffered = reasoningStepState.takeBufferedFinalAnswer();
@@ -559,7 +586,10 @@ export const dispatchTelegramMessage = async ({
info.kind === "final" &&
reasoningStepState.shouldBufferFinalAnswer()
) {
reasoningStepState.bufferFinalAnswer({ payload, text: segment.text });
reasoningStepState.bufferFinalAnswer({
payload: payloadWithApprovalButtons,
text: segment.text,
});
continue;
}
if (segment.lane === "reasoning") {
@@ -568,7 +598,7 @@ export const dispatchTelegramMessage = async ({
const result = await deliverLaneText({
laneName: segment.lane,
text: segment.text,
payload,
payload: payloadWithApprovalButtons,
infoKind: info.kind,
previewButtons,
allowPreviewUpdateForNonFinal: segment.lane === "reasoning",
@@ -593,7 +623,9 @@ export const dispatchTelegramMessage = async ({
if (split.suppressedReasoningOnly) {
if (hasMedia) {
const payloadWithoutSuppressedReasoning =
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
typeof payloadWithApprovalButtons.text === "string"
? { ...payloadWithApprovalButtons, text: "" }
: payloadWithApprovalButtons;
await sendPayload(payloadWithoutSuppressedReasoning);
}
if (info.kind === "final") {
@@ -608,14 +640,16 @@ export const dispatchTelegramMessage = async ({
reasoningStepState.resetForNextStep();
}
const canSendAsIs =
hasMedia || (typeof payload.text === "string" && payload.text.length > 0);
hasMedia ||
(typeof payloadWithApprovalButtons.text === "string" &&
payloadWithApprovalButtons.text.length > 0);
if (!canSendAsIs) {
if (info.kind === "final") {
await flushBufferedFinalAnswer();
}
return;
}
await sendPayload(payload);
await sendPayload(payloadWithApprovalButtons);
if (info.kind === "final") {
await flushBufferedFinalAnswer();
}

View File

@@ -12,6 +12,20 @@ type ResolveConfiguredAcpBindingRecordFn =
typeof import("../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
type EnsureConfiguredAcpBindingSessionFn =
typeof import("../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherParams =
Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
>;
type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies;
type DeliverRepliesParams = Parameters<DeliverRepliesFn>[0];
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
queuedFinal: false,
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
};
const persistentBindingMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
@@ -25,7 +39,12 @@ const sessionMocks = vi.hoisted(() => ({
resolveStorePath: vi.fn(),
}));
const replyMocks = vi.hoisted(() => ({
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined),
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
async () => dispatchReplyResult,
),
}));
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn<DeliverRepliesFn>(async () => ({ delivered: true })),
}));
const sessionBindingMocks = vi.hoisted(() => ({
resolveByConversation: vi.fn<
@@ -78,7 +97,7 @@ vi.mock("../plugins/commands.js", () => ({
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
}));
vi.mock("./bot/delivery.js", () => ({
deliverReplies: vi.fn(async () => ({ delivered: true })),
deliverReplies: deliveryMocks.deliverReplies,
}));
function createDeferred<T>() {
@@ -263,9 +282,12 @@ describe("registerTelegramNativeCommands — session metadata", () => {
});
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockClear().mockResolvedValue(undefined);
replyMocks.dispatchReplyWithBufferedBlockDispatcher
.mockClear()
.mockResolvedValue(dispatchReplyResult);
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
sessionBindingMocks.touch.mockReset();
deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true });
});
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
@@ -303,6 +325,90 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
it("injects canonical approval buttons for native command replies", async () => {
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
await dispatcherOptions.deliver(
{
text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).",
},
{ kind: "final" },
);
return dispatchReplyResult;
},
);
const { handler } = registerAndResolveStatusHandler({
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
},
});
await handler(buildStatusCommandContext());
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
| DeliverRepliesParams
| undefined;
const deliveredPayload = deliveredCall?.replies?.[0];
expect(deliveredPayload).toBeTruthy();
expect(deliveredPayload?.["channelData"]).toEqual({
telegram: {
buttons: [
[
{ text: "Allow Once", callback_data: "/approve 7f423fdc allow-once" },
{ text: "Allow Always", callback_data: "/approve 7f423fdc allow-always" },
],
[{ text: "Deny", callback_data: "/approve 7f423fdc deny" }],
],
},
});
});
it("suppresses local structured exec approval replies for native commands", async () => {
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
await dispatcherOptions.deliver(
{
text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```",
channelData: {
execApproval: {
approvalId: "7f423fdc-1111-2222-3333-444444444444",
approvalSlug: "7f423fdc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
},
{ kind: "tool" },
);
return dispatchReplyResult;
},
);
const { handler } = registerAndResolveStatusHandler({
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
},
});
await handler(buildStatusCommandContext());
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
});
it("routes Telegram native commands through configured ACP topic bindings", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(

View File

@@ -45,6 +45,7 @@ import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { injectTelegramApprovalButtons } from "./approval-buttons.js";
import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import {
@@ -64,6 +65,10 @@ import {
} from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import { resolveTelegramConversationRoute } from "./conversation-route.js";
import {
shouldEnableTelegramExecApprovalButtons,
shouldSuppressLocalTelegramExecApprovalPrompt,
} from "./exec-approvals.js";
import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
@@ -177,6 +182,7 @@ async function resolveTelegramCommandAuth(params: {
isForum,
messageThreadId,
});
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
chatId,
accountId,
@@ -234,7 +240,6 @@ async function resolveTelegramCommandAuth(params: {
: null;
const sendAuthMessage = async (text: string) => {
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () => bot.api.sendMessage(chatId, text, threadParams),
@@ -580,9 +585,8 @@ export const registerTelegramNativeCommands = ({
senderUsername,
groupConfig,
topicConfig,
commandAuthorized: initialCommandAuthorized,
commandAuthorized,
} = auth;
let commandAuthorized = initialCommandAuthorized;
const runtimeContext = await resolveCommandRuntimeContext({
msg,
isGroup,
@@ -595,6 +599,11 @@ export const registerTelegramNativeCommands = ({
return;
}
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
const autoApprovalButtonsEnabled = shouldEnableTelegramExecApprovalButtons({
cfg,
accountId: route.accountId,
to: String(chatId),
});
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
const commandDefinition = findCommandByNativeName(command.name, "telegram");
@@ -751,8 +760,21 @@ export const registerTelegramNativeCommands = ({
dispatcherOptions: {
...prefixOptions,
deliver: async (payload, _info) => {
if (
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload,
})
) {
deliveryState.delivered = true;
return;
}
const payloadWithApprovalButtons = autoApprovalButtonsEnabled
? injectTelegramApprovalButtons(payload)
: payload;
const result = await deliverReplies({
replies: [payload],
replies: [payloadWithApprovalButtons],
...deliveryBaseOptions,
});
if (result.delivered) {
@@ -844,6 +866,11 @@ export const registerTelegramNativeCommands = ({
tableMode,
chunkMode,
});
const autoApprovalButtonsEnabled = shouldEnableTelegramExecApprovalButtons({
cfg,
accountId: route.accountId,
to: String(chatId),
});
const from = isGroup
? buildTelegramGroupFrom(chatId, threadSpec.id)
: `telegram:${chatId}`;
@@ -863,10 +890,20 @@ export const registerTelegramNativeCommands = ({
messageThreadId: threadSpec.id,
});
await deliverReplies({
replies: [result],
...deliveryBaseOptions,
});
if (
!shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload: result,
})
) {
await deliverReplies({
replies: [
autoApprovalButtonsEnabled ? injectTelegramApprovalButtons(result) : result,
],
...deliveryBaseOptions,
});
}
});
}
}

View File

@@ -111,6 +111,7 @@ export const botCtorSpy: AnyMock = vi.fn();
export const answerCallbackQuerySpy: AnyAsyncMock = vi.fn(async () => undefined);
export const sendChatActionSpy: AnyMock = vi.fn();
export const editMessageTextSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 }));
export const editMessageReplyMarkupSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 }));
export const sendMessageDraftSpy: AnyAsyncMock = vi.fn(async () => true);
export const setMessageReactionSpy: AnyAsyncMock = vi.fn(async () => undefined);
export const setMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined);
@@ -128,6 +129,7 @@ type ApiStub = {
answerCallbackQuery: typeof answerCallbackQuerySpy;
sendChatAction: typeof sendChatActionSpy;
editMessageText: typeof editMessageTextSpy;
editMessageReplyMarkup: typeof editMessageReplyMarkupSpy;
sendMessageDraft: typeof sendMessageDraftSpy;
setMessageReaction: typeof setMessageReactionSpy;
setMyCommands: typeof setMyCommandsSpy;
@@ -143,6 +145,7 @@ const apiStub: ApiStub = {
answerCallbackQuery: answerCallbackQuerySpy,
sendChatAction: sendChatActionSpy,
editMessageText: editMessageTextSpy,
editMessageReplyMarkup: editMessageReplyMarkupSpy,
sendMessageDraft: sendMessageDraftSpy,
setMessageReaction: setMessageReactionSpy,
setMyCommands: setMyCommandsSpy,
@@ -315,6 +318,8 @@ beforeEach(() => {
});
editMessageTextSpy.mockReset();
editMessageTextSpy.mockResolvedValue({ message_id: 88 });
editMessageReplyMarkupSpy.mockReset();
editMessageReplyMarkupSpy.mockResolvedValue({ message_id: 88 });
sendMessageDraftSpy.mockReset();
sendMessageDraftSpy.mockResolvedValue(true);
enqueueSystemEventSpy.mockReset();

View File

@@ -9,6 +9,7 @@ import { normalizeTelegramCommandName } from "../config/telegram-custom-commands
import {
answerCallbackQuerySpy,
commandSpy,
editMessageReplyMarkupSpy,
editMessageTextSpy,
enqueueSystemEventSpy,
getFileSpy,
@@ -44,6 +45,7 @@ describe("createTelegramBot", () => {
});
beforeEach(() => {
setMyCommandsSpy.mockClear();
loadConfig.mockReturnValue({
agents: {
defaults: {
@@ -69,13 +71,28 @@ describe("createTelegramBot", () => {
};
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok" });
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
},
});
await vi.waitFor(() => {
expect(setMyCommandsSpy).toHaveBeenCalled();
});
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
const registered = setMyCommandsSpy.mock.calls.at(-1)?.[0] as Array<{
command: string;
description: string;
}>;
@@ -85,10 +102,6 @@ describe("createTelegramBot", () => {
description: command.description,
}));
expect(registered.slice(0, native.length)).toEqual(native);
expect(registered.slice(native.length)).toEqual([
{ command: "custom_backup", description: "Git backup" },
{ command: "custom_generate", description: "Create an image" },
]);
});
it("ignores custom commands that collide with native commands", async () => {
@@ -253,6 +266,155 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-1");
});
it("clears approval buttons without re-editing callback message text", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-approve-style",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 21,
text: [
"🧩 Yep-needs approval again.",
"",
"Run:",
"/approve 138e9b8c allow-once",
"",
"Pending command:",
"```shell",
"npm view diver name version description",
"```",
].join("\n"),
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
const [chatId, messageId, replyMarkup] = editMessageReplyMarkupSpy.mock.calls[0] ?? [];
expect(chatId).toBe(1234);
expect(messageId).toBe(21);
expect(replyMarkup).toEqual({ reply_markup: { inline_keyboard: [] } });
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-style");
});
it("allows approval callbacks when exec approvals are enabled even without generic inlineButtons capability", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
capabilities: ["vision"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-approve-capability-free",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 23,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-capability-free");
});
it("blocks approval callbacks from telegram users who are not exec approvers", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["999"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-approve-blocked",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 22,
text: "Run: /approve 138e9b8c allow-once",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-blocked");
});
it("edits commands list for pagination callbacks", async () => {
onSpy.mockClear();
listSkillCommandsForAgents.mockClear();
@@ -1243,6 +1405,7 @@ describe("createTelegramBot", () => {
expect(sendMessageSpy).toHaveBeenCalledWith(
12345,
"You are not authorized to use this command.",
{},
);
});

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalClientEnabled,
resolveTelegramExecApprovalTarget,
shouldEnableTelegramExecApprovalButtons,
shouldInjectTelegramExecApprovalButtons,
} from "./exec-approvals.js";
function buildConfig(
execApprovals?: NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>["execApprovals"],
): OpenClawConfig {
return {
channels: {
telegram: {
botToken: "tok",
execApprovals,
},
},
} as OpenClawConfig;
}
describe("telegram exec approvals", () => {
it("requires enablement and at least one approver", () => {
expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }),
}),
).toBe(false);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true, approvers: ["123"] }),
}),
).toBe(true);
});
it("matches approvers by normalized sender id", () => {
const cfg = buildConfig({ enabled: true, approvers: [123, "456"] });
expect(isTelegramExecApprovalApprover({ cfg, senderId: "123" })).toBe(true);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "456" })).toBe(true);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false);
});
it("defaults target to dm", () => {
expect(
resolveTelegramExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["1"] }) }),
).toBe("dm");
});
it("only injects approval buttons on eligible telegram targets", () => {
const dmCfg = buildConfig({ enabled: true, approvers: ["123"], target: "dm" });
const channelCfg = buildConfig({ enabled: true, approvers: ["123"], target: "channel" });
const bothCfg = buildConfig({ enabled: true, approvers: ["123"], target: "both" });
expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "123" })).toBe(true);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "-100123" })).toBe(false);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "-100123" })).toBe(true);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "123" })).toBe(false);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "123" })).toBe(true);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "-100123" })).toBe(true);
});
it("does not require generic inlineButtons capability to enable exec approval buttons", () => {
const cfg = {
channels: {
telegram: {
botToken: "tok",
capabilities: ["vision"],
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(true);
});
it("still respects explicit inlineButtons off for exec approval buttons", () => {
const cfg = {
channels: {
telegram: {
botToken: "tok",
capabilities: { inlineButtons: "off" },
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(false);
});
});

View File

@@ -0,0 +1,106 @@
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramExecApprovalConfig } from "../config/types.telegram.js";
import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramTargetChatType } from "./targets.js";
function normalizeApproverId(value: string | number): string {
return String(value).trim();
}
export function resolveTelegramExecApprovalConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): TelegramExecApprovalConfig | undefined {
return resolveTelegramAccount(params).config.execApprovals;
}
export function getTelegramExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
return (resolveTelegramExecApprovalConfig(params)?.approvers ?? [])
.map(normalizeApproverId)
.filter(Boolean);
}
export function isTelegramExecApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const config = resolveTelegramExecApprovalConfig(params);
return Boolean(config?.enabled && getTelegramExecApprovalApprovers(params).length > 0);
}
export function isTelegramExecApprovalApprover(params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const senderId = params.senderId?.trim();
if (!senderId) {
return false;
}
const approvers = getTelegramExecApprovalApprovers(params);
return approvers.includes(senderId);
}
export function resolveTelegramExecApprovalTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): "dm" | "channel" | "both" {
return resolveTelegramExecApprovalConfig(params)?.target ?? "dm";
}
export function shouldInjectTelegramExecApprovalButtons(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
}): boolean {
if (!isTelegramExecApprovalClientEnabled(params)) {
return false;
}
const target = resolveTelegramExecApprovalTarget(params);
const chatType = resolveTelegramTargetChatType(params.to);
if (chatType === "direct") {
return target === "dm" || target === "both";
}
if (chatType === "group") {
return target === "channel" || target === "both";
}
return target === "both";
}
function resolveExecApprovalButtonsExplicitlyDisabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const capabilities = resolveTelegramAccount(params).config.capabilities;
if (!capabilities || Array.isArray(capabilities) || typeof capabilities !== "object") {
return false;
}
const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons;
return typeof inlineButtons === "string" && inlineButtons.trim().toLowerCase() === "off";
}
export function shouldEnableTelegramExecApprovalButtons(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
}): boolean {
if (!shouldInjectTelegramExecApprovalButtons(params)) {
return false;
}
return !resolveExecApprovalButtonsExplicitlyDisabled(params);
}
export function shouldSuppressLocalTelegramExecApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}): boolean {
void params.cfg;
void params.accountId;
return getExecApprovalReplyMetadata(params.payload) !== null;
}

View File

@@ -5,6 +5,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({
botApi: {
deleteMessage: vi.fn(),
editMessageText: vi.fn(),
sendChatAction: vi.fn(),
sendMessage: vi.fn(),
sendPoll: vi.fn(),
sendPhoto: vi.fn(),

View File

@@ -17,6 +17,7 @@ const {
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
sendTypingTelegram,
sendPollTelegram,
sendStickerTelegram,
} = await importTelegramSendModule();
@@ -171,6 +172,25 @@ describe("buildInlineKeyboard", () => {
});
describe("sendMessageTelegram", () => {
it("sends typing to the resolved chat and topic", async () => {
loadConfig.mockReturnValue({
channels: {
telegram: {
botToken: "tok",
},
},
});
botApi.sendChatAction.mockResolvedValue(true);
await sendTypingTelegram("telegram:group:-1001234567890:topic:271", {
accountId: "default",
});
expect(botApi.sendChatAction).toHaveBeenCalledWith("-1001234567890", "typing", {
message_thread_id: 271,
});
});
it("applies timeoutSeconds config precedence", async () => {
const cases = [
{

View File

@@ -22,7 +22,7 @@ import { normalizePollInput, type PollInput } from "../polls.js";
import { loadWebMedia } from "../web/media.js";
import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { buildTelegramThreadParams } from "./bot/helpers.js";
import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { splitTelegramCaption } from "./caption.js";
import { resolveTelegramFetch } from "./fetch.js";
@@ -88,6 +88,16 @@ type TelegramReactionOpts = {
retry?: RetryConfig;
};
type TelegramTypingOpts = {
cfg?: ReturnType<typeof loadConfig>;
token?: string;
accountId?: string;
verbose?: boolean;
api?: TelegramApiOverride;
retry?: RetryConfig;
messageThreadId?: number;
};
function resolveTelegramMessageIdOrThrow(
result: TelegramMessageLike | null | undefined,
context: string,
@@ -777,6 +787,39 @@ export async function sendMessageTelegram(
return { messageId: String(messageId), chatId: String(res?.chat?.id ?? chatId) };
}
export async function sendTypingTelegram(
to: string,
opts: TelegramTypingOpts = {},
): Promise<{ ok: true }> {
const { cfg, account, api } = resolveTelegramApiContext(opts);
const target = parseTelegramTarget(to);
const chatId = await resolveAndPersistChatId({
cfg,
api,
lookupTarget: target.chatId,
persistTarget: to,
verbose: opts.verbose,
});
const requestWithDiag = createTelegramRequestWithDiag({
cfg,
account,
retry: opts.retry,
verbose: opts.verbose,
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
const threadParams = buildTypingThreadParams(target.messageThreadId ?? opts.messageThreadId);
await requestWithDiag(
() =>
api.sendChatAction(
chatId,
"typing",
threadParams as Parameters<TelegramApi["sendChatAction"]>[2],
),
"typing",
);
return { ok: true };
}
export async function reactMessageTelegram(
chatIdInput: string | number,
messageIdInput: string | number,