@gonzih/cc-tg 0.9.0 → 0.9.2

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