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
|
@@ -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
|
+
}
|