@grinev/opencode-telegram-bot 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -46,6 +46,10 @@ OPENCODE_MODEL_ID=big-pickle
46
46
  # Maximum number of scheduled tasks allowed at once (default: 10)
47
47
  # TASK_LIMIT=10
48
48
 
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
+
49
53
  # Bot locale: supported locale code (default: en)
50
54
  # Supported locales: en, de, es, fr, ru, zh
51
55
  # BOT_LOCALE=en
package/README.md CHANGED
@@ -147,30 +147,31 @@ When installed via npm, the configuration wizard handles the initial setup. The
147
147
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
148
148
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
149
149
 
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` |
174
175
 
175
176
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
176
177
 
package/dist/bot/index.js CHANGED
@@ -43,6 +43,7 @@ import { getCurrentSession } from "../session/manager.js";
43
43
  import { ingestSessionInfoForCache } from "../session/cache-manager.js";
44
44
  import { logger } from "../utils/logger.js";
45
45
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
46
+ import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
46
47
  import { pinnedMessageManager } from "../pinned/manager.js";
47
48
  import { t } from "../i18n/index.js";
48
49
  import { processUserPrompt } from "./handlers/prompt.js";
@@ -63,7 +64,7 @@ let botInstance = null;
63
64
  let chatIdInstance = null;
64
65
  let commandsInitialized = false;
65
66
  const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
66
- const RESPONSE_STREAM_THROTTLE_MS = 200;
67
+ const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
67
68
  const RESPONSE_STREAM_TEXT_LIMIT = 3800;
68
69
  const SESSION_RETRY_PREFIX = "🔁";
69
70
  const SUBAGENT_STREAM_PREFIX = "🧩";
@@ -93,7 +94,7 @@ function prepareStreamingPayload(messageText) {
93
94
  }
94
95
  return {
95
96
  parts,
96
- format: config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw",
97
+ format: "raw",
97
98
  };
98
99
  }
99
100
  const toolMessageBatcher = new ToolMessageBatcher({
@@ -323,13 +324,15 @@ async function ensureEventSubscription(directory) {
323
324
  ]).then(() => undefined),
324
325
  prepareStreamingPayload,
325
326
  formatSummary,
327
+ formatRawSummary: (text) => formatSummaryWithMode(text, "raw"),
326
328
  resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
327
329
  getReplyKeyboard: getCurrentReplyKeyboard,
328
- sendText: async (text, options, format) => {
330
+ sendText: async (text, rawFallbackText, options, format) => {
329
331
  await sendBotText({
330
332
  api: botApi,
331
333
  chatId,
332
334
  text,
335
+ rawFallbackText,
333
336
  options: options,
334
337
  format,
335
338
  });
@@ -675,11 +678,17 @@ export function createBot() {
675
678
  const timeSinceLast = now - lastGetUpdatesTime;
676
679
  logger.debug(`[Bot API] getUpdates called (${timeSinceLast}ms since last)`);
677
680
  lastGetUpdatesTime = now;
681
+ return prev(method, payload, signal);
678
682
  }
679
- else if (method === "sendMessage") {
683
+ if (method === "sendMessage") {
680
684
  logger.debug(`[Bot API] sendMessage to chat ${payload.chat_id}`);
681
685
  }
682
- return prev(method, payload, signal);
686
+ return withTelegramRateLimitRetry(() => prev(method, payload, signal), {
687
+ maxRetries: 5,
688
+ onRetry: ({ attempt, retryAfterMs, error }) => {
689
+ logger.warn(`[Bot API] Telegram rate limit on ${method}, retrying in ${retryAfterMs}ms (attempt=${attempt})`, error);
690
+ },
691
+ });
683
692
  });
684
693
  bot.use((ctx, next) => {
685
694
  const hasCallbackQuery = !!ctx.callbackQuery;
@@ -1,5 +1,5 @@
1
1
  import { logger } from "../../utils/logger.js";
2
- export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, resolveFormat, getReplyKeyboard, sendText, deleteMessages, }) {
2
+ export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, formatRawSummary, resolveFormat, getReplyKeyboard, sendText, deleteMessages, }) {
3
3
  let streamedMessageIds = [];
4
4
  const preparedStreamPayload = prepareStreamingPayload(messageText);
5
5
  if (preparedStreamPayload) {
@@ -22,11 +22,14 @@ export async function finalizeAssistantResponse({ sessionId, messageId, messageT
22
22
  }
23
23
  }
24
24
  const parts = formatSummary(messageText);
25
+ const rawParts = formatRawSummary(messageText);
25
26
  const format = resolveFormat();
26
- for (const part of parts) {
27
+ for (let partIndex = 0; partIndex < parts.length; partIndex++) {
28
+ const part = parts[partIndex];
29
+ const rawFallbackText = rawParts[partIndex];
27
30
  const keyboard = getReplyKeyboard();
28
31
  const options = keyboard ? { reply_markup: keyboard } : undefined;
29
- await sendText(part, options, format);
32
+ await sendText(part, rawFallbackText, options, format);
30
33
  }
31
34
  return false;
32
35
  }
@@ -6,6 +6,50 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
6
6
  "entity beginning",
7
7
  "bad request: can't parse",
8
8
  ];
9
+ const MARKDOWN_V2_RESERVED_CHARS = new Set([
10
+ "_",
11
+ "*",
12
+ "[",
13
+ "]",
14
+ "(",
15
+ ")",
16
+ "~",
17
+ "`",
18
+ ">",
19
+ "#",
20
+ "+",
21
+ "-",
22
+ "=",
23
+ "|",
24
+ "{",
25
+ "}",
26
+ ".",
27
+ "!",
28
+ "\\",
29
+ ]);
30
+ const MARKDOWN_V2_ESCAPED_CHAR = /\\([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
31
+ function escapeTelegramMarkdownV2(text) {
32
+ let result = "";
33
+ let trailingBackslashes = 0;
34
+ for (const char of text) {
35
+ if (char === "\\") {
36
+ result += char;
37
+ trailingBackslashes += 1;
38
+ continue;
39
+ }
40
+ const isEscaped = trailingBackslashes % 2 === 1;
41
+ trailingBackslashes = 0;
42
+ if (MARKDOWN_V2_RESERVED_CHARS.has(char) && !isEscaped) {
43
+ result += `\\${char}`;
44
+ continue;
45
+ }
46
+ result += char;
47
+ }
48
+ return result;
49
+ }
50
+ function unescapeTelegramMarkdownV2(text) {
51
+ return text.replace(MARKDOWN_V2_ESCAPED_CHAR, "$1");
52
+ }
9
53
  function stripMarkdownFormattingOptions(options) {
10
54
  if (!options) {
11
55
  return options;
@@ -47,7 +91,7 @@ export function isTelegramMarkdownParseError(error) {
47
91
  }
48
92
  return MARKDOWN_PARSE_ERROR_MARKERS.some((marker) => errorText.includes(marker));
49
93
  }
50
- export async function sendMessageWithMarkdownFallback({ api, chatId, text, options, parseMode, }) {
94
+ export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFallbackText, options, parseMode, }) {
51
95
  if (!parseMode) {
52
96
  return api.sendMessage(chatId, text, options);
53
97
  }
@@ -55,6 +99,7 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, optio
55
99
  ...(options || {}),
56
100
  parse_mode: parseMode,
57
101
  };
102
+ const fallbackText = rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
58
103
  try {
59
104
  return await api.sendMessage(chatId, text, markdownOptions);
60
105
  }
@@ -62,11 +107,27 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, optio
62
107
  if (!isTelegramMarkdownParseError(error)) {
63
108
  throw error;
64
109
  }
110
+ if (parseMode === "MarkdownV2") {
111
+ const escapedText = escapeTelegramMarkdownV2(text);
112
+ if (escapedText !== text) {
113
+ logger.warn("[Bot] Markdown parse failed, retrying assistant message with escaped MarkdownV2", error);
114
+ try {
115
+ return await api.sendMessage(chatId, escapedText, markdownOptions);
116
+ }
117
+ catch (escapedError) {
118
+ if (!isTelegramMarkdownParseError(escapedError)) {
119
+ throw escapedError;
120
+ }
121
+ logger.warn("[Bot] Escaped Markdown parse failed, retrying assistant message in raw mode", escapedError);
122
+ return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
123
+ }
124
+ }
125
+ }
65
126
  logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
66
- return api.sendMessage(chatId, text, stripMarkdownFormattingOptions(options));
127
+ return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
67
128
  }
68
129
  }
