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.
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Command handlers for Claude Telegram Bot.
3
+ *
4
+ * /start, /new, /stop, /status, /resume, /restart, /cd, /bookmarks
5
+ */
6
+
7
+ import { existsSync, statSync } from "node:fs";
8
+ import type { Context } from "grammy";
9
+ import { InlineKeyboard } from "grammy";
10
+ import { isBookmarked, loadBookmarks, resolvePath } from "../bookmarks";
11
+ import { ALLOWED_USERS, RESTART_FILE } from "../config";
12
+ import { isAuthorized, isPathAllowed } from "../security";
13
+ import { session } from "../session";
14
+
15
+ /**
16
+ * /start - Show welcome message and status.
17
+ */
18
+ export async function handleStart(ctx: Context): Promise<void> {
19
+ const userId = ctx.from?.id;
20
+
21
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
22
+ await ctx.reply("Unauthorized. Contact the bot owner for access.");
23
+ return;
24
+ }
25
+
26
+ const status = session.isActive ? "Active session" : "No active session";
27
+ const workDir = session.workingDir;
28
+
29
+ await ctx.reply(
30
+ `🤖 <b>Claude Telegram Bot</b>\n\n` +
31
+ `Status: ${status}\n` +
32
+ `Working directory: <code>${workDir}</code>\n\n` +
33
+ `<b>Commands:</b>\n` +
34
+ `/new - Start fresh session\n` +
35
+ `/stop - Stop current query\n` +
36
+ `/status - Show detailed status\n` +
37
+ `/resume - Resume last session\n` +
38
+ `/retry - Retry last message\n` +
39
+ `/cd - Change working directory\n` +
40
+ `/bookmarks - Manage directory bookmarks\n` +
41
+ `/restart - Restart the bot\n\n` +
42
+ `<b>Tips:</b>\n` +
43
+ `• Prefix with <code>!</code> to interrupt current query\n` +
44
+ `• Use "think" keyword for extended reasoning\n` +
45
+ `• Send photos, voice, or documents`,
46
+ { parse_mode: "HTML" },
47
+ );
48
+ }
49
+
50
+ /**
51
+ * /new - Start a fresh session.
52
+ */
53
+ export async function handleNew(ctx: Context): Promise<void> {
54
+ const userId = ctx.from?.id;
55
+
56
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
57
+ await ctx.reply("Unauthorized.");
58
+ return;
59
+ }
60
+
61
+ // Stop any running query
62
+ if (session.isRunning) {
63
+ const result = await session.stop();
64
+ if (result) {
65
+ await Bun.sleep(100);
66
+ session.clearStopRequested();
67
+ }
68
+ }
69
+
70
+ // Clear session
71
+ await session.kill();
72
+
73
+ await ctx.reply("🆕 Session cleared. Next message starts fresh.");
74
+ }
75
+
76
+ /**
77
+ * /stop - Stop the current query (silently).
78
+ */
79
+ export async function handleStop(ctx: Context): Promise<void> {
80
+ const userId = ctx.from?.id;
81
+
82
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
83
+ await ctx.reply("Unauthorized.");
84
+ return;
85
+ }
86
+
87
+ if (session.isRunning) {
88
+ const result = await session.stop();
89
+ if (result) {
90
+ // Wait for the abort to be processed, then clear stopRequested so next message can proceed
91
+ await Bun.sleep(100);
92
+ session.clearStopRequested();
93
+ }
94
+ // Silent stop - no message shown
95
+ }
96
+ // If nothing running, also stay silent
97
+ }
98
+
99
+ /**
100
+ * /status - Show detailed status.
101
+ */
102
+ export async function handleStatus(ctx: Context): Promise<void> {
103
+ const userId = ctx.from?.id;
104
+
105
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
106
+ await ctx.reply("Unauthorized.");
107
+ return;
108
+ }
109
+
110
+ const lines: string[] = ["📊 <b>Bot Status</b>\n"];
111
+
112
+ // Session status
113
+ if (session.isActive) {
114
+ lines.push(`✅ Session: Active (${session.sessionId?.slice(0, 8)}...)`);
115
+ } else {
116
+ lines.push("⚪ Session: None");
117
+ }
118
+
119
+ // Query status
120
+ if (session.isRunning) {
121
+ const elapsed = session.queryStarted
122
+ ? Math.floor((Date.now() - session.queryStarted.getTime()) / 1000)
123
+ : 0;
124
+ lines.push(`🔄 Query: Running (${elapsed}s)`);
125
+ if (session.currentTool) {
126
+ lines.push(` └─ ${session.currentTool}`);
127
+ }
128
+ } else {
129
+ lines.push("⚪ Query: Idle");
130
+ if (session.lastTool) {
131
+ lines.push(` └─ Last: ${session.lastTool}`);
132
+ }
133
+ }
134
+
135
+ // Last activity
136
+ if (session.lastActivity) {
137
+ const ago = Math.floor(
138
+ (Date.now() - session.lastActivity.getTime()) / 1000,
139
+ );
140
+ lines.push(`\n⏱️ Last activity: ${ago}s ago`);
141
+ }
142
+
143
+ // Usage stats
144
+ if (session.lastUsage) {
145
+ const usage = session.lastUsage;
146
+ lines.push(
147
+ `\n📈 Last query usage:`,
148
+ ` Input: ${usage.input_tokens?.toLocaleString() || "?"} tokens`,
149
+ ` Output: ${usage.output_tokens?.toLocaleString() || "?"} tokens`,
150
+ );
151
+ if (usage.cache_read_input_tokens) {
152
+ lines.push(
153
+ ` Cache read: ${usage.cache_read_input_tokens.toLocaleString()}`,
154
+ );
155
+ }
156
+ }
157
+
158
+ // Error status
159
+ if (session.lastError) {
160
+ const ago = session.lastErrorTime
161
+ ? Math.floor((Date.now() - session.lastErrorTime.getTime()) / 1000)
162
+ : "?";
163
+ lines.push(`\n⚠️ Last error (${ago}s ago):`, ` ${session.lastError}`);
164
+ }
165
+
166
+ // Working directory
167
+ lines.push(`\n📁 Working dir: <code>${session.workingDir}</code>`);
168
+
169
+ await ctx.reply(lines.join("\n"), { parse_mode: "HTML" });
170
+ }
171
+
172
+ /**
173
+ * /resume - Resume the last session.
174
+ */
175
+ export async function handleResume(ctx: Context): Promise<void> {
176
+ const userId = ctx.from?.id;
177
+
178
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
179
+ await ctx.reply("Unauthorized.");
180
+ return;
181
+ }
182
+
183
+ if (session.isActive) {
184
+ await ctx.reply("Session already active. Use /new to start fresh first.");
185
+ return;
186
+ }
187
+
188
+ const [success, message] = session.resumeLast();
189
+ if (success) {
190
+ await ctx.reply(`✅ ${message}`);
191
+ } else {
192
+ await ctx.reply(`❌ ${message}`);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * /restart - Restart the bot process.
198
+ */
199
+ export async function handleRestart(ctx: Context): Promise<void> {
200
+ const userId = ctx.from?.id;
201
+ const chatId = ctx.chat?.id;
202
+
203
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
204
+ await ctx.reply("Unauthorized.");
205
+ return;
206
+ }
207
+
208
+ const msg = await ctx.reply("🔄 Restarting bot...");
209
+
210
+ // Save message info so we can update it after restart
211
+ if (chatId && msg.message_id) {
212
+ try {
213
+ await Bun.write(
214
+ RESTART_FILE,
215
+ JSON.stringify({
216
+ chat_id: chatId,
217
+ message_id: msg.message_id,
218
+ timestamp: Date.now(),
219
+ }),
220
+ );
221
+ } catch (e) {
222
+ console.warn("Failed to save restart info:", e);
223
+ }
224
+ }
225
+
226
+ // Give time for the message to send
227
+ await Bun.sleep(500);
228
+
229
+ // Exit - launchd will restart us
230
+ process.exit(0);
231
+ }
232
+
233
+ /**
234
+ * /retry - Retry the last message (resume session and re-send).
235
+ */
236
+ export async function handleRetry(ctx: Context): Promise<void> {
237
+ const userId = ctx.from?.id;
238
+
239
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
240
+ await ctx.reply("Unauthorized.");
241
+ return;
242
+ }
243
+
244
+ // Check if there's a message to retry
245
+ if (!session.lastMessage) {
246
+ await ctx.reply("❌ No message to retry.");
247
+ return;
248
+ }
249
+
250
+ // Check if something is already running
251
+ if (session.isRunning) {
252
+ await ctx.reply("⏳ A query is already running. Use /stop first.");
253
+ return;
254
+ }
255
+
256
+ const message = session.lastMessage;
257
+ await ctx.reply(
258
+ `🔄 Retrying: "${message.slice(0, 50)}${message.length > 50 ? "..." : ""}"`,
259
+ );
260
+
261
+ // Simulate sending the message again by emitting a fake text message event
262
+ // We do this by directly calling the text handler logic
263
+ const { handleText } = await import("./text");
264
+
265
+ // Create a modified context with the last message
266
+ const fakeCtx = {
267
+ ...ctx,
268
+ message: {
269
+ ...ctx.message,
270
+ text: message,
271
+ },
272
+ } as Context;
273
+
274
+ await handleText(fakeCtx);
275
+ }
276
+
277
+ /**
278
+ * /cd - Change working directory.
279
+ */
280
+ export async function handleCd(ctx: Context): Promise<void> {
281
+ const userId = ctx.from?.id;
282
+
283
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
284
+ await ctx.reply("Unauthorized.");
285
+ return;
286
+ }
287
+
288
+ // Get the path argument from command
289
+ const text = ctx.message?.text || "";
290
+ const match = text.match(/^\/cd\s+(.+)$/);
291
+
292
+ if (!match) {
293
+ await ctx.reply(
294
+ `📁 Current directory: <code>${session.workingDir}</code>\n\n` +
295
+ `Usage: <code>/cd /path/to/directory</code>`,
296
+ { parse_mode: "HTML" },
297
+ );
298
+ return;
299
+ }
300
+
301
+ const inputPath = (match[1] ?? "").trim();
302
+ const resolvedPath = resolvePath(inputPath);
303
+
304
+ // Validate path exists and is a directory
305
+ if (!existsSync(resolvedPath)) {
306
+ await ctx.reply(`❌ Path does not exist: <code>${resolvedPath}</code>`, {
307
+ parse_mode: "HTML",
308
+ });
309
+ return;
310
+ }
311
+
312
+ const stats = statSync(resolvedPath);
313
+ if (!stats.isDirectory()) {
314
+ await ctx.reply(
315
+ `❌ Path is not a directory: <code>${resolvedPath}</code>`,
316
+ {
317
+ parse_mode: "HTML",
318
+ },
319
+ );
320
+ return;
321
+ }
322
+
323
+ // Check if path is allowed
324
+ if (!isPathAllowed(resolvedPath)) {
325
+ await ctx.reply(
326
+ `❌ Access denied: <code>${resolvedPath}</code>\n\nPath must be in allowed directories.`,
327
+ { parse_mode: "HTML" },
328
+ );
329
+ return;
330
+ }
331
+
332
+ // Change directory
333
+ session.setWorkingDir(resolvedPath);
334
+
335
+ // Build inline keyboard
336
+ const keyboard = new InlineKeyboard();
337
+ if (isBookmarked(resolvedPath)) {
338
+ keyboard.text("⭐ Already bookmarked", "bookmark:noop");
339
+ } else {
340
+ keyboard.text("➕ Add to bookmarks", `bookmark:add:${resolvedPath}`);
341
+ }
342
+
343
+ await ctx.reply(
344
+ `📁 Changed to: <code>${resolvedPath}</code>\n\nSession cleared. Next message starts fresh.`,
345
+ {
346
+ parse_mode: "HTML",
347
+ reply_markup: keyboard,
348
+ },
349
+ );
350
+ }
351
+
352
+ /**
353
+ * /bookmarks - List and manage bookmarks.
354
+ */
355
+ export async function handleBookmarks(ctx: Context): Promise<void> {
356
+ const userId = ctx.from?.id;
357
+
358
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
359
+ await ctx.reply("Unauthorized.");
360
+ return;
361
+ }
362
+
363
+ const bookmarks = loadBookmarks();
364
+
365
+ if (bookmarks.length === 0) {
366
+ await ctx.reply(
367
+ "📚 No bookmarks yet.\n\n" +
368
+ "Use <code>/cd /path/to/dir</code> and click 'Add to bookmarks'.",
369
+ { parse_mode: "HTML" },
370
+ );
371
+ return;
372
+ }
373
+
374
+ // Build message and keyboards
375
+ let message = "📚 <b>Bookmarks</b>\n\n";
376
+
377
+ const keyboard = new InlineKeyboard();
378
+ for (const bookmark of bookmarks) {
379
+ message += `📁 <code>${bookmark.path}</code>\n`;
380
+
381
+ // Each bookmark gets two buttons on the same row
382
+ keyboard
383
+ .text(`🆕 ${bookmark.name}`, `bookmark:new:${bookmark.path}`)
384
+ .text("🗑️", `bookmark:remove:${bookmark.path}`)
385
+ .row();
386
+ }
387
+
388
+ await ctx.reply(message, {
389
+ parse_mode: "HTML",
390
+ reply_markup: keyboard,
391
+ });
392
+ }