Skip to content

Commit dae0f82

Browse files
committed
fix(bot): add Telegram 429 retries and reduce update spam
1 parent 5632f2f commit dae0f82

8 files changed

Lines changed: 361 additions & 55 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ OPENCODE_MODEL_ID=big-pickle
4646
# Maximum number of scheduled tasks allowed at once (default: 10)
4747
# TASK_LIMIT=10
4848

49+
# Stream update throttle in milliseconds for assistant/tool message edits (default: 500)
50+
# Higher value = fewer Telegram edit requests, lower value = more real-time updates
51+
# RESPONSE_STREAM_THROTTLE_MS=500
52+
4953
# Bot locale: supported locale code (default: en)
5054
# Supported locales: en, de, es, fr, ru, zh
5155
# BOT_LOCALE=en

README.md

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -147,30 +147,31 @@ When installed via npm, the configuration wizard handles the initial setup. The
147147
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
148148
- **Linux:** `~/.config/opencode-telegram-bot/.env`
149149

150-
| Variable | Description | Required | Default |
151-
| -------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
152-
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes ||
153-
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes ||
154-
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No ||
155-
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
156-
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
157-
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No ||
158-
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
159-
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
160-
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
161-
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
162-
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
163-
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
164-
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
165-
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
166-
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
167-
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
168-
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
169-
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No ||
170-
| `STT_API_KEY` | API key for your STT provider | No ||
171-
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
172-
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No ||
173-
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
150+
| Variable | Description | Required | Default |
151+
| ----------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
152+
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes ||
153+
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes ||
154+
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No ||
155+
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
156+
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
157+
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No ||
158+
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
159+
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
160+
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
161+
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
162+
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
163+
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
164+
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
165+
| `RESPONSE_STREAM_THROTTLE_MS` | Stream edit throttle (ms) for assistant and tool updates | No | `500` |
166+
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
167+
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
168+
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
169+
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
170+
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No ||
171+
| `STT_API_KEY` | API key for your STT provider | No ||
172+
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
173+
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No ||
174+
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
174175

175176
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
176177

src/bot/index.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { getCurrentSession } from "../session/manager.js";
6060
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
6161
import { logger } from "../utils/logger.js";
6262
import { safeBackgroundTask } from "../utils/safe-background-task.js";
63+
import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
6364
import { pinnedMessageManager } from "../pinned/manager.js";
6465
import { t } from "../i18n/index.js";
6566
import { processUserPrompt } from "./handlers/prompt.js";
@@ -87,7 +88,7 @@ let chatIdInstance: number | null = null;
8788
let commandsInitialized = false;
8889

8990
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
90-
const RESPONSE_STREAM_THROTTLE_MS = 200;
91+
const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
9192
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
9293
const SESSION_RETRY_PREFIX = "🔁";
9394
const SUBAGENT_STREAM_PREFIX = "🧩";
@@ -828,10 +829,22 @@ export function createBot(): Bot<Context> {
828829
const timeSinceLast = now - lastGetUpdatesTime;
829830
logger.debug(`[Bot API] getUpdates called (${timeSinceLast}ms since last)`);
830831
lastGetUpdatesTime = now;
831-
} else if (method === "sendMessage") {
832+
return prev(method, payload, signal);
833+
}
834+
835+
if (method === "sendMessage") {
832836
logger.debug(`[Bot API] sendMessage to chat ${(payload as { chat_id?: number }).chat_id}`);
833837
}
834-
return prev(method, payload, signal);
838+
839+
return withTelegramRateLimitRetry(() => prev(method, payload, signal), {
840+
maxRetries: 5,
841+
onRetry: ({ attempt, retryAfterMs, error }) => {
842+
logger.warn(
843+
`[Bot API] Telegram rate limit on ${method}, retrying in ${retryAfterMs}ms (attempt=${attempt})`,
844+
error,
845+
);
846+
},
847+
});
835848
});
836849

