anthropics / anthropics/claude-plugins-official
telegram: getUpdates offset is never persisted — crash/forced-exit redelivers the last batch, and there is no update_id dedupe
- 主要言語
- Python
- スター
- 36.3k
- フォーク
- 4.1k
- 平均マージ
- 2日 14時間
- マージ済み PR(30日)
- 539
説明
## Summary
The telegram plugin (v0.0.6, `external_plugins/telegram/server.ts`) polls via grammY's `bot.start()`. grammY tracks the getUpdates offset (`lastTriedUpdateId`) **in memory only**, and the plugin never persists it. Combined with two other details below, an unclean process exit makes Telegram redeliver the last unconfirmed batch of updates on the next session start — and because there is no `update_id` dedupe on the inbound path, redelivered messages are handed to the Claude session again as fresh messages. Worst case, the agent re-executes an instruction it already handled.
## Details
1. **No offset persistence.** Nothing in `server.ts` writes the getUpdates offset to disk (the state dir already exists and holds `access.json` / `bot.pid`, so there's a natural home for it). Telegram only marks updates confirmed when a *later* `getUpdates` call passes a higher offset, so the confirmation state lives entirely in the process.
2. **Graceful shutdown usually works — but races a 2s force-exit.** On stdin EOF/SIGTERM/SIGINT the plugin calls `bot.stop()`, and grammY's `stop()` does confirm the offset (final `getUpdates({ offset: lastTriedUpdateId + 1, limit: 1 })`). However the shutdown handler also schedules `setTimeout(() => process.exit(0), 2000)` — if the confirm call hasn't completed within 2s (slow network, long-poll abort latency), the process exits with the batch unconfirmed:
```ts
setTimeout(() => process.exit(0), 2000)
void Promise.resolve(bot.stop()).finally(() => process.exit(0))
```
3. **Hard crashes always lose the offset.** SIGKILL, power loss, or a crashed host exits without `bot.stop()` — the last batch is redelivered on next start. (The new stale-poller `bot.pid` handling shows this lifecycle is already a known-rough area: a SIGKILLed session is exactly the case where the offset was also never confirmed.)
4. **No inbound dedupe.** Redelivered updates aren't detectable downstream: the inbound path doesn't track recently seen `(chat_id, update_id)` pairs, so duplicates flow into the session as new `` messages.
## Impact
Low frequency (crash-shaped window), but the consequence is an agent acting twice on the same message — e.g. re-running a command or re-sending a reply — with no indication anything was wrong. It's the class of bug that's invisible until the one time it double-fires something that matters.
## Repro sketch
1. Send the bot a message; while the session is processing it (offset not yet confirmed by a subsequent poll), `kill -9` the plugin process (or the whole Claude session).
2. Start a new session with the same bot token.
3. The same update is delivered again and handed to the session as a fresh message.
## Suggested fix
- Persist the last handled `update_id` to the channel state dir (atomic tmp+rename) after each handled batch, and pass it as the initial offset on startup (`bot.start({ ... })` accepts nothing directly, but a persisted offset can be confirmed with one manual `getUpdates({ offset, limit: 0 })` before `bot.start()`, or via grammY's `UpdateSource`/custom polling).
- In `shutdown()`, prefer awaiting the `bot.stop()` confirm with a grace period slightly longer than the long-poll abort takes, rather than a fixed 2s race.
- Keep a small ring buffer of recently seen `(chat_id, update_id)` and drop duplicates on the inbound path — this also covers the hard-crash case where no offset write could happen.
Happy to provide more detail. (Found while auditing offset persistence across several Telegram long-poll consumers; other transports we compared persist the offset to disk after every processed batch for exactly this reason.)
コントリビューションガイド
このリポジトリのコントリビューションガイドは索引されていません
調査の方向性
Start in external_plugins/telegram/server.ts by reading bot.start(), shutdown(), and the inbound update path. Trace the existing state directory alongside access.json and bot.pid; done means handled update IDs and the polling offset survive restarts, duplicates are dropped, and shutdown no longer relies on the fixed 2-second race.
索引モデルが issue の本文から書いたものです。
評価
- 技術スタック
- typescript
- 領域
- backend
- issue の種類
- バグ
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 活発さ
- 静か
- 明瞭さ
- おおむね明確
- 初心者へのやさしさ
- 48/100