ctb 1.0.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/.env.example +76 -0
- package/CLAUDE.md +116 -0
- package/LICENSE +21 -0
- package/Makefile +142 -0
- package/README.md +268 -0
- package/SECURITY.md +177 -0
- package/ask_user_mcp/server.ts +115 -0
- package/assets/demo-research.gif +0 -0
- package/assets/demo-video-summary.gif +0 -0
- package/assets/demo-workout.gif +0 -0
- package/assets/demo.gif +0 -0
- package/bun.lock +266 -0
- package/bunfig.toml +2 -0
- package/docs/personal-assistant-guide.md +549 -0
- package/launchagent/com.claude-telegram-ts.plist.template +76 -0
- package/launchagent/start.sh +14 -0
- package/mcp-config.example.ts +42 -0
- package/package.json +46 -0
- package/src/__tests__/formatting.test.ts +118 -0
- package/src/__tests__/security.test.ts +124 -0
- package/src/__tests__/setup.ts +8 -0
- package/src/bookmarks.ts +106 -0
- package/src/bot.ts +151 -0
- package/src/cli.ts +278 -0
- package/src/config.ts +254 -0
- package/src/formatting.ts +309 -0
- package/src/handlers/callback.ts +248 -0
- package/src/handlers/commands.ts +392 -0
- package/src/handlers/document.ts +585 -0
- package/src/handlers/index.ts +21 -0
- package/src/handlers/media-group.ts +205 -0
- package/src/handlers/photo.ts +215 -0
- package/src/handlers/streaming.ts +231 -0
- package/src/handlers/text.ts +128 -0
- package/src/handlers/voice.ts +138 -0
- package/src/index.ts +150 -0
- package/src/security.ts +209 -0
- package/src/session.ts +565 -0
- package/src/types.ts +77 -0
- package/src/utils.ts +246 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting module for Claude Telegram Bot.
|
|
3
|
+
*
|
|
4
|
+
* Markdown conversion and tool status display formatting.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Escape HTML special characters.
|
|
9
|
+
*/
|
|
10
|
+
export function escapeHtml(text: string): string {
|
|
11
|
+
return text
|
|
12
|
+
.replace(/&/g, "&")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">")
|
|
15
|
+
.replace(/"/g, """);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert standard markdown to Telegram-compatible HTML.
|
|
20
|
+
*
|
|
21
|
+
* HTML is more reliable than Telegram's Markdown which breaks on special chars.
|
|
22
|
+
* Telegram HTML supports: <b>, <i>, <code>, <pre>, <a href="">
|
|
23
|
+
*/
|
|
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[] = [];
|
|
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
|
+
});
|
|
34
|
+
|
|
35
|
+
// Save inline code (`code`)
|
|
36
|
+
text = text.replace(/`([^`]+)`/g, (_, code) => {
|
|
37
|
+
inlineCodes.push(code);
|
|
38
|
+
return `\x00INLINECODE${inlineCodes.length - 1}\x00`;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Escape HTML entities in the remaining text
|
|
42
|
+
text = escapeHtml(text);
|
|
43
|
+
|
|
44
|
+
// Headers: ## Header -> <b>Header</b>
|
|
45
|
+
text = text.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>\n");
|
|
46
|
+
|
|
47
|
+
// Bold: **text** -> <b>text</b>
|
|
48
|
+
text = text.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
|
49
|
+
|
|
50
|
+
// Also handle *text* as bold (single asterisk)
|
|
51
|
+
text = text.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, "<b>$1</b>");
|
|
52
|
+
|
|
53
|
+
// Double underscore: __text__ -> <b>text</b>
|
|
54
|
+
text = text.replace(/__([^_]+)__/g, "<b>$1</b>");
|
|
55
|
+
|
|
56
|
+
// Italic: _text_ -> <i>text</i> (but not __text__)
|
|
57
|
+
text = text.replace(/(?<!_)_([^_]+)_(?!_)/g, "<i>$1</i>");
|
|
58
|
+
|
|
59
|
+
// Blockquotes: > text -> <blockquote>text</blockquote>
|
|
60
|
+
text = convertBlockquotes(text);
|
|
61
|
+
|
|
62
|
+
// Bullet lists: - item or * item -> • item
|
|
63
|
+
text = text.replace(/^[-*] /gm, "• ");
|
|
64
|
+
|
|
65
|
+
// Horizontal rules: --- or *** -> blank line
|
|
66
|
+
text = text.replace(/^[-*]{3,}$/gm, "");
|
|
67
|
+
|
|
68
|
+
// Links: [text](url) -> <a href="url">text</a>
|
|
69
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
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
|
+
}
|
|
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
|
+
}
|
|
85
|
+
|
|
86
|
+
// Collapse multiple newlines
|
|
87
|
+
text = text.replace(/\n{3,}/g, "\n\n");
|
|
88
|
+
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert blockquotes (handles multi-line).
|
|
94
|
+
*/
|
|
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("> ") || line === ">") {
|
|
103
|
+
if (line === ">") {
|
|
104
|
+
blockquoteLines.push("");
|
|
105
|
+
} else {
|
|
106
|
+
// Remove '> ' 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");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Legacy alias
|
|
132
|
+
export const convertMarkdownForTelegram = convertMarkdownToHtml;
|
|
133
|
+
|
|
134
|
+
// ============== Tool Status Formatting ==============
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Shorten a file path for display (last 2 components).
|
|
138
|
+
*/
|
|
139
|
+
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;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Truncate text with ellipsis.
|
|
150
|
+
*/
|
|
151
|
+
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) + "...";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Wrap text in HTML code tags, escaping special chars.
|
|
161
|
+
*/
|
|
162
|
+
function code(text: string): string {
|
|
163
|
+
return `<code>${escapeHtml(text)}</code>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Format tool use for display in Telegram with HTML formatting.
|
|
168
|
+
*/
|
|
169
|
+
export function formatToolStatus(
|
|
170
|
+
toolName: string,
|
|
171
|
+
toolInput: Record<string, unknown>
|
|
172
|
+
): 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)}`;
|
|
309
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callback query handler for Claude Telegram Bot.
|
|
3
|
+
*
|
|
4
|
+
* Handles inline keyboard button presses (ask_user MCP integration, bookmarks).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { unlinkSync } from "node:fs";
|
|
8
|
+
import type { Context } from "grammy";
|
|
9
|
+
import { addBookmark, removeBookmark } from "../bookmarks";
|
|
10
|
+
import { ALLOWED_USERS } from "../config";
|
|
11
|
+
import { isAuthorized } from "../security";
|
|
12
|
+
import { session } from "../session";
|
|
13
|
+
import { auditLog, startTypingIndicator } from "../utils";
|
|
14
|
+
import { createStatusCallback, StreamingState } from "./streaming";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Handle callback queries from inline keyboards.
|
|
18
|
+
*/
|
|
19
|
+
export async function handleCallback(ctx: Context): Promise<void> {
|
|
20
|
+
const userId = ctx.from?.id;
|
|
21
|
+
const username = ctx.from?.username || "unknown";
|
|
22
|
+
const chatId = ctx.chat?.id;
|
|
23
|
+
const callbackData = ctx.callbackQuery?.data;
|
|
24
|
+
|
|
25
|
+
if (!userId || !chatId || !callbackData) {
|
|
26
|
+
await ctx.answerCallbackQuery();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 1. Authorization check
|
|
31
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
32
|
+
await ctx.answerCallbackQuery({ text: "Unauthorized" });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Handle bookmark callbacks
|
|
37
|
+
if (callbackData.startsWith("bookmark:")) {
|
|
38
|
+
await handleBookmarkCallback(ctx, callbackData);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. Parse callback data: askuser:{request_id}:{option_index}
|
|
43
|
+
if (!callbackData.startsWith("askuser:")) {
|
|
44
|
+
await ctx.answerCallbackQuery();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const parts = callbackData.split(":");
|
|
49
|
+
const requestId = parts[1];
|
|
50
|
+
const optionPart = parts[2];
|
|
51
|
+
if (parts.length !== 3 || !requestId || !optionPart) {
|
|
52
|
+
await ctx.answerCallbackQuery({ text: "Invalid callback data" });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const optionIndex = parseInt(optionPart, 10);
|
|
57
|
+
|
|
58
|
+
// 3. Load request file
|
|
59
|
+
const requestFile = `/tmp/ask-user-${requestId}.json`;
|
|
60
|
+
let requestData: {
|
|
61
|
+
question: string;
|
|
62
|
+
options: string[];
|
|
63
|
+
status: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const file = Bun.file(requestFile);
|
|
68
|
+
const text = await file.text();
|
|
69
|
+
requestData = JSON.parse(text);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error(`Failed to load ask-user request ${requestId}:`, error);
|
|
72
|
+
await ctx.answerCallbackQuery({ text: "Request expired or invalid" });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4. Get selected option
|
|
77
|
+
if (optionIndex < 0 || optionIndex >= requestData.options.length) {
|
|
78
|
+
await ctx.answerCallbackQuery({ text: "Invalid option" });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const selectedOption = requestData.options[optionIndex];
|
|
83
|
+
if (!selectedOption) {
|
|
84
|
+
await ctx.answerCallbackQuery({ text: "Invalid option" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 5. Update the message to show selection
|
|
89
|
+
try {
|
|
90
|
+
await ctx.editMessageText(`✓ ${selectedOption}`);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.debug("Failed to edit callback message:", error);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 6. Answer the callback
|
|
96
|
+
await ctx.answerCallbackQuery({
|
|
97
|
+
text: `Selected: ${selectedOption.slice(0, 50)}`,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// 7. Delete request file
|
|
101
|
+
try {
|
|
102
|
+
unlinkSync(requestFile);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.debug("Failed to delete request file:", error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 8. Send the choice to Claude as a message
|
|
108
|
+
const message = selectedOption;
|
|
109
|
+
|
|
110
|
+
// Interrupt any running query - button responses are always immediate
|
|
111
|
+
if (session.isRunning) {
|
|
112
|
+
console.log("Interrupting current query for button response");
|
|
113
|
+
await session.stop();
|
|
114
|
+
// Small delay to ensure clean interruption
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Start typing
|
|
119
|
+
const typing = startTypingIndicator(ctx);
|
|
120
|
+
|
|
121
|
+
// Create streaming state
|
|
122
|
+
const state = new StreamingState();
|
|
123
|
+
const statusCallback = createStatusCallback(ctx, state);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const response = await session.sendMessageStreaming(
|
|
127
|
+
message,
|
|
128
|
+
username,
|
|
129
|
+
userId,
|
|
130
|
+
statusCallback,
|
|
131
|
+
chatId,
|
|
132
|
+
ctx,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await auditLog(userId, username, "CALLBACK", message, response);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error("Error processing callback:", error);
|
|
138
|
+
|
|
139
|
+
for (const toolMsg of state.toolMessages) {
|
|
140
|
+
try {
|
|
141
|
+
await ctx.api.deleteMessage(toolMsg.chat.id, toolMsg.message_id);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.debug("Failed to delete tool message:", error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (String(error).includes("abort") || String(error).includes("cancel")) {
|
|
148
|
+
// Only show "Query stopped" if it was an explicit stop, not an interrupt from a new message
|
|
149
|
+
const wasInterrupt = session.consumeInterruptFlag();
|
|
150
|
+
if (!wasInterrupt) {
|
|
151
|
+
await ctx.reply("🛑 Query stopped.");
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
await ctx.reply(`❌ Error: ${String(error).slice(0, 200)}`);
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
typing.stop();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Handle bookmark-related callbacks.
|
|
163
|
+
*/
|
|
164
|
+
async function handleBookmarkCallback(
|
|
165
|
+
ctx: Context,
|
|
166
|
+
callbackData: string,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const parts = callbackData.split(":");
|
|
169
|
+
if (parts.length < 2) {
|
|
170
|
+
await ctx.answerCallbackQuery({ text: "Invalid bookmark action" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const action = parts[1];
|
|
175
|
+
const path = parts.slice(2).join(":"); // Path may contain colons
|
|
176
|
+
|
|
177
|
+
switch (action) {
|
|
178
|
+
case "noop":
|
|
179
|
+
await ctx.answerCallbackQuery({ text: "Already bookmarked" });
|
|
180
|
+
break;
|
|
181
|
+
|
|
182
|
+
case "add":
|
|
183
|
+
if (addBookmark(path)) {
|
|
184
|
+
await ctx.answerCallbackQuery({ text: "Bookmark added!" });
|
|
185
|
+
try {
|
|
186
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined });
|
|
187
|
+
} catch {
|
|
188
|
+
// Message may have been deleted
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
await ctx.answerCallbackQuery({ text: "Already bookmarked" });
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
|
|
195
|
+
case "new":
|
|
196
|
+
session.setWorkingDir(path);
|
|
197
|
+
await ctx.answerCallbackQuery({
|
|
198
|
+
text: `Changed to: ${path.slice(-30)}`,
|
|
199
|
+
});
|
|
200
|
+
await ctx.reply(
|
|
201
|
+
`📁 Changed to: <code>${path}</code>\n\nSession cleared. Next message starts fresh.`,
|
|
202
|
+
{ parse_mode: "HTML" },
|
|
203
|
+
);
|
|
204
|
+
break;
|
|
205
|
+
|
|
206
|
+
case "remove":
|
|
207
|
+
if (removeBookmark(path)) {
|
|
208
|
+
await ctx.answerCallbackQuery({ text: "Bookmark removed" });
|
|
209
|
+
// Remove the row from the keyboard by editing message
|
|
210
|
+
try {
|
|
211
|
+
// Re-fetch bookmarks and rebuild keyboard
|
|
212
|
+
const { loadBookmarks } = await import("../bookmarks");
|
|
213
|
+
const { InlineKeyboard } = await import("grammy");
|
|
214
|
+
const bookmarks = loadBookmarks();
|
|
215
|
+
|
|
216
|
+
if (bookmarks.length === 0) {
|
|
217
|
+
await ctx.editMessageText(
|
|
218
|
+
"📚 No bookmarks.\n\n" +
|
|
219
|
+
"Use <code>/cd /path/to/dir</code> and click 'Add to bookmarks'.",
|
|
220
|
+
{ parse_mode: "HTML" },
|
|
221
|
+
);
|
|
222
|
+
} else {
|
|
223
|
+
let message = "📚 <b>Bookmarks</b>\n\n";
|
|
224
|
+
const keyboard = new InlineKeyboard();
|
|
225
|
+
for (const bookmark of bookmarks) {
|
|
226
|
+
message += `📁 <code>${bookmark.path}</code>\n`;
|
|
227
|
+
keyboard
|
|
228
|
+
.text(`🆕 ${bookmark.name}`, `bookmark:new:${bookmark.path}`)
|
|
229
|
+
.text("🗑️", `bookmark:remove:${bookmark.path}`)
|
|
230
|
+
.row();
|
|
231
|
+
}
|
|
232
|
+
await ctx.editMessageText(message, {
|
|
233
|
+
parse_mode: "HTML",
|
|
234
|
+
reply_markup: keyboard,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// Message may have been deleted
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
await ctx.answerCallbackQuery({ text: "Bookmark not found" });
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
244
|
+
|
|
245
|
+
default:
|
|
246
|
+
await ctx.answerCallbackQuery({ text: "Unknown action" });
|
|
247
|
+
}
|
|
248
|
+
}
|