@gonzih/cc-tg 0.9.13 → 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,232 +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);
312
+ await this.bot.sendMessage(chatId, reply);
382
313
  return;
383
314
  }
384
- // /voice_retry retry failed voice message transcriptions
385
- if (text === "/voice_retry") {
386
- await this.handleVoiceRetry(chatId, threadId);
387
- return;
388
- }
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
- const errMsg = err.message;
548
- failed++;
549
- errors.push(`${fileId}: ${errMsg}`);
550
- // Permanently unretryable (expired Telegram link) — remove from voice:pending
551
- if (errMsg.includes("Bad Request") || errMsg.includes("file_id")) {
552
- const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
553
- if (matchPending)
554
- await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
555
- }
556
- }
557
- }
558
- // Purge stale entries from voice:pending older than 48h
559
- const staleThreshold = 48 * 60 * 60 * 1000;
560
- let purged = 0;
561
- for (const raw of pendingRaw) {
562
- try {
563
- const entry = JSON.parse(raw);
564
- if (entry.timestamp && Date.now() - entry.timestamp > staleThreshold) {
565
- await this.redis.lrem("voice:pending", 0, raw).catch(() => { });
566
- purged++;
567
- }
568
- }
569
- catch { /* skip malformed entries */ }
570
- }
571
- const lines = [`Voice retry complete: ${succeeded} succeeded, ${failed} failed, ${purged} stale entries purged.`];
572
- if (errors.length > 0)
573
- lines.push(...errors.map((e) => `• ${e}`));
574
- await this.replyToChat(chatId, lines.join("\n"), threadId);
575
- }
576
- async handlePhoto(chatId, msg, threadId, threadName) {
363
+ async handlePhoto(chatId, msg) {
577
364
  // Pick highest resolution photo
578
365
  const photos = msg.photo;
579
366
  const best = photos[photos.length - 1];
580
367
  const caption = msg.caption?.trim();
581
368
  console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
582
- this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
369
+ this.bot.sendChatAction(chatId, "typing").catch(() => { });
583
370
  try {
584
371
  const fileLink = await this.bot.getFileLink(best.file_id);
585
372
  const imageData = await fetchAsBase64(fileLink);
586
373
  // Telegram photos are always JPEG
587
- const session = this.getOrCreateSession(chatId, threadId, threadName);
374
+ const session = this.getOrCreateSession(chatId);
588
375
  session.claude.sendImage(imageData, "image/jpeg", caption);
589
376
  this.startTyping(chatId, session);
590
377
  }
591
378
  catch (err) {
592
379
  console.error(`[photo:${chatId}] error:`, err.message);
593
- await this.replyToChat(chatId, `Failed to process image: ${err.message}`, threadId);
380
+ await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
594
381
  }
595
382
  }
596
- async handleDocument(chatId, msg, threadId, threadName) {
383
+ async handleDocument(chatId, msg) {
597
384
  const doc = msg.document;
598
385
  const caption = msg.caption?.trim();
599
386
  const fileName = doc.file_name ?? `file_${doc.file_id}`;
600
387
  console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
601
- this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
388
+ this.bot.sendChatAction(chatId, "typing").catch(() => { });
602
389
  try {
603
390
  const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
604
391
  mkdirSync(uploadsDir, { recursive: true });
@@ -609,34 +396,22 @@ export class CcTgBot {
609
396
  const prompt = caption
610
397
  ? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
611
398
  : `ATTACHMENTS: [${fileName}](${destPath})`;
612
- const session = this.getOrCreateSession(chatId, threadId, threadName);
399
+ const session = this.getOrCreateSession(chatId);
613
400
  session.claude.sendPrompt(prompt);
614
401
  this.startTyping(chatId, session);
615
402
  }
616
403
  catch (err) {
617
404
  console.error(`[doc:${chatId}] error:`, err.message);
618
- await this.replyToChat(chatId, `Failed to receive document: ${err.message}`, threadId);
405
+ await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
619
406
  }
620
407
  }
621
- getOrCreateSession(chatId, threadId, threadName) {
622
- const key = this.sessionKey(chatId, threadId);
623
- const existing = this.sessions.get(key);
408
+ getOrCreateSession(chatId) {
409
+ const existing = this.sessions.get(chatId);
624
410
  if (existing && !existing.claude.exited)
625
411
  return existing;
626
- // Determine CWD for this thread — check THREAD_CWD_MAP by name then by ID
627
- let sessionCwd = this.opts.cwd;
628
- const threadCwdMap = this.getThreadCwdMap();
629
- if (threadName && threadCwdMap[threadName]) {
630
- sessionCwd = threadCwdMap[threadName];
631
- console.log(`[cc-tg] thread "${threadName}" → cwd: ${sessionCwd}`);
632
- }
633
- else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
634
- sessionCwd = threadCwdMap[String(threadId)];
635
- console.log(`[cc-tg] thread ${threadId} → cwd: ${sessionCwd}`);
636
- }
637
412
  const claude = new ClaudeProcess({
638
- cwd: sessionCwd,
639
- token: getCurrentToken() || this.opts.claudeToken,
413
+ cwd: this.opts.cwd,
414
+ token: this.opts.claudeToken,
640
415
  });
641
416
  const session = {
642
417
  claude,
@@ -646,7 +421,6 @@ export class CcTgBot {
646
421
  writtenFiles: new Set(),
647
422
  currentPrompt: "",
648
423
  isRetry: false,
649
- threadId,
650
424
  };
651
425
  claude.on("usage", (usage) => {
652
426
  this.costStore.addUsage(chatId, usage);
@@ -655,47 +429,33 @@ export class CcTgBot {
655
429
  // Verbose logging — log every message type and subtype
656
430
  const subtype = msg.payload.subtype ?? "";
657
431
  const toolName = this.extractToolName(msg);
658
- const logParts = [`[claude:${key}] msg=${msg.type}`];
432
+ const logParts = [`[claude:${chatId}] msg=${msg.type}`];
659
433
  if (subtype)
660
434
  logParts.push(`subtype=${subtype}`);
661
435
  if (toolName)
662
436
  logParts.push(`tool=${toolName}`);
663
437
  console.log(logParts.join(" "));
664
438
  // Track files written by Write/Edit tool calls
665
- this.trackWrittenFiles(msg, session, sessionCwd);
666
- // Publish tool call events to the chat log
667
- if (msg.type === "assistant") {
668
- const message = msg.payload.message;
669
- const content = message?.content;
670
- if (Array.isArray(content)) {
671
- for (const block of content) {
672
- if (block.type !== "tool_use")
673
- continue;
674
- const name = block.name;
675
- const input = block.input;
676
- this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {})}`, chatId);
677
- }
678
- }
679
- }
439
+ this.trackWrittenFiles(msg, session, this.opts.cwd);
680
440
  this.handleClaudeMessage(chatId, session, msg);
681
441
  });
682
442
  claude.on("stderr", (data) => {
683
443
  const line = data.trim();
684
444
  if (line)
685
- console.error(`[claude:${key}:stderr]`, line);
445
+ console.error(`[claude:${chatId}:stderr]`, line);
686
446
  });
687
447
  claude.on("exit", (code) => {
688
- console.log(`[claude:${key}] exited code=${code}`);
448
+ console.log(`[claude:${chatId}] exited code=${code}`);
689
449
  this.stopTyping(session);
690
- this.sessions.delete(key);
450
+ this.sessions.delete(chatId);
691
451
  });
692
452
  claude.on("error", (err) => {
693
- console.error(`[claude:${key}] process error: ${err.message}`);
453
+ console.error(`[claude:${chatId}] process error: ${err.message}`);
694
454
  this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
695
455
  this.stopTyping(session);
696
- this.sessions.delete(key);
456
+ this.sessions.delete(chatId);
697
457
  });
698
- this.sessions.set(key, session);
458
+ this.sessions.set(chatId, session);
699
459
  return session;
700
460
  }
701
461
  handleClaudeMessage(chatId, session, msg) {
@@ -711,58 +471,33 @@ export class CcTgBot {
711
471
  // Check for usage/rate limit signals before forwarding to Telegram
712
472
  const sig = detectUsageLimit(text);
713
473
  if (sig.detected) {
714
- const threadId = session.threadId;
715
- const retryKey = this.sessionKey(chatId, threadId);
716
474
  const lastPrompt = session.currentPrompt;
717
- const prevRetry = this.pendingRetries.get(retryKey);
475
+ const prevRetry = this.pendingRetries.get(chatId);
718
476
  const attempt = (prevRetry?.attempt ?? 0) + 1;
719
477
  if (prevRetry)
720
478
  clearTimeout(prevRetry.timer);
721
- this.replyToChat(chatId, sig.humanMessage, threadId).catch(() => { });
722
- this.killSession(chatId, true, threadId);
723
- // Token rotation: if this is a usage_exhausted signal and we have multiple
724
- // tokens, rotate to the next one and retry immediately instead of sleeping.
725
- // Only rotate if we haven't yet cycled through all tokens (attempt <= count-1).
726
- if (sig.reason === "usage_exhausted" && getTokenCount() > 1 && attempt <= getTokenCount() - 1) {
727
- const prevIdx = getTokenIndex();
728
- rotateToken();
729
- const newIdx = getTokenIndex();
730
- const total = getTokenCount();
731
- console.log(`[cc-tg] Token ${prevIdx + 1}/${total} exhausted, rotating to token ${newIdx + 1}/${total}`);
732
- this.replyToChat(chatId, `🔄 Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`, threadId).catch(() => { });
733
- this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
734
- try {
735
- const retrySession = this.getOrCreateSession(chatId, threadId);
736
- retrySession.currentPrompt = lastPrompt;
737
- retrySession.isRetry = true;
738
- retrySession.claude.sendPrompt(lastPrompt);
739
- this.startTyping(chatId, retrySession);
740
- }
741
- catch (err) {
742
- this.replyToChat(chatId, `❌ Failed to retry with rotated token: ${err.message}`, threadId).catch(() => { });
743
- }
744
- return;
745
- }
479
+ this.bot.sendMessage(chatId, sig.humanMessage).catch(() => { });
480
+ this.killSession(chatId);
746
481
  if (attempt > 3) {
747
- this.replyToChat(chatId, "❌ Claude usage limit persists after 3 retries. Please try again later.", threadId).catch(() => { });
748
- 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);
749
484
  return;
750
485
  }
751
- 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`);
752
487
  const timer = setTimeout(() => {
753
- this.pendingRetries.delete(retryKey);
488
+ this.pendingRetries.delete(chatId);
754
489
  try {
755
- const retrySession = this.getOrCreateSession(chatId, threadId);
490
+ const retrySession = this.getOrCreateSession(chatId);
756
491
  retrySession.currentPrompt = lastPrompt;
757
492
  retrySession.isRetry = true;
758
493
  retrySession.claude.sendPrompt(lastPrompt);
759
494
  this.startTyping(chatId, retrySession);
760
495
  }
761
496
  catch (err) {
762
- this.replyToChat(chatId, `❌ Failed to retry: ${err.message}`, threadId).catch(() => { });
497
+ this.bot.sendMessage(chatId, `❌ Failed to retry: ${err.message}`).catch(() => { });
763
498
  }
764
499
  }, sig.retryAfterMs);
765
- this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer });
500
+ this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer });
766
501
  return;
767
502
  }
768
503
  // Accumulate text and debounce — Claude streams chunks rapidly
@@ -774,11 +509,9 @@ export class CcTgBot {
774
509
  startTyping(chatId, session) {
775
510
  this.stopTyping(session);
776
511
  // Send immediately, then keep alive every 4s
777
- // Pass message_thread_id so typing appears in the correct forum topic thread
778
- const threadOpts = session.threadId !== undefined ? { message_thread_id: session.threadId } : undefined;
779
- this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
512
+ this.bot.sendChatAction(chatId, "typing").catch(() => { });
780
513
  session.typingTimer = setInterval(() => {
781
- this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
514
+ this.bot.sendChatAction(chatId, "typing").catch(() => { });
782
515
  }, TYPING_INTERVAL_MS);
783
516
  }
784
517
  stopTyping(session) {
@@ -793,17 +526,15 @@ export class CcTgBot {
793
526
  session.flushTimer = null;
794
527
  if (!raw)
795
528
  return;
796
- this.writeChatMessage("assistant", "cc-tg", raw, chatId);
797
529
  const text = session.isRetry ? `✅ Claude is back!\n\n${raw}` : raw;
798
530
  session.isRetry = false;
799
- // Format for Telegram HTML and split if needed (max 4096 chars)
531
+ // Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
800
532
  const formatted = formatForTelegram(text);
801
533
  const chunks = splitLongMessage(formatted);
802
- const threadId = session.threadId;
803
534
  for (const chunk of chunks) {
804
- this.replyToChat(chatId, chunk, threadId, { parse_mode: "HTML" }).catch(() => {
805
- // HTML parse failed — retry as plain text
806
- 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));
807
538
  });
808
539
  }
809
540
  // Hybrid file upload: find files mentioned in result text that Claude actually wrote
@@ -976,12 +707,11 @@ export class CcTgBot {
976
707
  const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
977
708
  if (fileSize > MAX_TG_FILE_BYTES) {
978
709
  const mb = (fileSize / (1024 * 1024)).toFixed(1);
979
- 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(() => { });
980
711
  continue;
981
712
  }
982
713
  console.log(`[claude:files] uploading to telegram: ${filePath}`);
983
- const docOpts = session.threadId ? { message_thread_id: session.threadId } : undefined;
984
- 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));
985
715
  }
986
716
  // Clear written files for next turn
987
717
  session.writtenFiles.clear();
@@ -996,6 +726,203 @@ export class CcTgBot {
996
726
  const toolUse = content.find((b) => b.type === "tool_use");
997
727
  return toolUse?.name ?? "";
998
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
+ }
999
926
  /** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
1000
927
  findCcAgentPids() {
1001
928
  try {
@@ -1021,33 +948,33 @@ export class CcTgBot {
1021
948
  }
1022
949
  return pids;
1023
950
  }
1024
- async handleReloadMcp(chatId, threadId) {
1025
- 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...");
1026
953
  try {
1027
954
  const home = process.env.HOME ?? "~";
1028
955
  execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
1029
956
  console.log("[mcp] cleared ~/.npm/_npx/");
1030
957
  }
1031
958
  catch (err) {
1032
- 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}`);
1033
960
  }
1034
961
  const pids = this.killCcAgent();
1035
962
  if (pids.length === 0) {
1036
- 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.");
1037
964
  return;
1038
965
  }
1039
- 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.`);
1040
967
  }
1041
- async handleMcpStatus(chatId, threadId) {
968
+ async handleMcpStatus(chatId) {
1042
969
  try {
1043
970
  const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
1044
- 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)"}`);
1045
972
  }
1046
973
  catch (err) {
1047
- 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}`);
1048
975
  }
1049
976
  }
1050
- async handleMcpVersion(chatId, threadId) {
977
+ async handleMcpVersion(chatId) {
1051
978
  let npmVersion = "unknown";
1052
979
  let cacheEntries = "(unavailable)";
1053
980
  try {
@@ -1064,9 +991,9 @@ export class CcTgBot {
1064
991
  catch {
1065
992
  cacheEntries = "(empty or not found)";
1066
993
  }
1067
- 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}`);
1068
995
  }
1069
- async handleClearNpxCache(chatId, threadId) {
996
+ async handleClearNpxCache(chatId) {
1070
997
  const home = process.env.HOME ?? "/tmp";
1071
998
  const cleared = [];
1072
999
  const failed = [];
@@ -1089,10 +1016,10 @@ export class CcTgBot {
1089
1016
  const clearNote = failed.length
1090
1017
  ? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
1091
1018
  : `Cleared: ${cleared.join(", ")}.`;
1092
- 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.`);
1093
1020
  }
1094
- async handleRestart(chatId, threadId) {
1095
- 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.");
1096
1023
  await new Promise(resolve => setTimeout(resolve, 300));
1097
1024
  // Clear npm caches before restart so launchd brings up fresh version
1098
1025
  const home = process.env.HOME ?? "/tmp";
@@ -1103,103 +1030,45 @@ export class CcTgBot {
1103
1030
  catch { }
1104
1031
  }
1105
1032
  // Kill all active Claude sessions cleanly
1106
- for (const session of this.sessions.values()) {
1107
- this.stopTyping(session);
1108
- session.claude.kill();
1033
+ for (const [cid] of this.sessions) {
1034
+ this.killSession(cid);
1109
1035
  }
1110
- this.sessions.clear();
1111
1036
  await new Promise(resolve => setTimeout(resolve, 200));
1112
1037
  process.exit(0);
1113
1038
  }
1114
- async handleCron(chatId, text, threadId) {
1115
- const args = text.slice("/cron".length).trim();
1116
- if (args === "list" || args === "") {
1117
- const jobs = this.cron.list(chatId);
1118
- if (!jobs.length) {
1119
- await this.replyToChat(chatId, "No cron jobs.", threadId);
1120
- return;
1121
- }
1122
- const lines = jobs.map((j, i) => {
1123
- const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
1124
- return `#${i + 1} ${j.schedule} — "${short}"`;
1125
- });
1126
- await this.replyToChat(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`, threadId);
1127
- return;
1128
- }
1129
- if (args === "clear") {
1130
- const n = this.cron.clearAll(chatId);
1131
- await this.replyToChat(chatId, `Cleared ${n} cron job(s).`, threadId);
1132
- return;
1133
- }
1134
- if (args.startsWith("remove ")) {
1135
- const id = args.slice("remove ".length).trim();
1136
- const ok = this.cron.remove(chatId, id);
1137
- await this.replyToChat(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`, threadId);
1138
- return;
1139
- }
1140
- const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
1141
- if (!scheduleMatch) {
1142
- await this.replyToChat(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron remove <id>\n/cron clear", threadId);
1143
- return;
1144
- }
1145
- const schedule = scheduleMatch[1];
1146
- const prompt = scheduleMatch[2];
1147
- const job = this.cron.add(chatId, schedule, prompt);
1148
- if (!job) {
1149
- await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
1150
- return;
1151
- }
1152
- await this.replyToChat(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`, threadId);
1153
- }
1154
- runCronTask(chatId, prompt, done = () => { }) {
1155
- const cronProcess = new ClaudeProcess({ cwd: this.opts.cwd ?? process.cwd() });
1156
- cronProcess.sendPrompt(prompt);
1157
- cronProcess.on("message", (msg) => {
1158
- const result = extractText(msg);
1159
- if (result) {
1160
- const formatted = formatForTelegram(`🕐 ${result}`);
1161
- const chunks = splitLongMessage(formatted);
1162
- for (const chunk of chunks) {
1163
- this.replyToChat(chatId, chunk).catch((err) => console.error("[cron] send failed:", err.message));
1164
- }
1165
- }
1166
- });
1167
- cronProcess.on("exit", () => done());
1168
- }
1169
- async handleGetFile(chatId, text, threadId) {
1039
+ async handleGetFile(chatId, text) {
1170
1040
  const arg = text.slice("/get_file".length).trim();
1171
1041
  if (!arg) {
1172
- await this.replyToChat(chatId, "Usage: /get_file <path>", threadId);
1042
+ await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
1173
1043
  return;
1174
1044
  }
1175
1045
  const filePath = resolve(arg);
1176
1046
  const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
1177
1047
  const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
1178
1048
  if (!inSafeDir) {
1179
- 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");
1180
1050
  return;
1181
1051
  }
1182
1052
  if (!existsSync(filePath)) {
1183
- await this.replyToChat(chatId, `File not found: ${filePath}`, threadId);
1053
+ await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
1184
1054
  return;
1185
1055
  }
1186
1056
  if (!statSync(filePath).isFile()) {
1187
- await this.replyToChat(chatId, `Not a file: ${filePath}`, threadId);
1057
+ await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
1188
1058
  return;
1189
1059
  }
1190
1060
  if (this.isSensitiveFile(filePath)) {
1191
- await this.replyToChat(chatId, "Access denied: sensitive file", threadId);
1061
+ await this.bot.sendMessage(chatId, "Access denied: sensitive file");
1192
1062
  return;
1193
1063
  }
1194
1064
  const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
1195
1065
  const fileSize = statSync(filePath).size;
1196
1066
  if (fileSize > MAX_TG_FILE_BYTES) {
1197
1067
  const mb = (fileSize / (1024 * 1024)).toFixed(1);
1198
- 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}`);
1199
1069
  return;
1200
1070
  }
1201
- const docOpts = threadId ? { message_thread_id: threadId } : undefined;
1202
- await this.bot.sendDocument(chatId, filePath, docOpts);
1071
+ await this.bot.sendDocument(chatId, filePath);
1203
1072
  }
1204
1073
  callCcAgentTool(toolName, args = {}) {
1205
1074
  return new Promise((resolve) => {
@@ -1272,25 +1141,21 @@ export class CcTgBot {
1272
1141
  proc.on("exit", () => { clearTimeout(timeout); done(null); });
1273
1142
  });
1274
1143
  }
1275
- killSession(chatId, _keepCrons = true, threadId) {
1276
- const key = this.sessionKey(chatId, threadId);
1277
- const session = this.sessions.get(key);
1144
+ killSession(chatId, keepCrons = true) {
1145
+ const session = this.sessions.get(chatId);
1278
1146
  if (session) {
1279
1147
  this.stopTyping(session);
1280
1148
  session.claude.kill();
1281
- this.sessions.delete(key);
1149
+ this.sessions.delete(chatId);
1282
1150
  }
1283
- }
1284
- getMe() {
1285
- return this.bot.getMe();
1151
+ if (!keepCrons)
1152
+ this.cron.clearAll(chatId);
1286
1153
  }
1287
1154
  stop() {
1288
1155
  this.bot.stopPolling();
1289
- for (const session of this.sessions.values()) {
1290
- this.stopTyping(session);
1291
- session.claude.kill();
1156
+ for (const [chatId] of this.sessions) {
1157
+ this.killSession(chatId);
1292
1158
  }
1293
- this.sessions.clear();
1294
1159
  }
1295
1160
  }
1296
1161
  function buildPromptWithReplyContext(text, msg) {
@@ -1329,85 +1194,6 @@ function downloadToFile(url, destPath) {
1329
1194
  }).on("error", reject);
1330
1195
  });
1331
1196
  }
1332
- /** Fetch URL via Jina Reader and return first maxChars characters */
1333
- function fetchUrlViaJina(url, maxChars = 2000) {
1334
- const jinaUrl = `https://r.jina.ai/${url}`;
1335
- return new Promise((resolve, reject) => {
1336
- https.get(jinaUrl, (res) => {
1337
- const chunks = [];
1338
- res.on("data", (chunk) => chunks.push(chunk));
1339
- res.on("end", () => {
1340
- const text = Buffer.concat(chunks).toString("utf8");
1341
- resolve(text.slice(0, maxChars));
1342
- });
1343
- res.on("error", reject);
1344
- }).on("error", reject);
1345
- });
1346
- }
1347
- /** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
1348
- export async function enrichPromptWithUrls(text) {
1349
- const urlRegex = /https?:\/\/[^\s]+/g;
1350
- const urls = text.match(urlRegex);
1351
- if (!urls || urls.length === 0)
1352
- return text;
1353
- const prefixes = [];
1354
- for (const url of urls) {
1355
- // Skip jina.ai URLs to avoid recursion
1356
- if (url.includes("r.jina.ai"))
1357
- continue;
1358
- try {
1359
- const content = await fetchUrlViaJina(url);
1360
- if (content.trim()) {
1361
- prefixes.push(`[Web content from ${url}]:\n${content}`);
1362
- }
1363
- }
1364
- catch (err) {
1365
- console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
1366
- }
1367
- }
1368
- if (prefixes.length === 0)
1369
- return text;
1370
- return prefixes.join("\n\n") + "\n\n" + text;
1371
- }
1372
- /** Parse frontmatter description from a skill markdown file */
1373
- function parseSkillDescription(content) {
1374
- const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
1375
- if (!match)
1376
- return null;
1377
- const frontmatter = match[1];
1378
- const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
1379
- return descMatch ? descMatch[1].trim() : null;
1380
- }
1381
- /** List available skills from ~/.claude/skills/ */
1382
- export function listSkills() {
1383
- const skillsDir = join(os.homedir(), ".claude", "skills");
1384
- if (!existsSync(skillsDir)) {
1385
- return "No skills directory found at ~/.claude/skills/";
1386
- }
1387
- let files;
1388
- try {
1389
- files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
1390
- }
1391
- catch {
1392
- return "Could not read skills directory.";
1393
- }
1394
- if (files.length === 0) {
1395
- return "No skills found in ~/.claude/skills/";
1396
- }
1397
- const lines = ["Available skills:"];
1398
- for (const file of files.sort()) {
1399
- const name = "/" + file.replace(/\.md$/, "");
1400
- try {
1401
- const content = readFileSync(join(skillsDir, file), "utf8");
1402
- const description = parseSkillDescription(content);
1403
- lines.push(description ? `${name} — ${description}` : name);
1404
- }
1405
- catch {
1406
- lines.push(name);
1407
- }
1408
- }
1409
- return lines.join("\n");
1410
- }
1411
1197
  export function splitMessage(text, maxLen = 4096) {
1412
1198
  if (text.length <= maxLen)
1413
1199
  return [text];