ctb 1.0.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.
@@ -6,11 +6,12 @@
6
6
 
7
7
  import { existsSync, statSync } from "node:fs";
8
8
  import type { Context } from "grammy";
9
- import { InlineKeyboard } from "grammy";
9
+ import { InlineKeyboard, InputFile } from "grammy";
10
10
  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,6 +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` +
41
+ `/file - Download a file\n` +
40
42
  `/bookmarks - Manage directory bookmarks\n` +
41
43
  `/restart - Restart the bot\n\n` +
42
44
  `<b>Tips:</b>\n` +
@@ -70,7 +72,24 @@ export async function handleNew(ctx: Context): Promise<void> {
70
72
  // Clear session
71
73
  await session.kill();
72
74
 
73
- await ctx.reply("🆕 Session cleared. Next message starts fresh.");
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
+ );
74
93
  }
75
94
 
76
95
  /**
@@ -274,6 +293,267 @@ export async function handleRetry(ctx: Context): Promise<void> {
274
293
  await handleText(fakeCtx);
275
294
  }
276
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 &lt;name&gt; [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 &lt;name&gt;</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
+
277
557
  /**
278
558
  * /cd - Change working directory.
279
559
  */
@@ -299,7 +579,8 @@ export async function handleCd(ctx: Context): Promise<void> {
299
579
  }
300
580
 
301
581
  const inputPath = (match[1] ?? "").trim();
302
- const resolvedPath = resolvePath(inputPath);
582
+ // Resolve relative paths from current working directory
583
+ const resolvedPath = resolvePath(inputPath, session.workingDir);
303
584
 
304
585
  // Validate path exists and is a directory
305
586
  if (!existsSync(resolvedPath)) {
@@ -349,6 +630,139 @@ export async function handleCd(ctx: Context): Promise<void> {
349
630
  );
350
631
  }
351
632
 
633
+ /**
634
+ * Send a single file to the user. Returns error message or null on success.
635
+ */
636
+ async function sendFile(
637
+ ctx: Context,
638
+ filePath: string,
639
+ ): Promise<string | null> {
640
+ // Resolve relative paths from current working directory
641
+ const resolvedPath = resolvePath(filePath, session.workingDir);
642
+
643
+ // Validate path exists
644
+ if (!existsSync(resolvedPath)) {
645
+ return `File not found: ${resolvedPath}`;
646
+ }
647
+
648
+ const stats = statSync(resolvedPath);
649
+ if (stats.isDirectory()) {
650
+ return `Cannot send directory: ${resolvedPath}`;
651
+ }
652
+
653
+ // Check if path is allowed
654
+ if (!isPathAllowed(resolvedPath)) {
655
+ return `Access denied: ${resolvedPath}`;
656
+ }
657
+
658
+ // Check file size (Telegram limit is 50MB for bots)
659
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
660
+ if (stats.size > MAX_FILE_SIZE) {
661
+ const sizeMB = (stats.size / (1024 * 1024)).toFixed(1);
662
+ return `File too large: ${resolvedPath} (${sizeMB}MB, max 50MB)`;
663
+ }
664
+
665
+ // Send the file
666
+ try {
667
+ const filename = resolvedPath.split("/").pop() || "file";
668
+ await ctx.replyWithDocument(new InputFile(resolvedPath, filename));
669
+ return null;
670
+ } catch (error) {
671
+ const errMsg = error instanceof Error ? error.message : String(error);
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 &lt;filepath&gt;</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>&lt;code&gt;</code> tags.\n\n` +
724
+ `Usage: <code>/file &lt;filepath&gt;</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" });
763
+ }
764
+ }
765
+
352
766
  /**
353
767
  * /bookmarks - List and manage bookmarks.
354
768
  */
@@ -6,13 +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,
14
+ handlePlan,
10
15
  handleRestart,
11
16
  handleResume,
12
17
  handleRetry,
18
+ handleSkill,
13
19
  handleStart,
14
20
  handleStatus,
15
21
  handleStop,
22
+ handleThink,
23
+ handleUndo,
16
24
  } from "./commands";
17
25
  export { handleDocument } from "./document";
18
26
  export { handlePhoto } from "./photo";