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/src/formatting.ts CHANGED
@@ -8,11 +8,11 @@
8
8
  * Escape HTML special characters.
9
9
  */
10
10
  export function escapeHtml(text: string): string {
11
- return text
12
- .replace(/&/g, "&")
13
- .replace(/</g, "&lt;")
14
- .replace(/>/g, "&gt;")
15
- .replace(/"/g, "&quot;");
11
+ return text
12
+ .replace(/&/g, "&amp;")
13
+ .replace(/</g, "&lt;")
14
+ .replace(/>/g, "&gt;")
15
+ .replace(/"/g, "&quot;");
16
16
  }
17
17
 
18
18
  /**
@@ -22,288 +22,340 @@ export function escapeHtml(text: string): string {
22
22
  * Telegram HTML supports: <b>, <i>, <code>, <pre>, <a href="">
23
23
  */
24
24
  export function convertMarkdownToHtml(text: string): string {
25
- // Store code blocks temporarily to avoid processing their contents
26
- const codeBlocks: string[] = [];
27
- const inlineCodes: string[] = [];
25
+ // Store code blocks temporarily to avoid processing their contents
26
+ const codeBlocks: string[] = [];
27
+ const inlineCodes: string[] = [];
28
28
 
29
- // Save code blocks first (```code```)
30
- text = text.replace(/```(?:\w+)?\n?([\s\S]*?)```/g, (_, code) => {
31
- codeBlocks.push(code);
32
- return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
33
- });
29
+ // Save code blocks first (```code```)
30
+ text = text.replace(/```(?:\w+)?\n?([\s\S]*?)```/g, (_, code) => {
31
+ codeBlocks.push(code);
32
+ return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
33
+ });
34
34
 
35
- // Save inline code (`code`)
36
- text = text.replace(/`([^`]+)`/g, (_, code) => {
37
- inlineCodes.push(code);
38
- return `\x00INLINECODE${inlineCodes.length - 1}\x00`;
39
- });
35
+ // Save inline code (`code`)
36
+ text = text.replace(/`([^`]+)`/g, (_, code) => {
37
+ inlineCodes.push(code);
38
+ return `\x00INLINECODE${inlineCodes.length - 1}\x00`;
39
+ });
40
40
 
41
- // Escape HTML entities in the remaining text
42
- text = escapeHtml(text);
41
+ // Escape HTML entities in the remaining text
42
+ text = escapeHtml(text);
43
43
 
44
- // Headers: ## Header -> <b>Header</b>
45
- text = text.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>\n");
44
+ // Headers: ## Header -> <b>Header</b>
45
+ text = text.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>\n");
46
46
 
47
- // Bold: **text** -> <b>text</b>
48
- text = text.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
47
+ // Bold: **text** -> <b>text</b>
48
+ text = text.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
49
49
 
50
- // Also handle *text* as bold (single asterisk)
51
- text = text.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, "<b>$1</b>");
50
+ // Also handle *text* as bold (single asterisk)
51
+ text = text.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, "<b>$1</b>");
52
52
 
53
- // Double underscore: __text__ -> <b>text</b>
54
- text = text.replace(/__([^_]+)__/g, "<b>$1</b>");
53
+ // Double underscore: __text__ -> <b>text</b>
54
+ text = text.replace(/__([^_]+)__/g, "<b>$1</b>");
55
55
 
56
- // Italic: _text_ -> <i>text</i> (but not __text__)
57
- text = text.replace(/(?<!_)_([^_]+)_(?!_)/g, "<i>$1</i>");
56
+ // Italic: _text_ -> <i>text</i> (but not __text__)
57
+ text = text.replace(/(?<!_)_([^_]+)_(?!_)/g, "<i>$1</i>");
58
58
 
59
- // Blockquotes: &gt; text -> <blockquote>text</blockquote>
60
- text = convertBlockquotes(text);
59
+ // Blockquotes: &gt; text -> <blockquote>text</blockquote>
60
+ text = convertBlockquotes(text);
61
61
 
62
- // Bullet lists: - item or * item -> • item
63
- text = text.replace(/^[-*] /gm, "• ");
62
+ // Bullet lists: - item or * item -> • item
63
+ text = text.replace(/^[-*] /gm, "• ");
64
64
 
65
- // Horizontal rules: --- or *** -> blank line
66
- text = text.replace(/^[-*]{3,}$/gm, "");
65
+ // Horizontal rules: --- or *** -> blank line
66
+ text = text.replace(/^[-*]{3,}$/gm, "");
67
67
 
68
- // Links: [text](url) -> <a href="url">text</a>
69
- text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
68
+ // Links: [text](url) -> <a href="url">text</a>
69
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
70
70
 
71
- // Restore code blocks
72
- for (let i = 0; i < codeBlocks.length; i++) {
73
- const escapedCode = escapeHtml(codeBlocks[i]!);
74
- text = text.replace(`\x00CODEBLOCK${i}\x00`, `<pre>${escapedCode}</pre>`);
75
- }
71
+ // Restore code blocks
72
+ for (let i = 0; i < codeBlocks.length; i++) {
73
+ const escapedCode = escapeHtml(codeBlocks[i]!);
74
+ text = text.replace(`\x00CODEBLOCK${i}\x00`, `<pre>${escapedCode}</pre>`);
75
+ }
76
76
 
77
- // Restore inline code
78
- for (let i = 0; i < inlineCodes.length; i++) {
79
- const escapedCode = escapeHtml(inlineCodes[i]!);
80
- text = text.replace(
81
- `\x00INLINECODE${i}\x00`,
82
- `<code>${escapedCode}</code>`
83
- );
84
- }
77
+ // Restore inline code
78
+ for (let i = 0; i < inlineCodes.length; i++) {
79
+ const escapedCode = escapeHtml(inlineCodes[i]!);
80
+ text = text.replace(
81
+ `\x00INLINECODE${i}\x00`,
82
+ `<code>${escapedCode}</code>`,
83
+ );
84
+ }
85
85
 
86
- // Collapse multiple newlines
87
- text = text.replace(/\n{3,}/g, "\n\n");
86
+ // Collapse multiple newlines
87
+ text = text.replace(/\n{3,}/g, "\n\n");
88
88
 
89
- return text;
89
+ return text;
90
90
  }
