@grinev/opencode-telegram-bot 0.14.0 → 0.15.0

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.
@@ -1,3 +1,5 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { logger } from "../utils/logger.js";
2
4
  import { opencodeClient } from "../opencode/client.js";
3
5
  import { getCurrentSession } from "../session/manager.js";
@@ -15,6 +17,7 @@ class PinnedMessageManager {
15
17
  sessionId: null,
16
18
  sessionTitle: t("pinned.default_session_title"),
17
19
  projectName: "",
20
+ projectBranch: null,
18
21
  tokensUsed: 0,
19
22
  tokensLimit: 0,
20
23
  lastUpdated: 0,
@@ -23,6 +26,11 @@ class PinnedMessageManager {
23
26
  };
24
27
  contextLimit = null;
25
28
  onKeyboardUpdateCallback;
29
+ updateDebounceTimer = null;
30
+ updateTask = null;
31
+ pendingUpdate = false;
32
+ pendingForceUpdate = false;
33
+ lastRenderedMessageText = null;
26
34
  /**
27
35
  * Initialize manager with bot API and chat ID
28
36
  */
@@ -47,9 +55,7 @@ class PinnedMessageManager {
47
55
  // Update state
48
56
  this.state.sessionId = sessionId;
49
57
  this.state.sessionTitle = sessionTitle || t("pinned.default_session_title");
50
- const project = getCurrentProject();
51
- this.state.projectName =
52
- project?.name || this.extractProjectName(project?.worktree) || t("pinned.unknown");
58
+ await this.refreshProjectMetadata();
53
59
  // Fetch context limit for current model
54
60
  await this.fetchContextLimit();
55
61
  // Trigger keyboard update callback with reset context (0 tokens)
@@ -58,6 +64,9 @@ class PinnedMessageManager {
58
64
  }
59
65
  // Reset changed files for new session
60
66
  this.state.changedFiles = [];
67
+ this.lastRenderedMessageText = null;
68
+ this.pendingUpdate = false;
69
+ this.pendingForceUpdate = false;
61
70
  // Unpin old message and create new one
62
71
  await this.unpinOldMessage();
63
72
  await this.createPinnedMessage();
@@ -163,12 +172,17 @@ class PinnedMessageManager {
163
172
  * Used at thinking time to push accumulated silent updates to Telegram.
164
173
  */
165
174
  async refresh() {
166
- await this.updatePinnedMessage();
175
+ await this.refreshProjectMetadata();
176
+ await this.updatePinnedMessage(true);
167
177
  }
168
178
  /**
169
179
  * Called when cost info is received from SSE events
170
180
  */
171
181
  async onCostUpdate(cost) {
182
+ if (!Number.isFinite(cost) || cost === 0) {
183
+ logger.debug("[PinnedManager] Ignoring non-impacting cost update");
184
+ return;
185
+ }
172
186
  const currentCost = this.state.cost || 0;
173
187
  this.state.cost = currentCost + cost;
174
188
  logger.debug(`[PinnedManager] Cost added: $${cost.toFixed(2)}, total session: $${(this.state.cost || 0).toFixed(2)}`);
@@ -223,6 +237,10 @@ class PinnedMessageManager {
223
237
  logger.debug("[PinnedManager] Ignoring empty session.diff, keeping tool-collected data");
224
238
  return;
225
239
  }
240
+ if (this.areFileDiffsEqual(this.state.changedFiles, diffs)) {
241
+ logger.debug("[PinnedManager] Ignoring unchanged session.diff");
242
+ return;
243
+ }
226
244
  this.state.changedFiles = diffs;
227
245
  logger.debug(`[PinnedManager] Session diff updated: ${diffs.length} files`);
228
246
  await this.updatePinnedMessage();
@@ -243,15 +261,14 @@ class PinnedMessageManager {
243
261
  // Schedule debounced update (avoid spamming Telegram API on rapid tool events)
244
262
  this.scheduleDebouncedUpdate();
245
263
  }
246
- updateDebounceTimer = null;
247
264
  scheduleDebouncedUpdate() {
248
265
  if (this.updateDebounceTimer) {
249
266
  clearTimeout(this.updateDebounceTimer);
250
267
  }
251
268
  this.updateDebounceTimer = setTimeout(() => {
252
269
  this.updateDebounceTimer = null;
253
- this.updatePinnedMessage();
254
- }, 500);
270
+ void this.updatePinnedMessage();
271
+ }, 1000);
255
272
  }
256
273
  /**
257
274
  * Load file diffs from API for current session.
@@ -397,6 +414,61 @@ class PinnedMessageManager {
397
414
  logger.debug("[PinnedManager] Could not refresh session title:", err);
398
415
  }
399
416
  }
417
+ /**
418
+ * Refresh current project name and git branch.
419
+ */
420
+ async refreshProjectMetadata() {
421
+ const project = getCurrentProject();
422
+ this.state.projectName =
423
+ project?.name || this.extractProjectName(project?.worktree) || t("pinned.unknown");
424
+ this.state.projectBranch = await this.getGitBranchName(project?.worktree);
425
+ }
426
+ /**
427
+ * Resolve current git branch for a project worktree.
428
+ */
429
+ async getGitBranchName(worktree) {
430
+ if (!worktree) {
431
+ return null;
432
+ }
433
+ try {
434
+ const gitDir = await this.resolveGitDir(worktree);
435
+ if (!gitDir) {
436
+ return null;
437
+ }
438
+ const headPath = path.join(gitDir, "HEAD");
439
+ const headContent = (await readFile(headPath, "utf-8")).trim();
440
+ const match = headContent.match(/^ref:\s+refs\/heads\/(.+)$/);
441
+ return match?.[1] || null;
442
+ }
443
+ catch (err) {
444
+ logger.debug("[PinnedManager] Could not resolve git branch:", err);
445
+ return null;
446
+ }
447
+ }
448
+ /**
449
+ * Resolve git directory for a normal repository or linked worktree.
450
+ */
451
+ async resolveGitDir(worktree) {
452
+ const gitPath = path.join(worktree, ".git");
453
+ try {
454
+ const gitStat = await stat(gitPath);
455
+ if (gitStat.isDirectory()) {
456
+ return gitPath;
457
+ }
458
+ if (!gitStat.isFile()) {
459
+ return null;
460
+ }
461
+ const gitPointer = (await readFile(gitPath, "utf-8")).trim();
462
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
463
+ if (!match) {
464
+ return null;
465
+ }
466
+ return path.resolve(worktree, match[1].trim());
467
+ }
468
+ catch {
469
+ return null;
470
+ }
471
+ }
400
472
  /**
401
473
  * Extract project name from worktree path
402
474
  */
@@ -430,6 +502,21 @@ class PinnedMessageManager {
430
502
  return normalized;
431
503
  return ".../" + segments.slice(-3).join("/");
432
504
  }
505
+ areFileDiffsEqual(current, next) {
506
+ if (current.length !== next.length) {
507
+ return false;
508
+ }
509
+ for (let index = 0; index < current.length; index++) {
510
+ const left = current[index];
511
+ const right = next[index];
512
+ if (left.file !== right.file ||
513
+ left.additions !== right.additions ||
514
+ left.deletions !== right.deletions) {
515
+ return false;
516
+ }
517
+ }
518
+ return true;
519
+ }
433
520
  /**
434
521
  * Fetch context limit from current model configuration
435
522
  */
@@ -452,9 +539,12 @@ class PinnedMessageManager {
452
539
  formatMessage() {
453
540
  const currentModel = getStoredModel();
454
541
  const modelName = formatModelDisplayName(currentModel.providerID, currentModel.modelID);
542
+ const projectDisplayName = this.state.projectBranch
543
+ ? `${this.state.projectName}: ${this.state.projectBranch}`
544
+ : this.state.projectName;
455
545
  const lines = [
456
546
  `${this.state.sessionTitle}`,
457
- t("pinned.line.project", { project: this.state.projectName }),
547
+ t("pinned.line.project", { project: projectDisplayName }),
458
548
  t("pinned.line.model", { model: modelName }),
459
549
  formatContextLine(this.state.tokensUsed, this.state.tokensLimit),
460
550
  ];
@@ -498,6 +588,7 @@ class PinnedMessageManager {
498
588
  this.state.messageId = sentMessage.message_id;
499
589
  this.state.chatId = this.chatId;
500
590
  this.state.lastUpdated = Date.now();
591
+ this.lastRenderedMessageText = text;
501
592
  // Save to settings for persistence
502
593
  setPinnedMessageId(sentMessage.message_id);
503
594
  // Pin the message (silently)
@@ -513,36 +604,67 @@ class PinnedMessageManager {
513
604
  /**
514
605
  * Update existing pinned message text
515
606
  */
516
- async updatePinnedMessage() {
607
+ async updatePinnedMessage(forceUpdate = false) {
517
608
  if (!this.api || !this.chatId || !this.state.messageId) {
518
609
  return;
519
610
  }
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
- }
611
+ this.pendingUpdate = true;
612
+ if (forceUpdate) {
613
+ this.pendingForceUpdate = true;
531
614
  }
532
- catch (err) {
533
- // Handle "message is not modified" error silently
534
- if (err instanceof Error && err.message.includes("message is not modified")) {
615
+ if (this.updateTask) {
616
+ await this.updateTask;
617
+ return;
618
+ }
619
+ this.updateTask = this.flushPendingPinnedUpdates().finally(() => {
620
+ this.updateTask = null;
621
+ });
622
+ await this.updateTask;
623
+ }
624
+ async flushPendingPinnedUpdates() {
625
+ while (this.pendingUpdate) {
626
+ this.pendingUpdate = false;
627
+ const shouldForceUpdate = this.pendingForceUpdate;
628
+ this.pendingForceUpdate = false;
629
+ if (!this.api || !this.chatId || !this.state.messageId) {
535
630
  return;
536
631
  }
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;
632
+ const text = this.formatMessage();
633
+ if (!shouldForceUpdate && text === this.lastRenderedMessageText) {
634
+ logger.debug("[PinnedManager] Skipping pinned update: message content unchanged");
635
+ continue;
636
+ }
637
+ try {
638
+ await this.api.editMessageText(this.chatId, this.state.messageId, text);
639
+ this.state.lastUpdated = Date.now();
640
+ this.lastRenderedMessageText = text;
641
+ logger.debug(`[PinnedManager] Updated pinned message: ${this.state.messageId}`);
642
+ // Trigger keyboard update callback
643
+ if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
644
+ setImmediate(() => {
645
+ this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
646
+ });
647
+ }
648
+ }
649
+ catch (err) {
650
+ const errorMessage = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
651
+ // Handle "message is not modified" error silently
652
+ if (errorMessage.includes("message is not modified")) {
653
+ this.lastRenderedMessageText = text;
654
+ continue;
655
+ }
656
+ // Handle "message to edit not found" - recreate
657
+ if (errorMessage.includes("message to edit not found")) {
658
+ logger.warn("[PinnedManager] Pinned message was deleted, recreating...");
659
+ this.state.messageId = null;
660
+ this.lastRenderedMessageText = null;
661
+ this.pendingForceUpdate = false;
662
+ clearPinnedMessageId();
663
+ await this.createPinnedMessage();
664
+ continue;
665
+ }
666
+ logger.error("[PinnedManager] Error updating pinned message:", err);
544
667
  }
545
- logger.error("[PinnedManager] Error updating pinned message:", err);
546
668
  }
547
669
  }
548
670
  /**
@@ -556,6 +678,9 @@ class PinnedMessageManager {
556
678
  // Unpin all messages (ensures clean state)
557
679
  await this.api.unpinAllChatMessages(this.chatId).catch(() => { });
558
680
  this.state.messageId = null;
681
+ this.lastRenderedMessageText = null;
682
+ this.pendingUpdate = false;
683
+ this.pendingForceUpdate = false;
559
684
  clearPinnedMessageId();
560
685
  logger.debug("[PinnedManager] Unpinned old messages");
561
686
  }
@@ -585,7 +710,11 @@ class PinnedMessageManager {
585
710
  this.state.sessionId = null;
586
711
  this.state.tokensUsed = 0;
587
712
  this.state.tokensLimit = 0;
713
+ this.state.projectBranch = null;
588
714
  this.state.changedFiles = [];
715
+ this.lastRenderedMessageText = null;
716
+ this.pendingUpdate = false;
717
+ this.pendingForceUpdate = false;
589
718
  clearPinnedMessageId();
590
719
  return;
591
720
  }
@@ -597,9 +726,13 @@ class PinnedMessageManager {
597
726
  this.state.sessionId = null;
598
727
  this.state.sessionTitle = t("pinned.default_session_title");
599
728
  this.state.projectName = "";
729
+ this.state.projectBranch = null;
600
730
  this.state.tokensUsed = 0;
601
731
  this.state.tokensLimit = 0;
602
732
  this.state.changedFiles = [];
733
+ this.lastRenderedMessageText = null;
734
+ this.pendingUpdate = false;
735
+ this.pendingForceUpdate = false;
603
736
  clearPinnedMessageId();
604
737
  logger.info("[PinnedManager] Cleared pinned message state");
605
738
  }
@@ -63,6 +63,13 @@ export function clearSession() {
63
63
  currentSettings.currentSession = undefined;
64
64
  void writeSettingsFile(currentSettings);
65
65
  }
66
+ export function isTtsEnabled() {
67
+ return currentSettings.ttsEnabled === true;
68
+ }
69
+ export function setTtsEnabled(enabled) {
70
+ currentSettings.ttsEnabled = enabled;
71
+ void writeSettingsFile(currentSettings);
72
+ }
66
73
  export function getCurrentAgent() {
67
74
  return currentSettings.currentAgent;
68
75
  }
@@ -6,6 +6,15 @@ import { t } from "../i18n/index.js";
6
6
  import { getCurrentProject } from "../settings/manager.js";
7
7
  const TELEGRAM_MESSAGE_LIMIT = 4096;
8
8
  const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
9
+ function truncateWithEllipsis(text, maxLength) {
10
+ if (text.length <= maxLength) {
11
+ return text;
12
+ }
13
+ if (maxLength <= 3) {
14
+ return ".".repeat(Math.max(0, maxLength));
15
+ }
16
+ return `${text.slice(0, maxLength - 3).trimEnd()}...`;
17
+ }
9
18
  function endsWithOddTrailingBackslashes(text, start, end) {
10
19
  let backslashCount = 0;
11
20
  for (let index = end - 1; index >= start; index--) {
@@ -380,7 +389,7 @@ export function formatToolInfo(toolInfo) {
380
389
  description = `${input.description}\n`;
381
390
  }
382
391
  if (tool === "bash" && input && typeof input.command === "string") {
383
- details = input.command;
392
+ details = truncateWithEllipsis(input.command, config.bot.bashToolDisplayMaxLength);
384
393
  }
385
394
  if (tool === "apply_patch") {
386
395
  const filediff = toolInfo.metadata && "filediff" in toolInfo.metadata
@@ -0,0 +1,59 @@
1
+ import { config } from "../config.js";
2
+ import { logger } from "../utils/logger.js";
3
+ const TTS_REQUEST_TIMEOUT_MS = 60_000;
4
+ export function isTtsConfigured() {
5
+ return Boolean(config.tts.apiUrl && config.tts.apiKey);
6
+ }
7
+ export async function synthesizeSpeech(text) {
8
+ if (!isTtsConfigured()) {
9
+ throw new Error("TTS is not configured: set TTS API credentials");
10
+ }
11
+ const input = text.trim();
12
+ if (!input) {
13
+ throw new Error("TTS input text is empty");
14
+ }
15
+ const controller = new AbortController();
16
+ const timeout = setTimeout(() => controller.abort(), TTS_REQUEST_TIMEOUT_MS);
17
+ try {
18
+ const url = `${config.tts.apiUrl}/audio/speech`;
19
+ logger.debug(`[TTS] Sending speech synthesis request: url=${url}, model=${config.tts.model}, voice=${config.tts.voice}, chars=${input.length}`);
20
+ const response = await fetch(url, {
21
+ method: "POST",
22
+ headers: {
23
+ Authorization: `Bearer ${config.tts.apiKey}`,
24
+ "Content-Type": "application/json",
25
+ },
26
+ body: JSON.stringify({
27
+ model: config.tts.model,
28
+ voice: config.tts.voice,
29
+ input,
30
+ response_format: "mp3",
31
+ }),
32
+ signal: controller.signal,
33
+ });
34
+ if (!response.ok) {
35
+ const errorBody = await response.text().catch(() => "");
36
+ throw new Error(`TTS API returned HTTP ${response.status}: ${errorBody || response.statusText}`);
37
+ }
38
+ const arrayBuffer = await response.arrayBuffer();
39
+ const buffer = Buffer.from(arrayBuffer);
40
+ if (buffer.length === 0) {
41
+ throw new Error("TTS API returned an empty audio response");
42
+ }
43
+ logger.debug(`[TTS] Generated speech audio: ${buffer.length} bytes`);
44
+ return {
45
+ buffer,
46
+ filename: "assistant-reply.mp3",
47
+ mimeType: "audio/mpeg",
48
+ };
49
+ }
50
+ catch (err) {
51
+ if (err instanceof DOMException && err.name === "AbortError") {
52
+ throw new Error(`TTS request timed out after ${TTS_REQUEST_TIMEOUT_MS}ms`);
53
+ }
54
+ throw err;
55
+ }
56
+ finally {
57
+ clearTimeout(timeout);
58
+ }
59
+ }
@@ -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.15.0",
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",