@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.
- package/.env.example +16 -0
- package/README.md +44 -25
- package/dist/agent/manager.js +34 -6
- package/dist/agent/types.js +7 -1
- package/dist/bot/commands/commands.js +2 -2
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/new.js +3 -2
- package/dist/bot/commands/projects.js +3 -2
- package/dist/bot/commands/sessions.js +3 -0
- package/dist/bot/commands/status.js +5 -2
- package/dist/bot/commands/tts.js +13 -0
- package/dist/bot/handlers/agent.js +3 -4
- package/dist/bot/handlers/model.js +3 -2
- package/dist/bot/handlers/prompt.js +22 -5
- package/dist/bot/handlers/variant.js +3 -2
- package/dist/bot/index.js +40 -10
- package/dist/bot/message-patterns.js +2 -2
- package/dist/bot/streaming/tool-call-streamer.js +31 -24
- package/dist/bot/utils/finalize-assistant-response.js +6 -3
- package/dist/bot/utils/keyboard.js +6 -6
- package/dist/bot/utils/send-tts-response.js +37 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +82 -4
- package/dist/bot/utils/telegram-text.js +4 -2
- package/dist/config.js +8 -0
- package/dist/i18n/de.js +17 -9
- package/dist/i18n/en.js +17 -9
- package/dist/i18n/es.js +17 -9
- package/dist/i18n/fr.js +17 -9
- package/dist/i18n/ru.js +17 -9
- package/dist/i18n/zh.js +17 -9
- package/dist/pinned/manager.js +164 -31
- package/dist/settings/manager.js +7 -0
- package/dist/summary/formatter.js +10 -1
- package/dist/tts/client.js +59 -0
- package/dist/utils/telegram-rate-limit-retry.js +93 -0
- package/package.json +1 -1
package/dist/pinned/manager.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
},
|
|
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:
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
538
|
-
if (
|
|
539
|
-
logger.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
}
|
package/dist/settings/manager.js
CHANGED
|
@@ -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
|
+
}
|