@gonzih/cc-tg 0.9.2 → 0.9.3

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/dist/bot.js CHANGED
@@ -11,16 +11,16 @@ import https from "https";
11
11
  import http from "http";
12
12
  import { ClaudeProcess, extractText } from "./claude.js";
13
13
  import { transcribeVoice, isVoiceAvailable } from "./voice.js";
14
- import { CronManager } from "./cron.js";
15
14
  import { formatForTelegram, splitLongMessage } from "./formatter.js";
16
15
  import { detectUsageLimit } from "./usage-limit.js";
16
+ import { getCurrentToken, rotateToken, getTokenIndex, getTokenCount } from "./tokens.js";
17
+ import { writeChatLog } from "./notifier.js";
17
18
  const BOT_COMMANDS = [
18
19
  { command: "start", description: "Reset session and start fresh" },
19
20
  { command: "reset", description: "Reset Claude session" },
20
21
  { command: "stop", description: "Stop the current Claude task" },
21
22
  { command: "status", description: "Check if a session is active" },
22
23
  { command: "help", description: "Show all available commands" },
23
- { command: "cron", description: "Manage cron jobs — add/list/edit/remove/clear" },
24
24
  { command: "reload_mcp", description: "Restart the cc-agent MCP server process" },
25
25
  { command: "mcp_status", description: "Check MCP server connection status" },
26
26
  { command: "mcp_version", description: "Show cc-agent npm version and npx cache info" },
@@ -28,6 +28,7 @@ const BOT_COMMANDS = [
28
28
  { command: "restart", description: "Restart the bot process in-place" },
29
29
  { command: "get_file", description: "Send a file from the server to this chat" },
30
30
  { command: "cost", description: "Show session token usage and cost" },
31
+ { command: "skills", description: "List available Claude skills with descriptions" },
31
32
  ];
32
33
  const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
33
34
  const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
@@ -64,10 +65,6 @@ function formatCostReport(cost) {
64
65
  ` Cache write: ${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`,
65
66
  ].join("\n");
66
67
  }
