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.
package/README.md CHANGED
@@ -38,12 +38,15 @@ To achieve this, I set up a folder with a CLAUDE.md that teaches Claude about me
38
38
  # Install globally
39
39
  npm install -g ctb
40
40
 
41
+ # Show setup tutorial
42
+ ctb tut
43
+
41
44
  # Run in any project directory
42
45
  cd ~/my-project
43
46
  ctb
44
47
  ```
45
48
 
46
- On first run, `ctb` will prompt for your Telegram bot token and allowed user IDs, then optionally save them to `.env`.
49
+ On first run, `ctb` will prompt for your Telegram bot token and allowed user IDs, then optionally save them to `.env`. Run `ctb tut` for a step-by-step setup guide.
47
50
 
48
51
  **Run multiple instances:** Each project directory gets its own isolated bot session. Open multiple terminals and run `ctb` in different directories.
49
52
 
@@ -102,8 +105,17 @@ new - Start a fresh session
102
105
  resume - Resume last session
103
106
  stop - Interrupt current query
104
107
  status - Check what Claude is doing
108
+ model - Switch model (sonnet, opus, haiku)
109
+ cost - Show token usage and estimated cost
110
+ think - Force thinking mode
111
+ plan - Toggle planning mode
112
+ compact - Trigger context compaction
113
+ undo - Revert file changes to last checkpoint
105
114
  cd - Change working directory
115
+ skill - Invoke a Claude Code skill
116
+ file - Download a file
106
117
  bookmarks - Manage directory bookmarks
118
+ retry - Retry last message
107
119
  restart - Restart the bot
108
120
  ```
109
121
 
@@ -148,16 +160,35 @@ The bot includes a built-in `ask_user` MCP server that lets Claude present optio
148
160
 
149
161
  ## Bot Commands
150
162
 
151
- | Command | Description |
152
- | ------------ | --------------------------------- |
153
- | `/start` | Show status and your user ID |
154
- | `/new` | Start a fresh session |
155
- | `/resume` | Resume last session after restart |
156
- | `/stop` | Interrupt current query |
157
- | `/status` | Check what Claude is doing |
158
- | `/cd <path>` | Change working directory |
159
- | `/bookmarks` | Manage directory bookmarks |
160
- | `/restart` | Restart the bot |
163
+ | Command | Description |
164
+ | --------------- | ---------------------------------------------------------- |
165
+ | `/start` | Show status and your user ID |
166
+ | `/new` | Start a fresh session |
167
+ | `/resume` | Resume last session after restart |
168
+ | `/stop` | Interrupt current query (aliases: `/c`, `/kill`, `/dc`) |
169
+ | `/status` | Check what Claude is doing |
170
+ | `/model <name>` | Switch model: sonnet, opus, haiku |
171
+ | `/cost` | Show token usage and estimated cost |
172
+ | `/think [lvl]` | Force thinking: off, normal, deep (default) |
173
+ | `/plan` | Toggle planning mode (no tool execution) |
174
+ | `/compact` | Trigger context compaction |
175
+ | `/undo` | Revert file changes to last checkpoint |
176
+ | `/cd <path>` | Change working directory |
177
+ | `/skill <name>` | Invoke a Claude Code skill (e.g., `/skill commit`) |
178
+ | `/file [path]` | Download file (auto-detects from last response if no path) |
179
+ | `/bookmarks` | Manage directory bookmarks |
180
+ | `/retry` | Retry last message |
181
+ | `/restart` | Restart the bot |
182
+
183
+ ### Shell Commands
184
+
185
+ Prefix any message with `!` to run it as a shell command in the working directory:
186
+
187
+ ```
188
+ !ls -la
189
+ !git status
190
+ !pwd
191
+ ```
161
192
 
162
193
  ### Directory Navigation
163
194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctb",
3
- "version": "1.0.0",
3
+ "version": "1.2.1",
4
4
  "description": "Control Claude Code from Telegram - run multiple bot instances per project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,9 @@
10
10
  "start": "bun run src/bot.ts",
11
11
  "dev": "bun --watch run src/bot.ts",
12
12
  "ctb": "bun run src/cli.ts",
13
- "typecheck": "bun run --bun tsc --noEmit"
13
+ "typecheck": "bun run --bun tsc --noEmit",
14
+ "test": "bun test",
15
+ "test:watch": "bun test --watch"
14
16
  },
