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.
@@ -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
- type: "thinking" | "tool" | "text" | "segment_end" | "done",
11
- content: string,
12
- segmentId?: number
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
- tokens: number;
18
- lastUpdate: number;
17
+ tokens: number;
18
+ lastUpdate: number;
19
19
  }
20
20
 
21
21
  // Session persistence data
22
22
  export interface SessionData {
23
- session_id: string;
24
- saved_at: string;
25
- working_dir: string;
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
- input_tokens: number;
31
- output_tokens: number;
32
- cache_read_input_tokens?: number;
33
- cache_creation_input_tokens?: number;
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
- command: string;
41
- args?: string[];
42
- env?: Record<string, string>;
41
+ command: string;
42
+ args?: string[];
43
+ env?: Record<string, string>;
43
44
  }
44
45
 
45
46
  export interface McpHttpConfig {
46
- type: "http";
47
- url: string;
48
- headers?: Record<string, string>;
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
- | "message"
54
- | "auth"
55
- | "tool_use"
56
- | "error"
57
- | "rate_limit";
54
+ | "message"
55
+ | "auth"
56
+ | "tool_use"
57
+ | "error"
58
+ | "rate_limit";
58
59
 
59
60
  export interface AuditEvent {
60
- timestamp: string;
61
- event: AuditEventType;
62
- user_id: number;
63
- username?: string;
64
- [key: string]: unknown;
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
- items: string[];
70
- ctx: Context;
71
- caption?: string;
72
- statusMsg?: Message;
73
- timeout: Timer;
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 (sessionModule.session.isRunning) {
237
- console.log("! prefix - interrupting current query");
238
- sessionModule.session.markInterrupt();
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;