@gonzih/cc-tg 0.9.1 → 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,16 +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
- lastActiveChatId;
160
161
  constructor(opts) {
161
162
  this.opts = opts;
162
- this.redis = opts.redis;
163
- this.namespace = opts.namespace ?? "default";
164
163
  this.bot = new TelegramBot(opts.telegramToken, { polling: true });
165
164
  this.bot.on("message", (msg) => this.handleTelegram(msg));
166
165
  this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
@@ -169,6 +168,10 @@ export class CcTgBot {
169
168
  this.botId = me.id;
170
169
  console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
171
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
+ });
172
175
  this.costStore = new CostStore(opts.cwd ?? process.cwd());
173
176
  this.registerBotCommands();
174
177
  console.log("cc-tg bot started");
@@ -179,55 +182,6 @@ export class CcTgBot {
179
182
  .then(() => console.log("[tg] bot commands registered"))
180
183
  .catch((err) => console.error("[tg] setMyCommands failed:", err.message));
181
184
  }
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
- }
231
185
  isAllowed(userId) {
232
186
  if (!this.opts.allowedUserIds?.length)
233
187
  return true;
@@ -236,20 +190,10 @@ export class CcTgBot {
236
190
  async handleTelegram(msg) {
237
191
  const chatId = msg.chat.id;
238
192
  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;
247
193
  if (!this.isAllowed(userId)) {
248
- await this.replyToChat(chatId, "Not authorized.", threadId);
194
+ await this.bot.sendMessage(chatId, "Not authorized.");
249
195
  return;
250
196
  }
251
- // Track the last chat that sent us a message for the chat bridge
252
- this.lastActiveChatId = chatId;
253
197
  // Group chat handling
254
198
  const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
255
199
  if (isGroup) {
@@ -268,17 +212,17 @@ export class CcTgBot {
268
212
  }
269
213
  // Voice message — transcribe then feed as text
270
214
  if (msg.voice || msg.audio) {
271
- await this.handleVoice(chatId, msg, threadId, threadName);
215
+ await this.handleVoice(chatId, msg);
272
216
  return;
273
217
  }
274
218
  // Photo — send as base64 image content block to Claude
275
219
  if (msg.photo?.length) {
276
- await this.handlePhoto(chatId, msg, threadId, threadName);
220
+ await this.handlePhoto(chatId, msg);
277
221
  return;
278
222
  }
279
223
  // Document — download to CWD/.cc-tg/uploads/, tell Claude the path
280
224
  if (msg.document) {
281
- await this.handleDocument(chatId, msg, threadId, threadName);
225
+ await this.handleDocument(chatId, msg);
282
226
  return;
283
227
  }
284
228
  let text = msg.text?.trim();
@@ -288,64 +232,68 @@ export class CcTgBot {
288
232
  if (this.botUsername) {
289
233
  text = text.replace(new RegExp(`@${this.botUsername}\\s*`, "g"), "").trim();
290
234
  }
291
- const sessionKey = this.sessionKey(chatId, threadId);
292
235
  // /start or /reset — kill existing session and ack
293
236
  if (text === "/start" || text === "/reset") {
294
- this.killSession(chatId, true, threadId);
295
- 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.");
296
239
  return;
297
240
  }
298
241
  // /stop — kill active session (interrupt running Claude task)
299
242
  if (text === "/stop") {
300
- const has = this.sessions.has(sessionKey);
301
- this.killSession(chatId, true, threadId);
302
- 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.");
303
246
  return;
304
247
  }
305
248
  // /help — list all commands
306
249
  if (text === "/help") {
307
250
  const lines = BOT_COMMANDS.map((c) => `/${c.command} — ${c.description}`);
308
- await this.replyToChat(chatId, lines.join("\n"), threadId);
251
+ await this.bot.sendMessage(chatId, lines.join("\n"));
309
252
  return;
310
253
  }
311
254
  // /status
312
255
  if (text === "/status") {
313
- const has = this.sessions.has(sessionKey);
256
+ const has = this.sessions.has(chatId);
314
257
  let status = has ? "Session active." : "No active session.";
315
258
  const sleeping = this.pendingRetries.size;
316
259
  if (sleeping > 0)
317
260
  status += `\nāø ${sleeping} request(s) sleeping (usage limit).`;
318
- 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);
319
267
  return;
320
268
  }
321
269
  // /reload_mcp — kill cc-agent process so Claude Code auto-restarts it
322
270
  if (text === "/reload_mcp") {
323
- await this.handleReloadMcp(chatId, threadId);
271
+ await this.handleReloadMcp(chatId);
324
272
  return;
325
273
  }
326
274
  // /mcp_status — run `claude mcp list` and show connection status
327
275
  if (text === "/mcp_status") {
328
- await this.handleMcpStatus(chatId, threadId);
276
+ await this.handleMcpStatus(chatId);
329
277
  return;
330
278
  }
331
279
  // /mcp_version — show published npm version and cached npx entries
332
280
  if (text === "/mcp_version") {
333
- await this.handleMcpVersion(chatId, threadId);
281
+ await this.handleMcpVersion(chatId);
334
282
  return;
335
283
  }
336
284
  // /clear_npx_cache — wipe ~/.npm/_npx/ then restart cc-agent
337
285
  if (text === "/clear_npx_cache") {
338
- await this.handleClearNpxCache(chatId, threadId);
286
+ await this.handleClearNpxCache(chatId);
339
287
  return;
340
288
  }
341
289
  // /restart — restart the bot process in-place
342
290
  if (text === "/restart") {
343
- await this.handleRestart(chatId, threadId);
291
+ await this.handleRestart(chatId);
344
292
  return;
345
293
  }
346
294
  // /get_file <path> — send a file from the server to the user
347
295
  if (text.startsWith("/get_file")) {
348
- await this.handleGetFile(chatId, text, threadId);
296
+ await this.handleGetFile(chatId, text);
349
297
  return;
350
298
  }
351
299
  // /cost — show session token usage and cost
@@ -361,62 +309,37 @@ export class CcTgBot {
361
309
  catch (err) {
362
310
  console.error("[cost] cc-agent cost_summary failed:", err.message);
363
311
  }
364
- await this.replyToChat(chatId, reply, threadId);
365
- return;
366
- }
367
- // /skills — list available Claude skills from ~/.claude/skills/
368
- if (text === "/skills") {
369
- await this.replyToChat(chatId, listSkills(), threadId);
312
+ await this.bot.sendMessage(chatId, reply);
370
313
  return;
371
314
  }
372
- const session = this.getOrCreateSession(chatId, threadId, threadName);
315
+ const session = this.getOrCreateSession(chatId);
373
316
  try {
374
- const enriched = await enrichPromptWithUrls(text);
375
- const prompt = buildPromptWithReplyContext(enriched, msg);
317
+ const prompt = buildPromptWithReplyContext(text, msg);
376
318
  session.currentPrompt = prompt;
377
319
  session.claude.sendPrompt(prompt);
378
320
  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);
398
321
  }
399
322
  catch (err) {
400
- await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`);
401
- this.killSession(chatId, true);
323
+ await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
324
+ this.killSession(chatId);
402
325
  }
403
326
  }
404
- async handleVoice(chatId, msg, threadId, threadName) {
327
+ async handleVoice(chatId, msg) {
405
328
  const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
406
329
  if (!fileId)
407
330
  return;
408
331
  console.log(`[voice:${chatId}] received voice message, transcribing...`);
409
- this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
332
+ this.bot.sendChatAction(chatId, "typing").catch(() => { });
410
333
  try {
411
334
  const fileLink = await this.bot.getFileLink(fileId);
412
335
  const transcript = await transcribeVoice(fileLink);
413
336
  console.log(`[voice:${chatId}] transcribed: ${transcript}`);
414
337
  if (!transcript || transcript === "[empty transcription]") {
415
- await this.replyToChat(chatId, "Could not transcribe voice message.", threadId);
338
+ await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
416
339
  return;
417
340
  }
418
341
  // Feed transcript into Claude as if user typed it
419
- const session = this.getOrCreateSession(chatId, threadId, threadName);
342
+ const session = this.getOrCreateSession(chatId);
420
343
  try {
421
344
  const prompt = buildPromptWithReplyContext(transcript, msg);
422
345
  session.currentPrompt = prompt;
@@ -424,41 +347,41 @@ export class CcTgBot {
424
347
  this.startTyping(chatId, session);
425
348
  }
426
349
  catch (err) {
427
- await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
428
- this.killSession(chatId, true, threadId);
350
+ await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
351
+ this.killSession(chatId);
429
352
  }
430
353
  }
431
354
  catch (err) {
432
355
  console.error(`[voice:${chatId}] error:`, err.message);
433
- await this.replyToChat(chatId, `Voice transcription failed: ${err.message}`, threadId);
356
+ await this.bot.sendMessage(chatId, `Voice transcription failed: ${err.message}`);
434
357
  }
435
358
  }
436
- async handlePhoto(chatId, msg, threadId, threadName) {
359
+ async handlePhoto(chatId, msg) {
437
360
  // Pick highest resolution photo
438
361
  const photos = msg.photo;
439
362
  const best = photos[photos.length - 1];
440
363
  const caption = msg.caption?.trim();
441
364
  console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
442
- this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
365
+ this.bot.sendChatAction(chatId, "typing").catch(() => { });
443
366
  try {
444
367
  const fileLink = await this.bot.getFileLink(best.file_id);
445
368
  const imageData = await fetchAsBase64(fileLink);
446
369
  // Telegram photos are always JPEG
447
- const session = this.getOrCreateSession(chatId, threadId, threadName);
370
+ const session = this.getOrCreateSession(chatId);
448
371
  session.claude.sendImage(imageData, "image/jpeg", caption);
449
372
  this.startTyping(chatId, session);
450
373
  }
451
374
  catch (err) {
452
375
  console.error(`[photo:${chatId}] error:`, err.message);
453
- await this.replyToChat(chatId, `Failed to process image: ${err.message}`, threadId);
376
+ await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
454
377
  }
455
378
  }
456
- async handleDocument(chatId, msg, threadId, threadName) {
379
+ async handleDocument(chatId, msg) {
457
380
  const doc = msg.document;
458
381
  const caption = msg.caption?.trim();
459
382
  const fileName = doc.file_name ?? `file_${doc.file_id}`;
460
383
  console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
461
- this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
384
+ this.bot.sendChatAction(chatId, "typing").catch(() => { });
462
385
  try {
463
386
  const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
464
387
  mkdirSync(uploadsDir, { recursive: true });
@@ -469,34 +392,22 @@ export class CcTgBot {
469
392
  const prompt = caption
470
393
  ? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
471
394
  : `ATTACHMENTS: [${fileName}](${destPath})`;
472
- const session = this.getOrCreateSession(chatId, threadId, threadName);
395
+ const session = this.getOrCreateSession(chatId);
473
396
  session.claude.sendPrompt(prompt);
474
397
  this.startTyping(chatId, session);
475
398
  }
476
399
  catch (err) {
477
400
  console.error(`[doc:${chatId}] error:`, err.message);
478
- await this.replyToChat(chatId, `Failed to receive document: ${err.message}`, threadId);
401
+ await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
479
402
  }
480
403
  }
481
- getOrCreateSession(chatId, threadId, threadName) {
482
- const key = this.sessionKey(chatId, threadId);
483
- const existing = this.sessions.get(key);
404
+ getOrCreateSession(chatId) {
405
+ const existing = this.sessions.get(chatId);
484
406
  if (existing && !existing.claude.exited)
485
407
  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
- }
497
408
  const claude = new ClaudeProcess({
498
- cwd: sessionCwd,
499
- token: getCurrentToken() || this.opts.claudeToken,
409
+ cwd: this.opts.cwd,
410
+ token: this.opts.claudeToken,
500
411
  });
501
412
  const session = {
502
413
  claude,
@@ -506,7 +417,6 @@ export class CcTgBot {
506
417
  writtenFiles: new Set(),
507
418
  currentPrompt: "",
508
419
  isRetry: false,
509
- threadId,
510
420
  };
511
421
  claude.on("usage", (usage) => {
512
422
  this.costStore.addUsage(chatId, usage);
@@ -515,47 +425,33 @@ export class CcTgBot {
515
425
  // Verbose logging — log every message type and subtype
516
426
  const subtype = msg.payload.subtype ?? "";
517
427
  const toolName = this.extractToolName(msg);
518
- const logParts = [`[claude:${key}] msg=${msg.type}`];
428
+ const logParts = [`[claude:${chatId}] msg=${msg.type}`];
519
429
  if (subtype)
520
430
  logParts.push(`subtype=${subtype}`);
521
431
  if (toolName)
522
432
  logParts.push(`tool=${toolName}`);
523
433
  console.log(logParts.join(" "));
524
434
  // Track files written by Write/Edit tool calls
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
- }
435
+ this.trackWrittenFiles(msg, session, this.opts.cwd);
540
436
  this.handleClaudeMessage(chatId, session, msg);
541
437
  });
542
438
  claude.on("stderr", (data) => {
543
439
  const line = data.trim();
544
440
  if (line)
545
- console.error(`[claude:${key}:stderr]`, line);
441
+ console.error(`[claude:${chatId}:stderr]`, line);
546
442
  });
547
443
  claude.on("exit", (code) => {
548
- console.log(`[claude:${key}] exited code=${code}`);
444
+ console.log(`[claude:${chatId}] exited code=${code}`);
549
445
  this.stopTyping(session);
550
- this.sessions.delete(key);
446
+ this.sessions.delete(chatId);
551
447
  });
552
448
  claude.on("error", (err) => {
553
- console.error(`[claude:${key}] process error: ${err.message}`);
449
+ console.error(`[claude:${chatId}] process error: ${err.message}`);
554
450
  this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
555
451
  this.stopTyping(session);
556
- this.sessions.delete(key);
452
+ this.sessions.delete(chatId);
557
453
  });
558
- this.sessions.set(key, session);
454
+ this.sessions.set(chatId, session);
559
455
  return session;
560
456
  }
561
457
  handleClaudeMessage(chatId, session, msg) {
@@ -571,58 +467,33 @@ export class CcTgBot {
571
467
  // Check for usage/rate limit signals before forwarding to Telegram
572
468
  const sig = detectUsageLimit(text);
573
469
  if (sig.detected) {
574
- const threadId = session.threadId;
575
- const retryKey = this.sessionKey(chatId, threadId);
576
470
  const lastPrompt = session.currentPrompt;
577
- const prevRetry = this.pendingRetries.get(retryKey);
471
+ const prevRetry = this.pendingRetries.get(chatId);
578
472
  const attempt = (prevRetry?.attempt ?? 0) + 1;
579
473
  if (prevRetry)
580
474
  clearTimeout(prevRetry.timer);
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
- }
475
+ this.bot.sendMessage(chatId, sig.humanMessage).catch(() => { });
476
+ this.killSession(chatId);
606
477
  if (attempt > 3) {
607
- this.replyToChat(chatId, "āŒ Claude usage limit persists after 3 retries. Please try again later.", threadId).catch(() => { });
608
- 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);
609
480
  return;
610
481
  }
611
- 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`);
612
483
  const timer = setTimeout(() => {
613
- this.pendingRetries.delete(retryKey);
484
+ this.pendingRetries.delete(chatId);
614
485
  try {
615
- const retrySession = this.getOrCreateSession(chatId, threadId);
486
+ const retrySession = this.getOrCreateSession(chatId);
616
487
  retrySession.currentPrompt = lastPrompt;
617
488
  retrySession.isRetry = true;
618
489
  retrySession.claude.sendPrompt(lastPrompt);
619
490
  this.startTyping(chatId, retrySession);
620
491
  }
621
492
  catch (err) {
622
- this.replyToChat(chatId, `āŒ Failed to retry: ${err.message}`, threadId).catch(() => { });
493
+ this.bot.sendMessage(chatId, `āŒ Failed to retry: ${err.message}`).catch(() => { });
623
494
  }
624
495
  }, sig.retryAfterMs);
625
- this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer });
496
+ this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer });
626
497
  return;
