mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Telegram: add deterministic exec approval prompts and config gating
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -321,6 +321,7 @@ export async function processGatewayAllowlist(
|
||||
host: "gateway",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
warningText,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -377,6 +377,7 @@ export async function executeNodeHostCommand(
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
nodeId,
|
||||
warningText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,4 +60,5 @@ export type ExecToolDetails =
|
||||
command: string;
|
||||
cwd?: string;
|
||||
nodeId?: string;
|
||||
warningText?: string;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = {
|
||||
actionFingerprint?: string;
|
||||
};
|
||||
didSendViaMessagingTool: boolean;
|
||||
didSendDeterministicApprovalPrompt?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -28,6 +28,7 @@ function createMockContext(overrides?: {
|
||||
messagingToolSentTextsNormalized: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
deterministicApprovalPromptSent: false,
|
||||
},
|
||||
log: { debug: vi.fn(), warn: vi.fn() },
|
||||
shouldEmitToolResult: vi.fn(() => false),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBaseToolHandlerState() {
|
||||
messagingToolSentTextsNormalized: [] as string[],
|
||||
messagingToolSentMediaUrls: [] as string[],
|
||||
messagingToolSentTargets: [] as unknown[],
|
||||
deterministicApprovalPromptSent: false,
|
||||
blockBuffer: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -445,8 +445,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
}
|
||||
await params.typingSignals.signalTextDelta(text);
|
||||
await onToolResult({
|
||||
...payload,
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -115,7 +115,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
quoteText,
|
||||
mediaLocalRoots,
|
||||
};
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
const result = await send(to, text, {
|
||||
...payloadOpts,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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````");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
113
src/infra/exec-approval-reply.ts
Normal file
113
src/infra/exec-approval-reply.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
134
src/telegram/approval-buttons.test.ts
Normal file
134
src/telegram/approval-buttons.test.ts
Normal 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 allow‑once")).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);
|
||||
});
|
||||
});
|
||||
108
src/telegram/approval-buttons.ts
Normal file
108
src/telegram/approval-buttons.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
92
src/telegram/exec-approvals.test.ts
Normal file
92
src/telegram/exec-approvals.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
106
src/telegram/exec-approvals.ts
Normal file
106
src/telegram/exec-approvals.ts
Normal 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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user