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/.env.example +76 -0
- package/CLAUDE.md +116 -0
- package/LICENSE +21 -0
- package/Makefile +142 -0
- package/README.md +268 -0
- package/SECURITY.md +177 -0
- package/ask_user_mcp/server.ts +115 -0
- package/assets/demo-research.gif +0 -0
- package/assets/demo-video-summary.gif +0 -0
- package/assets/demo-workout.gif +0 -0
- package/assets/demo.gif +0 -0
- package/bun.lock +266 -0
- package/bunfig.toml +2 -0
- package/docs/personal-assistant-guide.md +549 -0
- package/launchagent/com.claude-telegram-ts.plist.template +76 -0
- package/launchagent/start.sh +14 -0
- package/mcp-config.example.ts +42 -0
- package/package.json +46 -0
- package/src/__tests__/formatting.test.ts +118 -0
- package/src/__tests__/security.test.ts +124 -0
- package/src/__tests__/setup.ts +8 -0
- package/src/bookmarks.ts +106 -0
- package/src/bot.ts +151 -0
- package/src/cli.ts +278 -0
- package/src/config.ts +254 -0
- package/src/formatting.ts +309 -0
- package/src/handlers/callback.ts +248 -0
- package/src/handlers/commands.ts +392 -0
- package/src/handlers/document.ts +585 -0
- package/src/handlers/index.ts +21 -0
- package/src/handlers/media-group.ts +205 -0
- package/src/handlers/photo.ts +215 -0
- package/src/handlers/streaming.ts +231 -0
- package/src/handlers/text.ts +128 -0
- package/src/handlers/voice.ts +138 -0
- package/src/index.ts +150 -0
- package/src/security.ts +209 -0
- package/src/session.ts +565 -0
- package/src/types.ts +77 -0
- package/src/utils.ts +246 -0
- package/tsconfig.json +29 -0
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;
|