ctb 1.1.0 → 1.2.1
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/README.md +42 -11
- package/package.json +1 -1
- package/src/__tests__/cli.test.ts +12 -0
- package/src/__tests__/session.test.ts +399 -0
- package/src/bot.ts +27 -3
- package/src/cli.ts +94 -0
- package/src/handlers/commands.ts +383 -50
- package/src/handlers/index.ts +8 -1
- package/src/index.ts +19 -2
- package/src/session.ts +140 -6
package/src/cli.ts
CHANGED
|
@@ -23,6 +23,7 @@ interface CliOptions {
|
|
|
23
23
|
dir?: string;
|
|
24
24
|
help?: boolean;
|
|
25
25
|
version?: boolean;
|
|
26
|
+
tut?: boolean;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
function parseArgs(args: string[]): CliOptions {
|
|
@@ -33,6 +34,8 @@ function parseArgs(args: string[]): CliOptions {
|
|
|
33
34
|
options.help = true;
|
|
34
35
|
} else if (arg === "--version" || arg === "-v") {
|
|
35
36
|
options.version = true;
|
|
37
|
+
} else if (arg === "tut" || arg === "tutorial") {
|
|
38
|
+
options.tut = true;
|
|
36
39
|
} else if (arg.startsWith("--token=")) {
|
|
37
40
|
options.token = arg.slice(8);
|
|
38
41
|
} else if (arg.startsWith("--users=")) {
|
|
@@ -53,6 +56,7 @@ Run a Telegram bot that controls Claude Code in your project directory.
|
|
|
53
56
|
|
|
54
57
|
USAGE:
|
|
55
58
|
ctb [options]
|
|
59
|
+
ctb tut Show setup tutorial
|
|
56
60
|
|
|
57
61
|
OPTIONS:
|
|
58
62
|
--help, -h Show this help message
|
|
@@ -75,6 +79,91 @@ Multiple instances can run simultaneously in different directories.
|
|
|
75
79
|
`);
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
function showTutorial(): void {
|
|
83
|
+
console.log(`
|
|
84
|
+
╔══════════════════════════════════════════════════════════════════╗
|
|
85
|
+
║ CTB Setup Tutorial ║
|
|
86
|
+
╚══════════════════════════════════════════════════════════════════╝
|
|
87
|
+
|
|
88
|
+
Follow these steps to set up your Claude Telegram Bot:
|
|
89
|
+
|
|
90
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
91
|
+
STEP 1: Create a Telegram Bot
|
|
92
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
93
|
+
|
|
94
|
+
1. Open Telegram and search for @BotFather
|
|
95
|
+
2. Send /newbot
|
|
96
|
+
3. Follow the prompts:
|
|
97
|
+
- Choose a name (e.g., "My Claude Bot")
|
|
98
|
+
- Choose a username (must end in "bot", e.g., "my_claude_bot")
|
|
99
|
+
4. Copy the token that looks like:
|
|
100
|
+
1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
|
101
|
+
|
|
102
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
103
|
+
STEP 2: Get Your Telegram User ID
|
|
104
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
105
|
+
|
|
106
|
+
1. Open Telegram and search for @userinfobot
|
|
107
|
+
2. Send any message to it
|
|
108
|
+
3. It will reply with your user ID (a number like 123456789)
|
|
109
|
+
4. Copy this number
|
|
110
|
+
|
|
111
|
+
Tip: Add multiple user IDs separated by commas for team access
|
|
112
|
+
|
|
113
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
114
|
+
STEP 3: Configure the Bot
|
|
115
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
116
|
+
|
|
117
|
+
Option A: Interactive setup (easiest)
|
|
118
|
+
Just run: ctb
|
|
119
|
+
It will prompt you for the token and user IDs.
|
|
120
|
+
|
|
121
|
+
Option B: Create a .env file
|
|
122
|
+
Create a file named .env in your project directory:
|
|
123
|
+
|
|
124
|
+
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
|
125
|
+
TELEGRAM_ALLOWED_USERS=123456789,987654321
|
|
126
|
+
|
|
127
|
+
Option C: Use command-line arguments
|
|
128
|
+
ctb --token=YOUR_TOKEN --users=YOUR_USER_ID
|
|
129
|
+
|
|
130
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
131
|
+
STEP 4: Set Up Bot Commands (Optional)
|
|
132
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
133
|
+
|
|
134
|
+
1. Go back to @BotFather
|
|
135
|
+
2. Send /setcommands
|
|
136
|
+
3. Select your bot
|
|
137
|
+
4. Paste this command list:
|
|
138
|
+
|
|
139
|
+
start - Show status and user ID
|
|
140
|
+
new - Start a fresh session
|
|
141
|
+
resume - Resume last session
|
|
142
|
+
stop - Interrupt current query
|
|
143
|
+
status - Check what Claude is doing
|
|
144
|
+
undo - Revert file changes
|
|
145
|
+
cd - Change working directory
|
|
146
|
+
file - Download a file
|
|
147
|
+
bookmarks - Manage directory bookmarks
|
|
148
|
+
retry - Retry last message
|
|
149
|
+
restart - Restart the bot
|
|
150
|
+
|
|
151
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
152
|
+
STEP 5: Start the Bot
|
|
153
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
154
|
+
|
|
155
|
+
cd ~/your-project
|
|
156
|
+
ctb
|
|
157
|
+
|
|
158
|
+
The bot will start and show "Bot started: @your_bot_username"
|
|
159
|
+
Open Telegram and message your bot to start using Claude!
|
|
160
|
+
|
|
161
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
162
|
+
Need help? https://github.com/htlin/claude-telegram-bot
|
|
163
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
164
|
+
`);
|
|
165
|
+
}
|
|
166
|
+
|
|
78
167
|
async function prompt(question: string): Promise<string> {
|
|
79
168
|
const rl = createInterface({
|
|
80
169
|
input: process.stdin,
|
|
@@ -226,6 +315,11 @@ async function main(): Promise<void> {
|
|
|
226
315
|
process.exit(0);
|
|
227
316
|
}
|
|
228
317
|
|
|
318
|
+
if (options.tut) {
|
|
319
|
+
showTutorial();
|
|
320
|
+
process.exit(0);
|
|
321
|
+
}
|
|
322
|
+
|
|
229
323
|
// Determine working directory
|
|
230
324
|
const workingDir = options.dir ? resolve(options.dir) : process.cwd();
|
|
231
325
|
|
package/src/handlers/commands.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { isBookmarked, loadBookmarks, resolvePath } from "../bookmarks";
|
|
|
11
11
|
import { ALLOWED_USERS, RESTART_FILE } from "../config";
|
|
12
12
|
import { isAuthorized, isPathAllowed } from "../security";
|
|
13
13
|
import { session } from "../session";
|
|
14
|
+
import { startTypingIndicator } from "../utils";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* /start - Show welcome message and status.
|
|
@@ -37,7 +38,7 @@ export async function handleStart(ctx: Context): Promise<void> {
|
|
|
37
38
|
`/resume - Resume last session\n` +
|
|
38
39
|
`/retry - Retry last message\n` +
|
|
39
40
|
`/cd - Change working directory\n` +
|
|
40
|
-
`/
|
|
41
|
+
`/file - Download a file\n` +
|
|
41
42
|
`/bookmarks - Manage directory bookmarks\n` +
|
|
42
43
|
`/restart - Restart the bot\n\n` +
|
|
43
44
|
`<b>Tips:</b>\n` +
|
|
@@ -71,7 +72,24 @@ export async function handleNew(ctx: Context): Promise<void> {
|
|
|
71
72
|
// Clear session
|
|
72
73
|
await session.kill();
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
// Get context info
|
|
76
|
+
const username = process.env.USER || process.env.USERNAME || "unknown";
|
|
77
|
+
const workDir = session.workingDir;
|
|
78
|
+
const now = new Date().toLocaleString("en-US", {
|
|
79
|
+
weekday: "short",
|
|
80
|
+
month: "short",
|
|
81
|
+
day: "numeric",
|
|
82
|
+
hour: "2-digit",
|
|
83
|
+
minute: "2-digit",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await ctx.reply(
|
|
87
|
+
`🆕 Session cleared. Next message starts fresh.\n\n` +
|
|
88
|
+
`👤 ${username}\n` +
|
|
89
|
+
`📁 <code>${workDir}</code>\n` +
|
|
90
|
+
`🕐 ${now}`,
|
|
91
|
+
{ parse_mode: "HTML" },
|
|
92
|
+
);
|
|
75
93
|
}
|
|
76
94
|
|
|
77
95
|
/**
|
|
@@ -275,6 +293,267 @@ export async function handleRetry(ctx: Context): Promise<void> {
|
|
|
275
293
|
await handleText(fakeCtx);
|
|
276
294
|
}
|
|
277
295
|
|
|
296
|
+
/**
|
|
297
|
+
* /skill - Invoke a Claude Code skill.
|
|
298
|
+
*/
|
|
299
|
+
export async function handleSkill(ctx: Context): Promise<void> {
|
|
300
|
+
const userId = ctx.from?.id;
|
|
301
|
+
|
|
302
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
303
|
+
await ctx.reply("Unauthorized.");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Get the skill name and args from command
|
|
308
|
+
const text = ctx.message?.text || "";
|
|
309
|
+
const match = text.match(/^\/skill\s+(\S+)(?:\s+(.*))?$/);
|
|
310
|
+
|
|
311
|
+
if (!match) {
|
|
312
|
+
await ctx.reply(
|
|
313
|
+
`🎯 <b>Invoke Skill</b>\n\n` +
|
|
314
|
+
`Usage: <code>/skill <name> [args]</code>\n\n` +
|
|
315
|
+
`Examples:\n` +
|
|
316
|
+
`• <code>/skill commit</code>\n` +
|
|
317
|
+
`• <code>/skill review-pr 123</code>\n` +
|
|
318
|
+
`• <code>/skill map</code>`,
|
|
319
|
+
{ parse_mode: "HTML" },
|
|
320
|
+
);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const skillName = match[1] ?? "";
|
|
325
|
+
const skillArgs = match[2] || "";
|
|
326
|
+
|
|
327
|
+
// Build the skill command (Claude Code format: /skill_name args)
|
|
328
|
+
const skillCommand = skillArgs
|
|
329
|
+
? `/${skillName} ${skillArgs}`
|
|
330
|
+
: `/${skillName}`;
|
|
331
|
+
|
|
332
|
+
// Send to Claude via handleText
|
|
333
|
+
const { handleText } = await import("./text");
|
|
334
|
+
const fakeCtx = {
|
|
335
|
+
...ctx,
|
|
336
|
+
message: {
|
|
337
|
+
...ctx.message,
|
|
338
|
+
text: skillCommand,
|
|
339
|
+
},
|
|
340
|
+
} as Context;
|
|
341
|
+
|
|
342
|
+
await handleText(fakeCtx);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* /model - Switch between models.
|
|
347
|
+
*/
|
|
348
|
+
export async function handleModel(ctx: Context): Promise<void> {
|
|
349
|
+
const userId = ctx.from?.id;
|
|
350
|
+
|
|
351
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
352
|
+
await ctx.reply("Unauthorized.");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const text = ctx.message?.text || "";
|
|
357
|
+
const match = text.match(/^\/model\s+(\w+)$/i);
|
|
358
|
+
|
|
359
|
+
if (!match) {
|
|
360
|
+
const current = session.currentModel;
|
|
361
|
+
await ctx.reply(
|
|
362
|
+
`🤖 <b>Model Selection</b>\n\n` +
|
|
363
|
+
`Current: <b>${current}</b>\n\n` +
|
|
364
|
+
`Usage: <code>/model <name></code>\n\n` +
|
|
365
|
+
`Available:\n` +
|
|
366
|
+
`• <code>/model sonnet</code> - Fast, balanced\n` +
|
|
367
|
+
`• <code>/model opus</code> - Most capable\n` +
|
|
368
|
+
`• <code>/model haiku</code> - Fastest, cheapest`,
|
|
369
|
+
{ parse_mode: "HTML" },
|
|
370
|
+
);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const modelName = match[1]?.toLowerCase();
|
|
375
|
+
if (modelName !== "sonnet" && modelName !== "opus" && modelName !== "haiku") {
|
|
376
|
+
await ctx.reply(
|
|
377
|
+
`❌ Unknown model: ${modelName}\n\nUse: sonnet, opus, or haiku`,
|
|
378
|
+
);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
session.currentModel = modelName;
|
|
383
|
+
await ctx.reply(`🤖 Switched to <b>${modelName}</b>`, { parse_mode: "HTML" });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* /cost - Show token usage and estimated cost.
|
|
388
|
+
*/
|
|
389
|
+
export async function handleCost(ctx: Context): Promise<void> {
|
|
390
|
+
const userId = ctx.from?.id;
|
|
391
|
+
|
|
392
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
393
|
+
await ctx.reply("Unauthorized.");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const cost = session.estimateCost();
|
|
398
|
+
const formatNum = (n: number) => n.toLocaleString();
|
|
399
|
+
const formatCost = (n: number) => `$${n.toFixed(4)}`;
|
|
400
|
+
|
|
401
|
+
await ctx.reply(
|
|
402
|
+
`💰 <b>Session Usage</b>\n\n` +
|
|
403
|
+
`Model: <b>${session.currentModel}</b>\n\n` +
|
|
404
|
+
`<b>Tokens:</b>\n` +
|
|
405
|
+
`• Input: ${formatNum(session.totalInputTokens)}\n` +
|
|
406
|
+
`• Output: ${formatNum(session.totalOutputTokens)}\n` +
|
|
407
|
+
`• Cache read: ${formatNum(session.totalCacheReadTokens)}\n\n` +
|
|
408
|
+
`<b>Estimated Cost:</b>\n` +
|
|
409
|
+
`• Input: ${formatCost(cost.inputCost)}\n` +
|
|
410
|
+
`• Output: ${formatCost(cost.outputCost)}\n` +
|
|
411
|
+
`• Total: <b>${formatCost(cost.total)}</b>`,
|
|
412
|
+
{ parse_mode: "HTML" },
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* /think - Force extended thinking for next message.
|
|
418
|
+
*/
|
|
419
|
+
export async function handleThink(ctx: Context): Promise<void> {
|
|
420
|
+
const userId = ctx.from?.id;
|
|
421
|
+
|
|
422
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
423
|
+
await ctx.reply("Unauthorized.");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const text = ctx.message?.text || "";
|
|
428
|
+
const match = text.match(/^\/think\s+(\w+)$/i);
|
|
429
|
+
|
|
430
|
+
let tokens: number;
|
|
431
|
+
let label: string;
|
|
432
|
+
|
|
433
|
+
if (!match) {
|
|
434
|
+
// Default to deep thinking
|
|
435
|
+
tokens = 50000;
|
|
436
|
+
label = "deep (50K tokens)";
|
|
437
|
+
} else {
|
|
438
|
+
const level = match[1]?.toLowerCase();
|
|
439
|
+
if (level === "off" || level === "0") {
|
|
440
|
+
tokens = 0;
|
|
441
|
+
label = "off";
|
|
442
|
+
} else if (level === "normal" || level === "10k") {
|
|
443
|
+
tokens = 10000;
|
|
444
|
+
label = "normal (10K tokens)";
|
|
445
|
+
} else if (level === "deep" || level === "50k") {
|
|
446
|
+
tokens = 50000;
|
|
447
|
+
label = "deep (50K tokens)";
|
|
448
|
+
} else {
|
|
449
|
+
await ctx.reply(`❌ Unknown level: ${level}\n\nUse: off, normal, deep`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
session.forceThinking = tokens;
|
|
455
|
+
await ctx.reply(`🧠 Next message will use <b>${label}</b> thinking`, {
|
|
456
|
+
parse_mode: "HTML",
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* /plan - Toggle planning mode.
|
|
462
|
+
*/
|
|
463
|
+
export async function handlePlan(ctx: Context): Promise<void> {
|
|
464
|
+
const userId = ctx.from?.id;
|
|
465
|
+
|
|
466
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
467
|
+
await ctx.reply("Unauthorized.");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
session.planMode = !session.planMode;
|
|
472
|
+
|
|
473
|
+
if (session.planMode) {
|
|
474
|
+
await ctx.reply(
|
|
475
|
+
`📋 <b>Plan mode ON</b>\n\n` +
|
|
476
|
+
`Claude will analyze and plan without executing tools.\n` +
|
|
477
|
+
`Use <code>/plan</code> again to exit.`,
|
|
478
|
+
{ parse_mode: "HTML" },
|
|
479
|
+
);
|
|
480
|
+
} else {
|
|
481
|
+
await ctx.reply(`📋 Plan mode OFF - Normal execution resumed`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* /compact - Request context compaction (sends a hint to Claude).
|
|
487
|
+
*/
|
|
488
|
+
export async function handleCompact(ctx: Context): Promise<void> {
|
|
489
|
+
const userId = ctx.from?.id;
|
|
490
|
+
|
|
491
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
492
|
+
await ctx.reply("Unauthorized.");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (!session.isActive) {
|
|
497
|
+
await ctx.reply("❌ No active session to compact.");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Send a message that triggers Claude to compact
|
|
502
|
+
const { handleText } = await import("./text");
|
|
503
|
+
const fakeCtx = {
|
|
504
|
+
...ctx,
|
|
505
|
+
message: {
|
|
506
|
+
...ctx.message,
|
|
507
|
+
text: "/compact",
|
|
508
|
+
},
|
|
509
|
+
} as Context;
|
|
510
|
+
|
|
511
|
+
await handleText(fakeCtx);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* /undo - Revert file changes to last checkpoint.
|
|
516
|
+
*/
|
|
517
|
+
export async function handleUndo(ctx: Context): Promise<void> {
|
|
518
|
+
const userId = ctx.from?.id;
|
|
519
|
+
|
|
520
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
521
|
+
await ctx.reply("Unauthorized.");
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (!session.isActive) {
|
|
526
|
+
await ctx.reply("❌ No active session.");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!session.canUndo) {
|
|
531
|
+
await ctx.reply(
|
|
532
|
+
`❌ No checkpoints available.\n\n` +
|
|
533
|
+
`Checkpoints are created when you send messages.`,
|
|
534
|
+
);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Show progress
|
|
539
|
+
const typing = startTypingIndicator(ctx);
|
|
540
|
+
const statusMsg = await ctx.reply("⏪ Reverting files...");
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
const [success, message] = await session.undo();
|
|
544
|
+
|
|
545
|
+
// Update status message with result
|
|
546
|
+
await ctx.api.editMessageText(
|
|
547
|
+
ctx.chat!.id,
|
|
548
|
+
statusMsg.message_id,
|
|
549
|
+
success ? message : `❌ ${message}`,
|
|
550
|
+
{ parse_mode: "HTML" },
|
|
551
|
+
);
|
|
552
|
+
} finally {
|
|
553
|
+
typing.stop();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
278
557
|
/**
|
|
279
558
|
* /cd - Change working directory.
|
|
280
559
|
*/
|
|
@@ -352,81 +631,135 @@ export async function handleCd(ctx: Context): Promise<void> {
|
|
|
352
631
|
}
|
|
353
632
|
|
|
354
633
|
/**
|
|
355
|
-
*
|
|
634
|
+
* Send a single file to the user. Returns error message or null on success.
|
|
356
635
|
*/
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
await ctx.reply("Unauthorized.");
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Get the path argument from command
|
|
366
|
-
const text = ctx.message?.text || "";
|
|
367
|
-
const match = text.match(/^\/preview\s+(.+)$/);
|
|
368
|
-
|
|
369
|
-
if (!match) {
|
|
370
|
-
await ctx.reply(
|
|
371
|
-
`📎 <b>Preview/Download File</b>\n\n` +
|
|
372
|
-
`Usage: <code>/preview <filepath></code>\n\n` +
|
|
373
|
-
`Examples:\n` +
|
|
374
|
-
`• <code>/preview output.txt</code> (relative to working dir)\n` +
|
|
375
|
-
`• <code>/preview /absolute/path/file.pdf</code>\n` +
|
|
376
|
-
`• <code>/preview ~/Documents/report.csv</code>`,
|
|
377
|
-
{ parse_mode: "HTML" },
|
|
378
|
-
);
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const inputPath = (match[1] ?? "").trim();
|
|
636
|
+
async function sendFile(
|
|
637
|
+
ctx: Context,
|
|
638
|
+
filePath: string,
|
|
639
|
+
): Promise<string | null> {
|
|
383
640
|
// Resolve relative paths from current working directory
|
|
384
|
-
const resolvedPath = resolvePath(
|
|
641
|
+
const resolvedPath = resolvePath(filePath, session.workingDir);
|
|
385
642
|
|
|
386
643
|
// Validate path exists
|
|
387
644
|
if (!existsSync(resolvedPath)) {
|
|
388
|
-
|
|
389
|
-
parse_mode: "HTML",
|
|
390
|
-
});
|
|
391
|
-
return;
|
|
645
|
+
return `File not found: ${resolvedPath}`;
|
|
392
646
|
}
|
|
393
647
|
|
|
394
648
|
const stats = statSync(resolvedPath);
|
|
395
649
|
if (stats.isDirectory()) {
|
|
396
|
-
|
|
397
|
-
`❌ Cannot preview directory: <code>${resolvedPath}</code>\n\nUse <code>/cd</code> to navigate.`,
|
|
398
|
-
{ parse_mode: "HTML" },
|
|
399
|
-
);
|
|
400
|
-
return;
|
|
650
|
+
return `Cannot send directory: ${resolvedPath}`;
|
|
401
651
|
}
|
|
402
652
|
|
|
403
653
|
// Check if path is allowed
|
|
404
654
|
if (!isPathAllowed(resolvedPath)) {
|
|
405
|
-
|
|
406
|
-
`❌ Access denied: <code>${resolvedPath}</code>\n\nPath must be in allowed directories.`,
|
|
407
|
-
{ parse_mode: "HTML" },
|
|
408
|
-
);
|
|
409
|
-
return;
|
|
655
|
+
return `Access denied: ${resolvedPath}`;
|
|
410
656
|
}
|
|
411
657
|
|
|
412
658
|
// Check file size (Telegram limit is 50MB for bots)
|
|
413
659
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
414
660
|
if (stats.size > MAX_FILE_SIZE) {
|
|
415
661
|
const sizeMB = (stats.size / (1024 * 1024)).toFixed(1);
|
|
416
|
-
|
|
417
|
-
`❌ File too large: <code>${resolvedPath}</code>\n\nSize: ${sizeMB}MB (max 50MB)`,
|
|
418
|
-
{ parse_mode: "HTML" },
|
|
419
|
-
);
|
|
420
|
-
return;
|
|
662
|
+
return `File too large: ${resolvedPath} (${sizeMB}MB, max 50MB)`;
|
|
421
663
|
}
|
|
422
664
|
|
|
423
665
|
// Send the file
|
|
424
666
|
try {
|
|
425
667
|
const filename = resolvedPath.split("/").pop() || "file";
|
|
426
668
|
await ctx.replyWithDocument(new InputFile(resolvedPath, filename));
|
|
669
|
+
return null;
|
|
427
670
|
} catch (error) {
|
|
428
671
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
429
|
-
|
|
672
|
+
return `Failed to send: ${errMsg}`;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* /file - Send a file to the user.
|
|
678
|
+
* Without arguments: auto-detect file paths from last bot response.
|
|
679
|
+
*/
|
|
680
|
+
export async function handleFile(ctx: Context): Promise<void> {
|
|
681
|
+
const userId = ctx.from?.id;
|
|
682
|
+
|
|
683
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
684
|
+
await ctx.reply("Unauthorized.");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Get the path argument from command
|
|
689
|
+
const text = ctx.message?.text || "";
|
|
690
|
+
const match = text.match(/^\/file\s+(.+)$/);
|
|
691
|
+
|
|
692
|
+
// If no argument, try to auto-detect from last bot response
|
|
693
|
+
if (!match) {
|
|
694
|
+
if (!session.lastBotResponse) {
|
|
695
|
+
await ctx.reply(
|
|
696
|
+
`📎 <b>Download File</b>\n\n` +
|
|
697
|
+
`Usage: <code>/file <filepath></code>\n` +
|
|
698
|
+
`Or just <code>/file</code> to send files from the last response.\n\n` +
|
|
699
|
+
`No recent response to extract files from.`,
|
|
700
|
+
{ parse_mode: "HTML" },
|
|
701
|
+
);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Extract paths from <code> tags (response is HTML)
|
|
706
|
+
const codeMatches = session.lastBotResponse.matchAll(
|
|
707
|
+
/<code>([^<]+)<\/code>/g,
|
|
708
|
+
);
|
|
709
|
+
const candidates: string[] = [];
|
|
710
|
+
for (const m of codeMatches) {
|
|
711
|
+
const content = m[1]?.trim();
|
|
712
|
+
// Must have file extension (contains . followed by letters)
|
|
713
|
+
if (content && /\.[a-zA-Z0-9]+$/.test(content)) {
|
|
714
|
+
candidates.push(content);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Deduplicate
|
|
719
|
+
const detected = [...new Set(candidates)];
|
|
720
|
+
|
|
721
|
+
if (detected.length === 0) {
|
|
722
|
+
await ctx.reply(
|
|
723
|
+
`📎 No file paths found in <code><code></code> tags.\n\n` +
|
|
724
|
+
`Usage: <code>/file <filepath></code>`,
|
|
725
|
+
{ parse_mode: "HTML" },
|
|
726
|
+
);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Send each detected file
|
|
731
|
+
const errors: string[] = [];
|
|
732
|
+
let sent = 0;
|
|
733
|
+
for (const filePath of detected) {
|
|
734
|
+
const error = await sendFile(ctx, filePath);
|
|
735
|
+
if (error) {
|
|
736
|
+
errors.push(`${filePath}: ${error}`);
|
|
737
|
+
} else {
|
|
738
|
+
sent++;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Report any errors
|
|
743
|
+
if (errors.length > 0) {
|
|
744
|
+
await ctx.reply(`⚠️ Some files failed:\n${errors.join("\n")}`, {
|
|
745
|
+
parse_mode: "HTML",
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (sent === 0 && errors.length > 0) {
|
|
750
|
+
// All failed, already reported above
|
|
751
|
+
} else if (sent > 0) {
|
|
752
|
+
// Success message optional, files speak for themselves
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Explicit path provided
|
|
759
|
+
const inputPath = (match[1] ?? "").trim();
|
|
760
|
+
const error = await sendFile(ctx, inputPath);
|
|
761
|
+
if (error) {
|
|
762
|
+
await ctx.reply(`❌ ${error}`, { parse_mode: "HTML" });
|
|
430
763
|
}
|
|
431
764
|
}
|
|
432
765
|
|
package/src/handlers/index.ts
CHANGED
|
@@ -6,14 +6,21 @@ export { handleCallback } from "./callback";
|
|
|
6
6
|
export {
|
|
7
7
|
handleBookmarks,
|
|
8
8
|
handleCd,
|
|
9
|
+
handleCompact,
|
|
10
|
+
handleCost,
|
|
11
|
+
handleFile,
|
|
12
|
+
handleModel,
|
|
9
13
|
handleNew,
|
|
10
|
-
|
|
14
|
+
handlePlan,
|
|
11
15
|
handleRestart,
|
|
12
16
|
handleResume,
|
|
13
17
|
handleRetry,
|
|
18
|
+
handleSkill,
|
|
14
19
|
handleStart,
|
|
15
20
|
handleStatus,
|
|
16
21
|
handleStop,
|
|
22
|
+
handleThink,
|
|
23
|
+
handleUndo,
|
|
17
24
|
} from "./commands";
|
|
18
25
|
export { handleDocument } from "./document";
|
|
19
26
|
export { handlePhoto } from "./photo";
|
package/src/index.ts
CHANGED
|
@@ -17,17 +17,24 @@ import {
|
|
|
17
17
|
handleBookmarks,
|
|
18
18
|
handleCallback,
|
|
19
19
|
handleCd,
|
|
20
|
+
handleCompact,
|
|
21
|
+
handleCost,
|
|
20
22
|
handleDocument,
|
|
23
|
+
handleFile,
|
|
24
|
+
handleModel,
|
|
21
25
|
handleNew,
|
|
22
26
|
handlePhoto,
|
|
23
|
-
|
|
27
|
+
handlePlan,
|
|
24
28
|
handleRestart,
|
|
25
29
|
handleResume,
|
|
26
30
|
handleRetry,
|
|
31
|
+
handleSkill,
|
|
27
32
|
handleStart,
|
|
28
33
|
handleStatus,
|
|
29
34
|
handleStop,
|
|
30
35
|
handleText,
|
|
36
|
+
handleThink,
|
|
37
|
+
handleUndo,
|
|
31
38
|
handleVoice,
|
|
32
39
|
} from "./handlers";
|
|
33
40
|
|
|
@@ -60,12 +67,22 @@ bot.use(
|
|
|
60
67
|
bot.command("start", handleStart);
|
|
61
68
|
bot.command("new", handleNew);
|
|
62
69
|
bot.command("stop", handleStop);
|
|
70
|
+
bot.command("c", handleStop);
|
|
71
|
+
bot.command("kill", handleStop);
|
|
72
|
+
bot.command("dc", handleStop);
|
|
63
73
|
bot.command("status", handleStatus);
|
|
64
74
|
bot.command("resume", handleResume);
|
|
65
75
|
bot.command("restart", handleRestart);
|
|
66
76
|
bot.command("retry", handleRetry);
|
|
67
77
|
bot.command("cd", handleCd);
|
|
68
|
-
bot.command("
|
|
78
|
+
bot.command("skill", handleSkill);
|
|
79
|
+
bot.command("file", handleFile);
|
|
80
|
+
bot.command("model", handleModel);
|
|
81
|
+
bot.command("cost", handleCost);
|
|
82
|
+
bot.command("think", handleThink);
|
|
83
|
+
bot.command("plan", handlePlan);
|
|
84
|
+
bot.command("compact", handleCompact);
|
|
85
|
+
bot.command("undo", handleUndo);
|
|
69
86
|
bot.command("bookmarks", handleBookmarks);
|
|
70
87
|
|
|
71
88
|
// ============== Message Handlers ==============
|