ctb 1.0.0 → 1.1.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/package.json +4 -2
- package/src/__tests__/callback.test.ts +286 -0
- package/src/__tests__/cli.test.ts +365 -0
- package/src/__tests__/file-detection.test.ts +311 -0
- package/src/__tests__/shell-command.test.ts +310 -0
- package/src/bookmarks.ts +5 -1
- package/src/bot.ts +17 -0
- package/src/formatting.ts +289 -237
- package/src/handlers/callback.ts +46 -1
- package/src/handlers/commands.ts +83 -2
- package/src/handlers/index.ts +1 -0
- package/src/handlers/streaming.ts +185 -185
- package/src/handlers/text.ts +191 -113
- package/src/index.ts +2 -0
package/src/handlers/commands.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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";
|
|
@@ -37,6 +37,7 @@ export async function handleStart(ctx: Context): Promise<void> {
|
|
|
37
37
|
`/resume - Resume last session\n` +
|
|
38
38
|
`/retry - Retry last message\n` +
|
|
39
39
|
`/cd - Change working directory\n` +
|
|
40
|
+
`/preview - Download a file\n` +
|
|
40
41
|
`/bookmarks - Manage directory bookmarks\n` +
|
|
41
42
|
`/restart - Restart the bot\n\n` +
|
|
42
43
|
`<b>Tips:</b>\n` +
|
|
@@ -299,7 +300,8 @@ export async function handleCd(ctx: Context): Promise<void> {
|
|
|
299
300
|
}
|
|
300
301
|
|
|
301
302
|
const inputPath = (match[1] ?? "").trim();
|
|
302
|
-
|
|
303
|
+
// Resolve relative paths from current working directory
|
|
304
|
+
const resolvedPath = resolvePath(inputPath, session.workingDir);
|
|
303
305
|
|
|
304
306
|
// Validate path exists and is a directory
|
|
305
307
|
if (!existsSync(resolvedPath)) {
|
|
@@ -349,6 +351,85 @@ export async function handleCd(ctx: Context): Promise<void> {
|
|
|
349
351
|
);
|
|
350
352
|
}
|
|
351
353
|
|
|
354
|
+
/**
|
|
355
|
+
* /preview - Send a file to the user.
|
|
356
|
+
*/
|
|
357
|
+
export async function handlePreview(ctx: Context): Promise<void> {
|
|
358
|
+
const userId = ctx.from?.id;
|
|
359
|
+
|
|
360
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
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();
|
|
383
|
+
// Resolve relative paths from current working directory
|
|
384
|
+
const resolvedPath = resolvePath(inputPath, session.workingDir);
|
|
385
|
+
|
|
386
|
+
// Validate path exists
|
|
387
|
+
if (!existsSync(resolvedPath)) {
|
|
388
|
+
await ctx.reply(`❌ File not found: <code>${resolvedPath}</code>`, {
|
|
389
|
+
parse_mode: "HTML",
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const stats = statSync(resolvedPath);
|
|
395
|
+
if (stats.isDirectory()) {
|
|
396
|
+
await ctx.reply(
|
|
397
|
+
`❌ Cannot preview directory: <code>${resolvedPath}</code>\n\nUse <code>/cd</code> to navigate.`,
|
|
398
|
+
{ parse_mode: "HTML" },
|
|
399
|
+
);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Check if path is allowed
|
|
404
|
+
if (!isPathAllowed(resolvedPath)) {
|
|
405
|
+
await ctx.reply(
|
|
406
|
+
`❌ Access denied: <code>${resolvedPath}</code>\n\nPath must be in allowed directories.`,
|
|
407
|
+
{ parse_mode: "HTML" },
|
|
408
|
+
);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Check file size (Telegram limit is 50MB for bots)
|
|
413
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
414
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
415
|
+
const sizeMB = (stats.size / (1024 * 1024)).toFixed(1);
|
|
416
|
+
await ctx.reply(
|
|
417
|
+
`❌ File too large: <code>${resolvedPath}</code>\n\nSize: ${sizeMB}MB (max 50MB)`,
|
|
418
|
+
{ parse_mode: "HTML" },
|
|
419
|
+
);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Send the file
|
|
424
|
+
try {
|
|
425
|
+
const filename = resolvedPath.split("/").pop() || "file";
|
|
426
|
+
await ctx.replyWithDocument(new InputFile(resolvedPath, filename));
|
|
427
|
+
} catch (error) {
|
|
428
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
429
|
+
await ctx.reply(`❌ Failed to send file: ${errMsg}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
352
433
|
/**
|
|
353
434
|
* /bookmarks - List and manage bookmarks.
|
|
354
435
|
*/
|
package/src/handlers/index.ts
CHANGED
|
@@ -5,227 +5,227 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Context } from "grammy";
|
|
8
|
-
import type { Message } from "grammy/types";
|
|
9
8
|
import { InlineKeyboard } from "grammy";
|
|
10
|
-
import type {
|
|
11
|
-
import { convertMarkdownToHtml, escapeHtml } from "../formatting";
|
|
9
|
+
import type { Message } from "grammy/types";
|
|
12
10
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
BUTTON_LABEL_MAX_LENGTH,
|
|
12
|
+
STREAMING_THROTTLE_MS,
|
|
13
|
+
TELEGRAM_MESSAGE_LIMIT,
|
|
14
|
+
TELEGRAM_SAFE_LIMIT,
|
|
17
15
|
} from "../config";
|
|
16
|
+
import { convertMarkdownToHtml, escapeHtml } from "../formatting";
|
|
17
|
+
import type { StatusCallback } from "../types";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Create inline keyboard for ask_user options.
|
|
21
21
|
*/
|
|
22
22
|
export function createAskUserKeyboard(
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
requestId: string,
|
|
24
|
+
options: string[],
|
|
25
25
|
): InlineKeyboard {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
const keyboard = new InlineKeyboard();
|
|
27
|
+
for (let idx = 0; idx < options.length; idx++) {
|
|
28
|
+
const option = options[idx]!;
|
|
29
|
+
// Truncate long options for button display
|
|
30
|
+
const display =
|
|
31
|
+
option.length > BUTTON_LABEL_MAX_LENGTH
|
|
32
|
+
? `${option.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
|
|
33
|
+
: option;
|
|
34
|
+
const callbackData = `askuser:${requestId}:${idx}`;
|
|
35
|
+
keyboard.text(display, callbackData).row();
|
|
36
|
+
}
|
|
37
|
+
return keyboard;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Check for pending ask-user requests and send inline keyboards.
|
|
42
42
|
*/
|
|
43
43
|
export async function checkPendingAskUserRequests(
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
ctx: Context,
|
|
45
|
+
chatId: number,
|
|
46
46
|
): Promise<boolean> {
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
const glob = new Bun.Glob("ask-user-*.json");
|
|
48
|
+
let buttonsSent = false;
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
for await (const filename of glob.scan({ cwd: "/tmp", absolute: false })) {
|
|
51
|
+
const filepath = `/tmp/${filename}`;
|
|
52
|
+
try {
|
|
53
|
+
const file = Bun.file(filepath);
|
|
54
|
+
const text = await file.text();
|
|
55
|
+
const data = JSON.parse(text);
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
// Only process pending requests for this chat
|
|
58
|
+
if (data.status !== "pending") continue;
|
|
59
|
+
if (String(data.chat_id) !== String(chatId)) continue;
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
const question = data.question || "Please choose:";
|
|
62
|
+
const options = data.options || [];
|
|
63
|
+
const requestId = data.request_id || "";
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
if (options.length > 0 && requestId) {
|
|
66
|
+
const keyboard = createAskUserKeyboard(requestId, options);
|
|
67
|
+
await ctx.reply(`❓ ${question}`, { reply_markup: keyboard });
|
|
68
|
+
buttonsSent = true;
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
// Mark as sent
|
|
71
|
+
data.status = "sent";
|
|
72
|
+
await Bun.write(filepath, JSON.stringify(data));
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.warn(`Failed to process ask-user file ${filepath}:`, error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
return buttonsSent;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* Tracks state for streaming message updates.
|
|
84
84
|
*/
|
|
85
85
|
export class StreamingState {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
textMessages = new Map<number, Message>(); // segment_id -> telegram message
|
|
87
|
+
toolMessages: Message[] = []; // ephemeral tool status messages
|
|
88
|
+
lastEditTimes = new Map<number, number>(); // segment_id -> last edit time
|
|
89
|
+
lastContent = new Map<number, string>(); // segment_id -> last sent content
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* Create a status callback for streaming updates.
|
|
94
94
|
*/
|
|
95
95
|
export function createStatusCallback(
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
ctx: Context,
|
|
97
|
+
state: StreamingState,
|
|
98
98
|
): StatusCallback {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
99
|
+
return async (statusType: string, content: string, segmentId?: number) => {
|
|
100
|
+
try {
|
|
101
|
+
if (statusType === "thinking") {
|
|
102
|
+
// Show thinking inline, compact (first 500 chars)
|
|
103
|
+
const preview =
|
|
104
|
+
content.length > 500 ? `${content.slice(0, 500)}...` : content;
|
|
105
|
+
const escaped = escapeHtml(preview);
|
|
106
|
+
const thinkingMsg = await ctx.reply(`🧠 <i>${escaped}</i>`, {
|
|
107
|
+
parse_mode: "HTML",
|
|
108
|
+
});
|
|
109
|
+
state.toolMessages.push(thinkingMsg);
|
|
110
|
+
} else if (statusType === "tool") {
|
|
111
|
+
const toolMsg = await ctx.reply(content, { parse_mode: "HTML" });
|
|
112
|
+
state.toolMessages.push(toolMsg);
|
|
113
|
+
} else if (statusType === "text" && segmentId !== undefined) {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const lastEdit = state.lastEditTimes.get(segmentId) || 0;
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
117
|
+
if (!state.textMessages.has(segmentId)) {
|
|
118
|
+
// New segment - create message
|
|
119
|
+
const display =
|
|
120
|
+
content.length > TELEGRAM_SAFE_LIMIT
|
|
121
|
+
? `${content.slice(0, TELEGRAM_SAFE_LIMIT)}...`
|
|
122
|
+
: content;
|
|
123
|
+
const formatted = convertMarkdownToHtml(display);
|
|
124
|
+
try {
|
|
125
|
+
const msg = await ctx.reply(formatted, { parse_mode: "HTML" });
|
|
126
|
+
state.textMessages.set(segmentId, msg);
|
|
127
|
+
state.lastContent.set(segmentId, formatted);
|
|
128
|
+
} catch (htmlError) {
|
|
129
|
+
// HTML parse failed, fall back to plain text
|
|
130
|
+
console.debug("HTML reply failed, using plain text:", htmlError);
|
|
131
|
+
const msg = await ctx.reply(formatted);
|
|
132
|
+
state.textMessages.set(segmentId, msg);
|
|
133
|
+
state.lastContent.set(segmentId, formatted);
|
|
134
|
+
}
|
|
135
|
+
state.lastEditTimes.set(segmentId, now);
|
|
136
|
+
} else if (now - lastEdit > STREAMING_THROTTLE_MS) {
|
|
137
|
+
// Update existing segment message (throttled)
|
|
138
|
+
const msg = state.textMessages.get(segmentId)!;
|
|
139
|
+
const display =
|
|
140
|
+
content.length > TELEGRAM_SAFE_LIMIT
|
|
141
|
+
? `${content.slice(0, TELEGRAM_SAFE_LIMIT)}...`
|
|
142
|
+
: content;
|
|
143
|
+
const formatted = convertMarkdownToHtml(display);
|
|
144
|
+
// Skip if content unchanged
|
|
145
|
+
if (formatted === state.lastContent.get(segmentId)) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
await ctx.api.editMessageText(
|
|
150
|
+
msg.chat.id,
|
|
151
|
+
msg.message_id,
|
|
152
|
+
formatted,
|
|
153
|
+
{
|
|
154
|
+
parse_mode: "HTML",
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
state.lastContent.set(segmentId, formatted);
|
|
158
|
+
} catch (htmlError) {
|
|
159
|
+
console.debug("HTML edit failed, trying plain text:", htmlError);
|
|
160
|
+
try {
|
|
161
|
+
await ctx.api.editMessageText(
|
|
162
|
+
msg.chat.id,
|
|
163
|
+
msg.message_id,
|
|
164
|
+
formatted,
|
|
165
|
+
);
|
|
166
|
+
state.lastContent.set(segmentId, formatted);
|
|
167
|
+
} catch (editError) {
|
|
168
|
+
console.debug("Edit message failed:", editError);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
state.lastEditTimes.set(segmentId, now);
|
|
172
|
+
}
|
|
173
|
+
} else if (statusType === "segment_end" && segmentId !== undefined) {
|
|
174
|
+
if (state.textMessages.has(segmentId) && content) {
|
|
175
|
+
const msg = state.textMessages.get(segmentId)!;
|
|
176
|
+
const formatted = convertMarkdownToHtml(content);
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
// Skip if content unchanged
|
|
179
|
+
if (formatted === state.lastContent.get(segmentId)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
183
|
+
if (formatted.length <= TELEGRAM_MESSAGE_LIMIT) {
|
|
184
|
+
try {
|
|
185
|
+
await ctx.api.editMessageText(
|
|
186
|
+
msg.chat.id,
|
|
187
|
+
msg.message_id,
|
|
188
|
+
formatted,
|
|
189
|
+
{
|
|
190
|
+
parse_mode: "HTML",
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.debug("Failed to edit final message:", error);
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
// Too long - delete and split
|
|
198
|
+
try {
|
|
199
|
+
await ctx.api.deleteMessage(msg.chat.id, msg.message_id);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.debug("Failed to delete message for splitting:", error);
|
|
202
|
+
}
|
|
203
|
+
for (let i = 0; i < formatted.length; i += TELEGRAM_SAFE_LIMIT) {
|
|
204
|
+
const chunk = formatted.slice(i, i + TELEGRAM_SAFE_LIMIT);
|
|
205
|
+
try {
|
|
206
|
+
await ctx.reply(chunk, { parse_mode: "HTML" });
|
|
207
|
+
} catch (htmlError) {
|
|
208
|
+
console.debug(
|
|
209
|
+
"HTML chunk failed, using plain text:",
|
|
210
|
+
htmlError,
|
|
211
|
+
);
|
|
212
|
+
await ctx.reply(chunk);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} else if (statusType === "done") {
|
|
218
|
+
// Delete tool messages - text messages stay
|
|
219
|
+
for (const toolMsg of state.toolMessages) {
|
|
220
|
+
try {
|
|
221
|
+
await ctx.api.deleteMessage(toolMsg.chat.id, toolMsg.message_id);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.debug("Failed to delete tool message:", error);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
console.error("Status callback error:", error);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
231
|
}
|