69
- export async function editMessageWithMarkdownFallback({ api, chatId, messageId, text, options, parseMode, }) {
130
+ export async function editMessageWithMarkdownFallback({ api, chatId, messageId, text, rawFallbackText, options, parseMode, }) {
70
131
  if (!parseMode) {
71
132
  return api.editMessageText(chatId, messageId, text, options);
72
133
  }
@@ -74,6 +135,7 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
74
135
  ...(options || {}),
75
136
  parse_mode: parseMode,
76
137
  };
138
+ const fallbackText = rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
77
139
  try {
78
140
  return await api.editMessageText(chatId, messageId, text, markdownOptions);
79
141
  }
@@ -81,7 +143,23 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
81
143
  if (!isTelegramMarkdownParseError(error)) {
82
144
  throw error;
83
145
  }
146
+ if (parseMode === "MarkdownV2") {
147
+ const escapedText = escapeTelegramMarkdownV2(text);
148
+ if (escapedText !== text) {
149
+ logger.warn("[Bot] Markdown parse failed, retrying edited message with escaped MarkdownV2", error);
150
+ try {
151
+ return await api.editMessageText(chatId, messageId, escapedText, markdownOptions);
152
+ }
153
+ catch (escapedError) {
154
+ if (!isTelegramMarkdownParseError(escapedError)) {
155
+ throw escapedError;
156
+ }
157
+ logger.warn("[Bot] Escaped Markdown parse failed, retrying edited message in raw mode", escapedError);
158
+ return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
159
+ }
160
+ }
161
+ }
84
162
  logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
85
- return api.editMessageText(chatId, messageId, text, stripMarkdownFormattingOptions(options));
163
+ return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
86
164
  }