837850
bot.use((ctx, next) => {

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export const config = {
9898
projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
9999
commandsListLimit: getOptionalPositiveIntEnvVar("COMMANDS_LIST_LIMIT", 10),
100100
taskLimit: getOptionalPositiveIntEnvVar("TASK_LIMIT", 10),
101+
responseStreamThrottleMs: getOptionalPositiveIntEnvVar("RESPONSE_STREAM_THROTTLE_MS", 500),
101102
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
102103
hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
103104
hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),

src/pinned/manager.ts

Lines changed: 115 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ class PinnedMessageManager {
3636
};
3737
private contextLimit: number | null = null;
3838
private onKeyboardUpdateCallback?: (tokensUsed: number, tokensLimit: number) => void;
39+
private updateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
40+
private updateTask: Promise<void> | null = null;
41+
private pendingUpdate = false;
42+
private pendingForceUpdate = false;
43+
private lastRenderedMessageText: string | null = null;
3944

4045
/**
4146
* Initialize manager with bot API and chat ID
@@ -80,6 +85,9 @@ class PinnedMessageManager {
8085

8186
// Reset changed files for new session
8287
this.state.changedFiles = [];
88+
this.lastRenderedMessageText = null;
89+
this.pendingUpdate = false;
90+
this.pendingForceUpdate = false;
8391

8492
// Unpin old message and create new one
8593
await this.unpinOldMessage();
@@ -224,13 +232,18 @@ class PinnedMessageManager {
224232
* Used at thinking time to push accumulated silent updates to Telegram.
225233
*/
226234
async refresh(): Promise<void> {
227-
await this.updatePinnedMessage();
235+
await this.updatePinnedMessage(true);
228236
}
229237

230238
/**
231239
* Called when cost info is received from SSE events
232240
*/
233241
async onCostUpdate(cost: number): Promise<void> {
242+
if (!Number.isFinite(cost) || cost === 0) {
243+
logger.debug("[PinnedManager] Ignoring non-impacting cost update");
244+
return;
245+
}
246+
234247
const currentCost = this.state.cost || 0;
235248
this.state.cost = currentCost + cost;
236249
logger.debug(
@@ -293,6 +306,12 @@ class PinnedMessageManager {
293306
logger.debug("[PinnedManager] Ignoring empty session.diff, keeping tool-collected data");
294307
return;
295308
}
309+
310+
if (this.areFileDiffsEqual(this.state.changedFiles, diffs)) {
311+
logger.debug("[PinnedManager] Ignoring unchanged session.diff");
312+
return;
313+
}
314+
296315
this.state.changedFiles = diffs;
297316
logger.debug(`[PinnedManager] Session diff updated: ${diffs.length} files`);
298317
await this.updatePinnedMessage();
@@ -317,16 +336,14 @@ class PinnedMessageManager {
317336
this.scheduleDebouncedUpdate();
318337
}
319338

320-
private updateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
321-
322339
private scheduleDebouncedUpdate(): void {
323340
if (this.updateDebounceTimer) {
324341
clearTimeout(this.updateDebounceTimer);
325342
}
326343
this.updateDebounceTimer = setTimeout(() => {
327344
this.updateDebounceTimer = null;
328-
this.updatePinnedMessage();
329-
}, 500);
345+
void this.updatePinnedMessage();
346+
}, 1000);
330347
}
331348

332349
/**
@@ -547,6 +564,26 @@ class PinnedMessageManager {
547564
return ".../" + segments.slice(-3).join("/");
548565
}
549566

567+
private areFileDiffsEqual(current: FileChange[], next: FileChange[]): boolean {
568+
if (current.length !== next.length) {
569+
return false;
570+
}
571+
572+
for (let index = 0; index < current.length; index++) {
573+
const left = current[index];
574+
const right = next[index];
575+
if (
576+
left.file !== right.file ||
577+
left.additions !== right.additions ||
578+
left.deletions !== right.deletions
579+
) {
580+
return false;
581+
}
582+
}
583+
584+
return true;
585+
}
586+
550587
/**
551588
* Fetch context limit from current model configuration
552589
*/
@@ -623,6 +660,7 @@ class PinnedMessageManager {
623660
this.state.messageId = sentMessage.message_id;
624661
this.state.chatId = this.chatId;
625662
this.state.lastUpdated = Date.now();
663+
this.lastRenderedMessageText = text;
626664

627665
// Save to settings for persistence
628666
setPinnedMessageId(sentMessage.message_id);
@@ -641,41 +679,81 @@ class PinnedMessageManager {
641679
/**
642680
* Update existing pinned message text
643681
*/
644-
private async updatePinnedMessage(): Promise<void> {
682+
private async updatePinnedMessage(forceUpdate: boolean = false): Promise<void> {
645683
if (!this.api || !this.chatId || !this.state.messageId) {
646684
return;
647685
}
648686

649-
try {
650-
const text = this.formatMessage();
687+
this.pendingUpdate = true;
688+
if (forceUpdate) {
689+
this.pendingForceUpdate = true;
690+
}
651691

652-
await this.api.editMessageText(this.chatId, this.state.messageId, text);
653-
this.state.lastUpdated = Date.now();
692+
if (this.updateTask) {
693+
await this.updateTask;
694+
return;
695+
}
654696

655-
logger.debug(`[PinnedManager] Updated pinned message: ${this.state.messageId}`);
697+
this.updateTask = this.flushPendingPinnedUpdates().finally(() => {
698+
this.updateTask = null;
699+
});
656700

657-
// Trigger keyboard update callback
658-
if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
659-
setImmediate(() => {
660-
this.onKeyboardUpdateCallback!(this.state.tokensUsed, this.state.tokensLimit);
661-
});
662-
}
663-
} catch (err: unknown) {
664-
// Handle "message is not modified" error silently
665-
if (err instanceof Error && err.message.includes("message is not modified")) {
701+
await this.updateTask;
702+
}
703+
704+
private async flushPendingPinnedUpdates(): Promise<void> {
705+
while (this.pendingUpdate) {
706+
this.pendingUpdate = false;
707+
const shouldForceUpdate = this.pendingForceUpdate;
708+
this.pendingForceUpdate = false;
709+
710+
if (!this.api || !this.chatId || !this.state.messageId) {
666711
return;
667712
}
668713

669-
// Handle "message to edit not found" - recreate
670-
if (err instanceof Error && err.message.includes("message to edit not found")) {
671-
logger.warn("[PinnedManager] Pinned message was deleted, recreating...");
672-
this.state.messageId = null;
673-
clearPinnedMessageId();
674-
await this.createPinnedMessage();
675-
return;
714+
const text = this.formatMessage();
715+
716+
if (!shouldForceUpdate && text === this.lastRenderedMessageText) {
717+
logger.debug("[PinnedManager] Skipping pinned update: message content unchanged");
718+
continue;
676719
}
677720

678-
logger.error("[PinnedManager] Error updating pinned message:", err);
721+
try {
722+
await this.api.editMessageText(this.chatId, this.state.messageId, text);
723+
this.state.lastUpdated = Date.now();
724+
this.lastRenderedMessageText = text;
725+
726+
logger.debug(`[PinnedManager] Updated pinned message: ${this.state.messageId}`);
727+
728+
// Trigger keyboard update callback
729+
if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
730+
setImmediate(() => {
731+
this.onKeyboardUpdateCallback!(this.state.tokensUsed, this.state.tokensLimit);
732+
});
733+
}
734+
} catch (err: unknown) {
735+
const errorMessage =
736+
err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
737+
738+
// Handle "message is not modified" error silently
739+
if (errorMessage.includes("message is not modified")) {
740+
this.lastRenderedMessageText = text;
741+
continue;
742+
}
743+
744+
// Handle "message to edit not found" - recreate
745+
if (errorMessage.includes("message to edit not found")) {
746+
logger.warn("[PinnedManager] Pinned message was deleted, recreating...");
747+
this.state.messageId = null;
748+
this.lastRenderedMessageText = null;
749+
this.pendingForceUpdate = false;
750+
clearPinnedMessageId();
751+
await this.createPinnedMessage();
752+
continue;
753+
}
754+
755+
logger.error("[PinnedManager] Error updating pinned message:", err);
756+
}
679757
}
680758
}
681759

@@ -692,6 +770,9 @@ class PinnedMessageManager {
692770
await this.api.unpinAllChatMessages(this.chatId).catch(() => {});
693771

694772
this.state.messageId = null;
773+
this.lastRenderedMessageText = null;
774+
this.pendingUpdate = false;
775+
this.pendingForceUpdate = false;
695776
clearPinnedMessageId();
696777

697778
logger.debug("[PinnedManager] Unpinned old messages");
@@ -725,6 +806,9 @@ class PinnedMessageManager {
725806
this.state.tokensUsed = 0;
726807
this.state.tokensLimit = 0;
727808
this.state.changedFiles = [];
809+
this.lastRenderedMessageText = null;
810+
this.pendingUpdate = false;
811+
this.pendingForceUpdate = false;
728812
clearPinnedMessageId();
729813
return;
730814
}
@@ -741,6 +825,9 @@ class PinnedMessageManager {
741825
this.state.tokensUsed = 0;
742826
this.state.tokensLimit = 0;
743827
this.state.changedFiles = [];
828+
this.lastRenderedMessageText = null;
829+
this.pendingUpdate = false;
830+
this.pendingForceUpdate = false;
744831
clearPinnedMessageId();
745832

746833
logger.info("[PinnedManager] Cleared pinned message state");

0 commit comments

Comments
 (0)