67
- function formatCronCostFooter(usage) {
68
- const cost = computeCostUsd(usage);
69
- return `\nšŸ’° Cron cost: $${cost.toFixed(4)} (${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out tokens)`;
70
- }
71
68
  function formatAgentCostSummary(text) {
72
69
  try {
73
70
  const data = JSON.parse(text);
@@ -154,12 +151,16 @@ export class CcTgBot {
154
151
  sessions = new Map();
155
152
  pendingRetries = new Map();
156
153
  opts;
157
- cron;
158
154
  costStore;
159
155
  botUsername = "";
160
156
  botId = 0;
157
+ redis;
158
+ namespace;
159
+ lastActiveChatId;
161
160
  constructor(opts) {
162
161
  this.opts = opts;
162
+ this.redis = opts.redis;
163
+ this.namespace = opts.namespace ?? "default";
163
164
  this.bot = new TelegramBot(opts.telegramToken, { polling: true });
164
165
  this.bot.on("message", (msg) => this.handleTelegram(msg));
165
166
  this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
@@ -168,10 +169,6 @@ export class CcTgBot {
168
169
  this.botId = me.id;
169
170
  console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
170
171
  }).catch((err) => console.error("[tg] getMe failed:", err.message));
171
- // Cron manager — fires each task into an isolated ClaudeProcess
172
- this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt) => {
173
- this.runCronTask(chatId, prompt);
174
- });
175
172
  this.costStore = new CostStore(opts.cwd ?? process.cwd());
176
173
  this.registerBotCommands();
177
174
  console.log("cc-tg bot started");
@@ -182,6 +179,55 @@ export class CcTgBot {
182
179
  .then(() => console.log("[tg] bot commands registered"))
183
180
  .catch((err) => console.error("[tg] setMyCommands failed:", err.message));
184
181
  }
182
+ /** Write a message to the Redis chat log. Fire-and-forget — no-op if Redis is not configured. */
183
+ writeChatMessage(role, source, content, chatId) {
184
+ if (!this.redis)
185
+ return;
186
+ const msg = {
187
+ id: `${source}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
188
+ source,
189
+ role,
190
+ content,
191
+ timestamp: new Date().toISOString(),
192
+ chatId,
193
+ };
194
+ writeChatLog(this.redis, this.namespace, msg);
195
+ }
196
+ /** Returns the last chatId that sent a message — used by the chat bridge when no fixed chatId is configured. */
197
+ getLastActiveChatId() {
198
+ return this.lastActiveChatId;
199
+ }
200
+ /** Session key: "chatId:threadId" for topics, "chatId:main" for DMs/non-topic groups */
201
+ sessionKey(chatId, threadId) {
202
+ return `${chatId}:${threadId ?? 'main'}`;
203
+ }
204
+ /**
205
+ * Send a message back to the correct thread (or plain chat if no thread).
206
+ * When threadId is undefined, calls sendMessage with exactly 2 args to preserve
207
+ * backward-compatible call signatures (no extra options object).
208
+ */
209
+ replyToChat(chatId, text, threadId, opts) {
210
+ if (threadId !== undefined) {
211
+ return this.bot.sendMessage(chatId, text, { ...opts, message_thread_id: threadId });
212
+ }
213
+ if (opts) {
214
+ return this.bot.sendMessage(chatId, text, opts);
215
+ }
216
+ return this.bot.sendMessage(chatId, text);
217
+ }
218
+ /** Parse THREAD_CWD_MAP env var — maps thread name or thread_id to a CWD path */
219
+ getThreadCwdMap() {
220
+ const raw = process.env.THREAD_CWD_MAP;
221
+ if (!raw)
222
+ return {};
223
+ try {
224
+ return JSON.parse(raw);
225
+ }
226
+ catch {
227
+ console.warn('[cc-tg] THREAD_CWD_MAP is not valid JSON, ignoring');
228
+ return {};
229
+ }
230
+ }
185
231
  isAllowed(userId) {
186
232
  if (!this.opts.allowedUserIds?.length)
187
233
  return true;
@@ -190,10 +236,20 @@ export class CcTgBot {
190
236
  async handleTelegram(msg) {
191
237
  const chatId = msg.chat.id;
192
238
  const userId = msg.from?.id ?? chatId;
239
+ // Forum topic thread_id — undefined for DMs and non-topic group messages
240
+ const threadId = msg.message_thread_id;
241
+ // Thread name is available on the service message that creates a new topic.
242
+ // forum_topic_created is not in older @types/node-telegram-bot-api versions, so cast via unknown.
243
+ const rawMsg = msg;
244
+ const threadName = rawMsg.forum_topic_created
245
+ ? rawMsg.forum_topic_created.name
246
+ : undefined;
193
247
  if (!this.isAllowed(userId)) {
194
- await this.bot.sendMessage(chatId, "Not authorized.");
248
+ await this.replyToChat(chatId, "Not authorized.", threadId);
195
249
  return;
196
250
  }
251
+ // Track the last chat that sent us a message for the chat bridge
252
+ this.lastActiveChatId = chatId;
197
253
  // Group chat handling
198
254
  const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
199
255
  if (isGroup) {
@@ -212,17 +268,17 @@ export class CcTgBot {
212
268
  }
213
269
  // Voice message — transcribe then feed as text
214
270
  if (msg.voice || msg.audio) {
215
- await this.handleVoice(chatId, msg);
271
+ await this.handleVoice(chatId, msg, threadId, threadName);
216
272
  return;
217
273
  }
218
274
  // Photo — send as base64 image content block to Claude
219
275
  if (msg.photo?.length) {
220
- await this.handlePhoto(chatId, msg);
276
+ await this.handlePhoto(chatId, msg, threadId, threadName);
221
277
  return;
222
278
  }
223
279
  // Document — download to CWD/.cc-tg/uploads/, tell Claude the path
224
280
  if (msg.document) {
225
- await this.handleDocument(chatId, msg);
281
+ await this.handleDocument(chatId, msg, threadId, threadName);
226
282
  return;
227
283
  }
228
284
  let text = msg.text?.trim();
@@ -232,68 +288,64 @@ export class CcTgBot {
232
288
  if (this.botUsername) {
233
289
  text = text.replace(new RegExp(`@${this.botUsername}\\s*`, "g"), "").trim();
234
290
  }
291
+ const sessionKey = this.sessionKey(chatId, threadId);
235
292
  // /start or /reset — kill existing session and ack
236
293
  if (text === "/start" || text === "/reset") {
237
- this.killSession(chatId);
238
- await this.bot.sendMessage(chatId, "Session reset. Send a message to start.");
294
+ this.killSession(chatId, true, threadId);
295
+ await this.replyToChat(chatId, "Session reset. Send a message to start.", threadId);
239
296
  return;
240
297
  }
241
298
  // /stop — kill active session (interrupt running Claude task)
242
299
  if (text === "/stop") {
243
- const has = this.sessions.has(chatId);
244
- this.killSession(chatId);
245
- await this.bot.sendMessage(chatId, has ? "Stopped." : "No active session.");
300
+ const has = this.sessions.has(sessionKey);
301
+ this.killSession(chatId, true, threadId);
302
+ await this.replyToChat(chatId, has ? "Stopped." : "No active session.", threadId);
246
303
  return;
247
304
  }
248
305
  // /help — list all commands
249
306
  if (text === "/help") {
250
307
  const lines = BOT_COMMANDS.map((c) => `/${c.command} — ${c.description}`);
251
- await this.bot.sendMessage(chatId, lines.join("\n"));
308
+ await this.replyToChat(chatId, lines.join("\n"), threadId);
252
309
  return;
253
310
  }
254
311
  // /status
255
312
  if (text === "/status") {
256
- const has = this.sessions.has(chatId);
313
+ const has = this.sessions.has(sessionKey);
257
314
  let status = has ? "Session active." : "No active session.";
258
315
  const sleeping = this.pendingRetries.size;
259
316
  if (sleeping > 0)
260
317
  status += `\nāø ${sleeping} request(s) sleeping (usage limit).`;
261
- await this.bot.sendMessage(chatId, status);
262
- return;
263
- }
264
- // /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
265
- if (text.startsWith("/cron")) {
266
- await this.handleCron(chatId, text);
318
+ await this.replyToChat(chatId, status, threadId);
267
319
  return;
268
320
  }
269
321
  // /reload_mcp — kill cc-agent process so Claude Code auto-restarts it
270
322
  if (text === "/reload_mcp") {
271
- await this.handleReloadMcp(chatId);
323
+ await this.handleReloadMcp(chatId, threadId);
272
324
  return;
273
325
  }
274
326
  // /mcp_status — run `claude mcp list` and show connection status
275
327
  if (text === "/mcp_status") {
276
- await this.handleMcpStatus(chatId);
328
+ await this.handleMcpStatus(chatId, threadId);
277
329
  return;
278
330
  }
279
331
  // /mcp_version — show published npm version and cached npx entries
280
332
  if (text === "/mcp_version") {
281
- await this.handleMcpVersion(chatId);
333
+ await this.handleMcpVersion(chatId, threadId);
282
334
  return;
283
335
  }
284
336
  // /clear_npx_cache — wipe ~/.npm/_npx/ then restart cc-agent
285
337
  if (text === "/clear_npx_cache") {
286
- await this.handleClearNpxCache(chatId);
338
+ await this.handleClearNpxCache(chatId, threadId);
287
339
  return;
288
340
  }
289
341
  // /restart — restart the bot process in-place
290
342
  if (text === "/restart") {
291
- await this.handleRestart(chatId);
343
+ await this.handleRestart(chatId, threadId);
292
344
  return;
293
345
  }
294
346
  // /get_file <path> — send a file from the server to the user
295
347
  if (text.startsWith("/get_file")) {
296
- await this.handleGetFile(chatId, text);
348
+ await this.handleGetFile(chatId, text, threadId);
297
349
  return;
298
350
  }
299
351
  // /cost — show session token usage and cost
@@ -309,37 +361,62 @@ export class CcTgBot {
309
361
  catch (err) {
310
362
  console.error("[cost] cc-agent cost_summary failed:", err.message);
311
363
  }
312
- await this.bot.sendMessage(chatId, reply);
364
+ await this.replyToChat(chatId, reply, threadId);
313
365
  return;
314
366
  }
315
- const session = this.getOrCreateSession(chatId);
367
+ // /skills — list available Claude skills from ~/.claude/skills/
368
+ if (text === "/skills") {
369
+ await this.replyToChat(chatId, listSkills(), threadId);
370
+ return;
371
+ }
372
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
316
373
  try {
317
- const prompt = buildPromptWithReplyContext(text, msg);
374
+ const enriched = await enrichPromptWithUrls(text);
375
+ const prompt = buildPromptWithReplyContext(enriched, msg);
318
376
  session.currentPrompt = prompt;
319
377
  session.claude.sendPrompt(prompt);
320
378
  this.startTyping(chatId, session);
379
+ this.writeChatMessage("user", "telegram", text, chatId);
380
+ }
381
+ catch (err) {
382
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
383
+ this.killSession(chatId, true, threadId);
384
+ }
385
+ }
386
+ /**
387
+ * Feed a text message into the active Claude session for the given chat.
388
+ * Called by the notifier when a UI message arrives via Redis pub/sub.
389
+ */
390
+ async handleUserMessage(chatId, text) {
391
+ const session = this.getOrCreateSession(chatId);
392
+ try {
393
+ const enriched = await enrichPromptWithUrls(text);
394
+ session.currentPrompt = enriched;
395
+ session.claude.sendPrompt(enriched);
396
+ this.startTyping(chatId, session);
397
+ this.writeChatMessage("user", "ui", text, chatId);
321
398
  }
322
399
  catch (err) {
323
- await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
324
- this.killSession(chatId);
400
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`);
401
+ this.killSession(chatId, true);
325
402
  }
326
403
  }
