ctb 1.0.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/src/session.ts ADDED
@@ -0,0 +1,565 @@
1
+ /**
2
+ * Session management for Claude Telegram Bot.
3
+ *
4
+ * ClaudeSession class manages Claude Code sessions using the Agent SDK V1.
5
+ * V1 supports full options (cwd, mcpServers, settingSources, etc.)
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import {
10
+ type Options,
11
+ query,
12
+ type SDKMessage,
13
+ } from "@anthropic-ai/claude-agent-sdk";
14
+ import type { Context } from "grammy";
15
+ import {
16
+ ALLOWED_PATHS,
17
+ MCP_SERVERS,
18
+ QUERY_TIMEOUT_MS,
19
+ SAFETY_PROMPT,
20
+ SESSION_FILE,
21
+ STREAMING_THROTTLE_MS,
22
+ TEMP_PATHS,
23
+ THINKING_DEEP_KEYWORDS,
24
+ THINKING_KEYWORDS,
25
+ WORKING_DIR,
26
+ } from "./config";
27
+ import { formatToolStatus } from "./formatting";
28
+ import { checkPendingAskUserRequests } from "./handlers/streaming";
29
+ import { checkCommandSafety, isPathAllowed } from "./security";
30
+ import type { SessionData, StatusCallback, TokenUsage } from "./types";
31
+
32
+ /**
33
+ * Determine thinking token budget based on message keywords.
34
+ */
35
+ function getThinkingLevel(message: string): number {
36
+ const msgLower = message.toLowerCase();
37
+
38
+ // Check deep thinking triggers first (more specific)
39
+ if (THINKING_DEEP_KEYWORDS.some((k) => msgLower.includes(k))) {
40
+ return 50000;
41
+ }
42
+
43
+ // Check normal thinking triggers
44
+ if (THINKING_KEYWORDS.some((k) => msgLower.includes(k))) {
45
+ return 10000;
46
+ }
47
+
48
+ // Default: no thinking
49
+ return 0;
50
+ }
51
+
52
+ /**
53
+ * Extract text content from SDK message.
54
+ * Note: Currently unused but kept for potential future use.
55
+ */
56
+ function _getTextFromMessage(msg: SDKMessage): string | null {
57
+ if (msg.type !== "assistant") return null;
58
+
59
+ const textParts: string[] = [];
60
+ for (const block of msg.message.content) {
61
+ if (block.type === "text") {
62
+ textParts.push(block.text);
63
+ }
64
+ }
65
+ return textParts.length > 0 ? textParts.join("") : null;
66
+ }
67
+
68
+ /**
69
+ * Manages Claude Code sessions using the Agent SDK V1.
70
+ */
71
+ class ClaudeSession {
72
+ sessionId: string | null = null;
73
+ lastActivity: Date | null = null;
74
+ queryStarted: Date | null = null;
75
+ currentTool: string | null = null;
76
+ lastTool: string | null = null;
77
+ lastError: string | null = null;
78
+ lastErrorTime: Date | null = null;
79
+ lastUsage: TokenUsage | null = null;
80
+ lastMessage: string | null = null;
81
+
82
+ // Mutable working directory (can be changed with /cd)
83
+ private _workingDir: string = WORKING_DIR;
84
+
85
+ private abortController: AbortController | null = null;
86
+ private isQueryRunning = false;
87
+ private stopRequested = false;
88
+ private _isProcessing = false;
89
+ private _wasInterruptedByNewMessage = false;
90
+
91
+ get workingDir(): string {
92
+ return this._workingDir;
93
+ }
94
+
95
+ /**
96
+ * Change the working directory for future sessions.
97
+ * Clears the current session since directory changed.
98
+ */
99
+ setWorkingDir(dir: string): void {
100
+ this._workingDir = dir;
101
+ // Clear session when changing directory
102
+ this.sessionId = null;
103
+ console.log(`Working directory changed to: ${dir}`);
104
+ }
105
+
106
+ get isActive(): boolean {
107
+ return this.sessionId !== null;
108
+ }
109
+
110
+ get isRunning(): boolean {
111
+ return this.isQueryRunning || this._isProcessing;
112
+ }
113
+
114
+ /**
115
+ * Check if the last stop was triggered by a new message interrupt (! prefix).
116
+ * Resets the flag when called. Also clears stopRequested so new messages can proceed.
117
+ */
118
+ consumeInterruptFlag(): boolean {
119
+ const was = this._wasInterruptedByNewMessage;
120
+ this._wasInterruptedByNewMessage = false;
121
+ if (was) {
122
+ // Clear stopRequested so the new message can proceed
123
+ this.stopRequested = false;
124
+ }
125
+ return was;
126
+ }
127
+
128
+ /**
129
+ * Mark that this stop is from a new message interrupt.
130
+ */
131
+ markInterrupt(): void {
132
+ this._wasInterruptedByNewMessage = true;
133
+ }
134
+
135
+ /**
136
+ * Clear the stopRequested flag (used after interrupt to allow new message to proceed).
137
+ */
138
+ clearStopRequested(): void {
139
+ this.stopRequested = false;
140
+ }
141
+
142
+ /**
143
+ * Mark processing as started.
144
+ * Returns a cleanup function to call when done.
145
+ */
146
+ startProcessing(): () => void {
147
+ this._isProcessing = true;
148
+ return () => {
149
+ this._isProcessing = false;
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Stop the currently running query or mark for cancellation.
155
+ * Returns: "stopped" if query was aborted, "pending" if processing will be cancelled, false if nothing running
156
+ */
157
+ async stop(): Promise<"stopped" | "pending" | false> {
158
+ // If a query is actively running, abort it
159
+ if (this.isQueryRunning && this.abortController) {
160
+ this.stopRequested = true;
161
+ this.abortController.abort();
162
+ console.log("Stop requested - aborting current query");
163
+ return "stopped";
164
+ }
165
+
166
+ // If processing but query not started yet
167
+ if (this._isProcessing) {
168
+ this.stopRequested = true;
169
+ console.log("Stop requested - will cancel before query starts");
170
+ return "pending";
171
+ }
172
+
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * Send a message to Claude with streaming updates via callback.
178
+ *
179
+ * @param ctx - grammY context for ask_user button display
180
+ */
181
+ async sendMessageStreaming(
182
+ message: string,
183
+ username: string,
184
+ userId: number,
185
+ statusCallback: StatusCallback,
186
+ chatId?: number,
187
+ ctx?: Context,
188
+ ): Promise<string> {
189
+ // Set chat context for ask_user MCP tool
190
+ if (chatId) {
191
+ process.env.TELEGRAM_CHAT_ID = String(chatId);
192
+ }
193
+
194
+ const isNewSession = !this.isActive;
195
+ const thinkingTokens = getThinkingLevel(message);
196
+ const thinkingLabel =
197
+ { 0: "off", 10000: "normal", 50000: "deep" }[thinkingTokens] ||
198
+ String(thinkingTokens);
199
+
200
+ // Inject current date/time at session start so Claude doesn't need to call a tool for it
201
+ let messageToSend = message;
202
+ if (isNewSession) {
203
+ const now = new Date();
204
+ const datePrefix = `[Current date/time: ${now.toLocaleDateString(
205
+ "en-US",
206
+ {
207
+ weekday: "long",
208
+ year: "numeric",
209
+ month: "long",
210
+ day: "numeric",
211
+ hour: "2-digit",
212
+ minute: "2-digit",
213
+ timeZoneName: "short",
214
+ },
215
+ )}]\n\n`;
216
+ messageToSend = datePrefix + message;
217
+ }
218
+
219
+ // Build SDK V1 options - supports all features
220
+ const options: Options = {
221
+ model: "claude-sonnet-4-5",
222
+ cwd: this._workingDir,
223
+ settingSources: ["user", "project"],
224
+ permissionMode: "bypassPermissions",
225
+ allowDangerouslySkipPermissions: true,
226
+ systemPrompt: SAFETY_PROMPT,
227
+ mcpServers: MCP_SERVERS,
228
+ maxThinkingTokens: thinkingTokens,
229
+ additionalDirectories: ALLOWED_PATHS,
230
+ resume: this.sessionId || undefined,
231
+ };
232
+
233
+ // Add Claude Code executable path if set (required for standalone builds)
234
+ if (process.env.CLAUDE_CODE_PATH) {
235
+ options.pathToClaudeCodeExecutable = process.env.CLAUDE_CODE_PATH;
236
+ }
237
+
238
+ if (this.sessionId && !isNewSession) {
239
+ console.log(
240
+ `RESUMING session ${this.sessionId.slice(
241
+ 0,
242
+ 8,
243
+ )}... (thinking=${thinkingLabel})`,
244
+ );
245
+ } else {
246
+ console.log(`STARTING new Claude session (thinking=${thinkingLabel})`);
247
+ this.sessionId = null;
248
+ }
249
+
250
+ // Check if stop was requested during processing phase
251
+ if (this.stopRequested) {
252
+ console.log(
253
+ "Query cancelled before starting (stop was requested during processing)",
254
+ );
255
+ this.stopRequested = false;
256
+ throw new Error("Query cancelled");
257
+ }
258
+
259
+ // Create abort controller for cancellation
260
+ this.abortController = new AbortController();
261
+ this.isQueryRunning = true;
262
+ this.stopRequested = false;
263
+ this.queryStarted = new Date();
264
+ this.currentTool = null;
265
+
266
+ // Response tracking
267
+ const responseParts: string[] = [];
268
+ let currentSegmentId = 0;
269
+ let currentSegmentText = "";
270
+ let lastTextUpdate = 0;
271
+ let queryCompleted = false;
272
+ let askUserTriggered = false;
273
+
274
+ try {
275
+ // Use V1 query() API - supports all options including cwd, mcpServers, etc.
276
+ const queryInstance = query({
277
+ prompt: messageToSend,
278
+ options: {
279
+ ...options,
280
+ abortController: this.abortController,
281
+ },
282
+ });
283
+
284
+ // Process streaming response
285
+ for await (const event of queryInstance) {
286
+ // Check for abort
287
+ if (this.stopRequested) {
288
+ console.log("Query aborted by user");
289
+ break;
290
+ }
291
+
292
+ // Check for timeout
293
+ const elapsed = this.queryStarted
294
+ ? Date.now() - this.queryStarted.getTime()
295
+ : 0;
296
+ if (elapsed > QUERY_TIMEOUT_MS) {
297
+ console.warn(`Query timeout after ${elapsed}ms`);
298
+ this.abortController?.abort();
299
+ throw new Error(
300
+ `Query timeout (${Math.round(elapsed / 1000)}s > ${Math.round(QUERY_TIMEOUT_MS / 1000)}s limit)`,
301
+ );
302
+ }
303
+
304
+ // Capture session_id from first message
305
+ if (!this.sessionId && event.session_id) {
306
+ this.sessionId = event.session_id;
307
+ console.log(`GOT session_id: ${this.sessionId.slice(0, 8)}...`);
308
+ this.saveSession();
309
+ }
310
+
311
+ // Handle different message types
312
+ if (event.type === "assistant") {
313
+ for (const block of event.message.content) {
314
+ // Thinking blocks
315
+ if (block.type === "thinking") {
316
+ const thinkingText = block.thinking;
317
+ if (thinkingText) {
318
+ console.log(`THINKING BLOCK: ${thinkingText.slice(0, 100)}...`);
319
+ await statusCallback("thinking", thinkingText);
320
+ }
321
+ }
322
+
323
+ // Tool use blocks
324
+ if (block.type === "tool_use") {
325
+ const toolName = block.name;
326
+ const toolInput = block.input as Record<string, unknown>;
327
+
328
+ // Safety check for Bash commands
329
+ if (toolName === "Bash") {
330
+ const command = String(toolInput.command || "");
331
+ const [isSafe, reason] = checkCommandSafety(command);
332
+ if (!isSafe) {
333
+ console.warn(`BLOCKED: ${reason}`);
334
+ await statusCallback("tool", `BLOCKED: ${reason}`);
335
+ throw new Error(`Unsafe command blocked: ${reason}`);
336
+ }
337
+ }
338
+
339
+ // Safety check for file operations
340
+ if (["Read", "Write", "Edit"].includes(toolName)) {
341
+ const filePath = String(toolInput.file_path || "");
342
+ if (filePath) {
343
+ // Allow reads from temp paths and .claude directories
344
+ const isTmpRead =
345
+ toolName === "Read" &&
346
+ (TEMP_PATHS.some((p) => filePath.startsWith(p)) ||
347
+ filePath.includes("/.claude/"));
348
+
349
+ if (!isTmpRead && !isPathAllowed(filePath)) {
350
+ console.warn(
351
+ `BLOCKED: File access outside allowed paths: ${filePath}`,
352
+ );
353
+ await statusCallback("tool", `Access denied: ${filePath}`);
354
+ throw new Error(`File access blocked: ${filePath}`);
355
+ }
356
+ }
357
+ }
358
+
359
+ // Segment ends when tool starts
360
+ if (currentSegmentText) {
361
+ await statusCallback(
362
+ "segment_end",
363
+ currentSegmentText,
364
+ currentSegmentId,
365
+ );
366
+ currentSegmentId++;
367
+ currentSegmentText = "";
368
+ }
369
+
370
+ // Format and show tool status
371
+ const toolDisplay = formatToolStatus(toolName, toolInput);
372
+ this.currentTool = toolDisplay;
373
+ this.lastTool = toolDisplay;
374
+ console.log(`Tool: ${toolDisplay}`);
375
+
376
+ // Don't show tool status for ask_user - the buttons are self-explanatory
377
+ if (!toolName.startsWith("mcp__ask-user")) {
378
+ await statusCallback("tool", toolDisplay);
379
+ }
380
+
381
+ // Check for pending ask_user requests after ask-user MCP tool
382
+ if (toolName.startsWith("mcp__ask-user") && ctx && chatId) {
383
+ // Small delay to let MCP server write the file
384
+ await new Promise((resolve) => setTimeout(resolve, 200));
385
+
386
+ // Retry a few times in case of timing issues
387
+ for (let attempt = 0; attempt < 3; attempt++) {
388
+ const buttonsSent = await checkPendingAskUserRequests(
389
+ ctx,
390
+ chatId,
391
+ );
392
+ if (buttonsSent) {
393
+ askUserTriggered = true;
394
+ break;
395
+ }
396
+ if (attempt < 2) {
397
+ await new Promise((resolve) => setTimeout(resolve, 100));
398
+ }
399
+ }
400
+ }
401
+ }
402
+
403
+ // Text content
404
+ if (block.type === "text") {
405
+ responseParts.push(block.text);
406
+ currentSegmentText += block.text;
407
+
408
+ // Stream text updates (throttled)
409
+ const now = Date.now();
410
+ if (
411
+ now - lastTextUpdate > STREAMING_THROTTLE_MS &&
412
+ currentSegmentText.length > 20
413
+ ) {
414
+ await statusCallback(
415
+ "text",
416
+ currentSegmentText,
417
+ currentSegmentId,
418
+ );
419
+ lastTextUpdate = now;
420
+ }
421
+ }
422
+ }
423
+
424
+ // Break out of event loop if ask_user was triggered
425
+ if (askUserTriggered) {
426
+ break;
427
+ }
428
+ }
429
+
430
+ // Result message
431
+ if (event.type === "result") {
432
+ console.log("Response complete");
433
+ queryCompleted = true;
434
+
435
+ // Capture usage if available
436
+ if ("usage" in event && event.usage) {
437
+ this.lastUsage = event.usage as TokenUsage;
438
+ const u = this.lastUsage;
439
+ console.log(
440
+ `Usage: in=${u.input_tokens} out=${u.output_tokens} cache_read=${
441
+ u.cache_read_input_tokens || 0
442
+ } cache_create=${u.cache_creation_input_tokens || 0}`,
443
+ );
444
+ }
445
+ }
446
+ }
447
+
448
+ // V1 query completes automatically when the generator ends
449
+ } catch (error) {
450
+ const errorStr = String(error).toLowerCase();
451
+ const isCleanupError =
452
+ errorStr.includes("cancel") || errorStr.includes("abort");
453
+
454
+ if (
455
+ isCleanupError &&
456
+ (queryCompleted || askUserTriggered || this.stopRequested)
457
+ ) {
458
+ console.warn(`Suppressed post-completion error: ${error}`);
459
+ } else {
460
+ console.error(`Error in query: ${error}`);
461
+ this.lastError = String(error).slice(0, 100);
462
+ this.lastErrorTime = new Date();
463
+ throw error;
464
+ }
465
+ } finally {
466
+ this.isQueryRunning = false;
467
+ this.abortController = null;
468
+ this.queryStarted = null;
469
+ this.currentTool = null;
470
+ }
471
+
472
+ this.lastActivity = new Date();
473
+ this.lastError = null;
474
+ this.lastErrorTime = null;
475
+
476
+ // If ask_user was triggered, return early - user will respond via button
477
+ if (askUserTriggered) {
478
+ await statusCallback("done", "");
479
+ return "[Waiting for user selection]";
480
+ }
481
+
482
+ // Emit final segment
483
+ if (currentSegmentText) {
484
+ await statusCallback("segment_end", currentSegmentText, currentSegmentId);
485
+ }
486
+
487
+ await statusCallback("done", "");
488
+
489
+ return responseParts.join("") || "No response from Claude.";
490
+ }
491
+
492
+ /**
493
+ * Kill the current session (clear session_id).
494
+ */
495
+ async kill(): Promise<void> {
496
+ this.sessionId = null;
497
+ this.lastActivity = null;
498
+ console.log("Session cleared");
499
+ }
500
+
501
+ /**
502
+ * Save session to disk for resume after restart.
503
+ */
504
+ private saveSession(): void {
505
+ if (!this.sessionId) return;
506
+
507
+ try {
508
+ const data: SessionData = {
509
+ session_id: this.sessionId,
510
+ saved_at: new Date().toISOString(),
511
+ working_dir: this._workingDir,
512
+ };
513
+ Bun.write(SESSION_FILE, JSON.stringify(data));
514
+ console.log(`Session saved to ${SESSION_FILE}`);
515
+ } catch (error) {
516
+ console.warn(`Failed to save session: ${error}`);
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Resume the last persisted session.
522
+ */
523
+ resumeLast(): [success: boolean, message: string] {
524
+ try {
525
+ const file = Bun.file(SESSION_FILE);
526
+ if (!file.size) {
527
+ return [false, "No saved session found"];
528
+ }
529
+
530
+ const text = readFileSync(SESSION_FILE, "utf-8");
531
+ const data: SessionData = JSON.parse(text);
532
+
533
+ if (!data.session_id) {
534
+ return [false, "Saved session file is empty"];
535
+ }
536
+
537
+ if (data.working_dir && data.working_dir !== this._workingDir) {
538
+ return [
539
+ false,
540
+ `Session was for different directory: ${data.working_dir}`,
541
+ ];
542
+ }
543
+
544
+ this.sessionId = data.session_id;
545
+ this.lastActivity = new Date();
546
+ console.log(
547
+ `Resumed session ${data.session_id.slice(0, 8)}... (saved at ${
548
+ data.saved_at
549
+ })`,
550
+ );
551
+ return [
552
+ true,
553
+ `Resumed session \`${data.session_id.slice(0, 8)}...\` (saved at ${
554
+ data.saved_at
555
+ })`,
556
+ ];
557
+ } catch (error) {
558
+ console.error(`Failed to resume session: ${error}`);
559
+ return [false, `Failed to load session: ${error}`];
560
+ }
561
+ }
562
+ }
563
+
564
+ // Global session instance
565
+ export const session = new ClaudeSession();
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared TypeScript types for the Claude Telegram Bot.
3
+ */
4
+
5
+ import type { Context } from "grammy";
6
+ import type { Message } from "grammy/types";
7
+
8
+ // Status callback for streaming updates
9
+ export type StatusCallback = (
10
+ type: "thinking" | "tool" | "text" | "segment_end" | "done",
11
+ content: string,
12
+ segmentId?: number
13
+ ) => Promise<void>;
14
+
15
+ // Rate limit bucket for token bucket algorithm
16
+ export interface RateLimitBucket {
17
+ tokens: number;
18
+ lastUpdate: number;
19
+ }
20
+
21
+ // Session persistence data
22
+ export interface SessionData {
23
+ session_id: string;
24
+ saved_at: string;
25
+ working_dir: string;
26
+ }
27
+
28
+ // Token usage from Claude
29
+ export interface TokenUsage {
30
+ input_tokens: number;
31
+ output_tokens: number;
32
+ cache_read_input_tokens?: number;
33
+ cache_creation_input_tokens?: number;
34
+ }
35
+
36
+ // MCP server configuration types
37
+ export type McpServerConfig = McpStdioConfig | McpHttpConfig;
38
+
39
+ export interface McpStdioConfig {
40
+ command: string;
41
+ args?: string[];
42
+ env?: Record<string, string>;
43
+ }
44
+
45
+ export interface McpHttpConfig {
46
+ type: "http";
47
+ url: string;
48
+ headers?: Record<string, string>;
49
+ }
50
+
51
+ // Audit log event types
52
+ export type AuditEventType =
53
+ | "message"
54
+ | "auth"
55
+ | "tool_use"
56
+ | "error"
57
+ | "rate_limit";
58
+
59
+ export interface AuditEvent {
60
+ timestamp: string;
61
+ event: AuditEventType;
62
+ user_id: number;
63
+ username?: string;
64
+ [key: string]: unknown;
65
+ }
66
+
67
+ // Pending media group for buffering albums
68
+ export interface PendingMediaGroup {
69
+ items: string[];
70
+ ctx: Context;
71
+ caption?: string;
72
+ statusMsg?: Message;
73
+ timeout: Timer;
74
+ }
75
+
76
+ // Bot context with optional message
77
+ export type BotContext = Context;