mirror of
https://github.com/wassname/pi-telegram.git
synced 2026-06-27 18:24:59 +08:00
b2444fd3cd
Two real bugs surfaced after the original retry helper: 1. Healthy long-polls were tripping our 15s per-attempt timeout. The getUpdates request asks Telegram for a 30s server-side long-poll, so our internal timeout aborted every healthy connection and turned it into ABORT_ERR -> retry -> exhausted -> "disconnected", with no auto-reconnect. The fix: long-poll bypasses the retry helper and uses a 60s per-attempt timeout, since the poll loop already retries by re-entering after sleep(). 2. Our own internal AbortController timeout produced a DOMException AbortError indistinguishable from a caller-abort. The poll loop's shouldStopTelegramPolling treated that as "user wants to stop" and exited. Now fetchWithRetry normalizes its own timeout into a tagged Error with code ATTEMPT_TIMEOUT, so only real caller-aborts surface as AbortError upstream. Also: per-attempt timeout default dropped 15s -> 5s, retry budget dropped from [500, 2000] to [500] (so 2 attempts, not 3) for outbound sends, since they serialize and a long retry tail makes the bridge feel hung. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
126 lines
3.3 KiB
TypeScript
126 lines
3.3 KiB
TypeScript
/**
|
|
* Telegram polling domain helpers
|
|
* Owns polling request builders, stop conditions, and the long-poll loop runtime for Telegram updates
|
|
*/
|
|
|
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
|
|
import type { TelegramConfig } from "./api.ts";
|
|
|
|
export interface TelegramUpdateLike {
|
|
update_id: number;
|
|
}
|
|
|
|
export const TELEGRAM_ALLOWED_UPDATES = [
|
|
"message",
|
|
"edited_message",
|
|
"callback_query",
|
|
"message_reaction",
|
|
] as const;
|
|
|
|
export function buildTelegramInitialSyncRequest(): {
|
|
offset: number;
|
|
limit: number;
|
|
timeout: number;
|
|
} {
|
|
return {
|
|
offset: -1,
|
|
limit: 1,
|
|
timeout: 0,
|
|
};
|
|
}
|
|
|
|
export function buildTelegramLongPollRequest(lastUpdateId?: number): {
|
|
offset?: number;
|
|
limit: number;
|
|
timeout: number;
|
|
allowed_updates: readonly string[];
|
|
} {
|
|
return {
|
|
offset: lastUpdateId !== undefined ? lastUpdateId + 1 : undefined,
|
|
limit: 10,
|
|
timeout: 30,
|
|
allowed_updates: TELEGRAM_ALLOWED_UPDATES,
|
|
};
|
|
}
|
|
|
|
export function getLatestTelegramUpdateId(
|
|
updates: TelegramUpdateLike[],
|
|
): number | undefined {
|
|
return updates.at(-1)?.update_id;
|
|
}
|
|
|
|
export function shouldStopTelegramPolling(
|
|
signalAborted: boolean,
|
|
error: unknown,
|
|
): boolean {
|
|
// AbortError-from-our-own-timeout is normalized in fetchWithRetry so it
|
|
// can't reach here as a DOMException. Any AbortError that does reach here
|
|
// is therefore a real caller-abort and should stop the loop.
|
|
return (
|
|
signalAborted ||
|
|
(error instanceof DOMException && error.name === "AbortError")
|
|
);
|
|
}
|
|
|
|
export interface TelegramPollLoopDeps<TUpdate extends TelegramUpdateLike> {
|
|
ctx: ExtensionContext;
|
|
signal: AbortSignal;
|
|
config: TelegramConfig;
|
|
deleteWebhook: (signal: AbortSignal) => Promise<void>;
|
|
getUpdates: (
|
|
body: Record<string, unknown>,
|
|
signal: AbortSignal,
|
|
) => Promise<TUpdate[]>;
|
|
persistConfig: () => Promise<void>;
|
|
handleUpdate: (update: TUpdate, ctx: ExtensionContext) => Promise<void>;
|
|
onErrorStatus: (message: string) => void;
|
|
onStatusReset: () => void;
|
|
sleep: (ms: number) => Promise<void>;
|
|
}
|
|
|
|
export async function runTelegramPollLoop<TUpdate extends TelegramUpdateLike>(
|
|
deps: TelegramPollLoopDeps<TUpdate>,
|
|
): Promise<void> {
|
|
if (!deps.config.botToken) return;
|
|
try {
|
|
await deps.deleteWebhook(deps.signal);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
if (deps.config.lastUpdateId === undefined) {
|
|
try {
|
|
const updates = await deps.getUpdates(
|
|
buildTelegramInitialSyncRequest(),
|
|
deps.signal,
|
|
);
|
|
const lastUpdateId = getLatestTelegramUpdateId(updates);
|
|
if (lastUpdateId !== undefined) {
|
|
deps.config.lastUpdateId = lastUpdateId;
|
|
await deps.persistConfig();
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
while (!deps.signal.aborted) {
|
|
try {
|
|
const updates = await deps.getUpdates(
|
|
buildTelegramLongPollRequest(deps.config.lastUpdateId),
|
|
deps.signal,
|
|
);
|
|
for (const update of updates) {
|
|
deps.config.lastUpdateId = update.update_id;
|
|
await deps.persistConfig();
|
|
await deps.handleUpdate(update, deps.ctx);
|
|
}
|
|
} catch (error) {
|
|
if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
deps.onErrorStatus(message);
|
|
await deps.sleep(3000);
|
|
deps.onStatusReset();
|
|
}
|
|
}
|
|
}
|