327
- async handleVoice(chatId, msg) {
404
+ async handleVoice(chatId, msg, threadId, threadName) {
328
405
  const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
329
406
  if (!fileId)
330
407
  return;
331
408
  console.log(`[voice:${chatId}] received voice message, transcribing...`);
332
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
409
+ this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
333
410
  try {
334
411
  const fileLink = await this.bot.getFileLink(fileId);
335
412
  const transcript = await transcribeVoice(fileLink);
336
413
  console.log(`[voice:${chatId}] transcribed: ${transcript}`);
337
414
  if (!transcript || transcript === "[empty transcription]") {
338
- await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
415
+ await this.replyToChat(chatId, "Could not transcribe voice message.", threadId);
339
416
  return;
340
417
  }
341
418
  // Feed transcript into Claude as if user typed it
342
- const session = this.getOrCreateSession(chatId);
419
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
343
420
  try {
344
421
  const prompt = buildPromptWithReplyContext(transcript, msg);
345
422
  session.currentPrompt = prompt;
@@ -347,41 +424,41 @@ export class CcTgBot {
347
424
  this.startTyping(chatId, session);
348
425
  }
349
426
  catch (err) {
350
- await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
351
- this.killSession(chatId);
427
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
428
+ this.killSession(chatId, true, threadId);
352
429
  }
353
430
  }
354
431
  catch (err) {
355
432
  console.error(`[voice:${chatId}] error:`, err.message);
356
- await this.bot.sendMessage(chatId, `Voice transcription failed: ${err.message}`);
433
+ await this.replyToChat(chatId, `Voice transcription failed: ${err.message}`, threadId);
357
434
  }
358
435
  }
359
- async handlePhoto(chatId, msg) {
436
+ async handlePhoto(chatId, msg, threadId, threadName) {
360
437
  // Pick highest resolution photo
361
438
  const photos = msg.photo;
362
439
  const best = photos[photos.length - 1];
363
440
  const caption = msg.caption?.trim();
364
441
  console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
365
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
442
+ this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
366
443
  try {
367
444
  const fileLink = await this.bot.getFileLink(best.file_id);
368
445
  const imageData = await fetchAsBase64(fileLink);
369
446
  // Telegram photos are always JPEG
370
- const session = this.getOrCreateSession(chatId);
447
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
371
448
  session.claude.sendImage(imageData, "image/jpeg", caption);
372
449
  this.startTyping(chatId, session);
373
450
  }
374
451
  catch (err) {
375
452
  console.error(`[photo:${chatId}] error:`, err.message);
376
- await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
453
+ await this.replyToChat(chatId, `Failed to process image: ${err.message}`, threadId);
377
454
  }
378
455
  }
379
- async handleDocument(chatId, msg) {
456
+ async handleDocument(chatId, msg, threadId, threadName) {
380
457
  const doc = msg.document;
381
458
  const caption = msg.caption?.trim();
382
459
  const fileName = doc.file_name ?? `file_${doc.file_id}`;
383
460
  console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
384
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
461
+ this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
385
462
  try {
386
463
  const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
387
464
  mkdirSync(uploadsDir, { recursive: true });
@@ -392,22 +469,34 @@ export class CcTgBot {
392
469
  const prompt = caption
393
470
  ? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
394
471
  : `ATTACHMENTS: [${fileName}](${destPath})`;
395
- const session = this.getOrCreateSession(chatId);
472
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
396
473
  session.claude.sendPrompt(prompt);
397
474
  this.startTyping(chatId, session);
398
475
  }
399
476
  catch (err) {
400
477
  console.error(`[doc:${chatId}] error:`, err.message);
401
- await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
478
+ await this.replyToChat(chatId, `Failed to receive document: ${err.message}`, threadId);
402
479
  }
403
480
  }
404
- getOrCreateSession(chatId) {
405
- const existing = this.sessions.get(chatId);
481
+ getOrCreateSession(chatId, threadId, threadName) {
482
+ const key = this.sessionKey(chatId, threadId);
483
+ const existing = this.sessions.get(key);
406
484
  if (existing && !existing.claude.exited)
407
485
  return existing;
486
+ // Determine CWD for this thread — check THREAD_CWD_MAP by name then by ID
487
+ let sessionCwd = this.opts.cwd;
488
+ const threadCwdMap = this.getThreadCwdMap();
489
+ if (threadName && threadCwdMap[threadName]) {
490
+ sessionCwd = threadCwdMap[threadName];
491
+ console.log(`[cc-tg] thread "${threadName}" → cwd: ${sessionCwd}`);
492
+ }
493
+ else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
494
+ sessionCwd = threadCwdMap[String(threadId)];
495
+ console.log(`[cc-tg] thread ${threadId} → cwd: ${sessionCwd}`);
496
+ }
408
497
  const claude = new ClaudeProcess({
409
- cwd: this.opts.cwd,
410
- token: this.opts.claudeToken,
498
+ cwd: sessionCwd,
499
+ token: getCurrentToken() || this.opts.claudeToken,
411
500
  });
412
501
  const session = {
413
502
  claude,
@@ -417,6 +506,7 @@ export class CcTgBot {
417
506
  writtenFiles: new Set(),
418
507
  currentPrompt: "",
419
508
  isRetry: false,
509
+ threadId,
420
510
  };
421
511
  claude.on("usage", (usage) => {
422
512
  this.costStore.addUsage(chatId, usage);
@@ -425,33 +515,47 @@ export class CcTgBot {
425
515
  // Verbose logging — log every message type and subtype
426
516
  const subtype = msg.payload.subtype ?? "";
427
517
  const toolName = this.extractToolName(msg);
428
- const logParts = [`[claude:${chatId}] msg=${msg.type}`];
518
+ const logParts = [`[claude:${key}] msg=${msg.type}`];
429
519
  if (subtype)
430
520
  logParts.push(`subtype=${subtype}`);
431
521
  if (toolName)
432
522
  logParts.push(`tool=${toolName}`);
433
523
  console.log(logParts.join(" "));
434
524
  // Track files written by Write/Edit tool calls
435
- this.trackWrittenFiles(msg, session, this.opts.cwd);
525
+ this.trackWrittenFiles(msg, session, sessionCwd);
526
+ // Publish tool call events to the chat log
527
+ if (msg.type === "assistant") {
528
+ const message = msg.payload.message;
529
+ const content = message?.content;
530
+ if (Array.isArray(content)) {
531
+ for (const block of content) {
532
+ if (block.type !== "tool_use")
533
+ continue;
534
+ const name = block.name;
535
+ const input = block.input;
536
+ this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {}).slice(0, 120)}`, chatId);
537
+ }
538
+ }
539
+ }
436
540
  this.handleClaudeMessage(chatId, session, msg);
437
541
  });
438
542
  claude.on("stderr", (data) => {
439
543
  const line = data.trim();
440
544
  if (line)
441
- console.error(`[claude:${chatId}:stderr]`, line);
545
+ console.error(`[claude:${key}:stderr]`, line);
442
546
  });
443
547
  claude.on("exit", (code) => {
444
- console.log(`[claude:${chatId}] exited code=${code}`);
548
+ console.log(`[claude:${key}] exited code=${code}`);
445
549
  this.stopTyping(session);
446
- this.sessions.delete(chatId);
550
+ this.sessions.delete(key);
447
551
  });
448
552
  claude.on("error", (err) => {
449
- console.error(`[claude:${chatId}] process error: ${err.message}`);
553
+ console.error(`[claude:${key}] process error: ${err.message}`);
450
554
  this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
451
555
  this.stopTyping(session);
452
- this.sessions.delete(chatId);
556
+ this.sessions.delete(key);
453
557
  });
454
- this.sessions.set(chatId, session);
558
+ this.sessions.set(key, session);
455
559
  return session;
456
560
  }
457
561
  handleClaudeMessage(chatId, session, msg) {
@@ -467,33 +571,58 @@ export class CcTgBot {
467
571
  // Check for usage/rate limit signals before forwarding to Telegram
468
572
  const sig = detectUsageLimit(text);
469
573
  if (sig.detected) {
574
+ const threadId = session.threadId;
575
+ const retryKey = this.sessionKey(chatId, threadId);
470
576
  const lastPrompt = session.currentPrompt;
471
- const prevRetry = this.pendingRetries.get(chatId);
577
+ const prevRetry = this.pendingRetries.get(retryKey);
472
578
  const attempt = (prevRetry?.attempt ?? 0) + 1;
473
579
  if (prevRetry)
474
580
  clearTimeout(prevRetry.timer);
475
- this.bot.sendMessage(chatId, sig.humanMessage).catch(() => { });
476
- this.killSession(chatId);
581
+ this.replyToChat(chatId, sig.humanMessage, threadId).catch(() => { });
582
+ this.killSession(chatId, true, threadId);
583
+ // Token rotation: if this is a usage_exhausted signal and we have multiple
584
+ // tokens, rotate to the next one and retry immediately instead of sleeping.
585
+ // Only rotate if we haven't yet cycled through all tokens (attempt <= count-1).
586
+ if (sig.reason === "usage_exhausted" && getTokenCount() > 1 && attempt <= getTokenCount() - 1) {
587
+ const prevIdx = getTokenIndex();
588
+ rotateToken();
589
+ const newIdx = getTokenIndex();
590
+ const total = getTokenCount();
591
+ console.log(`[cc-tg] Token ${prevIdx + 1}/${total} exhausted, rotating to token ${newIdx + 1}/${total}`);
592
+ this.replyToChat(chatId, `šŸ”„ Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`, threadId).catch(() => { });
593
+ this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
594
+ try {
595
+ const retrySession = this.getOrCreateSession(chatId, threadId);
596
+ retrySession.currentPrompt = lastPrompt;
597
+ retrySession.isRetry = true;
598
+ retrySession.claude.sendPrompt(lastPrompt);
599
+ this.startTyping(chatId, retrySession);
600
+ }
601
+ catch (err) {
602
+ this.replyToChat(chatId, `āŒ Failed to retry with rotated token: ${err.message}`, threadId).catch(() => { });
603
+ }
604
+ return;
605
+ }
477
606
  if (attempt > 3) {
478
- this.bot.sendMessage(chatId, "āŒ Claude usage limit persists after 3 retries. Please try again later.").catch(() => { });
479
- this.pendingRetries.delete(chatId);
607
+ this.replyToChat(chatId, "āŒ Claude usage limit persists after 3 retries. Please try again later.", threadId).catch(() => { });
608
+ this.pendingRetries.delete(retryKey);
480
609
  return;
481
610
  }
482
- console.log(`[usage-limit:${chatId}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
611
+ console.log(`[usage-limit:${retryKey}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
483
612
  const timer = setTimeout(() => {
484
- this.pendingRetries.delete(chatId);
613
+ this.pendingRetries.delete(retryKey);
485
614
  try {
486
- const retrySession = this.getOrCreateSession(chatId);
615
+ const retrySession = this.getOrCreateSession(chatId, threadId);
487
616
  retrySession.currentPrompt = lastPrompt;
488
617
  retrySession.isRetry = true;
489
618
  retrySession.claude.sendPrompt(lastPrompt);
490
619
  this.startTyping(chatId, retrySession);
491
620
  }
492
621
  catch (err) {
493
- this.bot.sendMessage(chatId, `āŒ Failed to retry: ${err.message}`).catch(() => { });
622
+ this.replyToChat(chatId, `āŒ Failed to retry: ${err.message}`, threadId).catch(() => { });
494
623
  }
495
624
  }, sig.retryAfterMs);
496
- this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer });
625
+ this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer });
497
626
  return;
498
627
  }
499
628
  // Accumulate text and debounce — Claude streams chunks rapidly
@@ -505,9 +634,11 @@ export class CcTgBot {
505
634
  startTyping(chatId, session) {
506
635
  this.stopTyping(session);
507
636
  // Send immediately, then keep alive every 4s
508
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
637
+ // Pass message_thread_id so typing appears in the correct forum topic thread
638
+ const threadOpts = session.threadId !== undefined ? { message_thread_id: session.threadId } : undefined;
639
+ this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
509
640
  session.typingTimer = setInterval(() => {
510
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
641
+ this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
511
642
  }, TYPING_INTERVAL_MS);
512
643
  }
513
644
  stopTyping(session) {
@@ -522,15 +653,17 @@ export class CcTgBot {
522
653
  session.flushTimer = null;
523
654
  if (!raw)
524
655
  return;
656
+ this.writeChatMessage("assistant", "cc-tg", raw, chatId);
525
657
  const text = session.isRetry ? `āœ… Claude is back!\n\n${raw}` : raw;
526
658
  session.isRetry = false;
527
- // Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
659
+ // Format for Telegram HTML and split if needed (max 4096 chars)
528
660
  const formatted = formatForTelegram(text);
529
661
  const chunks = splitLongMessage(formatted);
662
+ const threadId = session.threadId;
530
663
  for (const chunk of chunks) {
531
- this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" }).catch(() => {
532
- // MarkdownV2 parse failed — retry as plain text
533
- this.bot.sendMessage(chatId, chunk).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
664
+ this.replyToChat(chatId, chunk, threadId, { parse_mode: "HTML" }).catch(() => {
665
+ // HTML parse failed — retry as plain text
666
+ this.replyToChat(chatId, chunk, threadId).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
534
667
  });
535
668
  }
536
669
  // Hybrid file upload: find files mentioned in result text that Claude actually wrote
@@ -703,11 +836,12 @@ export class CcTgBot {
703
836
  const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
704
837
  if (fileSize > MAX_TG_FILE_BYTES) {
705
838
  const mb = (fileSize / (1024 * 1024)).toFixed(1);
706
- this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`).catch(() => { });
839
+ this.replyToChat(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`, session.threadId).catch(() => { });
707
840
  continue;
708
841
  }
709
842
  console.log(`[claude:files] uploading to telegram: ${filePath}`);
710
- this.bot.sendDocument(chatId, filePath).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
843
+ const docOpts = session.threadId ? { message_thread_id: session.threadId } : undefined;
844
+ this.bot.sendDocument(chatId, filePath, docOpts).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
711
845
  }
712
846
  // Clear written files for next turn
713
847
  session.writtenFiles.clear();
@@ -722,203 +856,6 @@ export class CcTgBot {
722
856
  const toolUse = content.find((b) => b.type === "tool_use");
723
857
  return toolUse?.name ?? "";
724
858
  }
725
- runCronTask(chatId, prompt) {
726
- // Fresh isolated Claude session — never touches main conversation
727
- const cronProcess = new ClaudeProcess({
728
- cwd: this.opts.cwd,
729
- token: this.opts.claudeToken,
730
- });
731
- const taskPrompt = [
732
- "You are handling a scheduled background task.",
733
- "This is NOT part of the user's ongoing conversation.",
734
- "Be concise. Report results only. No greetings or pleasantries.",
735
- "If there is nothing to report, say so in one sentence.",
736
- "",
737
- `SCHEDULED TASK: ${prompt}`,
738
- ].join("\n");
739
- let output = "";
740
- const cronUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
741
- cronProcess.on("usage", (usage) => {
742
- cronUsage.inputTokens += usage.inputTokens;
743
- cronUsage.outputTokens += usage.outputTokens;
744
- cronUsage.cacheReadTokens += usage.cacheReadTokens;
745
- cronUsage.cacheWriteTokens += usage.cacheWriteTokens;
746
- });
747
- cronProcess.on("message", (msg) => {
748
- if (msg.type === "result") {
749
- const text = extractText(msg);
750
- if (text)
751
- output += text;
752
- const result = output.trim();
753
- if (result) {
754
- let footer = "";
755
- try {
756
- footer = formatCronCostFooter(cronUsage);
757
- }
758
- catch (err) {
759
- console.error(`[cron] cost footer error:`, err.message);
760
- }
761
- const cronFormatted = formatForTelegram(`šŸ• ${result}${footer}`);
762
- const chunks = splitLongMessage(cronFormatted);
763
- (async () => {
764
- for (const chunk of chunks) {
765
- try {
766
- await this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" });
767
- }
768
- catch {
769
- // MarkdownV2 parse failed — retry as plain text
770
- try {
771
- await this.bot.sendMessage(chatId, chunk);
772
- }
773
- catch (err) {
774
- console.error(`[cron] failed to send result to chat=${chatId}:`, err.message);
775
- }
776
- }
777
- }
778
- })();
779
- }
780
- cronProcess.kill();
781
- }
782
- });
783
- cronProcess.on("error", (err) => {
784
- console.error(`[cron] task error for chat=${chatId}:`, err.message);
785
- cronProcess.kill();
786
- });
787
- cronProcess.on("exit", () => {
788
- console.log(`[cron] task complete for chat=${chatId}`);
789
- });
790
- cronProcess.sendPrompt(taskPrompt);
791
- }
792
- async handleCron(chatId, text) {
793
- const args = text.slice("/cron".length).trim();
794
- // /cron list
795
- if (args === "list" || args === "") {
796
- const jobs = this.cron.list(chatId);
797
- if (!jobs.length) {
798
- await this.bot.sendMessage(chatId, "No cron jobs.");
799
- return;
800
- }
801
- const lines = jobs.map((j, i) => {
802
- const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
803
- return `#${i + 1} ${j.schedule} — "${short}"`;
804
- });
805
- await this.bot.sendMessage(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`);
806
- return;
807
- }
808
- // /cron clear
809
- if (args === "clear") {
810
- const n = this.cron.clearAll(chatId);
811
- await this.bot.sendMessage(chatId, `Cleared ${n} cron job(s).`);
812
- return;
813
- }
814
- // /cron remove <id>
815
- if (args.startsWith("remove ")) {
816
- const id = args.slice("remove ".length).trim();
817
- const ok = this.cron.remove(chatId, id);
818
- await this.bot.sendMessage(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`);
819
- return;
820
- }
821
- // /cron edit [<#> ...]
822
- if (args === "edit" || args.startsWith("edit ")) {
823
- await this.handleCronEdit(chatId, args.slice("edit".length).trim());
824
- return;
825
- }
826
- // /cron every 1h <prompt>
827
- const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
828
- if (!scheduleMatch) {
829
- await this.bot.sendMessage(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron edit\n/cron remove <id>\n/cron clear");
830
- return;
831
- }
832
- const schedule = scheduleMatch[1];
833
- const prompt = scheduleMatch[2];
834
- const job = this.cron.add(chatId, schedule, prompt);
835
- if (!job) {
836
- await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
837
- return;
838
- }
839
- await this.bot.sendMessage(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`);
840
- }
841
- async handleCronEdit(chatId, editArgs) {
842
- const jobs = this.cron.list(chatId);
843
- // No args — show numbered list with edit instructions
844
- if (!editArgs) {
845
- if (!jobs.length) {
846
- await this.bot.sendMessage(chatId, "No cron jobs to edit.");
847
- return;
848
- }
849
- const lines = jobs.map((j, i) => {
850
- const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
851
- return `#${i + 1} ${j.schedule} — "${short}"`;
852
- });
853
- await this.bot.sendMessage(chatId, `Cron jobs:\n${lines.join("\n")}\n\n` +
854
- "Edit options:\n" +
855
- "/cron edit <#> every <N><unit> <new prompt>\n" +
856
- "/cron edit <#> schedule every <N><unit>\n" +
857
- "/cron edit <#> prompt <new prompt>");
858
- return;
859
- }
860
- // Expect: <index> <rest>
861
- const indexMatch = editArgs.match(/^(\d+)\s+(.+)$/);
862
- if (!indexMatch) {
863
- await this.bot.sendMessage(chatId, "Usage: /cron edit <#> every <N><unit> <new prompt>");
864
- return;
865
- }
866
- const index = parseInt(indexMatch[1], 10) - 1;
867
- if (index < 0 || index >= jobs.length) {
868
- await this.bot.sendMessage(chatId, `Invalid job number. Use /cron edit to see the list.`);
869
- return;
870
- }
871
- const job = jobs[index];
872
- const editCmd = indexMatch[2];
873
- // /cron edit <#> schedule every <N><unit>
874
- if (editCmd.startsWith("schedule ")) {
875
- const newSchedule = editCmd.slice("schedule ".length).trim();
876
- const result = this.cron.update(chatId, job.id, { schedule: newSchedule });
877
- if (result === null) {
878
- await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
879
- }
880
- else if (result === false) {
881
- await this.bot.sendMessage(chatId, "Job not found.");
882
- }
883
- else {
884
- await this.bot.sendMessage(chatId, `#${index + 1} schedule updated to ${newSchedule}.`);
885
- }
886
- return;
887
- }
888
- // /cron edit <#> prompt <new-prompt>
889
- if (editCmd.startsWith("prompt ")) {
890
- const newPrompt = editCmd.slice("prompt ".length).trim();
891
- const result = this.cron.update(chatId, job.id, { prompt: newPrompt });
892
- if (result === false) {
893
- await this.bot.sendMessage(chatId, "Job not found.");
894
- }
895
- else {
896
- await this.bot.sendMessage(chatId, `#${index + 1} prompt updated to "${newPrompt}".`);
897
- }
898
- return;
899
- }
900
- // /cron edit <#> every <N><unit> <new-prompt>
901
- const fullMatch = editCmd.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
902
- if (fullMatch) {
903
- const newSchedule = fullMatch[1];
904
- const newPrompt = fullMatch[2];
905
- const result = this.cron.update(chatId, job.id, { schedule: newSchedule, prompt: newPrompt });
906
- if (result === null) {
907
- await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
908
- }
909
- else if (result === false) {
910
- await this.bot.sendMessage(chatId, "Job not found.");
911
- }
912
- else {
913
- await this.bot.sendMessage(chatId, `#${index + 1} updated: ${newSchedule} — "${newPrompt}"`);
914
- }
915
- return;
916
- }
917
- await this.bot.sendMessage(chatId, "Edit options:\n" +
918
- "/cron edit <#> every <N><unit> <new prompt>\n" +
919
- "/cron edit <#> schedule every <N><unit>\n" +
920
- "/cron edit <#> prompt <new prompt>");
921
- }
922
859
  /** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
923
860
  findCcAgentPids() {
924
861
  try {
@@ -944,33 +881,33 @@ export class CcTgBot {
944
881
  }
945
882
  return pids;
946
883
  }
947
- async handleReloadMcp(chatId) {
948
- await this.bot.sendMessage(chatId, "Clearing npx cache and reloading MCP...");
884
+ async handleReloadMcp(chatId, threadId) {
885
+ await this.replyToChat(chatId, "Clearing npx cache and reloading MCP...", threadId);
949
886
  try {
950
887
  const home = process.env.HOME ?? "~";
951
888
  execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
952
889
  console.log("[mcp] cleared ~/.npm/_npx/");
953
890
  }
954
891
  catch (err) {
955
- await this.bot.sendMessage(chatId, `Warning: failed to clear npx cache: ${err.message}`);
892
+ await this.replyToChat(chatId, `Warning: failed to clear npx cache: ${err.message}`, threadId);
956
893
  }
957
894
  const pids = this.killCcAgent();
958
895
  if (pids.length === 0) {
959
- await this.bot.sendMessage(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.");
896
+ await this.replyToChat(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.", threadId);
960
897
  return;
961
898
  }
962
- await this.bot.sendMessage(chatId, `NPX cache cleared. Sent SIGTERM to cc-agent (pid${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}).\nMCP restarted. New process will load on next agent call.`);
899
+ await this.replyToChat(chatId, `NPX cache cleared. Sent SIGTERM to cc-agent (pid${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}).\nMCP restarted. New process will load on next agent call.`, threadId);
963
900
  }
964
- async handleMcpStatus(chatId) {
901
+ async handleMcpStatus(chatId, threadId) {
965
902
  try {
966
903
  const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
967
- await this.bot.sendMessage(chatId, `MCP server status:\n\n${output || "(no output)"}`);
904
+ await this.replyToChat(chatId, `MCP server status:\n\n${output || "(no output)"}`, threadId);
968
905
  }
969
906
  catch (err) {
970
- await this.bot.sendMessage(chatId, `Failed to run claude mcp list: ${err.message}`);
907
+ await this.replyToChat(chatId, `Failed to run claude mcp list: ${err.message}`, threadId);
971
908
  }
972
909
  }
973
- async handleMcpVersion(chatId) {
910
+ async handleMcpVersion(chatId, threadId) {
974
911
  let npmVersion = "unknown";
975
912
  let cacheEntries = "(unavailable)";
976
913
  try {
@@ -987,9 +924,9 @@ export class CcTgBot {
987
924
  catch {
988
925
  cacheEntries = "(empty or not found)";
989
926
  }
990
- await this.bot.sendMessage(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`);
927
+ await this.replyToChat(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`, threadId);
991
928
  }
992
- async handleClearNpxCache(chatId) {
929
+ async handleClearNpxCache(chatId, threadId) {
993
930
  const home = process.env.HOME ?? "/tmp";
994
931
  const cleared = [];
995
932
  const failed = [];
@@ -1012,10 +949,10 @@ export class CcTgBot {
1012
949
  const clearNote = failed.length
1013
950
  ? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
1014
951
  : `Cleared: ${cleared.join(", ")}.`;
1015
- await this.bot.sendMessage(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`);
952
+ await this.replyToChat(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`, threadId);
1016
953
  }
1017
- async handleRestart(chatId) {
1018
- await this.bot.sendMessage(chatId, "Clearing cache and restarting... brb.");
954
+ async handleRestart(chatId, threadId) {
955
+ await this.replyToChat(chatId, "Clearing cache and restarting... brb.", threadId);
1019
956
  await new Promise(resolve => setTimeout(resolve, 300));
1020
957
  // Clear npm caches before restart so launchd brings up fresh version
1021
958
  const home = process.env.HOME ?? "/tmp";
@@ -1026,45 +963,48 @@ export class CcTgBot {
1026
963
  catch { }
1027
964
  }
1028
965
  // Kill all active Claude sessions cleanly
1029
- for (const [cid] of this.sessions) {
1030
- this.killSession(cid);
966
+ for (const session of this.sessions.values()) {
967
+ this.stopTyping(session);
968
+ session.claude.kill();
1031
969
  }
970
+ this.sessions.clear();
1032
971
  await new Promise(resolve => setTimeout(resolve, 200));
1033
972
  process.exit(0);
1034
973
  }
1035
- async handleGetFile(chatId, text) {
974
+ async handleGetFile(chatId, text, threadId) {
1036
975
  const arg = text.slice("/get_file".length).trim();
1037
976
  if (!arg) {
1038
- await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
977
+ await this.replyToChat(chatId, "Usage: /get_file <path>", threadId);
1039
978
  return;
1040
979
  }
1041
980
  const filePath = resolve(arg);
1042
981
  const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
1043
982
  const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
1044
983
  if (!inSafeDir) {
1045
- await this.bot.sendMessage(chatId, "Access denied: path not in allowed directories");
984
+ await this.replyToChat(chatId, "Access denied: path not in allowed directories", threadId);
1046
985
  return;
1047
986
  }
1048
987
  if (!existsSync(filePath)) {
1049
- await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
988
+ await this.replyToChat(chatId, `File not found: ${filePath}`, threadId);
1050
989
  return;
1051
990
  }
1052
991
  if (!statSync(filePath).isFile()) {
1053
- await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
992
+ await this.replyToChat(chatId, `Not a file: ${filePath}`, threadId);
1054
993
  return;
1055
994
  }
1056
995
  if (this.isSensitiveFile(filePath)) {
1057
- await this.bot.sendMessage(chatId, "Access denied: sensitive file");
996
+ await this.replyToChat(chatId, "Access denied: sensitive file", threadId);
1058
997
  return;
1059
998
  }
1060
999
  const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
1061
1000
  const fileSize = statSync(filePath).size;
1062
1001
  if (fileSize > MAX_TG_FILE_BYTES) {
1063
1002
  const mb = (fileSize / (1024 * 1024)).toFixed(1);
1064
- await this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`);
1003
+ await this.replyToChat(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`, threadId);
1065
1004
  return;
1066
1005
  }
1067
- await this.bot.sendDocument(chatId, filePath);
1006
+ const docOpts = threadId ? { message_thread_id: threadId } : undefined;
1007
+ await this.bot.sendDocument(chatId, filePath, docOpts);
1068
1008
  }
1069
1009
  callCcAgentTool(toolName, args = {}) {
1070
1010
  return new Promise((resolve) => {
@@ -1137,21 +1077,25 @@ export class CcTgBot {
1137
1077
  proc.on("exit", () => { clearTimeout(timeout); done(null); });
1138
1078
  });
1139
1079
  }
1140
- killSession(chatId, keepCrons = true) {
1141
- const session = this.sessions.get(chatId);
1080
+ killSession(chatId, _keepCrons = true, threadId) {
1081
+ const key = this.sessionKey(chatId, threadId);
1082
+ const session = this.sessions.get(key);
1142
1083
  if (session) {
1143
1084
  this.stopTyping(session);
1144
1085
  session.claude.kill();
1145
- this.sessions.delete(chatId);
1086
+ this.sessions.delete(key);
1146
1087
  }
1147
- if (!keepCrons)
1148
- this.cron.clearAll(chatId);
1088
+ }
1089
+ getMe() {
1090
+ return this.bot.getMe();
1149
1091
  }
1150
1092
  stop() {
1151
1093
  this.bot.stopPolling();
1152
- for (const [chatId] of this.sessions) {
1153
- this.killSession(chatId);
1094
+ for (const session of this.sessions.values()) {
1095
+ this.stopTyping(session);
1096
+ session.claude.kill();
1154
1097
  }
1098
+ this.sessions.clear();
1155
1099
  }
1156
1100
  }
1157
1101
  function buildPromptWithReplyContext(text, msg) {
@@ -1190,6 +1134,85 @@ function downloadToFile(url, destPath) {
1190
1134
  }).on("error", reject);
1191
1135
  });
1192
1136
  }
1137
+ /** Fetch URL via Jina Reader and return first maxChars characters */
1138
+ function fetchUrlViaJina(url, maxChars = 2000) {
1139
+ const jinaUrl = `https://r.jina.ai/${url}`;
1140
+ return new Promise((resolve, reject) => {
1141
+ https.get(jinaUrl, (res) => {
1142
+ const chunks = [];
1143
+ res.on("data", (chunk) => chunks.push(chunk));
1144
+ res.on("end", () => {
1145
+ const text = Buffer.concat(chunks).toString("utf8");
1146
+ resolve(text.slice(0, maxChars));
1147
+ });
1148
+ res.on("error", reject);
1149
+ }).on("error", reject);
1150
+ });
1151
+ }
1152
+ /** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
1153
+ export async function enrichPromptWithUrls(text) {
1154
+ const urlRegex = /https?:\/\/[^\s]+/g;
1155
+ const urls = text.match(urlRegex);
1156
+ if (!urls || urls.length === 0)
1157
+ return text;
1158
+ const prefixes = [];
1159
+ for (const url of urls) {
1160
+ // Skip jina.ai URLs to avoid recursion
1161
+ if (url.includes("r.jina.ai"))
1162
+ continue;
1163
+ try {
1164
+ const content = await fetchUrlViaJina(url);
1165
+ if (content.trim()) {
1166
+ prefixes.push(`[Web content from ${url}]:\n${content}`);
1167
+ }
1168
+ }
1169
+ catch (err) {
1170
+ console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
1171
+ }
1172
+ }
1173
+ if (prefixes.length === 0)
1174
+ return text;
1175
+ return prefixes.join("\n\n") + "\n\n" + text;
1176
+ }
1177
+ /** Parse frontmatter description from a skill markdown file */
1178
+ function parseSkillDescription(content) {
1179
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
1180
+ if (!match)
1181
+ return null;
1182
+ const frontmatter = match[1];
1183
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
1184
+ return descMatch ? descMatch[1].trim() : null;
1185
+ }
1186
+ /** List available skills from ~/.claude/skills/ */
1187
+ export function listSkills() {
1188
+ const skillsDir = join(os.homedir(), ".claude", "skills");
1189
+ if (!existsSync(skillsDir)) {
1190
+ return "No skills directory found at ~/.claude/skills/";
1191
+ }
1192
+ let files;
1193
+ try {
1194
+ files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
1195
+ }
1196
+ catch {
1197
+ return "Could not read skills directory.";
1198
+ }
1199
+ if (files.length === 0) {
1200
+ return "No skills found in ~/.claude/skills/";
1201
+ }
1202
+ const lines = ["Available skills:"];
1203
+ for (const file of files.sort()) {
1204
+ const name = "/" + file.replace(/\.md$/, "");
1205
+ try {
1206
+ const content = readFileSync(join(skillsDir, file), "utf8");
1207
+ const description = parseSkillDescription(content);
1208
+ lines.push(description ? `${name} — ${description}` : name);
1209
+ }
1210
+ catch {
1211
+ lines.push(name);
1212
+ }
1213
+ }
1214
+ return lines.join("\n");
1215
+ }
1193
1216
  export function splitMessage(text, maxLen = 4096) {
1194
1217
  if (text.length <= maxLen)
1195
1218
  return [text];