627
498
  }
628
499
  // Accumulate text and debounce — Claude streams chunks rapidly
@@ -634,11 +505,9 @@ export class CcTgBot {
634
505
  startTyping(chatId, session) {
635
506
  this.stopTyping(session);
636
507
  // Send immediately, then keep alive every 4s
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(() => { });
508
+ this.bot.sendChatAction(chatId, "typing").catch(() => { });
640
509
  session.typingTimer = setInterval(() => {
641
- this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
510
+ this.bot.sendChatAction(chatId, "typing").catch(() => { });
642
511
  }, TYPING_INTERVAL_MS);
643
512
  }
644
513
  stopTyping(session) {
@@ -653,17 +522,15 @@ export class CcTgBot {
653
522
  session.flushTimer = null;
654
523
  if (!raw)
655
524
  return;
656
- this.writeChatMessage("assistant", "cc-tg", raw, chatId);
657
525
  const text = session.isRetry ? `āœ… Claude is back!\n\n${raw}` : raw;
658
526
  session.isRetry = false;
659
- // Format for Telegram HTML and split if needed (max 4096 chars)
527
+ // Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
660
528
  const formatted = formatForTelegram(text);
661
529
  const chunks = splitLongMessage(formatted);
662
- const threadId = session.threadId;
663
530
  for (const chunk of chunks) {
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));
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));
667
534
  });