15
17
  "keywords": [
16
18
  "claude",
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Unit tests for callback handlers (file sending, bookmarks).
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+
7
+ describe("File sending callback data", () => {
8
+ describe("base64 encoding/decoding", () => {
9
+ test("encodes and decodes simple path", () => {
10
+ const path = "/Users/test/file.txt";
11
+ const encoded = Buffer.from(path).toString("base64");
12
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
13
+ expect(decoded).toBe(path);
14
+ });
15
+
16
+ test("encodes and decodes path with spaces", () => {
17
+ const path = "/Users/test/my file.txt";
18
+ const encoded = Buffer.from(path).toString("base64");
19
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
20
+ expect(decoded).toBe(path);
21
+ });
22
+
23
+ test("encodes and decodes path with special characters", () => {
24
+ const path = "/Users/test/file-name_v2.1.txt";
25
+ const encoded = Buffer.from(path).toString("base64");
26
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
27
+ expect(decoded).toBe(path);
28
+ });
29
+
30
+ test("encodes and decodes unicode path", () => {
31
+ const path = "/Users/test/文件.txt";
32
+ const encoded = Buffer.from(path).toString("base64");
33
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
34
+ expect(decoded).toBe(path);
35
+ });
36
+
37
+ test("encodes and decodes long path", () => {
38
+ const path = "/Users/test/very/deep/nested/directory/structure/file.txt";
39
+ const encoded = Buffer.from(path).toString("base64");
40
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
41
+ expect(decoded).toBe(path);
42
+ });
43
+ });
44
+
45
+ describe("callback data format", () => {
46
+ test("creates valid callback data", () => {
47
+ const path = "/tmp/test.txt";
48
+ const encoded = Buffer.from(path).toString("base64");
49
+ const callbackData = `sendfile:${encoded}`;
50
+ expect(callbackData.startsWith("sendfile:")).toBe(true);
51
+ });
52
+
53
+ test("extracts path from callback data", () => {
54
+ const path = "/tmp/test.txt";
55
+ const encoded = Buffer.from(path).toString("base64");
56
+ const callbackData = `sendfile:${encoded}`;
57
+
58
+ const extractedEncoded = callbackData.slice("sendfile:".length);
59
+ const extractedPath = Buffer.from(extractedEncoded, "base64").toString(
60
+ "utf-8",
61
+ );
62
+ expect(extractedPath).toBe(path);
63
+ });
64
+
65
+ test("callback data fits within Telegram limits", () => {
66
+ // Telegram callback data max is 64 bytes
67
+ const maxPath = "/Users/verylongusername/very/deep/path/file.txt";
68
+ const encoded = Buffer.from(maxPath).toString("base64");
69
+ const callbackData = `sendfile:${encoded}`;
70
+ // This might exceed 64 bytes for very long paths
71
+ // The implementation should handle this
72
+ expect(callbackData.length).toBeGreaterThan(0);
73
+ });
74
+ });
75
+
76
+ describe("path validation", () => {
77
+ test("identifies absolute paths", () => {
78
+ const absolutePaths = [
79
+ "/tmp/file.txt",
80
+ "/Users/test/doc.pdf",
81
+ "/home/user/data.json",
82
+ "/var/log/app.log",
83
+ ];
84
+
85
+ for (const path of absolutePaths) {
86
+ expect(path.startsWith("/")).toBe(true);
87
+ }
88
+ });
89
+
90
+ test("identifies relative paths", () => {
91
+ const relativePaths = [
92
+ "./file.txt",
93
+ "../parent/file.txt",
94
+ "subdir/file.txt",
95
+ "file.txt",
96
+ ];
97
+
98
+ for (const path of relativePaths) {
99
+ expect(path.startsWith("/")).toBe(false);
100
+ }
101
+ });
102
+ });
103
+ });
104
+
105
+ describe("Bookmark callback data", () => {
106
+ describe("callback data format", () => {
107
+ test("creates add bookmark callback", () => {
108
+ const path = "/Users/test/project";
109
+ const callbackData = `bookmark:add:${path}`;
110
+ expect(callbackData).toBe("bookmark:add:/Users/test/project");
111
+ });
112
+
113
+ test("creates remove bookmark callback", () => {
114
+ const path = "/Users/test/project";
115
+ const callbackData = `bookmark:remove:${path}`;
116
+ expect(callbackData).toBe("bookmark:remove:/Users/test/project");
117
+ });
118
+
119
+ test("creates new session callback", () => {
120
+ const path = "/Users/test/project";
121
+ const callbackData = `bookmark:new:${path}`;
122
+ expect(callbackData).toBe("bookmark:new:/Users/test/project");
123
+ });
124
+
125
+ test("handles paths with colons", () => {
126
+ // Path parsing should handle colons in path
127
+ const callbackData = "bookmark:add:/path/with:colon/file.txt";
128
+ const parts = callbackData.split(":");
129
+ expect(parts[0]).toBe("bookmark");
130
+ expect(parts[1]).toBe("add");
131
+ // Path is everything after second colon
132
+ const path = parts.slice(2).join(":");
133
+ expect(path).toBe("/path/with:colon/file.txt");
134
+ });
135
+ });
136
+
137
+ describe("action parsing", () => {
138
+ test("parses add action", () => {
139
+ const callbackData = "bookmark:add:/path";
140
+ const parts = callbackData.split(":");
141
+ expect(parts[1]).toBe("add");
142
+ });
143
+
144
+ test("parses remove action", () => {
145
+ const callbackData = "bookmark:remove:/path";
146
+ const parts = callbackData.split(":");
147
+ expect(parts[1]).toBe("remove");
148
+ });
149
+
150
+ test("parses new action", () => {
151
+ const callbackData = "bookmark:new:/path";
152
+ const parts = callbackData.split(":");
153
+ expect(parts[1]).toBe("new");
154
+ });
155
+
156
+ test("parses noop action", () => {
157
+ const callbackData = "bookmark:noop:";
158
+ const parts = callbackData.split(":");
159
+ expect(parts[1]).toBe("noop");
160
+ });
161
+ });
162
+ });
163
+
164
+ describe("Ask user callback data", () => {
165
+ describe("callback data format", () => {
166
+ test("creates valid ask user callback", () => {
167
+ const requestId = "abc123";
168
+ const optionIndex = 0;
169
+ const callbackData = `askuser:${requestId}:${optionIndex}`;
170
+ expect(callbackData).toBe("askuser:abc123:0");
171
+ });
172
+
173
+ test("parses request ID", () => {
174
+ const callbackData = "askuser:request-id-123:2";
175
+ const parts = callbackData.split(":");
176
+ expect(parts[1]).toBe("request-id-123");
177
+ });
178
+
179
+ test("parses option index", () => {
180
+ const callbackData = "askuser:abc:3";
181
+ const parts = callbackData.split(":");
182
+ expect(Number.parseInt(parts[2] ?? "", 10)).toBe(3);
183
+ });
184
+
185
+ test("handles multi-digit option index", () => {
186
+ const callbackData = "askuser:abc:15";
187
+ const parts = callbackData.split(":");
188
+ expect(Number.parseInt(parts[2] ?? "", 10)).toBe(15);
189
+ });
190
+ });
191
+
192
+ describe("validation", () => {
193
+ test("validates callback format", () => {
194
+ const validCallbacks = [
195
+ "askuser:id:0",
196
+ "askuser:long-request-id:5",
197
+ "askuser:123:99",
198
+ ];
199
+
200
+ for (const cb of validCallbacks) {
201
+ const parts = cb.split(":");
202
+ expect(parts).toHaveLength(3);
203
+ expect(parts[0]).toBe("askuser");
204
+ expect(parts[1]?.length).toBeGreaterThan(0);
205
+ expect(Number.parseInt(parts[2] ?? "", 10)).toBeGreaterThanOrEqual(0);
206
+ }
207
+ });
208
+
209
+ test("detects invalid callback format", () => {
210
+ const invalidCallbacks = [
211
+ "askuser:", // Missing parts
212
+ "askuser:id", // Missing option
213
+ "askuser::", // Empty parts
214
+ "other:id:0", // Wrong prefix
215
+ ];
216
+
217
+ for (const cb of invalidCallbacks) {
218
+ const parts = cb.split(":");
219
+ const isValid =
220
+ parts.length === 3 &&
221
+ parts[0] === "askuser" &&
222
+ (parts[1]?.length ?? 0) > 0 &&
223
+ !Number.isNaN(Number.parseInt(parts[2] ?? "", 10));
224
+ expect(isValid).toBe(false);
225
+ }
226
+ });
227
+ });
228
+ });
229
+
230
+ describe("Inline keyboard button labels", () => {
231
+ const BUTTON_LABEL_MAX_LENGTH = 30;
232
+
233
+ test("truncates long labels", () => {
234
+ const longLabel = "This is a very long option that should be truncated";
235
+ const display =
236
+ longLabel.length > BUTTON_LABEL_MAX_LENGTH
237
+ ? `${longLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
238
+ : longLabel;
239
+ expect(display.length).toBeLessThanOrEqual(BUTTON_LABEL_MAX_LENGTH + 3);
240
+ });
241
+
242
+ test("keeps short labels unchanged", () => {
243
+ const shortLabel = "Short option";
244
+ const display =
245
+ shortLabel.length > BUTTON_LABEL_MAX_LENGTH
246
+ ? `${shortLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
247
+ : shortLabel;
248
+ expect(display).toBe(shortLabel);
249
+ });
250
+
251
+ test("handles exact length labels", () => {
252
+ const exactLabel = "A".repeat(BUTTON_LABEL_MAX_LENGTH);
253
+ const display =
254
+ exactLabel.length > BUTTON_LABEL_MAX_LENGTH
255
+ ? `${exactLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
256
+ : exactLabel;
257
+ expect(display).toBe(exactLabel);
258
+ });
259
+
260
+ test("handles empty labels", () => {
261
+ const emptyLabel = "";
262
+ const display =
263
+ emptyLabel.length > BUTTON_LABEL_MAX_LENGTH
264
+ ? `${emptyLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
265
+ : emptyLabel;
266
+ expect(display).toBe("");
267
+ });
268
+
269
+ test("handles unicode labels", () => {
270
+ const unicodeLabel = "选择这个选项";
271
+ const display =
272
+ unicodeLabel.length > BUTTON_LABEL_MAX_LENGTH
273
+ ? `${unicodeLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
274
+ : unicodeLabel;
275
+ expect(display).toBe(unicodeLabel);
276
+ });
277
+
278
+ test("handles emoji labels", () => {
279
+ const emojiLabel = "📁 Download file";
280
+ const display =
281
+ emojiLabel.length > BUTTON_LABEL_MAX_LENGTH
282
+ ? `${emojiLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
283
+ : emojiLabel;
284
+ expect(display).toBe(emojiLabel);
285
+ });
286
+ });