Skip to content

Commit d481994

Browse files
committed
fix(telegram): restart isolated polling on getUpdates conflict and surface it in status
1 parent 4a3d06e commit d481994

2 files changed

Lines changed: 132 additions & 5 deletions

File tree

extensions/telegram/src/polling-session.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ type WorkerPollSuccessListener = (message: {
104104
type WorkerPollErrorListener = (message: {
105105
type: "poll-error";
106106
message: string;
107+
errorCode?: number;
107108
finishedAt: number;
108109
}) => void;
109110
type WorkerMessageListener = (message: TelegramIngressWorkerMessage) => void;
@@ -2356,6 +2357,89 @@ describe("TelegramPollingSession", () => {
23562357
}
23572358
});
23582359

2360+
it("restarts isolated ingress on a getUpdates conflict instead of crashing the account", async () => {
2361+
const abort = new AbortController();
2362+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
2363+
const log = vi.fn();
2364+
const setStatus = vi.fn();
2365+
// 409 conflicts are not "recoverable network errors"; the conflict branch
2366+
// must restart the cycle before that classifier is consulted.
2367+
isRecoverableTelegramNetworkErrorMock.mockReturnValue(false);
2368+
const deleteWebhook = vi.fn(async () => true);
2369+
createTelegramBotMock.mockImplementation(() => ({
2370+
api: {
2371+
deleteWebhook,
2372+
config: { use: vi.fn() },
2373+
},
2374+
init: vi.fn(async () => undefined),
2375+
handleUpdate: vi.fn(async () => undefined),
2376+
stop: vi.fn(async () => undefined),
2377+
}));
2378+
const transport1 = makeTelegramTransport();
2379+
const transport2 = makeTelegramTransport();
2380+
const createTelegramTransport = vi
2381+
.fn<() => ReturnType<typeof makeTelegramTransport>>()
2382+
.mockReturnValueOnce(transport2);
2383+
2384+
let workerCycle = 0;
2385+
let listener: WorkerPollErrorListener | undefined;
2386+
const createWorker = vi.fn(() => ({
2387+
onMessage: vi.fn((next: WorkerPollErrorListener) => {
2388+
listener = next;
2389+
return () => undefined;
2390+
}),
2391+
stop: vi.fn(async () => undefined),
2392+
task: vi.fn(async () => {
2393+
workerCycle += 1;
2394+
if (workerCycle === 1) {
2395+
listener?.({
2396+
type: "poll-error",
2397+
message: "Conflict: terminated by other getUpdates request",
2398+
errorCode: 409,
2399+
finishedAt: Date.now(),
2400+
});
2401+
throw new Error("Telegram ingress worker exited with code 1");
2402+
}
2403+
abort.abort();
2404+
}),
2405+
}));
2406+
2407+
try {
2408+
const session = createPollingSession({
2409+
abortSignal: abort.signal,
2410+
log,
2411+
setStatus,
2412+
telegramTransport: transport1,
2413+
createTelegramTransport,
2414+
isolatedIngress: {
2415+
enabled: true,
2416+
spoolDir: tempDir,
2417+
createWorker,
2418+
drainIntervalMs: 100,
2419+
},
2420+
});
2421+
2422+
await session.runUntilAbort();
2423+
2424+
expect(createWorker).toHaveBeenCalledTimes(2);
2425+
// The conflict resets webhook cleanup so the next cycle re-runs deleteWebhook.
2426+
expect(deleteWebhook).toHaveBeenCalledTimes(2);
2427+
// The conflict marks the transport dirty so the next cycle gets a fresh socket.
2428+
expect(createTelegramTransport).toHaveBeenCalledTimes(1);
2429+
expectLogIncludes(log, "Another OpenClaw gateway, script, or Telegram poller");
2430+
expect(
2431+
statusPatches(setStatus).some(
2432+
(patch) =>
2433+
patch.connected === false &&
2434+
String(patch.lastError).includes("Another OpenClaw gateway"),
2435+
),
2436+
).toBe(true);
2437+
} finally {
2438+
abort.abort();
2439+
await fs.rm(tempDir, { recursive: true, force: true });
2440+
}
2441+
});
2442+
23592443
it("keeps active spooled lanes blocked across account restarts", async () => {
23602444
vi.useFakeTimers({ shouldAdvanceTime: true });
23612445
const firstAbort = new AbortController();
@@ -3533,9 +3617,11 @@ describe("TelegramPollingSession", () => {
35333617
const watchdogHarness = installPollingStallWatchdogHarness();
35343618

35353619
const log = vi.fn();
3620+
const setStatus = vi.fn();
35363621
const session = createPollingSession({
35373622
abortSignal: abort.signal,
35383623
log,
3624+
setStatus,
35393625
});
35403626

35413627
try {
@@ -3567,6 +3653,14 @@ describe("TelegramPollingSession", () => {
35673653
abort.abort();
35683654
resolveFirstTask();
35693655
await runPromise;
3656+
3657+
// The stall must reach channel status, not just the gateway log.
3658+
expect(
3659+
statusPatches(setStatus).some(
3660+
(patch) =>
3661+
patch.connected === false && String(patch.lastError).includes("Polling stall detected"),
3662+
),
3663+
).toBe(true);
35703664
} finally {
35713665
watchdogHarness.restore();
35723666
}
@@ -3614,6 +3708,7 @@ describe("TelegramPollingSession", () => {
36143708
it("logs an actionable duplicate-poller hint for getUpdates conflicts", async () => {
36153709
const abort = new AbortController();
36163710
const log = vi.fn();
3711+
const setStatus = vi.fn();
36173712
const conflictError = Object.assign(
36183713
new Error("Conflict: terminated by other getUpdates request"),
36193714
{
@@ -3628,11 +3723,19 @@ describe("TelegramPollingSession", () => {
36283723
const session = createPollingSession({
36293724
abortSignal: abort.signal,
36303725
log,
3726+
setStatus,
36313727
});
36323728

36333729
await session.runUntilAbort();
36343730

36353731
expectLogIncludes(log, "Another OpenClaw gateway, script, or Telegram poller");
3732+
// The hint must reach channel status, not just the gateway log.
3733+
expect(
3734+
statusPatches(setStatus).some(
3735+
(patch) =>
3736+
patch.connected === false && String(patch.lastError).includes("Another OpenClaw gateway"),
3737+
),
3738+
).toBe(true);
36363739
});
36373740

36383741
it("logs polling cycle start after a transport rebuild", async () => {

extensions/telegram/src/polling-session.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ function resolveTelegramRestartDelayMs(
110110
return { delayMs, stopTimeoutSuffix };
111111
}
112112

113+
// Surfaced in logs and channel status when getUpdates returns 409; the only
114+
// user-fixable causes are a second poller on the same token or a stale webhook.
115+
const TELEGRAM_GET_UPDATES_CONFLICT_HINT =
116+
" Another OpenClaw gateway, script, or Telegram poller may be using this bot token; stop the duplicate poller or switch this account to webhook mode.";
117+
113118
const DEFAULT_POLL_STALL_THRESHOLD_MS = 120_000;
114119
const MIN_POLL_STALL_THRESHOLD_MS = 30_000;
115120
const MAX_POLL_STALL_THRESHOLD_MS = 600_000;
@@ -813,10 +818,12 @@ export class TelegramPollingSession {
813818
offset: number | null;
814819
outcome: string;
815820
error?: string;
821+
errorCode: number | null;
816822
} = {
817823
startedAt: null,
818824
offset: null,
819825
outcome: "not-started",
826+
errorCode: null,
820827
};
821828
const liveness = new TelegramPollingLivenessTracker();
822829
let consecutiveDrainFailures = 0;
@@ -853,6 +860,7 @@ export class TelegramPollingSession {
853860
pollState.offset = message.offset;
854861
pollState.outcome = "started";
855862
delete pollState.error;
863+
pollState.errorCode = null;
856864
return;
857865
}
858866
if (message.type === "poll-success") {
@@ -871,6 +879,7 @@ export class TelegramPollingSession {
871879
liveness.noteGetUpdatesFinished();
872880
pollState.outcome = "error";
873881
pollState.error = message.message;
882+
pollState.errorCode = message.errorCode ?? null;
874883
return;
875884
}
876885
if (message.type === "update") {
@@ -1002,14 +1011,24 @@ export class TelegramPollingSession {
10021011
if (this.opts.abortSignal?.aborted) {
10031012
return "exit";
10041013
}
1005-
if (
1014+
// The worker only issues getUpdates, so a 409 is always a duplicate
1015+
// poller (or stale webhook) conflict. Mirror the classic polling
1016+
// cycle: re-clear the webhook, rotate the transport (#69787), and
1017+
// restart with backoff instead of crashing the whole account.
1018+
const isConflict = pollState.errorCode === 409;
1019+
if (isConflict) {
1020+
this.#webhookCleared = false;
1021+
this.#transportState.markDirty();
1022+
} else if (
10061023
pollState.error &&
10071024
!isRecoverableTelegramNetworkError(new Error(pollState.error), { context: "polling" })
10081025
) {
10091026
this.#status.notePollingError(pollState.error);
10101027
throw new Error(pollState.error, { cause: err });
10111028
}
1012-
const message = formatErrorMessage(err);
1029+
const message = isConflict
1030+
? `Telegram getUpdates conflict: ${pollState.error}.${TELEGRAM_GET_UPDATES_CONFLICT_HINT}`
1031+
: formatErrorMessage(err);
10131032
this.opts.log(`[telegram][diag] isolated polling ingress failed: ${message}`);
10141033
this.#status.notePollingError(message);
10151034
clearForceCycleTimer();
@@ -1162,6 +1181,7 @@ export class TelegramPollingSession {
11621181
this.#transportState.markDirty();
11631182
stalledRestart = true;
11641183
this.opts.log(`[telegram] ${stall.message}`);
1184+
this.#status.notePollingError(stall.message);
11651185
requestStopForRestart();
11661186
}
11671187
}, POLL_WATCHDOG_INTERVAL_MS);
@@ -1210,12 +1230,16 @@ export class TelegramPollingSession {
12101230
}
12111231
const reason = isConflict ? "getUpdates conflict" : "network error";
12121232
const errMsg = formatErrorMessage(err);
1213-
const conflictHint = isConflict
1214-
? " Another OpenClaw gateway, script, or Telegram poller may be using this bot token; stop the duplicate poller or switch this account to webhook mode."
1215-
: "";
1233+
const conflictHint = isConflict ? TELEGRAM_GET_UPDATES_CONFLICT_HINT : "";
12161234
this.opts.log(
12171235
`[telegram][diag] polling cycle error reason=${reason} ${liveness.formatDiagnosticFields("lastGetUpdatesError")} err=${errMsg}${conflictHint}`,
12181236
);
1237+
// Conflicts carry a user-fixable diagnosis, so surface them in channel
1238+
// status. Recoverable network blips stay log-only; the stall watchdog
1239+
// owns status for extended outages (see detectStall above).
1240+
if (isConflict) {
1241+
this.#status.notePollingError(`Telegram ${reason}: ${errMsg}.${conflictHint}`);
1242+
}
12191243
clearForceCycleTimer();
12201244
const shouldRestart = await this.#waitBeforeRestart(
12211245
(delay) => `Telegram ${reason}: ${errMsg};${conflictHint} retrying in ${delay}.`,

0 commit comments

Comments
 (0)