87
165
  }
@@ -5,21 +5,23 @@ function resolveParseMode(format) {
5
5
  }
6
6
  return undefined;
7
7
  }
8
- export async function sendBotText({ api, chatId, text, options, format = "raw", }) {
8
+ export async function sendBotText({ api, chatId, text, rawFallbackText, options, format = "raw", }) {
9
9
  await sendMessageWithMarkdownFallback({
10
10
  api,
11
11
  chatId,
12
12
  text,
13
+ rawFallbackText,
13
14
  options,
14
15
  parseMode: resolveParseMode(format),
15
16
  });
16
17
  }
17
- export async function editBotText({ api, chatId, messageId, text, options, format = "raw", }) {
18
+ export async function editBotText({ api, chatId, messageId, text, rawFallbackText, options, format = "raw", }) {
18
19
  await editMessageWithMarkdownFallback({
19
20
  api,
20
21
  chatId,
21
22
  messageId,
22
23
  text,
24
+ rawFallbackText,
23
25
  options,
24
26
  parseMode: resolveParseMode(format),
25
27
  });
package/dist/config.js CHANGED
@@ -73,6 +73,7 @@ export const config = {
73
73
  projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
74
74
  commandsListLimit: getOptionalPositiveIntEnvVar("COMMANDS_LIST_LIMIT", 10),
75
75
  taskLimit: getOptionalPositiveIntEnvVar("TASK_LIMIT", 10),
76
+ responseStreamThrottleMs: getOptionalPositiveIntEnvVar("RESPONSE_STREAM_THROTTLE_MS", 500),
76
77
  locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
77
78
  hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
78
79
  hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
@@ -23,6 +23,11 @@ class PinnedMessageManager {
23
23
  };
24
24
  contextLimit = null;
25
25
  onKeyboardUpdateCallback;
26
+ updateDebounceTimer = null;
27
+ updateTask = null;
28
+ pendingUpdate = false;
29
+ pendingForceUpdate = false;
30
+ lastRenderedMessageText = null;
26
31
  /**
27
32
  * Initialize manager with bot API and chat ID
28
33
  */
@@ -58,6 +63,9 @@ class PinnedMessageManager {
58
63
  }
59
64
  // Reset changed files for new session
60
65
  this.state.changedFiles = [];
66
+ this.lastRenderedMessageText = null;
67
+ this.pendingUpdate = false;
68
+ this.pendingForceUpdate = false;
61
69
  // Unpin old message and create new one
62
70
  await this.unpinOldMessage();
63
71
  await this.createPinnedMessage();
@@ -163,12 +171,16 @@ class PinnedMessageManager {
163
171
  * Used at thinking time to push accumulated silent updates to Telegram.
164
172
  */
165
173
  async refresh() {
166
- await this.updatePinnedMessage();
174
+ await this.updatePinnedMessage(true);
167
175
  }
168
176
  /**
169
177
  * Called when cost info is received from SSE events
170
178
  */
171
179
  async onCostUpdate(cost) {
180
+ if (!Number.isFinite(cost) || cost === 0) {
181
+ logger.debug("[PinnedManager] Ignoring non-impacting cost update");
182
+ return;
183
+ }
172
184
  const currentCost = this.state.cost || 0;
173
185
  this.state.cost = currentCost + cost;
174
186
  logger.debug(`[PinnedManager] Cost added: $${cost.toFixed(2)}, total session: $${(this.state.cost || 0).toFixed(2)}`);
@@ -223,6 +235,10 @@ class PinnedMessageManager {
223
235
  logger.debug("[PinnedManager] Ignoring empty session.diff, keeping tool-collected data");
224
236
  return;
225
237
  }
238
+ if (this.areFileDiffsEqual(this.state.changedFiles, diffs)) {
239
+ logger.debug("[PinnedManager] Ignoring unchanged session.diff");
240
+ return;
241
+ }
226
242
  this.state.changedFiles = diffs;
227
243
  logger.debug(`[PinnedManager] Session diff updated: ${diffs.length} files`);
228
244
  await this.updatePinnedMessage();
@@ -243,15 +259,14 @@ class PinnedMessageManager {
243
259
  // Schedule debounced update (avoid spamming Telegram API on rapid tool events)
244
260
  this.scheduleDebouncedUpdate();
245
261
  }
246
- updateDebounceTimer = null;
247
262
  scheduleDebouncedUpdate() {
248
263
  if (this.updateDebounceTimer) {
249
264
  clearTimeout(this.updateDebounceTimer);
250
265
  }
251
266
  this.updateDebounceTimer = setTimeout(() => {
252
267
  this.updateDebounceTimer = null;
253
- this.updatePinnedMessage();
254
- }, 500);
268
+ void this.updatePinnedMessage();
269
+ }, 1000);
255
270
  }
256
271
  /**
257
272
  * Load file diffs from API for current session.
@@ -430,6 +445,21 @@ class PinnedMessageManager {
430
445
  return normalized;
431
446
  return ".../" + segments.slice(-3).join("/");
432
447
  }
448
+ areFileDiffsEqual(current, next) {
449
+ if (current.length !== next.length) {
450
+ return false;
451
+ }
452
+ for (let index = 0; index < current.length; index++) {
453
+ const left = current[index];
454
+ const right = next[index];
455
+ if (left.file !== right.file ||
456
+ left.additions !== right.additions ||
457
+ left.deletions !== right.deletions) {
458
+ return false;
459
+ }
460
+ }
461
+ return true;
462
+ }
433
463
  /**
434
464
  * Fetch context limit from current model configuration
435
465
  */
@@ -498,6 +528,7 @@ class PinnedMessageManager {
498
528
  this.state.messageId = sentMessage.message_id;
499
529
  this.state.chatId = this.chatId;
500
530
  this.state.lastUpdated = Date.now();
531
+ this.lastRenderedMessageText = text;
501
532
  // Save to settings for persistence
502
533
  setPinnedMessageId(sentMessage.message_id);
503
534
  // Pin the message (silently)
@@ -513,36 +544,67 @@ class PinnedMessageManager {
513
544
  /**
514
545
  * Update existing pinned message text
515
546
  */
516
- async updatePinnedMessage() {
547
+ async updatePinnedMessage(forceUpdate = false) {
517
548
  if (!this.api || !this.chatId || !this.state.messageId) {
518
549
  return;
519
550
  }
520
- try {
521
- const text = this.formatMessage();
522
- await this.api.editMessageText(this.chatId, this.state.messageId, text);
523
- this.state.lastUpdated = Date.now();
524
- logger.debug(`[PinnedManager] Updated pinned message: ${this.state.messageId}`);
525
- // Trigger keyboard update callback
526
- if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
527
- setImmediate(() => {
528
- this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
529
- });
530
- }
551
+ this.pendingUpdate = true;
552
+ if (forceUpdate) {
553
+ this.pendingForceUpdate = true;
531
554
  }
532
- catch (err) {
533
- // Handle "message is not modified" error silently
534
- if (err instanceof Error && err.message.includes("message is not modified")) {
555
+ if (this.updateTask) {
556
+ await this.updateTask;
557
+ return;
558
+ }
559
+ this.updateTask = this.flushPendingPinnedUpdates().finally(() => {
560
+ this.updateTask = null;
561
+ });
562
+ await this.updateTask;
563
+ }
564
+ async flushPendingPinnedUpdates() {
565
+ while (this.pendingUpdate) {
566
+ this.pendingUpdate = false;
567
+ const shouldForceUpdate = this.pendingForceUpdate;
568
+ this.pendingForceUpdate = false;
569
+ if (!this.api || !this.chatId || !this.state.messageId) {
535
570
  return;
536
571
  }
537
- // Handle "message to edit not found" - recreate
538
- if (err instanceof Error && err.message.includes("message to edit not found")) {
539
- logger.warn("[PinnedManager] Pinned message was deleted, recreating...");
540
- this.state.messageId = null;
541
- clearPinnedMessageId();
542
- await this.createPinnedMessage();
543
- return;
572
+ const text = this.formatMessage();
573
+ if (!shouldForceUpdate && text === this.lastRenderedMessageText) {
574
+ logger.debug("[PinnedManager] Skipping pinned update: message content unchanged");
575
+ continue;
576
+ }
577
+ try {
578
+ await this.api.editMessageText(this.chatId, this.state.messageId, text);
579
+ this.state.lastUpdated = Date.now();
580
+ this.lastRenderedMessageText = text;
581
+ logger.debug(`[PinnedManager] Updated pinned message: ${this.state.messageId}`);
582
+ // Trigger keyboard update callback
583
+ if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
584
+ setImmediate(() => {
585
+ this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
586
+ });
587
+ }
588
+ }
589
+ catch (err) {
590
+ const errorMessage = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
591
+ // Handle "message is not modified" error silently
592
+ if (errorMessage.includes("message is not modified")) {
593
+ this.lastRenderedMessageText = text;
594
+ continue;
595
+ }
596
+ // Handle "message to edit not found" - recreate
597
+ if (errorMessage.includes("message to edit not found")) {
598
+ logger.warn("[PinnedManager] Pinned message was deleted, recreating...");
599
+ this.state.messageId = null;
600
+ this.lastRenderedMessageText = null;
601
+ this.pendingForceUpdate = false;
602
+ clearPinnedMessageId();
603
+ await this.createPinnedMessage();
604
+ continue;
605
+ }
606
+ logger.error("[PinnedManager] Error updating pinned message:", err);
544
607
  }
545
- logger.error("[PinnedManager] Error updating pinned message:", err);
546
608
  }
547
609
  }
548
610
  /**
@@ -556,6 +618,9 @@ class PinnedMessageManager {
556
618
  // Unpin all messages (ensures clean state)
557
619
  await this.api.unpinAllChatMessages(this.chatId).catch(() => { });
558
620
  this.state.messageId = null;
621
+ this.lastRenderedMessageText = null;
622
+ this.pendingUpdate = false;
623
+ this.pendingForceUpdate = false;
559
624
  clearPinnedMessageId();
560
625
  logger.debug("[PinnedManager] Unpinned old messages");
561
626
  }
@@ -586,6 +651,9 @@ class PinnedMessageManager {
586
651
  this.state.tokensUsed = 0;
587
652
  this.state.tokensLimit = 0;
588
653
  this.state.changedFiles = [];
654
+ this.lastRenderedMessageText = null;
655
+ this.pendingUpdate = false;
656
+ this.pendingForceUpdate = false;
589
657
  clearPinnedMessageId();
590
658
  return;
591
659
  }
@@ -600,6 +668,9 @@ class PinnedMessageManager {
600
668
  this.state.tokensUsed = 0;
601
669
  this.state.tokensLimit = 0;
602
670
  this.state.changedFiles = [];
671
+ this.lastRenderedMessageText = null;
672
+ this.pendingUpdate = false;
673
+ this.pendingForceUpdate = false;
603
674
  clearPinnedMessageId();
604
675
  logger.info("[PinnedManager] Cleared pinned message state");
605
676
  }
@@ -0,0 +1,93 @@
1
+ function getErrorMessage(error) {
2
+ const parts = [];
3
+ if (error instanceof Error) {
4
+ parts.push(error.message);
5
+ }
6
+ if (typeof error === "object" && error !== null) {
7
+ const description = Reflect.get(error, "description");
8
+ if (typeof description === "string") {
9
+ parts.push(description);
10
+ }
11
+ const message = Reflect.get(error, "message");
12
+ if (typeof message === "string") {
13
+ parts.push(message);
14
+ }
15
+ }
16
+ if (typeof error === "string") {
17
+ parts.push(error);
18
+ }
19
+ return parts.join("\n");
20
+ }
21
+ function getRetryAfterSecondsFromError(error) {
22
+ if (typeof error === "object" && error !== null) {
23
+ const parameters = Reflect.get(error, "parameters");
24
+ if (typeof parameters === "object" && parameters !== null) {
25
+ const retryAfter = Reflect.get(parameters, "retry_after");
26
+ if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter > 0) {
27
+ return retryAfter;
28
+ }
29
+ }
30
+ }
31
+ const message = getErrorMessage(error);
32
+ const retryMatch = message.match(/retry after\s+(\d+)/i);
33
+ if (!retryMatch) {
34
+ return null;
35
+ }
36
+ const parsedSeconds = Number.parseInt(retryMatch[1], 10);
37
+ if (!Number.isFinite(parsedSeconds) || parsedSeconds <= 0) {
38
+ return null;
39
+ }
40
+ return parsedSeconds;
41
+ }
42
+ function isTelegramRateLimitError(error) {
43
+ if (typeof error === "object" && error !== null) {
44
+ const status = Reflect.get(error, "status");
45
+ if (typeof status === "number" && status === 429) {
46
+ return true;
47
+ }
48
+ const errorCode = Reflect.get(error, "error_code");
49
+ if (typeof errorCode === "number" && errorCode === 429) {
50
+ return true;
51
+ }
52
+ }
53
+ const message = getErrorMessage(error).toLowerCase();
54
+ return /\b429\b/.test(message) || message.includes("too many requests");
55
+ }
56
+ function wait(ms) {
57
+ return new Promise((resolve) => {
58
+ setTimeout(resolve, ms);
59
+ });
60
+ }
61
+ export function getTelegramRetryAfterMs(error, fallbackDelayMs = 1000) {
62
+ if (!isTelegramRateLimitError(error)) {
63
+ return null;
64
+ }
65
+ const retryAfterSeconds = getRetryAfterSecondsFromError(error);
66
+ if (retryAfterSeconds !== null) {
67
+ return retryAfterSeconds * 1000;
68
+ }
69
+ return Math.max(1, Math.floor(fallbackDelayMs));
70
+ }
71
+ export async function withTelegramRateLimitRetry(operation, options) {
72
+ const maxRetries = Math.max(0, Math.floor(options?.maxRetries ?? 3));
73
+ const fallbackDelayMs = options?.fallbackDelayMs ?? 1000;
74
+ let attempt = 0;
75
+ while (true) {
76
+ try {
77
+ return await operation();
78
+ }
79
+ catch (error) {
80
+ const retryAfterMs = getTelegramRetryAfterMs(error, fallbackDelayMs);
81
+ if (retryAfterMs === null || attempt >= maxRetries) {
82
+ throw error;
83
+ }
84
+ attempt += 1;
85
+ options?.onRetry?.({
86
+ attempt,
87
+ retryAfterMs,
88
+ error,
89
+ });
90
+ await wait(retryAfterMs);
91
+ }
92
+ }
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",