fix(telegram): prevent update offset skipping queued updates (#23284)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 92efaf956bf906a176d1e6c5488ddcb02d89b4e1
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
Frank Yang
2026-02-22 02:20:33 -08:00
committed by GitHub
parent 98a03c490b
commit e33d7fcd13
3 changed files with 129 additions and 13 deletions

View File

@@ -445,6 +445,83 @@ describe("createTelegramBot", () => {
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("does not persist update offset past pending updates", async () => {
// For this test we need sequentialize(...) to behave like a normal middleware and call next().
sequentializeSpy.mockImplementationOnce(
() => async (_ctx: unknown, next: () => Promise<void>) => {
await next();
},
);
const onUpdateId = vi.fn();
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
});
createTelegramBot({
token: "tok",
updateOffset: {
lastUpdateId: 100,
onUpdateId,
},
});
type Middleware = (
ctx: Record<string, unknown>,
next: () => Promise<void>,
) => Promise<void> | void;
const middlewares = middlewareUseSpy.mock.calls
.map((call) => call[0])
.filter((fn): fn is Middleware => typeof fn === "function");
const runMiddlewareChain = async (
ctx: Record<string, unknown>,
finalNext: () => Promise<void>,
) => {
let idx = -1;
const dispatch = async (i: number): Promise<void> => {
if (i <= idx) {
throw new Error("middleware dispatch called multiple times");
}
idx = i;
const fn = middlewares[i];
if (!fn) {
await finalNext();
return;
}
await fn(ctx, async () => dispatch(i + 1));
};
await dispatch(0);
};
let releaseUpdate101: (() => void) | undefined;
const update101Gate = new Promise<void>((resolve) => {
releaseUpdate101 = resolve;
});
// Start processing update 101 but keep it pending (simulates an update queued behind sequentialize()).
const p101 = runMiddlewareChain({ update: { update_id: 101 } }, async () => update101Gate);
// Let update 101 enter the chain and mark itself pending before 102 completes.
await Promise.resolve();
// Complete update 102 while 101 is still pending. The persisted watermark must not jump to 102.
await runMiddlewareChain({ update: { update_id: 102 } }, async () => {});
const persistedValues = onUpdateId.mock.calls.map((call) => Number(call[0]));
const maxPersisted = persistedValues.length > 0 ? Math.max(...persistedValues) : -Infinity;
expect(maxPersisted).toBeLessThan(101);
releaseUpdate101?.();
await p101;
// Once the pending update finishes, the watermark can safely catch up.
const persistedAfterDrain = onUpdateId.mock.calls.map((call) => Number(call[0]));
const maxPersistedAfterDrain =
persistedAfterDrain.length > 0 ? Math.max(...persistedAfterDrain) : -Infinity;
expect(maxPersistedAfterDrain).toBe(102);
});
it("allows distinct callback_query ids without update_id", async () => {
loadConfig.mockReturnValue({
channels: {