91
91
 
92
92
  /**
93
93
  * Convert blockquotes (handles multi-line).
94
94
  */
95
95
  function convertBlockquotes(text: string): string {
96
- const lines = text.split("\n");
97
- const result: string[] = [];
98
- let inBlockquote = false;
99
- const blockquoteLines: string[] = [];
100
-
101
- for (const line of lines) {
102
- if (line.startsWith("&gt; ") || line === "&gt;") {
103
- if (line === "&gt;") {
104
- blockquoteLines.push("");
105
- } else {
106
- // Remove '&gt; ' and strip # from hashtags (Telegram mobile bug workaround)
107
- const content = line.slice(5).replace(/#/g, "");
108
- blockquoteLines.push(content);
109
- }
110
- inBlockquote = true;
111
- } else {
112
- if (inBlockquote) {
113
- result.push(
114
- "<blockquote>" + blockquoteLines.join("\n") + "</blockquote>"
115
- );
116
- blockquoteLines.length = 0;
117
- inBlockquote = false;
118
- }
119
- result.push(line);
120
- }
121
- }
122
-
123
- // Handle blockquote at end
124
- if (inBlockquote) {
125
- result.push("<blockquote>" + blockquoteLines.join("\n") + "</blockquote>");
126
- }
127
-
128
- return result.join("\n");
96
+ const lines = text.split("\n");
97
+ const result: string[] = [];
98
+ let inBlockquote = false;
99
+ const blockquoteLines: string[] = [];
100
+
101
+ for (const line of lines) {
102
+ if (line.startsWith("&gt; ") || line === "&gt;") {
103
+ if (line === "&gt;") {
104
+ blockquoteLines.push("");
105
+ } else {
106
+ // Remove '&gt; ' and strip # from hashtags (Telegram mobile bug workaround)
107
+ const content = line.slice(5).replace(/#/g, "");
108
+ blockquoteLines.push(content);
109
+ }
110
+ inBlockquote = true;
111
+ } else {
112
+ if (inBlockquote) {
113
+ result.push(`<blockquote>${blockquoteLines.join("\n")}</blockquote>`);
114
+ blockquoteLines.length = 0;
115
+ inBlockquote = false;
116
+ }
117
+ result.push(line);
118
+ }
119
+ }
120
+
121
+ // Handle blockquote at end
122
+ if (inBlockquote) {
123
+ result.push(`<blockquote>${blockquoteLines.join("\n")}</blockquote>`);
124
+ }
125
+
126
+ return result.join("\n");
129
127
  }
130
128
 
131
129
  // Legacy alias
132
130
  export const convertMarkdownForTelegram = convertMarkdownToHtml;
133
131
 
132
+ /**
133
+ * Detect file paths in text that could be sent as documents.
134
+ * Returns array of { path, display } objects.
135
+ * If workingDir is provided, relative paths are resolved and checked for existence.
136
+ */
137
+ export function detectFilePaths(
138
+ text: string,
139
+ workingDir?: string,
140
+ ): Array<{ path: string; display: string }> {
141
+ const paths: Array<{ path: string; display: string }> = [];
142
+ const seen = new Set<string>();
143
+
144
+ // Match paths in backticks, after prefixes, or standalone absolute paths
145
+ const patterns = [
146
+ // Absolute paths in backticks: `/path/to/file.ext`
147
+ /`(\/[^\s`]+\.[a-zA-Z0-9]+)`/g,
148
+ // Relative paths in backticks with extension: `name.txt`, `path/to/file.ext`
149
+ /`([a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)*\.[a-zA-Z0-9]+)`/g,
150
+ // Paths after common prefixes (absolute)
151
+ /(?:file|path|saved|created|wrote|output|generated):\s*(\/[^\s,)]+\.[a-zA-Z0-9]+)/gi,
152
+ // Paths after common prefixes (relative)
153
+ /(?:file|path|saved|created|wrote|output|generated):\s*([a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)*\.[a-zA-Z0-9]+)/gi,
154
+ // Standalone absolute paths with extensions
155
+ /(?:^|\s)(\/(?:Users|home|tmp|var|etc|opt)[^\s,)]+\.[a-zA-Z0-9]+)/gm,
156
+ ];
157
+
158
+ for (const pattern of patterns) {
159
+ const matches = text.matchAll(pattern);
160
+ for (const match of matches) {
161
+ let filePath = match[1];
162
+ if (!filePath) continue;
163
+
164
+ // Resolve relative paths if workingDir is provided
165
+ if (!filePath.startsWith("/") && workingDir) {
166
+ filePath = `${workingDir}/${filePath}`;
167
+ }
168
+
169
+ // Skip if we've already seen this path
170
+ if (seen.has(filePath)) continue;
171
+ seen.add(filePath);
172
+
173
+ // Get display name (last 2 path components)
174
+ const parts = filePath.split("/");
175
+ const display =
176
+ parts.length >= 2
177
+ ? parts.slice(-2).join("/")
178
+ : parts[parts.length - 1] || filePath;
179
+ paths.push({ path: filePath, display });
180
+ }
181
+ }
182
+
183
+ return paths;
184
+ }
185
+
134
186
  // ============== Tool Status Formatting ==============
135
187
 
136
188
  /**
137
189
  * Shorten a file path for display (last 2 components).
138
190
  */
139
191
  function shortenPath(path: string): string {
140
- if (!path) return "file";
141
- const parts = path.split("/");
142
- if (parts.length >= 2) {
143
- return parts.slice(-2).join("/");
144
- }
145
- return parts[parts.length - 1] || path;
192
+ if (!path) return "file";
193
+ const parts = path.split("/");
194
+ if (parts.length >= 2) {
195
+ return parts.slice(-2).join("/");
196
+ }
197
+ return parts[parts.length - 1] || path;
146
198
  }
147
199
 
148
200
  /**
149
201
  * Truncate text with ellipsis.
150
202
  */
151
203
  function truncate(text: string, maxLen = 60): string {
152
- if (!text) return "";
153
- // Clean up newlines for display
154
- const cleaned = text.replace(/\n/g, " ").trim();
155
- if (cleaned.length <= maxLen) return cleaned;
156
- return cleaned.slice(0, maxLen) + "...";
204
+ if (!text) return "";
205
+ // Clean up newlines for display
206
+ const cleaned = text.replace(/\n/g, " ").trim();
207
+ if (cleaned.length <= maxLen) return cleaned;
208
+ return `${cleaned.slice(0, maxLen)}...`;
157
209
  }
158
210
 
159
211
  /**
160
212
  * Wrap text in HTML code tags, escaping special chars.
161
213
  */
162
214
  function code(text: string): string {
163
- return `<code>${escapeHtml(text)}</code>`;
215
+ return `<code>${escapeHtml(text)}</code>`;
164
216
  }
165
217
 
166
218
  /**
167
219
  * Format tool use for display in Telegram with HTML formatting.
168
220
  */
169
221
  export function formatToolStatus(
170
- toolName: string,
171
- toolInput: Record<string, unknown>
222
+ toolName: string,
223
+ toolInput: Record<string, unknown>,
172
224
  ): string {
173
- const emojiMap: Record<string, string> = {
174
- Read: "📖",
175
- Write: "📝",
176
- Edit: "✏️",
177
- Bash: "▶️",
178
- Glob: "🔍",
179
- Grep: "🔎",
180
- WebSearch: "🔍",
181
- WebFetch: "🌐",
182
- Task: "🎯",
183
- TodoWrite: "📋",
184
- mcp__: "🔧",
185
- };
186
-
187
- // Find matching emoji
188
- let emoji = "🔧";
189
- for (const [key, val] of Object.entries(emojiMap)) {
190
- if (toolName.includes(key)) {
191
- emoji = val;
192
- break;
193
- }
194
- }
195
-
196
- // Format based on tool type
197
- if (toolName === "Read") {
198
- const filePath = String(toolInput.file_path || "file");
199
- const shortPath = shortenPath(filePath);
200
- const imageExtensions = [
201
- ".jpg",
202
- ".jpeg",
203
- ".png",
204
- ".gif",
205
- ".webp",
206
- ".bmp",
207
- ".svg",
208
- ".ico",
209
- ];
210
- if (imageExtensions.some((ext) => filePath.toLowerCase().endsWith(ext))) {
211
- return "👀 Viewing";
212
- }
213
- return `${emoji} Reading ${code(shortPath)}`;
214
- }
215
-
216
- if (toolName === "Write") {
217
- const filePath = String(toolInput.file_path || "file");
218
- return `${emoji} Writing ${code(shortenPath(filePath))}`;
219
- }
220
-
221
- if (toolName === "Edit") {
222
- const filePath = String(toolInput.file_path || "file");
223
- return `${emoji} Editing ${code(shortenPath(filePath))}`;
224
- }
225
-
226
- if (toolName === "Bash") {
227
- const cmd = String(toolInput.command || "");
228
- const desc = String(toolInput.description || "");
229
- if (desc) {
230
- return `${emoji} ${escapeHtml(desc)}`;
231
- }
232
- return `${emoji} ${code(truncate(cmd, 50))}`;
233
- }
234
-
235
- if (toolName === "Grep") {
236
- const pattern = String(toolInput.pattern || "");
237
- const path = String(toolInput.path || "");
238
- if (path) {
239
- return `${emoji} Searching ${code(truncate(pattern, 30))} in ${code(
240
- shortenPath(path)
241
- )}`;
242
- }
243
- return `${emoji} Searching ${code(truncate(pattern, 40))}`;
244
- }
245
-
246
- if (toolName === "Glob") {
247
- const pattern = String(toolInput.pattern || "");
248
- return `${emoji} Finding ${code(truncate(pattern, 50))}`;
249
- }
250
-
251
- if (toolName === "WebSearch") {
252
- const query = String(toolInput.query || "");
253
- return `${emoji} Searching: ${escapeHtml(truncate(query, 50))}`;
254
- }
255
-
256
- if (toolName === "WebFetch") {
257
- const url = String(toolInput.url || "");
258
- return `${emoji} Fetching ${code(truncate(url, 50))}`;
259
- }
260
-
261
- if (toolName === "Task") {
262
- const desc = String(toolInput.description || "");
263
- if (desc) {
264
- return `${emoji} Agent: ${escapeHtml(desc)}`;
265
- }
266
- return `${emoji} Running agent...`;
267
- }
268
-
269
- if (toolName === "Skill") {
270
- const skillName = String(toolInput.skill || "");
271
- if (skillName) {
272
- return `💭 Using skill: ${escapeHtml(skillName)}`;
273
- }
274
- return `💭 Using skill...`;
275
- }
276
-
277
- if (toolName.startsWith("mcp__")) {
278
- // Generic MCP tool formatting
279
- const parts = toolName.split("__");
280
- if (parts.length >= 3) {
281
- const server = parts[1]!;
282
- let action = parts[2]!;
283
- // Remove redundant server prefix from action
284
- if (action.startsWith(`${server}_`)) {
285
- action = action.slice(server.length + 1);
286
- }
287
- action = action.replace(/_/g, " ");
288
-
289
- // Try to get meaningful summary
290
- const summary =
291
- toolInput.title ||
292
- toolInput.query ||
293
- toolInput.content ||
294
- toolInput.text ||
295
- toolInput.id ||
296
- "";
297
-
298
- if (summary) {
299
- return `🔧 ${server} ${action}: ${escapeHtml(
300
- truncate(String(summary), 40)
301
- )}`;
302
- }
303
- return `🔧 ${server}: ${action}`;
304
- }
305
- return `🔧 ${escapeHtml(toolName)}`;
306
- }
307
-
308
- return `${emoji} ${escapeHtml(toolName)}`;
225
+ const emojiMap: Record<string, string> = {
226
+ Read: "📖",
227
+ Write: "📝",
228
+ Edit: "✏️",
229
+ Bash: "▶️",
230
+ Glob: "🔍",
231
+ Grep: "🔎",
232
+ WebSearch: "🔍",
233
+ WebFetch: "🌐",
234
+ Task: "🎯",
235
+ TodoWrite: "📋",
236
+ mcp__: "🔧",
237
+ };
238
+
239
+ // Find matching emoji
240
+ let emoji = "🔧";
241
+ for (const [key, val] of Object.entries(emojiMap)) {
242
+ if (toolName.includes(key)) {
243
+ emoji = val;
244
+ break;
245
+ }
246
+ }
247
+
248
+ // Format based on tool type
249
+ if (toolName === "Read") {
250
+ const filePath = String(toolInput.file_path || "file");
251
+ const shortPath = shortenPath(filePath);
252
+ const imageExtensions = [
253
+ ".jpg",
254
+ ".jpeg",
255
+ ".png",
256
+ ".gif",
257
+ ".webp",
258
+ ".bmp",
259
+ ".svg",
260
+ ".ico",
261
+ ];
262
+ if (imageExtensions.some((ext) => filePath.toLowerCase().endsWith(ext))) {
263
+ return "👀 Viewing";
264
+ }
265
+ return `${emoji} Reading ${code(shortPath)}`;
266
+ }
267
+
268
+ if (toolName === "Write") {
269
+ const filePath = String(toolInput.file_path || "file");
270
+ return `${emoji} Writing ${code(shortenPath(filePath))}`;
271
+ }
272
+
273
+ if (toolName === "Edit") {
274
+ const filePath = String(toolInput.file_path || "file");
275
+ return `${emoji} Editing ${code(shortenPath(filePath))}`;
276
+ }
277
+
278
+ if (toolName === "Bash") {
279
+ const cmd = String(toolInput.command || "");
280
+ const desc = String(toolInput.description || "");
281
+ if (desc) {
282
+ return `${emoji} ${escapeHtml(desc)}`;
283
+ }
284
+ return `${emoji} ${code(truncate(cmd, 50))}`;
285
+ }
286
+
287
+ if (toolName === "Grep") {
288
+ const pattern = String(toolInput.pattern || "");
289
+ const path = String(toolInput.path || "");
290
+ if (path) {
291
+ return `${emoji} Searching ${code(truncate(pattern, 30))} in ${code(
292
+ shortenPath(path),
293
+ )}`;
294
+ }
295
+ return `${emoji} Searching ${code(truncate(pattern, 40))}`;
296
+ }
297
+
298
+ if (toolName === "Glob") {
299
+ const pattern = String(toolInput.pattern || "");
300
+ return `${emoji} Finding ${code(truncate(pattern, 50))}`;
301
+ }
302
+
303
+ if (toolName === "WebSearch") {
304
+ const query = String(toolInput.query || "");
305
+ return `${emoji} Searching: ${escapeHtml(truncate(query, 50))}`;
306
+ }
307
+
308
+ if (toolName === "WebFetch") {
309
+ const url = String(toolInput.url || "");
310
+ return `${emoji} Fetching ${code(truncate(url, 50))}`;
311
+ }
312
+
313
+ if (toolName === "Task") {
314
+ const desc = String(toolInput.description || "");
315
+ if (desc) {
316
+ return `${emoji} Agent: ${escapeHtml(desc)}`;
317
+ }
318
+ return `${emoji} Running agent...`;
319
+ }
320
+
321
+ if (toolName === "Skill") {
322
+ const skillName = String(toolInput.skill || "");
323
+ if (skillName) {
324
+ return `💭 Using skill: ${escapeHtml(skillName)}`;
325
+ }
326
+ return `💭 Using skill...`;
327
+ }
328
+
329
+ if (toolName.startsWith("mcp__")) {
330
+ // Generic MCP tool formatting
331
+ const parts = toolName.split("__");
332
+ if (parts.length >= 3) {
333
+ const server = parts[1]!;
334
+ let action = parts[2]!;
335
+ // Remove redundant server prefix from action
336
+ if (action.startsWith(`${server}_`)) {
337
+ action = action.slice(server.length + 1);
338
+ }
339
+ action = action.replace(/_/g, " ");
340
+
341
+ // Try to get meaningful summary
342
+ const summary =
343
+ toolInput.title ||
344
+ toolInput.query ||
345
+ toolInput.content ||
346
+ toolInput.text ||
347
+ toolInput.id ||
348
+ "";
349
+
350
+ if (summary) {
351
+ return `🔧 ${server} ${action}: ${escapeHtml(
352
+ truncate(String(summary), 40),
353
+ )}`;
354
+ }
355
+ return `🔧 ${server}: ${action}`;
356
+ }
357
+ return `🔧 ${escapeHtml(toolName)}`;
358
+ }
359
+
360
+ return `${emoji} ${escapeHtml(toolName)}`;
309
361
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Callback query handler for Claude Telegram Bot.
3
3
  *
4
- * Handles inline keyboard button presses (ask_user MCP integration, bookmarks).
4
+ * Handles inline keyboard button presses (ask_user MCP integration, bookmarks, file sending).
5
5
  */
6
6
 
7
7
  import { unlinkSync } from "node:fs";
@@ -39,6 +39,12 @@ export async function handleCallback(ctx: Context): Promise<void> {
39
39
  return;
40
40
  }
41
41
 
42
+ // 2b. Handle file sending callbacks
43
+ if (callbackData.startsWith("sendfile:")) {
44
+ await handleSendFileCallback(ctx, callbackData);
45
+ return;
46
+ }
47
+
42
48
  // 3. Parse callback data: askuser:{request_id}:{option_index}
43
49
  if (!callbackData.startsWith("askuser:")) {
44
50
  await ctx.answerCallbackQuery();
@@ -246,3 +252,42 @@ async function handleBookmarkCallback(
246
252
  await ctx.answerCallbackQuery({ text: "Unknown action" });
247
253
  }
248
254
  }
255
+
256
+ /**
257
+ * Handle file sending callbacks.
258
+ * Format: sendfile:base64encodedpath
259
+ */
260
+ async function handleSendFileCallback(
261
+ ctx: Context,
262
+ callbackData: string,
263
+ ): Promise<void> {
264
+ const { existsSync } = await import("node:fs");
265
+ const { basename } = await import("node:path");
266
+ const { InputFile } = await import("grammy");
267
+
268
+ // Decode the file path (base64 encoded to handle special chars)
269
+ const encodedPath = callbackData.slice("sendfile:".length);
270
+ let filePath: string;
271
+ try {
272
+ filePath = Buffer.from(encodedPath, "base64").toString("utf-8");
273
+ } catch {
274
+ await ctx.answerCallbackQuery({ text: "Invalid file path" });
275
+ return;
276
+ }
277
+
278
+ // Check file exists
279
+ if (!existsSync(filePath)) {
280
+ await ctx.answerCallbackQuery({ text: "File not found" });
281
+ return;
282
+ }
283
+
284
+ // Send the file
285
+ try {
286
+ await ctx.answerCallbackQuery({ text: "Sending file..." });
287
+ const fileName = basename(filePath);
288
+ await ctx.replyWithDocument(new InputFile(filePath, fileName));
289
+ } catch (error) {
290
+ console.error("Failed to send file:", error);
291
+ await ctx.reply(`❌ Failed to send file: ${String(error).slice(0, 100)}`);
292
+ }
293
+ }