@gonzih/cc-tg 0.9.12 → 0.9.14

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