mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 21:48:11 +00:00
Telegram: exec approvals for OpenCode/Codex (#37233)
Merged via squash.
Prepared head SHA: f243379094
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
This commit is contained in:
18
src/telegram/approval-buttons.test.ts
Normal file
18
src/telegram/approval-buttons.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
|
||||
|
||||
describe("telegram approval buttons", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
42
src/telegram/approval-buttons.ts
Normal file
42
src/telegram/approval-buttons.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ExecApprovalReplyDecision } from "../infra/exec-approval-reply.js";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
|
||||
const MAX_CALLBACK_DATA_BYTES = 64;
|
||||
|
||||
function fitsCallbackData(value: string): boolean {
|
||||
return Buffer.byteLength(value, "utf8") <= MAX_CALLBACK_DATA_BYTES;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -202,6 +202,7 @@ export async function buildTelegramInboundContextPayload(params: {
|
||||
SenderUsername: senderUsername || undefined,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
BotUsername: primaryCtx.me?.username ?? undefined,
|
||||
MessageSid: options?.messageIdOverride ?? String(msg.message_id),
|
||||
ReplyToId: replyTarget?.id,
|
||||
ReplyToBody: replyTarget?.body,
|
||||
|
||||
@@ -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,48 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not inject approval buttons in local dispatch once the monitor owns approvals", 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({
|
||||
text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const deliveredPayload = (deliverReplies.mock.calls[0]?.[0] as { replies?: Array<unknown> })
|
||||
?.replies?.[0] as { channelData?: unknown } | undefined;
|
||||
expect(deliveredPayload?.channelData).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses 30-char preview debounce for legacy block stream mode", async () => {
|
||||
const draftStream = createDraftStream();
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
|
||||
@@ -30,6 +30,7 @@ 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 { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
|
||||
import { renderTelegramHtmlText } from "./format.js";
|
||||
import {
|
||||
type ArchivedPreview,
|
||||
@@ -526,6 +527,16 @@ 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 previewButtons = (
|
||||
payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined
|
||||
)?.buttons;
|
||||
@@ -559,7 +570,10 @@ export const dispatchTelegramMessage = async ({
|
||||
info.kind === "final" &&
|
||||
reasoningStepState.shouldBufferFinalAnswer()
|
||||
) {
|
||||
reasoningStepState.bufferFinalAnswer({ payload, text: segment.text });
|
||||
reasoningStepState.bufferFinalAnswer({
|
||||
payload,
|
||||
text: segment.text,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (segment.lane === "reasoning") {
|
||||
|
||||
@@ -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,81 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not inject approval buttons for native command replies once the monitor owns approvals", 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?.["text"]).toContain("/approve 7f423fdc allow-once");
|
||||
expect(deliveredPayload?.["channelData"]).toBeUndefined();
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
} from "./bot/helpers.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
import { resolveTelegramConversationRoute } from "./conversation-route.js";
|
||||
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
|
||||
import {
|
||||
evaluateTelegramGroupBaseAccess,
|
||||
evaluateTelegramGroupPolicyAccess,
|
||||
@@ -177,6 +178,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
|
||||
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
|
||||
chatId,
|
||||
accountId,
|
||||
@@ -234,7 +236,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 +581,8 @@ export const registerTelegramNativeCommands = ({
|
||||
senderUsername,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
commandAuthorized: initialCommandAuthorized,
|
||||
commandAuthorized,
|
||||
} = auth;
|
||||
let commandAuthorized = initialCommandAuthorized;
|
||||
const runtimeContext = await resolveCommandRuntimeContext({
|
||||
msg,
|
||||
isGroup,
|
||||
@@ -751,6 +751,16 @@ export const registerTelegramNativeCommands = ({
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload, _info) => {
|
||||
if (
|
||||
shouldSuppressLocalTelegramExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId: route.accountId,
|
||||
payload,
|
||||
})
|
||||
) {
|
||||
deliveryState.delivered = true;
|
||||
return;
|
||||
}
|
||||
const result = await deliverReplies({
|
||||
replies: [payload],
|
||||
...deliveryBaseOptions,
|
||||
@@ -863,10 +873,18 @@ export const registerTelegramNativeCommands = ({
|
||||
messageThreadId: threadSpec.id,
|
||||
});
|
||||
|
||||
await deliverReplies({
|
||||
replies: [result],
|
||||
...deliveryBaseOptions,
|
||||
});
|
||||
if (
|
||||
!shouldSuppressLocalTelegramExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId: route.accountId,
|
||||
payload: result,
|
||||
})
|
||||
) {
|
||||
await deliverReplies({
|
||||
replies: [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.",
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
156
src/telegram/exec-approvals-handler.test.ts
Normal file
156
src/telegram/exec-approvals-handler.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
|
||||
|
||||
const baseRequest = {
|
||||
id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
|
||||
request: {
|
||||
command: "npm view diver name version description",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "-1003841603622",
|
||||
turnSourceThreadId: "928",
|
||||
turnSourceAccountId: "default",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 61_000,
|
||||
};
|
||||
|
||||
function createHandler(cfg: OpenClawConfig) {
|
||||
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "m1", chatId: "-1003841603622" })
|
||||
.mockResolvedValue({ messageId: "m2", chatId: "8460800771" });
|
||||
const editReplyMarkup = vi.fn().mockResolvedValue({ ok: true });
|
||||
const handler = new TelegramExecApprovalHandler(
|
||||
{
|
||||
token: "tg-token",
|
||||
accountId: "default",
|
||||
cfg,
|
||||
},
|
||||
{
|
||||
nowMs: () => 1000,
|
||||
sendTyping,
|
||||
sendMessage,
|
||||
editReplyMarkup,
|
||||
},
|
||||
);
|
||||
return { handler, sendTyping, sendMessage, editReplyMarkup };
|
||||
}
|
||||
|
||||
describe("TelegramExecApprovalHandler", () => {
|
||||
it("sends approval prompts to the originating telegram topic when target=channel", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["8460800771"],
|
||||
target: "channel",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const { handler, sendTyping, sendMessage } = createHandler(cfg);
|
||||
|
||||
await handler.handleRequested(baseRequest);
|
||||
|
||||
expect(sendTyping).toHaveBeenCalledWith(
|
||||
"-1003841603622",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
messageThreadId: 928,
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"-1003841603622",
|
||||
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
messageThreadId: 928,
|
||||
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("falls back to approver DMs when channel routing is unavailable", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["111", "222"],
|
||||
target: "channel",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const { handler, sendMessage } = createHandler(cfg);
|
||||
|
||||
await handler.handleRequested({
|
||||
...baseRequest,
|
||||
request: {
|
||||
...baseRequest.request,
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "U1",
|
||||
turnSourceAccountId: null,
|
||||
turnSourceThreadId: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessage.mock.calls.map((call) => call[0])).toEqual(["111", "222"]);
|
||||
});
|
||||
|
||||
it("clears buttons from tracked approval messages when resolved", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["8460800771"],
|
||||
target: "both",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const { handler, editReplyMarkup } = createHandler(cfg);
|
||||
|
||||
await handler.handleRequested(baseRequest);
|
||||
await handler.handleResolved({
|
||||
id: baseRequest.id,
|
||||
decision: "allow-once",
|
||||
resolvedBy: "telegram:8460800771",
|
||||
ts: 2000,
|
||||
});
|
||||
|
||||
expect(editReplyMarkup).toHaveBeenCalled();
|
||||
expect(editReplyMarkup).toHaveBeenCalledWith(
|
||||
"-1003841603622",
|
||||
"m1",
|
||||
[],
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
418
src/telegram/exec-approvals-handler.ts
Normal file
418
src/telegram/exec-approvals-handler.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
|
||||
import type { EventFrame } from "../gateway/protocol/index.js";
|
||||
import {
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
type ExecApprovalPendingReplyParams,
|
||||
} from "../infra/exec-approval-reply.js";
|
||||
import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js";
|
||||
import { resolveSessionDeliveryTarget } from "../infra/outbound/targets.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
|
||||
import {
|
||||
getTelegramExecApprovalApprovers,
|
||||
resolveTelegramExecApprovalConfig,
|
||||
resolveTelegramExecApprovalTarget,
|
||||
} from "./exec-approvals.js";
|
||||
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
|
||||
|
||||
const log = createSubsystemLogger("telegram/exec-approvals");
|
||||
|
||||
type PendingMessage = {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
type PendingApproval = {
|
||||
timeoutId: NodeJS.Timeout;
|
||||
messages: PendingMessage[];
|
||||
};
|
||||
|
||||
type TelegramApprovalTarget = {
|
||||
to: string;
|
||||
threadId?: number;
|
||||
};
|
||||
|
||||
export type TelegramExecApprovalHandlerOpts = {
|
||||
token: string;
|
||||
accountId: string;
|
||||
cfg: OpenClawConfig;
|
||||
gatewayUrl?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
};
|
||||
|
||||
export type TelegramExecApprovalHandlerDeps = {
|
||||
nowMs?: () => number;
|
||||
sendTyping?: typeof sendTypingTelegram;
|
||||
sendMessage?: typeof sendMessageTelegram;
|
||||
editReplyMarkup?: typeof editMessageReplyMarkupTelegram;
|
||||
};
|
||||
|
||||
function matchesFilters(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
request: ExecApprovalRequest;
|
||||
}): boolean {
|
||||
const config = resolveTelegramExecApprovalConfig({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!config?.enabled) {
|
||||
return false;
|
||||
}
|
||||
const approvers = getTelegramExecApprovalApprovers({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (approvers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (config.agentFilter?.length) {
|
||||
const agentId =
|
||||
params.request.request.agentId ??
|
||||
parseAgentSessionKey(params.request.request.sessionKey)?.agentId;
|
||||
if (!agentId || !config.agentFilter.includes(agentId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (config.sessionFilter?.length) {
|
||||
const sessionKey = params.request.request.sessionKey;
|
||||
if (!sessionKey) {
|
||||
return false;
|
||||
}
|
||||
const matches = config.sessionFilter.some((pattern) => {
|
||||
if (sessionKey.includes(pattern)) {
|
||||
return true;
|
||||
}
|
||||
const regex = compileSafeRegex(pattern);
|
||||
return regex ? testRegexWithBoundedInput(regex, sessionKey) : false;
|
||||
});
|
||||
if (!matches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean {
|
||||
const config = resolveTelegramExecApprovalConfig({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!config?.enabled) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
getTelegramExecApprovalApprovers({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRequestSessionTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest;
|
||||
}): { to: string; accountId?: string; threadId?: number; channel?: string } | null {
|
||||
const sessionKey = params.request.request.sessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
const agentId = parsed?.agentId ?? params.request.request.agentId ?? "main";
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const target = resolveSessionDeliveryTarget({
|
||||
entry,
|
||||
requestedChannel: "last",
|
||||
turnSourceChannel: params.request.request.turnSourceChannel ?? undefined,
|
||||
turnSourceTo: params.request.request.turnSourceTo ?? undefined,
|
||||
turnSourceAccountId: params.request.request.turnSourceAccountId ?? undefined,
|
||||
turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined,
|
||||
});
|
||||
if (!target.to) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel: target.channel ?? undefined,
|
||||
to: target.to,
|
||||
accountId: target.accountId ?? undefined,
|
||||
threadId:
|
||||
typeof target.threadId === "number"
|
||||
? target.threadId
|
||||
: typeof target.threadId === "string"
|
||||
? Number.parseInt(target.threadId, 10)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTelegramSourceTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
request: ExecApprovalRequest;
|
||||
}): TelegramApprovalTarget | null {
|
||||
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
|
||||
const turnSourceTo = params.request.request.turnSourceTo?.trim() || "";
|
||||
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
|
||||
if (turnSourceChannel === "telegram" && turnSourceTo) {
|
||||
if (
|
||||
turnSourceAccountId &&
|
||||
normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const threadId =
|
||||
typeof params.request.request.turnSourceThreadId === "number"
|
||||
? params.request.request.turnSourceThreadId
|
||||
: typeof params.request.request.turnSourceThreadId === "string"
|
||||
? Number.parseInt(params.request.request.turnSourceThreadId, 10)
|
||||
: undefined;
|
||||
return { to: turnSourceTo, threadId: Number.isFinite(threadId) ? threadId : undefined };
|
||||
}
|
||||
|
||||
const sessionTarget = resolveRequestSessionTarget(params);
|
||||
if (!sessionTarget || sessionTarget.channel !== "telegram") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
sessionTarget.accountId &&
|
||||
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: sessionTarget.to,
|
||||
threadId: sessionTarget.threadId,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeTargets(targets: TelegramApprovalTarget[]): TelegramApprovalTarget[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: TelegramApprovalTarget[] = [];
|
||||
for (const target of targets) {
|
||||
const key = `${target.to}:${target.threadId ?? ""}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(target);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export class TelegramExecApprovalHandler {
|
||||
private gatewayClient: GatewayClient | null = null;
|
||||
private pending = new Map<string, PendingApproval>();
|
||||
private started = false;
|
||||
private readonly nowMs: () => number;
|
||||
private readonly sendTyping: typeof sendTypingTelegram;
|
||||
private readonly sendMessage: typeof sendMessageTelegram;
|
||||
private readonly editReplyMarkup: typeof editMessageReplyMarkupTelegram;
|
||||
|
||||
constructor(
|
||||
private readonly opts: TelegramExecApprovalHandlerOpts,
|
||||
deps: TelegramExecApprovalHandlerDeps = {},
|
||||
) {
|
||||
this.nowMs = deps.nowMs ?? Date.now;
|
||||
this.sendTyping = deps.sendTyping ?? sendTypingTelegram;
|
||||
this.sendMessage = deps.sendMessage ?? sendMessageTelegram;
|
||||
this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram;
|
||||
}
|
||||
|
||||
shouldHandle(request: ExecApprovalRequest): boolean {
|
||||
return matchesFilters({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
|
||||
if (!isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({
|
||||
config: this.opts.cfg,
|
||||
url: this.opts.gatewayUrl,
|
||||
});
|
||||
const gatewayUrlOverrideSource =
|
||||
urlSource === "cli --url"
|
||||
? "cli"
|
||||
: urlSource === "env OPENCLAW_GATEWAY_URL"
|
||||
? "env"
|
||||
: undefined;
|
||||
const auth = await resolveGatewayConnectionAuth({
|
||||
config: this.opts.cfg,
|
||||
env: process.env,
|
||||
urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined,
|
||||
urlOverrideSource: gatewayUrlOverrideSource,
|
||||
});
|
||||
|
||||
this.gatewayClient = new GatewayClient({
|
||||
url: gatewayUrl,
|
||||
token: auth.token,
|
||||
password: auth.password,
|
||||
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`,
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
scopes: ["operator.approvals"],
|
||||
onEvent: (evt) => this.handleGatewayEvent(evt),
|
||||
onConnectError: (err) => {
|
||||
log.error(`telegram exec approvals: connect error: ${err.message}`);
|
||||
},
|
||||
});
|
||||
this.gatewayClient.start();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = false;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.gatewayClient?.stop();
|
||||
this.gatewayClient = null;
|
||||
}
|
||||
|
||||
async handleRequested(request: ExecApprovalRequest): Promise<void> {
|
||||
if (!this.shouldHandle(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMode = resolveTelegramExecApprovalTarget({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
});
|
||||
const targets: TelegramApprovalTarget[] = [];
|
||||
const sourceTarget = resolveTelegramSourceTarget({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
request,
|
||||
});
|
||||
let fallbackToDm = false;
|
||||
if (targetMode === "channel" || targetMode === "both") {
|
||||
if (sourceTarget) {
|
||||
targets.push(sourceTarget);
|
||||
} else {
|
||||
fallbackToDm = true;
|
||||
}
|
||||
}
|
||||
if (targetMode === "dm" || targetMode === "both" || fallbackToDm) {
|
||||
for (const approver of getTelegramExecApprovalApprovers({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
})) {
|
||||
targets.push({ to: approver });
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTargets = dedupeTargets(targets);
|
||||
if (resolvedTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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: this.nowMs(),
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload(payloadParams);
|
||||
const buttons = buildTelegramExecApprovalButtons(request.id);
|
||||
const sentMessages: PendingMessage[] = [];
|
||||
|
||||
for (const target of resolvedTargets) {
|
||||
try {
|
||||
await this.sendTyping(target.to, {
|
||||
cfg: this.opts.cfg,
|
||||
token: this.opts.token,
|
||||
accountId: this.opts.accountId,
|
||||
...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}),
|
||||
}).catch(() => {});
|
||||
|
||||
const result = await this.sendMessage(target.to, payload.text ?? "", {
|
||||
cfg: this.opts.cfg,
|
||||
token: this.opts.token,
|
||||
accountId: this.opts.accountId,
|
||||
buttons,
|
||||
...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}),
|
||||
});
|
||||
sentMessages.push({
|
||||
chatId: result.chatId,
|
||||
messageId: result.messageId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error(`telegram exec approvals: failed to send request ${request.id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sentMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(0, request.expiresAtMs - this.nowMs());
|
||||
const timeoutId = setTimeout(() => {
|
||||
void this.handleResolved({ id: request.id, decision: "deny", ts: Date.now() });
|
||||
}, timeoutMs);
|
||||
timeoutId.unref?.();
|
||||
|
||||
this.pending.set(request.id, {
|
||||
timeoutId,
|
||||
messages: sentMessages,
|
||||
});
|
||||
}
|
||||
|
||||
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
|
||||
const pending = this.pending.get(resolved.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pending.delete(resolved.id);
|
||||
|
||||
await Promise.allSettled(
|
||||
pending.messages.map(async (message) => {
|
||||
await this.editReplyMarkup(message.chatId, message.messageId, [], {
|
||||
cfg: this.opts.cfg,
|
||||
token: this.opts.token,
|
||||
accountId: this.opts.accountId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleGatewayEvent(evt: EventFrame): void {
|
||||
if (evt.event === "exec.approval.requested") {
|
||||
void this.handleRequested(evt.payload as ExecApprovalRequest);
|
||||
return;
|
||||
}
|
||||
if (evt.event === "exec.approval.resolved") {
|
||||
void this.handleResolved(evt.payload as ExecApprovalResolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
|
||||
import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
|
||||
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
|
||||
import { TelegramPollingSession } from "./polling-session.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
@@ -73,6 +74,7 @@ const isGrammyHttpError = (err: unknown): boolean => {
|
||||
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
||||
const log = opts.runtime?.error ?? console.error;
|
||||
let pollingSession: TelegramPollingSession | undefined;
|
||||
let execApprovalsHandler: TelegramExecApprovalHandler | undefined;
|
||||
|
||||
const unregisterHandler = registerUnhandledRejectionHandler((err) => {
|
||||
const isNetworkError = isRecoverableTelegramNetworkError(err, { context: "polling" });
|
||||
@@ -111,6 +113,14 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
||||
const proxyFetch =
|
||||
opts.proxyFetch ?? (account.config.proxy ? makeProxyFetch(account.config.proxy) : undefined);
|
||||
|
||||
execApprovalsHandler = new TelegramExecApprovalHandler({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
cfg,
|
||||
runtime: opts.runtime,
|
||||
});
|
||||
await execApprovalsHandler.start();
|
||||
|
||||
const persistedOffsetRaw = await readTelegramUpdateOffset({
|
||||
accountId: account.accountId,
|
||||
botToken: token,
|
||||
@@ -178,6 +188,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
||||
});
|
||||
await pollingSession.runUntilAbort();
|
||||
} finally {
|
||||
await execApprovalsHandler?.stop().catch(() => {});
|
||||
unregisterHandler();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -873,6 +916,61 @@ type TelegramEditOpts = {
|
||||
cfg?: ReturnType<typeof loadConfig>;
|
||||
};
|
||||
|
||||
type TelegramEditReplyMarkupOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
api?: TelegramApiOverride;
|
||||
retry?: RetryConfig;
|
||||
/** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */
|
||||
buttons?: TelegramInlineButtons;
|
||||
/** Optional config injection to avoid global loadConfig() (improves testability). */
|
||||
cfg?: ReturnType<typeof loadConfig>;
|
||||
};
|
||||
|
||||
export async function editMessageReplyMarkupTelegram(
|
||||
chatIdInput: string | number,
|
||||
messageIdInput: string | number,
|
||||
buttons: TelegramInlineButtons,
|
||||
opts: TelegramEditReplyMarkupOpts = {},
|
||||
): Promise<{ ok: true; messageId: string; chatId: string }> {
|
||||
const { cfg, account, api } = resolveTelegramApiContext({
|
||||
...opts,
|
||||
cfg: opts.cfg,
|
||||
});
|
||||
const rawTarget = String(chatIdInput);
|
||||
const chatId = await resolveAndPersistChatId({
|
||||
cfg,
|
||||
api,
|
||||
lookupTarget: rawTarget,
|
||||
persistTarget: rawTarget,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const requestWithDiag = createTelegramRequestWithDiag({
|
||||
cfg,
|
||||
account,
|
||||
retry: opts.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const replyMarkup = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] };
|
||||
try {
|
||||
await requestWithDiag(
|
||||
() => api.editMessageReplyMarkup(chatId, messageId, { reply_markup: replyMarkup }),
|
||||
"editMessageReplyMarkup",
|
||||
{
|
||||
shouldLog: (err) => !isTelegramMessageNotModifiedError(err),
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isTelegramMessageNotModifiedError(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
logVerbose(`[telegram] Edited reply markup for message ${messageId} in chat ${chatId}`);
|
||||
return { ok: true, messageId: String(messageId), chatId };
|
||||
}
|
||||
|
||||
export async function editMessageTelegram(
|
||||
chatIdInput: string | number,
|
||||
messageIdInput: string | number,
|
||||
|
||||
Reference in New Issue
Block a user