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 +42 -11
- package/package.json +4 -2
- package/src/__tests__/callback.test.ts +286 -0
- package/src/__tests__/cli.test.ts +377 -0
- package/src/__tests__/file-detection.test.ts +311 -0
- package/src/__tests__/session.test.ts +399 -0
- package/src/__tests__/shell-command.test.ts +310 -0
- package/src/bookmarks.ts +5 -1
- package/src/bot.ts +41 -0
- package/src/cli.ts +94 -0
- package/src/formatting.ts +289 -237
- package/src/handlers/callback.ts +46 -1
- package/src/handlers/commands.ts +417 -3
- package/src/handlers/index.ts +8 -0
- package/src/handlers/streaming.ts +185 -185
- package/src/handlers/text.ts +191 -113
- package/src/index.ts +19 -0
- package/src/session.ts +140 -6
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
|
|
152
|
-
|
|
|
153
|
-
| `/start`
|
|
154
|
-
| `/new`
|
|
155
|
-
| `/resume`
|
|
156
|
-
| `/stop`
|
|
157
|
-
| `/status`
|
|
158
|
-
| `/
|
|
159
|
-
| `/
|
|
160
|
-
| `/
|
|
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.
|
|
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
|
+
});
|