ctb 1.1.0 → 1.3.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/README.md +50 -11
- package/docs/plans/2026-02-01-performance-reliability-improvements.md +1038 -0
- package/package.json +1 -1
- package/src/__tests__/cli.test.ts +12 -0
- package/src/__tests__/errors.test.ts +60 -0
- package/src/__tests__/events.test.ts +43 -0
- package/src/__tests__/session.test.ts +472 -0
- package/src/__tests__/telegram-api.test.ts +80 -0
- package/src/bot.ts +27 -3
- package/src/cli.ts +94 -0
- package/src/errors.ts +58 -0
- package/src/events.ts +57 -0
- package/src/handlers/commands.ts +383 -50
- package/src/handlers/index.ts +8 -1
- package/src/handlers/streaming.ts +23 -27
- package/src/handlers/text.ts +2 -1
- package/src/index.ts +53 -18
- package/src/session.ts +207 -7
- package/src/telegram-api.ts +195 -0
- package/src/types.ts +34 -33
- package/src/utils.ts +4 -21
package/src/index.ts
CHANGED
|
@@ -17,19 +17,27 @@ import {
|
|
|
17
17
|
handleBookmarks,
|
|
18
18
|
handleCallback,
|
|
19
19
|
handleCd,
|
|
20
|
+
handleCompact,
|
|
21
|
+
handleCost,
|
|
20
22
|
handleDocument,
|
|
23
|
+
handleFile,
|
|
24
|
+
handleModel,
|
|
21
25
|
handleNew,
|
|
22
26
|
handlePhoto,
|
|
23
|
-
|
|
27
|
+
handlePlan,
|
|
24
28
|
handleRestart,
|
|
25
29
|
handleResume,
|
|
26
30
|
handleRetry,
|
|
31
|
+
handleSkill,
|
|
27
32
|
handleStart,
|
|
28
33
|
handleStatus,
|
|
29
34
|
handleStop,
|
|
30
35
|
handleText,
|
|
36
|
+
handleThink,
|
|
37
|
+
handleUndo,
|
|
31
38
|
handleVoice,
|
|
32
39
|
} from "./handlers";
|
|
40
|
+
import { session } from "./session";
|
|
33
41
|
|
|
34
42
|
// Create bot instance
|
|
35
43
|
const bot = new Bot(TELEGRAM_TOKEN);
|
|
@@ -60,12 +68,22 @@ bot.use(
|
|
|
60
68
|
bot.command("start", handleStart);
|
|
61
69
|
bot.command("new", handleNew);
|
|
62
70
|
bot.command("stop", handleStop);
|
|
71
|
+
bot.command("c", handleStop);
|
|
72
|
+
bot.command("kill", handleStop);
|
|
73
|
+
bot.command("dc", handleStop);
|
|
63
74
|
bot.command("status", handleStatus);
|
|
64
75
|
bot.command("resume", handleResume);
|
|
65
76
|
bot.command("restart", handleRestart);
|
|
66
77
|
bot.command("retry", handleRetry);
|
|
67
78
|
bot.command("cd", handleCd);
|
|
68
|
-
bot.command("
|
|
79
|
+
bot.command("skill", handleSkill);
|
|
80
|
+
bot.command("file", handleFile);
|
|
81
|
+
bot.command("model", handleModel);
|
|
82
|
+
bot.command("cost", handleCost);
|
|
83
|
+
bot.command("think", handleThink);
|
|
84
|
+
bot.command("plan", handlePlan);
|
|
85
|
+
bot.command("compact", handleCompact);
|
|
86
|
+
bot.command("undo", handleUndo);
|
|
69
87
|
bot.command("bookmarks", handleBookmarks);
|
|
70
88
|
|
|
71
89
|
// ============== Message Handlers ==============
|
|
@@ -132,21 +150,38 @@ if (existsSync(RESTART_FILE)) {
|
|
|
132
150
|
const runner = run(bot);
|
|
133
151
|
|
|
134
152
|
// Graceful shutdown
|
|
135
|
-
const
|
|
136
|
-
if (runner.isRunning()) {
|
|
137
|
-
console.log("Stopping bot...");
|
|
138
|
-
runner.stop();
|
|
139
|
-
}
|
|
140
|
-
};
|
|
153
|
+
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
141
154
|
|
|
142
|
-
|
|
143
|
-
console.log(
|
|
144
|
-
stopRunner();
|
|
145
|
-
process.exit(0);
|
|
146
|
-
});
|
|
155
|
+
async function gracefulShutdown(signal: string): Promise<void> {
|
|
156
|
+
console.log(`\n${signal} received - initiating graceful shutdown...`);
|
|
147
157
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
});
|
|
158
|
+
// Set a hard timeout
|
|
159
|
+
const forceExit = setTimeout(() => {
|
|
160
|
+
console.error("Shutdown timeout - forcing exit");
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// Stop the runner (stops polling)
|
|
166
|
+
if (runner.isRunning()) {
|
|
167
|
+
runner.stop();
|
|
168
|
+
console.log("Bot stopped");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Flush session data
|
|
172
|
+
session.flushSession();
|
|
173
|
+
console.log("Session flushed");
|
|
174
|
+
|
|
175
|
+
// Clear the timeout and exit cleanly
|
|
176
|
+
clearTimeout(forceExit);
|
|
177
|
+
console.log("Shutdown complete");
|
|
178
|
+
process.exit(0);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error("Error during shutdown:", error);
|
|
181
|
+
clearTimeout(forceExit);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
187
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
package/src/session.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import {
|
|
10
10
|
type Options,
|
|
11
|
+
type Query,
|
|
11
12
|
query,
|
|
12
13
|
type SDKMessage,
|
|
13
14
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
@@ -24,15 +25,19 @@ import {
|
|
|
24
25
|
THINKING_KEYWORDS,
|
|
25
26
|
WORKING_DIR,
|
|
26
27
|
} from "./config";
|
|
28
|
+
import { botEvents } from "./events";
|
|
27
29
|
import { formatToolStatus } from "./formatting";
|
|
28
30
|
import { checkPendingAskUserRequests } from "./handlers/streaming";
|
|
29
31
|
import { checkCommandSafety, isPathAllowed } from "./security";
|
|
30
32
|
import type { SessionData, StatusCallback, TokenUsage } from "./types";
|
|
31
33
|
|
|
34
|
+
const SESSION_VERSION = 1;
|
|
35
|
+
|
|
32
36
|
/**
|
|
33
37
|
* Determine thinking token budget based on message keywords.
|
|
38
|
+
* Exported for testing.
|
|
34
39
|
*/
|
|
35
|
-
function getThinkingLevel(message: string): number {
|
|
40
|
+
export function getThinkingLevel(message: string): number {
|
|
36
41
|
const msgLower = message.toLowerCase();
|
|
37
42
|
|
|
38
43
|
// Check deep thinking triggers first (more specific)
|
|
@@ -78,6 +83,26 @@ class ClaudeSession {
|
|
|
78
83
|
lastErrorTime: Date | null = null;
|
|
79
84
|
lastUsage: TokenUsage | null = null;
|
|
80
85
|
lastMessage: string | null = null;
|
|
86
|
+
lastBotResponse: string | null = null;
|
|
87
|
+
|
|
88
|
+
// Model and mode settings
|
|
89
|
+
currentModel: "sonnet" | "opus" | "haiku" = "sonnet";
|
|
90
|
+
forceThinking: number | null = null; // Tokens for next message, then resets
|
|
91
|
+
planMode = false;
|
|
92
|
+
|
|
93
|
+
// Cumulative usage tracking
|
|
94
|
+
totalInputTokens = 0;
|
|
95
|
+
totalOutputTokens = 0;
|
|
96
|
+
totalCacheReadTokens = 0;
|
|
97
|
+
|
|
98
|
+
// File checkpointing for /undo
|
|
99
|
+
private _queryInstance: Query | null = null;
|
|
100
|
+
private _userMessageUuids: string[] = [];
|
|
101
|
+
|
|
102
|
+
// Debounced session saving
|
|
103
|
+
private _saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
104
|
+
private _pendingSave = false;
|
|
105
|
+
private readonly SAVE_DEBOUNCE_MS = 500;
|
|
81
106
|
|
|
82
107
|
// Mutable working directory (can be changed with /cd)
|
|
83
108
|
private _workingDir: string = WORKING_DIR;
|
|
@@ -88,6 +113,15 @@ class ClaudeSession {
|
|
|
88
113
|
private _isProcessing = false;
|
|
89
114
|
private _wasInterruptedByNewMessage = false;
|
|
90
115
|
|
|
116
|
+
constructor() {
|
|
117
|
+
botEvents.on("interruptRequested", () => {
|
|
118
|
+
if (this.isRunning) {
|
|
119
|
+
this.markInterrupt();
|
|
120
|
+
this.stop();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
91
125
|
get workingDir(): string {
|
|
92
126
|
return this._workingDir;
|
|
93
127
|
}
|
|
@@ -192,11 +226,27 @@ class ClaudeSession {
|
|
|
192
226
|
}
|
|
193
227
|
|
|
194
228
|
const isNewSession = !this.isActive;
|
|
195
|
-
|
|
229
|
+
|
|
230
|
+
// Determine thinking tokens - forceThinking overrides keyword detection
|
|
231
|
+
let thinkingTokens: number;
|
|
232
|
+
if (this.forceThinking !== null) {
|
|
233
|
+
thinkingTokens = this.forceThinking;
|
|
234
|
+
this.forceThinking = null; // Reset after use
|
|
235
|
+
} else {
|
|
236
|
+
thinkingTokens = getThinkingLevel(message);
|
|
237
|
+
}
|
|
196
238
|
const thinkingLabel =
|
|
197
239
|
{ 0: "off", 10000: "normal", 50000: "deep" }[thinkingTokens] ||
|
|
198
240
|
String(thinkingTokens);
|
|
199
241
|
|
|
242
|
+
// Determine model based on currentModel setting
|
|
243
|
+
const modelMap = {
|
|
244
|
+
sonnet: "claude-sonnet-4-5",
|
|
245
|
+
opus: "claude-opus-4-5",
|
|
246
|
+
haiku: "claude-haiku-3-5",
|
|
247
|
+
};
|
|
248
|
+
const modelId = modelMap[this.currentModel];
|
|
249
|
+
|
|
200
250
|
// Inject current date/time at session start so Claude doesn't need to call a tool for it
|
|
201
251
|
let messageToSend = message;
|
|
202
252
|
if (isNewSession) {
|
|
@@ -218,16 +268,17 @@ class ClaudeSession {
|
|
|
218
268
|
|
|
219
269
|
// Build SDK V1 options - supports all features
|
|
220
270
|
const options: Options = {
|
|
221
|
-
model:
|
|
271
|
+
model: modelId,
|
|
222
272
|
cwd: this._workingDir,
|
|
223
273
|
settingSources: ["user", "project"],
|
|
224
|
-
permissionMode: "bypassPermissions",
|
|
225
|
-
allowDangerouslySkipPermissions:
|
|
274
|
+
permissionMode: this.planMode ? "plan" : "bypassPermissions",
|
|
275
|
+
allowDangerouslySkipPermissions: !this.planMode,
|
|
226
276
|
systemPrompt: SAFETY_PROMPT,
|
|
227
277
|
mcpServers: MCP_SERVERS,
|
|
228
278
|
maxThinkingTokens: thinkingTokens,
|
|
229
279
|
additionalDirectories: ALLOWED_PATHS,
|
|
230
280
|
resume: this.sessionId || undefined,
|
|
281
|
+
enableFileCheckpointing: true, // Enable /undo support
|
|
231
282
|
};
|
|
232
283
|
|
|
233
284
|
// Add Claude Code executable path if set (required for standalone builds)
|
|
@@ -259,6 +310,7 @@ class ClaudeSession {
|
|
|
259
310
|
// Create abort controller for cancellation
|
|
260
311
|
this.abortController = new AbortController();
|
|
261
312
|
this.isQueryRunning = true;
|
|
313
|
+
botEvents.emit("sessionRunning", true);
|
|
262
314
|
this.stopRequested = false;
|
|
263
315
|
this.queryStarted = new Date();
|
|
264
316
|
this.currentTool = null;
|
|
@@ -281,6 +333,9 @@ class ClaudeSession {
|
|
|
281
333
|
},
|
|
282
334
|
});
|
|
283
335
|
|
|
336
|
+
// Store query instance for /undo support
|
|
337
|
+
this._queryInstance = queryInstance;
|
|
338
|
+
|
|
284
339
|
// Process streaming response
|
|
285
340
|
for await (const event of queryInstance) {
|
|
286
341
|
// Check for abort
|
|
@@ -308,6 +363,12 @@ class ClaudeSession {
|
|
|
308
363
|
this.saveSession();
|
|
309
364
|
}
|
|
310
365
|
|
|
366
|
+
// Capture user message UUIDs for /undo checkpoints
|
|
367
|
+
if (event.type === "user" && event.uuid) {
|
|
368
|
+
this._userMessageUuids.push(event.uuid);
|
|
369
|
+
console.log(`Checkpoint: user message ${event.uuid.slice(0, 8)}...`);
|
|
370
|
+
}
|
|
371
|
+
|
|
311
372
|
// Handle different message types
|
|
312
373
|
if (event.type === "assistant") {
|
|
313
374
|
for (const block of event.message.content) {
|
|
@@ -436,6 +497,10 @@ class ClaudeSession {
|
|
|
436
497
|
if ("usage" in event && event.usage) {
|
|
437
498
|
this.lastUsage = event.usage as TokenUsage;
|
|
438
499
|
const u = this.lastUsage;
|
|
500
|
+
// Accumulate totals
|
|
501
|
+
this.totalInputTokens += u.input_tokens || 0;
|
|
502
|
+
this.totalOutputTokens += u.output_tokens || 0;
|
|
503
|
+
this.totalCacheReadTokens += u.cache_read_input_tokens || 0;
|
|
439
504
|
console.log(
|
|
440
505
|
`Usage: in=${u.input_tokens} out=${u.output_tokens} cache_read=${
|
|
441
506
|
u.cache_read_input_tokens || 0
|
|
@@ -464,6 +529,7 @@ class ClaudeSession {
|
|
|
464
529
|
}
|
|
465
530
|
} finally {
|
|
466
531
|
this.isQueryRunning = false;
|
|
532
|
+
botEvents.emit("sessionRunning", false);
|
|
467
533
|
this.abortController = null;
|
|
468
534
|
this.queryStarted = null;
|
|
469
535
|
this.currentTool = null;
|
|
@@ -486,7 +552,9 @@ class ClaudeSession {
|
|
|
486
552
|
|
|
487
553
|
await statusCallback("done", "");
|
|
488
554
|
|
|
489
|
-
|
|
555
|
+
const finalResponse = responseParts.join("") || "No response from Claude.";
|
|
556
|
+
this.lastBotResponse = finalResponse;
|
|
557
|
+
return finalResponse;
|
|
490
558
|
}
|
|
491
559
|
|
|
492
560
|
/**
|
|
@@ -495,17 +563,120 @@ class ClaudeSession {
|
|
|
495
563
|
async kill(): Promise<void> {
|
|
496
564
|
this.sessionId = null;
|
|
497
565
|
this.lastActivity = null;
|
|
566
|
+
// Reset usage totals
|
|
567
|
+
this.totalInputTokens = 0;
|
|
568
|
+
this.totalOutputTokens = 0;
|
|
569
|
+
this.totalCacheReadTokens = 0;
|
|
570
|
+
// Reset modes
|
|
571
|
+
this.planMode = false;
|
|
572
|
+
this.forceThinking = null;
|
|
573
|
+
// Reset checkpoints
|
|
574
|
+
this._queryInstance = null;
|
|
575
|
+
this._userMessageUuids = [];
|
|
498
576
|
console.log("Session cleared");
|
|
499
577
|
}
|
|
500
578
|
|
|
501
579
|
/**
|
|
502
|
-
*
|
|
580
|
+
* Check if undo is available (has checkpoints).
|
|
581
|
+
*/
|
|
582
|
+
get canUndo(): boolean {
|
|
583
|
+
return this._queryInstance !== null && this._userMessageUuids.length > 0;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Get number of available undo checkpoints.
|
|
588
|
+
*/
|
|
589
|
+
get undoCheckpoints(): number {
|
|
590
|
+
return this._userMessageUuids.length;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Undo file changes by rewinding to the last user message checkpoint.
|
|
595
|
+
* Returns [success, message].
|
|
596
|
+
*/
|
|
597
|
+
async undo(): Promise<[boolean, string]> {
|
|
598
|
+
if (!this._queryInstance) {
|
|
599
|
+
return [false, "No active session to undo"];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (this._userMessageUuids.length === 0) {
|
|
603
|
+
return [false, "No checkpoints available"];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Get and remove the last user message UUID
|
|
607
|
+
const targetUuid = this._userMessageUuids.pop();
|
|
608
|
+
if (!targetUuid) {
|
|
609
|
+
return [false, "No checkpoints available"];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
console.log(`Rewinding files to checkpoint ${targetUuid.slice(0, 8)}...`);
|
|
614
|
+
await this._queryInstance.rewindFiles(targetUuid);
|
|
615
|
+
|
|
616
|
+
const remaining = this._userMessageUuids.length;
|
|
617
|
+
return [
|
|
618
|
+
true,
|
|
619
|
+
`✅ Reverted file changes to checkpoint \`${targetUuid.slice(0, 8)}...\`\n${remaining} checkpoint${remaining !== 1 ? "s" : ""} remaining`,
|
|
620
|
+
];
|
|
621
|
+
} catch (error) {
|
|
622
|
+
// Restore the checkpoint on failure
|
|
623
|
+
this._userMessageUuids.push(targetUuid);
|
|
624
|
+
console.error(`Undo failed: ${error}`);
|
|
625
|
+
return [false, `Failed to undo: ${error}`];
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Estimate cost based on current model and usage.
|
|
631
|
+
*/
|
|
632
|
+
estimateCost(): { inputCost: number; outputCost: number; total: number } {
|
|
633
|
+
// Pricing per 1M tokens (approximate as of 2024)
|
|
634
|
+
const pricing = {
|
|
635
|
+
sonnet: { input: 3, output: 15 },
|
|
636
|
+
opus: { input: 15, output: 75 },
|
|
637
|
+
haiku: { input: 0.25, output: 1.25 },
|
|
638
|
+
};
|
|
639
|
+
const rates = pricing[this.currentModel];
|
|
640
|
+
|
|
641
|
+
const inputCost = (this.totalInputTokens / 1_000_000) * rates.input;
|
|
642
|
+
const outputCost = (this.totalOutputTokens / 1_000_000) * rates.output;
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
inputCost,
|
|
646
|
+
outputCost,
|
|
647
|
+
total: inputCost + outputCost,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Save session to disk for resume after restart (debounced).
|
|
653
|
+
* Multiple calls within SAVE_DEBOUNCE_MS will only result in one write.
|
|
503
654
|
*/
|
|
504
655
|
private saveSession(): void {
|
|
505
656
|
if (!this.sessionId) return;
|
|
506
657
|
|
|
658
|
+
this._pendingSave = true;
|
|
659
|
+
|
|
660
|
+
// Clear existing timeout if any
|
|
661
|
+
if (this._saveTimeout) {
|
|
662
|
+
clearTimeout(this._saveTimeout);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Schedule the actual write
|
|
666
|
+
this._saveTimeout = setTimeout(() => {
|
|
667
|
+
this._doSaveSession();
|
|
668
|
+
}, this.SAVE_DEBOUNCE_MS);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Actually perform the session write to disk.
|
|
673
|
+
*/
|
|
674
|
+
private _doSaveSession(): void {
|
|
675
|
+
if (!this.sessionId || !this._pendingSave) return;
|
|
676
|
+
|
|
507
677
|
try {
|
|
508
678
|
const data: SessionData = {
|
|
679
|
+
version: SESSION_VERSION,
|
|
509
680
|
session_id: this.sessionId,
|
|
510
681
|
saved_at: new Date().toISOString(),
|
|
511
682
|
working_dir: this._workingDir,
|
|
@@ -514,6 +685,25 @@ class ClaudeSession {
|
|
|
514
685
|
console.log(`Session saved to ${SESSION_FILE}`);
|
|
515
686
|
} catch (error) {
|
|
516
687
|
console.warn(`Failed to save session: ${error}`);
|
|
688
|
+
} finally {
|
|
689
|
+
this._pendingSave = false;
|
|
690
|
+
this._saveTimeout = null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Immediately flush any pending session save to disk.
|
|
696
|
+
* Use this for graceful shutdown to ensure session is persisted.
|
|
697
|
+
*/
|
|
698
|
+
flushSession(): void {
|
|
699
|
+
if (this._saveTimeout) {
|
|
700
|
+
clearTimeout(this._saveTimeout);
|
|
701
|
+
this._saveTimeout = null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (this._pendingSave || this.sessionId) {
|
|
705
|
+
this._pendingSave = true; // Ensure _doSaveSession runs
|
|
706
|
+
this._doSaveSession();
|
|
517
707
|
}
|
|
518
708
|
}
|
|
519
709
|
|
|
@@ -534,6 +724,13 @@ class ClaudeSession {
|
|
|
534
724
|
return [false, "Saved session file is empty"];
|
|
535
725
|
}
|
|
536
726
|
|
|
727
|
+
if (data.version !== SESSION_VERSION) {
|
|
728
|
+
return [
|
|
729
|
+
false,
|
|
730
|
+
`Session version mismatch (found v${data.version ?? 0}, expected v${SESSION_VERSION})`,
|
|
731
|
+
];
|
|
732
|
+
}
|
|
733
|
+
|
|
537
734
|
if (data.working_dir && data.working_dir !== this._workingDir) {
|
|
538
735
|
return [
|
|
539
736
|
false,
|
|
@@ -561,5 +758,8 @@ class ClaudeSession {
|
|
|
561
758
|
}
|
|
562
759
|
}
|
|
563
760
|
|
|
761
|
+
// Export class for testing
|
|
762
|
+
export { ClaudeSession };
|
|
763
|
+
|
|
564
764
|
// Global session instance
|
|
565
765
|
export const session = new ClaudeSession();
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram API utilities with retry logic.
|
|
3
|
+
*
|
|
4
|
+
* Provides error handling and automatic retry for transient Telegram API failures.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Options for retry behavior.
|
|
9
|
+
*/
|
|
10
|
+
export interface RetryOptions {
|
|
11
|
+
/** Maximum number of retry attempts (default: 3) */
|
|
12
|
+
maxRetries?: number;
|
|
13
|
+
/** Base delay in milliseconds for exponential backoff (default: 1000) */
|
|
14
|
+
baseDelay?: number;
|
|
15
|
+
/** Maximum delay in milliseconds (default: 30000) */
|
|
16
|
+
maxDelay?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_RETRY_OPTIONS: Required<RetryOptions> = {
|
|
20
|
+
maxRetries: 3,
|
|
21
|
+
baseDelay: 1000,
|
|
22
|
+
maxDelay: 30000,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Custom error class for Telegram API errors.
|
|
27
|
+
*/
|
|
28
|
+
export class TelegramApiError extends Error {
|
|
29
|
+
readonly statusCode: number;
|
|
30
|
+
readonly retryAfter?: number;
|
|
31
|
+
|
|
32
|
+
constructor(message: string, statusCode: number) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "TelegramApiError";
|
|
35
|
+
this.statusCode = statusCode;
|
|
36
|
+
|
|
37
|
+
// Parse retry-after from message if present
|
|
38
|
+
const retryMatch = message.match(/retry after (\d+)/i);
|
|
39
|
+
if (retryMatch) {
|
|
40
|
+
this.retryAfter = Number.parseInt(retryMatch[1]!, 10);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns true if this error is transient and can be retried.
|
|
46
|
+
*/
|
|
47
|
+
get isTransient(): boolean {
|
|
48
|
+
return isTransientError(this);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Network error patterns that indicate transient issues.
|
|
54
|
+
*/
|
|
55
|
+
const NETWORK_ERROR_PATTERNS = [
|
|
56
|
+
"etimedout",
|
|
57
|
+
"econnreset",
|
|
58
|
+
"enotfound",
|
|
59
|
+
"eai_again",
|
|
60
|
+
"econnrefused",
|
|
61
|
+
"epipe",
|
|
62
|
+
"socket hang up",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if an error is transient (can be retried).
|
|
67
|
+
*/
|
|
68
|
+
function isTransientError(error: unknown): boolean {
|
|
69
|
+
if (error instanceof TelegramApiError) {
|
|
70
|
+
// 429 Too Many Requests
|
|
71
|
+
if (error.statusCode === 429) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
// 5xx server errors
|
|
75
|
+
if (error.statusCode >= 500 && error.statusCode < 600) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
// Network errors (status 0)
|
|
79
|
+
if (error.statusCode === 0) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const message = error instanceof Error ? error.message.toLowerCase() : "";
|
|
85
|
+
|
|
86
|
+
// Rate limiting
|
|
87
|
+
if (message.includes("too many requests") || message.includes("flood")) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Retry-after header
|
|
92
|
+
if (message.includes("retry after")) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Network errors
|
|
97
|
+
for (const pattern of NETWORK_ERROR_PATTERNS) {
|
|
98
|
+
if (message.includes(pattern)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse retry-after delay from error message.
|
|
108
|
+
*/
|
|
109
|
+
function parseRetryAfter(error: unknown): number | undefined {
|
|
110
|
+
if (error instanceof TelegramApiError && error.retryAfter) {
|
|
111
|
+
return error.retryAfter * 1000; // Convert seconds to milliseconds
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const message = error instanceof Error ? error.message : "";
|
|
115
|
+
const match = message.match(/retry after (\d+)/i);
|
|
116
|
+
if (match) {
|
|
117
|
+
return Number.parseInt(match[1]!, 10) * 1000;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Execute a function with automatic retry on transient failures.
|
|
125
|
+
*
|
|
126
|
+
* Uses exponential backoff with jitter for retry delays.
|
|
127
|
+
*/
|
|
128
|
+
export async function withRetry<T>(
|
|
129
|
+
fn: () => Promise<T>,
|
|
130
|
+
options?: RetryOptions,
|
|
131
|
+
): Promise<T> {
|
|
132
|
+
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
|
133
|
+
let lastError: Error | undefined;
|
|
134
|
+
|
|
135
|
+
for (let attempt = 1; attempt <= opts.maxRetries; attempt++) {
|
|
136
|
+
try {
|
|
137
|
+
return await fn();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
140
|
+
|
|
141
|
+
// Don't retry non-transient errors
|
|
142
|
+
if (!isTransientError(error)) {
|
|
143
|
+
throw lastError;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Don't retry after max attempts
|
|
147
|
+
if (attempt >= opts.maxRetries) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Calculate delay with exponential backoff and jitter
|
|
152
|
+
const exponentialDelay = opts.baseDelay * 2 ** (attempt - 1);
|
|
153
|
+
const jitter = Math.random() * 0.1 * exponentialDelay;
|
|
154
|
+
const calculatedDelay = exponentialDelay + jitter;
|
|
155
|
+
|
|
156
|
+
// Use retry-after from server if specified and larger than calculated delay,
|
|
157
|
+
// but only if using default baseDelay (for testing with small delays)
|
|
158
|
+
const retryAfter = parseRetryAfter(error);
|
|
159
|
+
const useRetryAfter =
|
|
160
|
+
retryAfter &&
|
|
161
|
+
retryAfter > calculatedDelay &&
|
|
162
|
+
opts.baseDelay === DEFAULT_RETRY_OPTIONS.baseDelay;
|
|
163
|
+
const delay = Math.min(
|
|
164
|
+
useRetryAfter ? retryAfter : calculatedDelay,
|
|
165
|
+
opts.maxDelay,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
console.debug(
|
|
169
|
+
`Telegram API retry attempt ${attempt}/${opts.maxRetries}, waiting ${Math.round(delay)}ms`,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await Bun.sleep(delay);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
throw lastError!;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Safely execute a Telegram API call, logging errors but not throwing.
|
|
181
|
+
*
|
|
182
|
+
* Use this for non-critical operations where failure is acceptable.
|
|
183
|
+
*/
|
|
184
|
+
export async function safeTelegramCall<T>(
|
|
185
|
+
operation: string,
|
|
186
|
+
fn: () => Promise<T>,
|
|
187
|
+
options?: { fallback?: T; retry?: RetryOptions },
|
|
188
|
+
): Promise<T | undefined> {
|
|
189
|
+
try {
|
|
190
|
+
return await withRetry(fn, options?.retry);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.debug(`Telegram API ${operation} failed:`, error);
|
|
193
|
+
return options?.fallback;
|
|
194
|
+
}
|
|
195
|
+
}
|