ctb 1.2.1 → 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 +8 -0
- package/docs/plans/2026-02-01-performance-reliability-improvements.md +1038 -0
- package/package.json +1 -1
- package/src/__tests__/errors.test.ts +60 -0
- package/src/__tests__/events.test.ts +43 -0
- package/src/__tests__/session.test.ts +73 -0
- package/src/__tests__/telegram-api.test.ts +80 -0
- package/src/errors.ts +58 -0
- package/src/events.ts +57 -0
- package/src/handlers/streaming.ts +23 -27
- package/src/handlers/text.ts +2 -1
- package/src/index.ts +34 -16
- package/src/session.ts +67 -1
- package/src/telegram-api.ts +195 -0
- package/src/types.ts +34 -33
- package/src/utils.ts +4 -21
|
@@ -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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -7,70 +7,71 @@ import type { Message } from "grammy/types";
|
|
|
7
7
|
|
|
8
8
|
// Status callback for streaming updates
|
|
9
9
|
export type StatusCallback = (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
type: "thinking" | "tool" | "text" | "segment_end" | "done",
|
|
11
|
+
content: string,
|
|
12
|
+
segmentId?: number,
|
|
13
13
|
) => Promise<void>;
|
|
14
14
|
|
|
15
15
|
// Rate limit bucket for token bucket algorithm
|
|
16
16
|
export interface RateLimitBucket {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
tokens: number;
|
|
18
|
+
lastUpdate: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// Session persistence data
|
|
22
22
|
export interface SessionData {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
version: number;
|
|
24
|
+
session_id: string;
|
|
25
|
+
saved_at: string;
|
|
26
|
+
working_dir: string;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
// Token usage from Claude
|
|
29
30
|
export interface TokenUsage {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
input_tokens: number;
|
|
32
|
+
output_tokens: number;
|
|
33
|
+
cache_read_input_tokens?: number;
|
|
34
|
+
cache_creation_input_tokens?: number;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
// MCP server configuration types
|
|
37
38
|
export type McpServerConfig = McpStdioConfig | McpHttpConfig;
|
|
38
39
|
|
|
39
40
|
export interface McpStdioConfig {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
command: string;
|
|
42
|
+
args?: string[];
|
|
43
|
+
env?: Record<string, string>;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export interface McpHttpConfig {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
type: "http";
|
|
48
|
+
url: string;
|
|
49
|
+
headers?: Record<string, string>;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
// Audit log event types
|
|
52
53
|
export type AuditEventType =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
| "message"
|
|
55
|
+
| "auth"
|
|
56
|
+
| "tool_use"
|
|
57
|
+
| "error"
|
|
58
|
+
| "rate_limit";
|
|
58
59
|
|
|
59
60
|
export interface AuditEvent {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
timestamp: string;
|
|
62
|
+
event: AuditEventType;
|
|
63
|
+
user_id: number;
|
|
64
|
+
username?: string;
|
|
65
|
+
[key: string]: unknown;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// Pending media group for buffering albums
|
|
68
69
|
export interface PendingMediaGroup {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
items: string[];
|
|
71
|
+
ctx: Context;
|
|
72
|
+
caption?: string;
|
|
73
|
+
statusMsg?: Message;
|
|
74
|
+
timeout: Timer;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
// Bot context with optional message
|
package/src/utils.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
TRANSCRIPTION_AVAILABLE,
|
|
15
15
|
TRANSCRIPTION_PROMPT,
|
|
16
16
|
} from "./config";
|
|
17
|
+
import { botEvents } from "./events";
|
|
17
18
|
import type { AuditEvent } from "./types";
|
|
18
19
|
|
|
19
20
|
// ============== OpenAI Client ==============
|
|
@@ -211,35 +212,17 @@ export function startTypingIndicator(ctx: Context): TypingController {
|
|
|
211
212
|
|
|
212
213
|
// ============== Message Interrupt ==============
|
|
213
214
|
|
|
214
|
-
// Import session lazily to avoid circular dependency
|
|
215
|
-
let sessionModule: {
|
|
216
|
-
session: {
|
|
217
|
-
isRunning: boolean;
|
|
218
|
-
stop: () => Promise<"stopped" | "pending" | false>;
|
|
219
|
-
markInterrupt: () => void;
|
|
220
|
-
clearStopRequested: () => void;
|
|
221
|
-
};
|
|
222
|
-
} | null = null;
|
|
223
|
-
|
|
224
215
|
export async function checkInterrupt(text: string): Promise<string> {
|
|
225
216
|
if (!text || !text.startsWith("!")) {
|
|
226
217
|
return text;
|
|
227
218
|
}
|
|
228
219
|
|
|
229
|
-
// Lazy import to avoid circular dependency
|
|
230
|
-
if (!sessionModule) {
|
|
231
|
-
sessionModule = await import("./session");
|
|
232
|
-
}
|
|
233
|
-
|
|
234
220
|
const strippedText = text.slice(1).trimStart();
|
|
235
221
|
|
|
236
|
-
if (
|
|
237
|
-
console.log("! prefix -
|
|
238
|
-
|
|
239
|
-
await sessionModule.session.stop();
|
|
222
|
+
if (botEvents.getSessionState()) {
|
|
223
|
+
console.log("! prefix - requesting interrupt");
|
|
224
|
+
botEvents.emit("interruptRequested", undefined);
|
|
240
225
|
await Bun.sleep(100);
|
|
241
|
-
// Clear stopRequested so the new message can proceed
|
|
242
|
-
sessionModule.session.clearStopRequested();
|
|
243
226
|
}
|
|
244
227
|
|
|
245
228
|
return strippedText;
|