668
535
  }
669
536
  // Hybrid file upload: find files mentioned in result text that Claude actually wrote
@@ -836,12 +703,11 @@ export class CcTgBot {
836
703
  const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
837
704
  if (fileSize > MAX_TG_FILE_BYTES) {
838
705
  const mb = (fileSize / (1024 * 1024)).toFixed(1);
839
- 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(() => { });
840
707
  continue;
841
708
  }
842
709
  console.log(`[claude:files] uploading to telegram: ${filePath}`);
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));
710
+ this.bot.sendDocument(chatId, filePath).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
845
711
  }
846
712
  // Clear written files for next turn
847
713
  session.writtenFiles.clear();
@@ -856,6 +722,203 @@ export class CcTgBot {
856
722
  const toolUse = content.find((b) => b.type === "tool_use");
857
723
  return toolUse?.name ?? "";
858
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
+ }
859
922
  /** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
860
923
  findCcAgentPids() {
861
924
  try {
@@ -881,33 +944,33 @@ export class CcTgBot {
881
944
  }
882
945
  return pids;
883
946
  }
884
- async handleReloadMcp(chatId, threadId) {
885
- 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...");
886
949
  try {
887
950
  const home = process.env.HOME ?? "~";
888
951
  execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
889
952
  console.log("[mcp] cleared ~/.npm/_npx/");
890
953
  }
891
954
  catch (err) {
892
- 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}`);
893
956
  }
894
957
  const pids = this.killCcAgent();
895
958
  if (pids.length === 0) {
896
- 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.");
897
960
  return;
898
961
  }
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);
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.`);
900
963
  }
901
- async handleMcpStatus(chatId, threadId) {
964
+ async handleMcpStatus(chatId) {
902
965
  try {
903
966
  const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
904
- 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)"}`);
905
968
  }
906
969
  catch (err) {
907
- 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}`);
908
971
  }
909
972
  }
910
- async handleMcpVersion(chatId, threadId) {
973
+ async handleMcpVersion(chatId) {
911
974
  let npmVersion = "unknown";
912
975
  let cacheEntries = "(unavailable)";
913
976
  try {
@@ -924,9 +987,9 @@ export class CcTgBot {
924
987
  catch {
925
988
  cacheEntries = "(empty or not found)";
926
989
  }
927
- 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}`);
928
991
  }
929
- async handleClearNpxCache(chatId, threadId) {
992
+ async handleClearNpxCache(chatId) {
930
993
  const home = process.env.HOME ?? "/tmp";
931
994
  const cleared = [];
932
995
  const failed = [];
@@ -949,10 +1012,10 @@ export class CcTgBot {
949
1012
  const clearNote = failed.length
950
1013
  ? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
951
1014
  : `Cleared: ${cleared.join(", ")}.`;
952
- 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.`);
953
1016
  }
954
- async handleRestart(chatId, threadId) {
955
- 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.");
956
1019
  await new Promise(resolve => setTimeout(resolve, 300));
957
1020
  // Clear npm caches before restart so launchd brings up fresh version
958
1021
  const home = process.env.HOME ?? "/tmp";
@@ -963,48 +1026,45 @@ export class CcTgBot {
963
1026
  catch { }
964
1027
  }
965
1028
  // Kill all active Claude sessions cleanly
966
- for (const session of this.sessions.values()) {
967
- this.stopTyping(session);
968
- session.claude.kill();
1029
+ for (const [cid] of this.sessions) {
1030
+ this.killSession(cid);
969
1031
  }
970
- this.sessions.clear();
971
1032
  await new Promise(resolve => setTimeout(resolve, 200));
972
1033
  process.exit(0);
973
1034
  }
974
- async handleGetFile(chatId, text, threadId) {
1035
+ async handleGetFile(chatId, text) {
975
1036
  const arg = text.slice("/get_file".length).trim();
976
1037
  if (!arg) {
977
- await this.replyToChat(chatId, "Usage: /get_file <path>", threadId);
1038
+ await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
978
1039
  return;
979
1040
  }
980
1041
  const filePath = resolve(arg);
981
1042
  const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
982
1043
  const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
983
1044
  if (!inSafeDir) {
984
- 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");
985
1046
  return;
986
1047
  }
987
1048
  if (!existsSync(filePath)) {
988
- await this.replyToChat(chatId, `File not found: ${filePath}`, threadId);
1049
+ await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
989
1050
  return;
990
1051
  }
991
1052
  if (!statSync(filePath).isFile()) {
992
- await this.replyToChat(chatId, `Not a file: ${filePath}`, threadId);
1053
+ await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
993
1054
  return;
994
1055
  }
995
1056
  if (this.isSensitiveFile(filePath)) {
996
- await this.replyToChat(chatId, "Access denied: sensitive file", threadId);
1057
+ await this.bot.sendMessage(chatId, "Access denied: sensitive file");
997
1058
  return;
998
1059
  }
999
1060
  const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
1000
1061
  const fileSize = statSync(filePath).size;
1001
1062
  if (fileSize > MAX_TG_FILE_BYTES) {
1002
1063
  const mb = (fileSize / (1024 * 1024)).toFixed(1);
1003
- 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}`);
1004
1065
  return;
1005
1066
  }
1006
- const docOpts = threadId ? { message_thread_id: threadId } : undefined;
1007
- await this.bot.sendDocument(chatId, filePath, docOpts);
1067
+ await this.bot.sendDocument(chatId, filePath);
1008
1068
  }
1009
1069
  callCcAgentTool(toolName, args = {}) {
1010
1070
  return new Promise((resolve) => {
@@ -1077,25 +1137,21 @@ export class CcTgBot {
1077
1137
  proc.on("exit", () => { clearTimeout(timeout); done(null); });
1078
1138
  });
1079
1139
  }
1080
- killSession(chatId, _keepCrons = true, threadId) {
1081
- const key = this.sessionKey(chatId, threadId);
1082
- const session = this.sessions.get(key);
1140
+ killSession(chatId, keepCrons = true) {
1141
+ const session = this.sessions.get(chatId);
1083
1142
  if (session) {
1084
1143
  this.stopTyping(session);
1085
1144
  session.claude.kill();
1086
- this.sessions.delete(key);
1145
+ this.sessions.delete(chatId);
1087
1146
  }
1088
- }
1089
- getMe() {
1090
- return this.bot.getMe();
1147
+ if (!keepCrons)
1148
+ this.cron.clearAll(chatId);
1091
1149
  }
1092
1150
  stop() {
1093
1151
  this.bot.stopPolling();
1094
- for (const session of this.sessions.values()) {
1095
- this.stopTyping(session);
1096
- session.claude.kill();
1152
+ for (const [chatId] of this.sessions) {
1153
+ this.killSession(chatId);
1097
1154
  }
1098
- this.sessions.clear();
1099
1155
  }
1100
1156
  }
1101
1157
  function buildPromptWithReplyContext(text, msg) {
@@ -1134,85 +1190,6 @@ function downloadToFile(url, destPath) {
1134
1190
  }).on("error", reject);
1135
1191
  });
1136
1192
  }
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
- }
1216
1193
  export function splitMessage(text, maxLen = 4096) {
1217
1194
  if (text.length <= maxLen)
1218
1195
  return [text];