openclaw/extensions/telegram/src/sendchataction-401-backoff.ts
2026-03-16 21:16:32 -07:00

138 lines
3.8 KiB
TypeScript

import {
computeBackoff,
sleepWithAbort,
type BackoffPolicy,
} from "openclaw/plugin-sdk/infra-runtime";
export type TelegramSendChatActionLogger = (message: string) => void;
type ChatAction =
| "typing"
| "upload_photo"
| "record_video"
| "upload_video"
| "record_voice"
| "upload_voice"
| "upload_document"
| "find_location"
| "record_video_note"
| "upload_video_note"
| "choose_sticker";
type SendChatActionFn = (
chatId: number | string,
action: ChatAction,
threadParams?: unknown,
) => Promise<unknown>;
export type TelegramSendChatActionHandler = {
/**
* Send a chat action with automatic 401 backoff and circuit breaker.
* Safe to call from multiple concurrent message contexts.
*/
sendChatAction: (
chatId: number | string,
action: ChatAction,
threadParams?: unknown,
) => Promise<void>;
isSuspended: () => boolean;
reset: () => void;
};
export type CreateTelegramSendChatActionHandlerParams = {
sendChatActionFn: SendChatActionFn;
logger: TelegramSendChatActionLogger;
maxConsecutive401?: number;
};
const BACKOFF_POLICY: BackoffPolicy = {
initialMs: 1000,
maxMs: 300_000, // 5 minutes
factor: 2,
jitter: 0.1,
};
function is401Error(error: unknown): boolean {
if (!error) {
return false;
}
const message = error instanceof Error ? error.message : JSON.stringify(error);
return message.includes("401") || message.toLowerCase().includes("unauthorized");
}
/**
* Creates a GLOBAL (per-account) handler for sendChatAction that tracks 401 errors
* across all message contexts. This prevents the infinite loop that caused Telegram
* to delete bots (issue #27092).
*
* When a 401 occurs, exponential backoff is applied (1s → 2s → 4s → ... → 5min).
* After maxConsecutive401 failures (default 10), all sendChatAction calls are
* suspended until reset() is called.
*/
export function createTelegramSendChatActionHandler({
sendChatActionFn,
logger,
maxConsecutive401 = 10,
}: CreateTelegramSendChatActionHandlerParams): TelegramSendChatActionHandler {
let consecutive401Failures = 0;
let suspended = false;
const reset = () => {
consecutive401Failures = 0;
suspended = false;
};
const sendChatAction = async (
chatId: number | string,
action: ChatAction,
threadParams?: unknown,
): Promise<void> => {
if (suspended) {
return;
}
if (consecutive401Failures > 0) {
const backoffMs = computeBackoff(BACKOFF_POLICY, consecutive401Failures);
logger(
`sendChatAction backoff: waiting ${backoffMs}ms before retry ` +
`(failure ${consecutive401Failures}/${maxConsecutive401})`,
);
await sleepWithAbort(backoffMs);
}
try {
await sendChatActionFn(chatId, action, threadParams);
// Success: reset failure counter
if (consecutive401Failures > 0) {
logger(`sendChatAction recovered after ${consecutive401Failures} consecutive 401 failures`);
consecutive401Failures = 0;
}
} catch (error) {
if (is401Error(error)) {
consecutive401Failures++;
if (consecutive401Failures >= maxConsecutive401) {
suspended = true;
logger(
`CRITICAL: sendChatAction suspended after ${consecutive401Failures} consecutive 401 errors. ` +
`Bot token is likely invalid. Telegram may DELETE the bot if requests continue. ` +
`Replace the token and restart: openclaw channels restart telegram`,
);
} else {
logger(
`sendChatAction 401 error (${consecutive401Failures}/${maxConsecutive401}). ` +
`Retrying with exponential backoff.`,
);
}
}
throw error;
}
};
return {
sendChatAction,
isSuspended: () => suspended,
reset,
};
}