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.
@@ -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
- const resolvedPath = resolvePath(inputPath);
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 &lt;filepath&gt;</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
  */
@@ -7,6 +7,7 @@ export {
7
7
  handleBookmarks,
8
8
  handleCd,
9
9
  handleNew,
10
+ handlePreview,
10
11
  handleRestart,
11
12
  handleResume,
12
13
  handleRetry,
@@ -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 { StatusCallback } from "../types";
11
- import { convertMarkdownToHtml, escapeHtml } from "../formatting";
9
+ import type { Message } from "grammy/types";
12
10
  import {
13
- TELEGRAM_MESSAGE_LIMIT,
14
- TELEGRAM_SAFE_LIMIT,
15
- STREAMING_THROTTLE_MS,
16
- BUTTON_LABEL_MAX_LENGTH,
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
- requestId: string,
24
- options: string[]
23
+ requestId: string,
24
+ options: string[],
25
25
  ): InlineKeyboard {
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;
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
- ctx: Context,
45
- chatId: number
44
+ ctx: Context,
45
+ chatId: number,
46
46
  ): Promise<boolean> {
47
- const glob = new Bun.Glob("ask-user-*.json");
48
- let buttonsSent = false;
47
+ const glob = new Bun.Glob("ask-user-*.json");
48
+ let buttonsSent = false;
49
49
 
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);
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
- // Only process pending requests for this chat
58
- if (data.status !== "pending") continue;
59
- if (String(data.chat_id) !== String(chatId)) continue;
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
- const question = data.question || "Please choose:";
62
- const options = data.options || [];
63
- const requestId = data.request_id || "";
61
+ const question = data.question || "Please choose:";
62
+ const options = data.options || [];
63
+ const requestId = data.request_id || "";
64
64
 
65
- if (options.length > 0 && requestId) {
66
- const keyboard = createAskUserKeyboard(requestId, options);
67
- await ctx.reply(`❓ ${question}`, { reply_markup: keyboard });
68
- buttonsSent = true;
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
- // 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
- }
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
- return buttonsSent;
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
- 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
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
- ctx: Context,
97
- state: StreamingState
96
+ ctx: Context,
97
+ state: StreamingState,
98
98
  ): StatusCallback {
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;
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
- 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);
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
- // Skip if content unchanged
179
- if (formatted === state.lastContent.get(segmentId)) {
180
- return;
181
- }
178
+ // Skip if content unchanged
179
+ if (formatted === state.lastContent.get(segmentId)) {
180
+ return;
181
+ }
182
182
 
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
- };
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
  }