@gonzih/cc-tg 0.4.1 → 0.5.1

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/README.md CHANGED
@@ -29,6 +29,7 @@ Open your bot in Telegram and start chatting.
29
29
  | `ANTHROPIC_API_KEY` | yes* | Alternative — API key from console.anthropic.com |
30
30
  | `ALLOWED_USER_IDS` | no | Comma-separated Telegram user IDs. Leave empty to allow anyone |
31
31
  | `CWD` | no | Working directory for Claude Code. Defaults to current directory |
32
+ | `THREAD_CWD_MAP` | no | JSON mapping of forum topic names or IDs to CWD paths (see [Multi-topic sessions](#multi-topic-sessions)) |
32
33
 
33
34
  *One of `CLAUDE_CODE_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, or `ANTHROPIC_API_KEY` required.
34
35
 
@@ -118,6 +119,37 @@ Manage the cc-agent MCP server from Telegram without SSH:
118
119
  ### Self-restart
119
120
  `/restart` — spawns a detached child process with the same Node binary and args, sends you a confirmation message, then exits. The new process inherits all environment variables. No SSH required to restart the bot after updates.
120
121
 
122
+ ### Multi-topic sessions
123
+
124
+ When you use cc-tg in a **Telegram group with Topics enabled** (a "Forum" group), each topic gets its own **isolated Claude Code session**. One bot token, one daemon, unlimited isolated project contexts.
125
+
126
+ **How it works:**
127
+ - Session key = `chatId:threadId` for forum topics
128
+ - Session key = `chatId:main` for direct messages and non-topic groups (backward compatible)
129
+ - Commands like `/reset`, `/stop`, `/status` are scoped to the current topic
130
+
131
+ **Setup:**
132
+ 1. Create a Telegram group → Settings → Topics → Enable
133
+ 2. Create topics for each project (e.g. "Simorgh", "LeWM", "EcoClaw")
134
+ 3. Each topic now has its own isolated Claude context
135
+
136
+ **Optional: route topics to different working directories**
137
+
138
+ Set `THREAD_CWD_MAP` to a JSON string mapping topic names (or thread IDs) to absolute paths:
139
+
140
+ ```bash
141
+ THREAD_CWD_MAP='{"Simorgh":"/Users/you/simorgh-app","LeWM":"/Users/you/le-wm","EcoClaw":"/Users/you/ecoclaw"}'
142
+ ```
143
+
144
+ When cc-tg creates a new session for a topic, it looks up the topic name in this map and starts Claude in the corresponding directory. If no match is found, falls back to `CWD`.
145
+
146
+ You can also map by numeric thread ID:
147
+ ```bash
148
+ THREAD_CWD_MAP='{"12345":"/Users/you/project-a","67890":"/Users/you/project-b"}'
149
+ ```
150
+
151
+ If `THREAD_CWD_MAP` is not set, all topics share the same CWD — context isolation still works, just without directory routing.
152
+
121
153
  ### Typing indicator
122
154
  While Claude is working, the bot sends a continuous typing indicator. Works for both regular messages and cron job execution.
123
155
 
package/dist/bot.d.ts CHANGED
@@ -21,6 +21,16 @@ export declare class CcTgBot {
21
21
  private botId;
22
22
  constructor(opts: BotOptions);
23
23
  private registerBotCommands;
24
+ /** Session key: "chatId:threadId" for topics, "chatId:main" for DMs/non-topic groups */
25
+ private sessionKey;
26
+ /**
27
+ * Send a message back to the correct thread (or plain chat if no thread).
28
+ * When threadId is undefined, calls sendMessage with exactly 2 args to preserve
29
+ * backward-compatible call signatures (no extra options object).
30
+ */
31
+ private replyToChat;
32
+ /** Parse THREAD_CWD_MAP env var — maps thread name or thread_id to a CWD path */
33
+ private getThreadCwdMap;
24
34
  private isAllowed;
25
35
  private handleTelegram;
26
36
  private handleVoice;
package/dist/bot.js CHANGED
@@ -185,6 +185,37 @@ export class CcTgBot {
185
185
  .then(() => console.log("[tg] bot commands registered"))
186
186
  .catch((err) => console.error("[tg] setMyCommands failed:", err.message));
187
187
  }
188
+ /** Session key: "chatId:threadId" for topics, "chatId:main" for DMs/non-topic groups */
189
+ sessionKey(chatId, threadId) {
190
+ return `${chatId}:${threadId ?? 'main'}`;
191
+ }
192
+ /**
193
+ * Send a message back to the correct thread (or plain chat if no thread).
194
+ * When threadId is undefined, calls sendMessage with exactly 2 args to preserve
195
+ * backward-compatible call signatures (no extra options object).
196
+ */
197
+ replyToChat(chatId, text, threadId, opts) {
198
+ if (threadId !== undefined) {
199
+ return this.bot.sendMessage(chatId, text, { ...opts, message_thread_id: threadId });
200
+ }
201
+ if (opts) {
202
+ return this.bot.sendMessage(chatId, text, opts);
203
+ }
204
+ return this.bot.sendMessage(chatId, text);
205
+ }
206
+ /** Parse THREAD_CWD_MAP env var — maps thread name or thread_id to a CWD path */
207
+ getThreadCwdMap() {
208
+ const raw = process.env.THREAD_CWD_MAP;
209
+ if (!raw)
210
+ return {};
211
+ try {
212
+ return JSON.parse(raw);
213
+ }
214
+ catch {
215
+ console.warn('[cc-tg] THREAD_CWD_MAP is not valid JSON, ignoring');
216
+ return {};
217
+ }
218
+ }
188
219
  isAllowed(userId) {
189
220
  if (!this.opts.allowedUserIds?.length)
190
221
  return true;
@@ -193,8 +224,16 @@ export class CcTgBot {
193
224
  async handleTelegram(msg) {
194
225
  const chatId = msg.chat.id;
195
226
  const userId = msg.from?.id ?? chatId;
227
+ // Forum topic thread_id — undefined for DMs and non-topic group messages
228
+ const threadId = msg.message_thread_id;
229
+ // Thread name is available on the service message that creates a new topic.
230
+ // forum_topic_created is not in older @types/node-telegram-bot-api versions, so cast via unknown.
231
+ const rawMsg = msg;
232
+ const threadName = rawMsg.forum_topic_created
233
+ ? rawMsg.forum_topic_created.name
234
+ : undefined;
196
235
  if (!this.isAllowed(userId)) {
197
- await this.bot.sendMessage(chatId, "Not authorized.");
236
+ await this.replyToChat(chatId, "Not authorized.", threadId);
198
237
  return;
199
238
  }
200
239
  // Group chat handling
@@ -215,17 +254,17 @@ export class CcTgBot {
215
254
  }
216
255
  // Voice message — transcribe then feed as text
217
256
  if (msg.voice || msg.audio) {
218
- await this.handleVoice(chatId, msg);
257
+ await this.handleVoice(chatId, msg, threadId, threadName);
219
258
  return;
220
259
  }
221
260
  // Photo — send as base64 image content block to Claude
222
261
  if (msg.photo?.length) {
223
- await this.handlePhoto(chatId, msg);
262
+ await this.handlePhoto(chatId, msg, threadId, threadName);
224
263
  return;
225
264
  }
226
265
  // Document — download to CWD/.cc-tg/uploads/, tell Claude the path
227
266
  if (msg.document) {
228
- await this.handleDocument(chatId, msg);
267
+ await this.handleDocument(chatId, msg, threadId, threadName);
229
268
  return;
230
269
  }
231
270
  let text = msg.text?.trim();
@@ -235,68 +274,69 @@ export class CcTgBot {
235
274
  if (this.botUsername) {
236
275
  text = text.replace(new RegExp(`@${this.botUsername}\\s*`, "g"), "").trim();
237
276
  }
277
+ const sessionKey = this.sessionKey(chatId, threadId);
238
278
  // /start or /reset — kill existing session and ack
239
279
  if (text === "/start" || text === "/reset") {
240
- this.killSession(chatId);
241
- await this.bot.sendMessage(chatId, "Session reset. Send a message to start.");
280
+ this.killSession(chatId, true, threadId);
281
+ await this.replyToChat(chatId, "Session reset. Send a message to start.", threadId);
242
282
  return;
243
283
  }
244
284
  // /stop — kill active session (interrupt running Claude task)
245
285
  if (text === "/stop") {
246
- const has = this.sessions.has(chatId);
247
- this.killSession(chatId);
248
- await this.bot.sendMessage(chatId, has ? "Stopped." : "No active session.");
286
+ const has = this.sessions.has(sessionKey);
287
+ this.killSession(chatId, true, threadId);
288
+ await this.replyToChat(chatId, has ? "Stopped." : "No active session.", threadId);
249
289
  return;
250
290
  }
251
291
  // /help — list all commands
252
292
  if (text === "/help") {
253
293
  const lines = BOT_COMMANDS.map((c) => `/${c.command} — ${c.description}`);
254
- await this.bot.sendMessage(chatId, lines.join("\n"));
294
+ await this.replyToChat(chatId, lines.join("\n"), threadId);
255
295
  return;
256
296
  }
257
297
  // /status
258
298
  if (text === "/status") {
259
- const has = this.sessions.has(chatId);
299
+ const has = this.sessions.has(sessionKey);
260
300
  let status = has ? "Session active." : "No active session.";
261
301
  const sleeping = this.pendingRetries.size;
262
302
  if (sleeping > 0)
263
303
  status += `\n⏸ ${sleeping} request(s) sleeping (usage limit).`;
264
- await this.bot.sendMessage(chatId, status);
304
+ await this.replyToChat(chatId, status, threadId);
265
305
  return;
266
306
  }
267
307
  // /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
268
308
  if (text.startsWith("/cron")) {
269
- await this.handleCron(chatId, text);
309
+ await this.handleCron(chatId, text, threadId);
270
310
  return;
271
311
  }
272
312
  // /reload_mcp — kill cc-agent process so Claude Code auto-restarts it
273
313
  if (text === "/reload_mcp") {
274
- await this.handleReloadMcp(chatId);
314
+ await this.handleReloadMcp(chatId, threadId);
275
315
  return;
276
316
  }
277
317
  // /mcp_status — run `claude mcp list` and show connection status
278
318
  if (text === "/mcp_status") {
279
- await this.handleMcpStatus(chatId);
319
+ await this.handleMcpStatus(chatId, threadId);
280
320
  return;
281
321
  }
282
322
  // /mcp_version — show published npm version and cached npx entries
283
323
  if (text === "/mcp_version") {
284
- await this.handleMcpVersion(chatId);
324
+ await this.handleMcpVersion(chatId, threadId);
285
325
  return;
286
326
  }
287
327
  // /clear_npx_cache — wipe ~/.npm/_npx/ then restart cc-agent
288
328
  if (text === "/clear_npx_cache") {
289
- await this.handleClearNpxCache(chatId);
329
+ await this.handleClearNpxCache(chatId, threadId);
290
330
  return;
291
331
  }
292
332
  // /restart — restart the bot process in-place
293
333
  if (text === "/restart") {
294
- await this.handleRestart(chatId);
334
+ await this.handleRestart(chatId, threadId);
295
335
  return;
296
336
  }
297
337
  // /get_file <path> — send a file from the server to the user
298
338
  if (text.startsWith("/get_file")) {
299
- await this.handleGetFile(chatId, text);
339
+ await this.handleGetFile(chatId, text, threadId);
300
340
  return;
301
341
  }
302
342
  // /cost — show session token usage and cost
@@ -312,10 +352,10 @@ export class CcTgBot {
312
352
  catch (err) {
313
353
  console.error("[cost] cc-agent cost_summary failed:", err.message);
314
354
  }
315
- await this.bot.sendMessage(chatId, reply);
355
+ await this.replyToChat(chatId, reply, threadId);
316
356
  return;
317
357
  }
318
- const session = this.getOrCreateSession(chatId);
358
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
319
359
  try {
320
360
  const prompt = buildPromptWithReplyContext(text, msg);
321
361
  session.currentPrompt = prompt;
@@ -323,11 +363,11 @@ export class CcTgBot {
323
363
  this.startTyping(chatId, session);
324
364
  }
325
365
  catch (err) {
326
- await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
327
- this.killSession(chatId);
366
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
367
+ this.killSession(chatId, true, threadId);
328
368
  }
329
369
  }
330
- async handleVoice(chatId, msg) {
370
+ async handleVoice(chatId, msg, threadId, threadName) {
331
371
  const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
332
372
  if (!fileId)
333
373
  return;
@@ -338,11 +378,11 @@ export class CcTgBot {
338
378
  const transcript = await transcribeVoice(fileLink);
339
379
  console.log(`[voice:${chatId}] transcribed: ${transcript}`);
340
380
  if (!transcript || transcript === "[empty transcription]") {
341
- await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
381
+ await this.replyToChat(chatId, "Could not transcribe voice message.", threadId);
342
382
  return;
343
383
  }
344
384
  // Feed transcript into Claude as if user typed it
345
- const session = this.getOrCreateSession(chatId);
385
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
346
386
  try {
347
387
  const prompt = buildPromptWithReplyContext(transcript, msg);
348
388
  session.currentPrompt = prompt;
@@ -350,16 +390,16 @@ export class CcTgBot {
350
390
  this.startTyping(chatId, session);
351
391
  }
352
392
  catch (err) {
353
- await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
354
- this.killSession(chatId);
393
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
394
+ this.killSession(chatId, true, threadId);
355
395
  }
356
396
  }
357
397
  catch (err) {
358
398
  console.error(`[voice:${chatId}] error:`, err.message);
359
- await this.bot.sendMessage(chatId, `Voice transcription failed: ${err.message}`);
399
+ await this.replyToChat(chatId, `Voice transcription failed: ${err.message}`, threadId);
360
400
  }
361
401
  }
362
- async handlePhoto(chatId, msg) {
402
+ async handlePhoto(chatId, msg, threadId, threadName) {
363
403
  // Pick highest resolution photo
364
404
  const photos = msg.photo;
365
405
  const best = photos[photos.length - 1];
@@ -370,16 +410,16 @@ export class CcTgBot {
370
410
  const fileLink = await this.bot.getFileLink(best.file_id);
371
411
  const imageData = await fetchAsBase64(fileLink);
372
412
  // Telegram photos are always JPEG
373
- const session = this.getOrCreateSession(chatId);
413
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
374
414
  session.claude.sendImage(imageData, "image/jpeg", caption);
375
415
  this.startTyping(chatId, session);
376
416
  }
377
417
  catch (err) {
378
418
  console.error(`[photo:${chatId}] error:`, err.message);
379
- await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
419
+ await this.replyToChat(chatId, `Failed to process image: ${err.message}`, threadId);
380
420
  }
381
421
  }
382
- async handleDocument(chatId, msg) {
422
+ async handleDocument(chatId, msg, threadId, threadName) {
383
423
  const doc = msg.document;
384
424
  const caption = msg.caption?.trim();
385
425
  const fileName = doc.file_name ?? `file_${doc.file_id}`;
@@ -395,21 +435,33 @@ export class CcTgBot {
395
435
  const prompt = caption
396
436
  ? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
397
437
  : `ATTACHMENTS: [${fileName}](${destPath})`;
398
- const session = this.getOrCreateSession(chatId);
438
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
399
439
  session.claude.sendPrompt(prompt);
400
440
  this.startTyping(chatId, session);
401
441
  }
402
442
  catch (err) {
403
443
  console.error(`[doc:${chatId}] error:`, err.message);
404
- await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
444
+ await this.replyToChat(chatId, `Failed to receive document: ${err.message}`, threadId);
405
445
  }
406
446
  }
407
- getOrCreateSession(chatId) {
408
- const existing = this.sessions.get(chatId);
447
+ getOrCreateSession(chatId, threadId, threadName) {
448
+ const key = this.sessionKey(chatId, threadId);
449
+ const existing = this.sessions.get(key);
409
450
  if (existing && !existing.claude.exited)
410
451
  return existing;
452
+ // Determine CWD for this thread — check THREAD_CWD_MAP by name then by ID
453
+ let sessionCwd = this.opts.cwd;
454
+ const threadCwdMap = this.getThreadCwdMap();
455
+ if (threadName && threadCwdMap[threadName]) {
456
+ sessionCwd = threadCwdMap[threadName];
457
+ console.log(`[cc-tg] thread "${threadName}" → cwd: ${sessionCwd}`);
458
+ }
459
+ else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
460
+ sessionCwd = threadCwdMap[String(threadId)];
461
+ console.log(`[cc-tg] thread ${threadId} → cwd: ${sessionCwd}`);
462
+ }
411
463
  const claude = new ClaudeProcess({
412
- cwd: this.opts.cwd,
464
+ cwd: sessionCwd,
413
465
  token: getCurrentToken() || this.opts.claudeToken,
414
466
  });
415
467
  const session = {
@@ -420,6 +472,7 @@ export class CcTgBot {
420
472
  writtenFiles: new Set(),
421
473
  currentPrompt: "",
422
474
  isRetry: false,
475
+ threadId,
423
476
  };
424
477
  claude.on("usage", (usage) => {
425
478
  this.costStore.addUsage(chatId, usage);
@@ -428,33 +481,33 @@ export class CcTgBot {
428
481
  // Verbose logging — log every message type and subtype
429
482
  const subtype = msg.payload.subtype ?? "";
430
483
  const toolName = this.extractToolName(msg);
431
- const logParts = [`[claude:${chatId}] msg=${msg.type}`];
484
+ const logParts = [`[claude:${key}] msg=${msg.type}`];
432
485
  if (subtype)
433
486
  logParts.push(`subtype=${subtype}`);
434
487
  if (toolName)
435
488
  logParts.push(`tool=${toolName}`);
436
489
  console.log(logParts.join(" "));
437
490
  // Track files written by Write/Edit tool calls
438
- this.trackWrittenFiles(msg, session, this.opts.cwd);
491
+ this.trackWrittenFiles(msg, session, sessionCwd);
439
492
  this.handleClaudeMessage(chatId, session, msg);
440
493
  });
441
494
  claude.on("stderr", (data) => {
442
495
  const line = data.trim();
443
496
  if (line)
444
- console.error(`[claude:${chatId}:stderr]`, line);
497
+ console.error(`[claude:${key}:stderr]`, line);
445
498
  });
446
499
  claude.on("exit", (code) => {
447
- console.log(`[claude:${chatId}] exited code=${code}`);
500
+ console.log(`[claude:${key}] exited code=${code}`);
448
501
  this.stopTyping(session);
449
- this.sessions.delete(chatId);
502
+ this.sessions.delete(key);
450
503
  });
451
504
  claude.on("error", (err) => {
452
- console.error(`[claude:${chatId}] process error: ${err.message}`);
505
+ console.error(`[claude:${key}] process error: ${err.message}`);
453
506
  this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
454
507
  this.stopTyping(session);
455
- this.sessions.delete(chatId);
508
+ this.sessions.delete(key);
456
509
  });
457
- this.sessions.set(chatId, session);
510
+ this.sessions.set(key, session);
458
511
  return session;
459
512
  }
460
513
  handleClaudeMessage(chatId, session, msg) {
@@ -470,13 +523,15 @@ export class CcTgBot {
470
523
  // Check for usage/rate limit signals before forwarding to Telegram
471
524
  const sig = detectUsageLimit(text);
472
525
  if (sig.detected) {
526
+ const threadId = session.threadId;
527
+ const retryKey = this.sessionKey(chatId, threadId);
473
528
  const lastPrompt = session.currentPrompt;
474
- const prevRetry = this.pendingRetries.get(chatId);
529
+ const prevRetry = this.pendingRetries.get(retryKey);
475
530
  const attempt = (prevRetry?.attempt ?? 0) + 1;
476
531
  if (prevRetry)
477
532
  clearTimeout(prevRetry.timer);
478
- this.bot.sendMessage(chatId, sig.humanMessage).catch(() => { });
479
- this.killSession(chatId);
533
+ this.replyToChat(chatId, sig.humanMessage, threadId).catch(() => { });
534
+ this.killSession(chatId, true, threadId);
480
535
  // Token rotation: if this is a usage_exhausted signal and we have multiple
481
536
  // tokens, rotate to the next one and retry immediately instead of sleeping.
482
537
  // Only rotate if we haven't yet cycled through all tokens (attempt <= count-1).
@@ -486,40 +541,40 @@ export class CcTgBot {
486
541
  const newIdx = getTokenIndex();
487
542
  const total = getTokenCount();
488
543
  console.log(`[cc-tg] Token ${prevIdx + 1}/${total} exhausted, rotating to token ${newIdx + 1}/${total}`);
489
- this.bot.sendMessage(chatId, `🔄 Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`).catch(() => { });
490
- this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
544
+ this.replyToChat(chatId, `🔄 Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`, threadId).catch(() => { });
545
+ this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
491
546
  try {
492
- const retrySession = this.getOrCreateSession(chatId);
547
+ const retrySession = this.getOrCreateSession(chatId, threadId);
493
548
  retrySession.currentPrompt = lastPrompt;
494
549
  retrySession.isRetry = true;
495
550
  retrySession.claude.sendPrompt(lastPrompt);
496
551
  this.startTyping(chatId, retrySession);
497
552
  }
498
553
  catch (err) {
499
- this.bot.sendMessage(chatId, `❌ Failed to retry with rotated token: ${err.message}`).catch(() => { });
554
+ this.replyToChat(chatId, `❌ Failed to retry with rotated token: ${err.message}`, threadId).catch(() => { });
500
555
  }
501
556
  return;
502
557
  }
503
558
  if (attempt > 3) {
504
- this.bot.sendMessage(chatId, "❌ Claude usage limit persists after 3 retries. Please try again later.").catch(() => { });
505
- this.pendingRetries.delete(chatId);
559
+ this.replyToChat(chatId, "❌ Claude usage limit persists after 3 retries. Please try again later.", threadId).catch(() => { });
560
+ this.pendingRetries.delete(retryKey);
506
561
  return;
507
562
  }
508
- console.log(`[usage-limit:${chatId}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
563
+ console.log(`[usage-limit:${retryKey}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
509
564
  const timer = setTimeout(() => {
510
- this.pendingRetries.delete(chatId);
565
+ this.pendingRetries.delete(retryKey);
511
566
  try {
512
- const retrySession = this.getOrCreateSession(chatId);
567
+ const retrySession = this.getOrCreateSession(chatId, threadId);
513
568
  retrySession.currentPrompt = lastPrompt;
514
569
  retrySession.isRetry = true;
515
570
  retrySession.claude.sendPrompt(lastPrompt);
516
571
  this.startTyping(chatId, retrySession);
517
572
  }
518
573
  catch (err) {
519
- this.bot.sendMessage(chatId, `❌ Failed to retry: ${err.message}`).catch(() => { });
574
+ this.replyToChat(chatId, `❌ Failed to retry: ${err.message}`, threadId).catch(() => { });
520
575
  }
521
576
  }, sig.retryAfterMs);
522
- this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer });
577
+ this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer });
523
578
  return;
524
579
  }
525
580
  // Accumulate text and debounce — Claude streams chunks rapidly
@@ -553,10 +608,11 @@ export class CcTgBot {
553
608
  // Format for Telegram HTML and split if needed (max 4096 chars)
554
609
  const formatted = formatForTelegram(text);
555
610
  const chunks = splitLongMessage(formatted);
611
+ const threadId = session.threadId;
556
612
  for (const chunk of chunks) {
557
- this.bot.sendMessage(chatId, chunk, { parse_mode: "HTML" }).catch(() => {
613
+ this.replyToChat(chatId, chunk, threadId, { parse_mode: "HTML" }).catch(() => {
558
614
  // HTML parse failed — retry as plain text
559
- this.bot.sendMessage(chatId, chunk).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
615
+ this.replyToChat(chatId, chunk, threadId).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
560
616
  });
561
617
  }
562
618
  // Hybrid file upload: find files mentioned in result text that Claude actually wrote
@@ -729,11 +785,12 @@ export class CcTgBot {
729
785
  const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
730
786
  if (fileSize > MAX_TG_FILE_BYTES) {
731
787
  const mb = (fileSize / (1024 * 1024)).toFixed(1);
732
- this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`).catch(() => { });
788
+ this.replyToChat(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`, session.threadId).catch(() => { });
733
789
  continue;
734
790
  }
735
791
  console.log(`[claude:files] uploading to telegram: ${filePath}`);
736
- this.bot.sendDocument(chatId, filePath).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
792
+ const docOpts = session.threadId ? { message_thread_id: session.threadId } : undefined;
793
+ this.bot.sendDocument(chatId, filePath, docOpts).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
737
794
  }
738
795
  // Clear written files for next turn
739
796
  session.writtenFiles.clear();
@@ -822,83 +879,83 @@ export class CcTgBot {
822
879
  });
823
880
  cronProcess.sendPrompt(taskPrompt);
824
881
  }
825
- async handleCron(chatId, text) {
882
+ async handleCron(chatId, text, threadId) {
826
883
  const args = text.slice("/cron".length).trim();
827
884
  // /cron list
828
885
  if (args === "list" || args === "") {
829
886
  const jobs = this.cron.list(chatId);
830
887
  if (!jobs.length) {
831
- await this.bot.sendMessage(chatId, "No cron jobs.");
888
+ await this.replyToChat(chatId, "No cron jobs.", threadId);
832
889
  return;
833
890
  }
834
891
  const lines = jobs.map((j, i) => {
835
892
  const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
836
893
  return `#${i + 1} ${j.schedule} — "${short}"`;
837
894
  });
838
- await this.bot.sendMessage(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`);
895
+ await this.replyToChat(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`, threadId);
839
896
  return;
840
897
  }
841
898
  // /cron clear
842
899
  if (args === "clear") {
843
900
  const n = this.cron.clearAll(chatId);
844
- await this.bot.sendMessage(chatId, `Cleared ${n} cron job(s).`);
901
+ await this.replyToChat(chatId, `Cleared ${n} cron job(s).`, threadId);
845
902
  return;
846
903
  }
847
904
  // /cron remove <id>
848
905
  if (args.startsWith("remove ")) {
849
906
  const id = args.slice("remove ".length).trim();
850
907
  const ok = this.cron.remove(chatId, id);
851
- await this.bot.sendMessage(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`);
908
+ await this.replyToChat(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`, threadId);
852
909
  return;
853
910
  }
854
911
  // /cron edit [<#> ...]
855
912
  if (args === "edit" || args.startsWith("edit ")) {
856
- await this.handleCronEdit(chatId, args.slice("edit".length).trim());
913
+ await this.handleCronEdit(chatId, args.slice("edit".length).trim(), threadId);
857
914
  return;
858
915
  }
859
916
  // /cron every 1h <prompt>
860
917
  const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
861
918
  if (!scheduleMatch) {
862
- await this.bot.sendMessage(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron edit\n/cron remove <id>\n/cron clear");
919
+ await this.replyToChat(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron edit\n/cron remove <id>\n/cron clear", threadId);
863
920
  return;
864
921
  }
865
922
  const schedule = scheduleMatch[1];
866
923
  const prompt = scheduleMatch[2];
867
924
  const job = this.cron.add(chatId, schedule, prompt);
868
925
  if (!job) {
869
- await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
926
+ await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
870
927
  return;
871
928
  }
872
- await this.bot.sendMessage(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`);
929
+ await this.replyToChat(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`, threadId);
873
930
  }
874
- async handleCronEdit(chatId, editArgs) {
931
+ async handleCronEdit(chatId, editArgs, threadId) {
875
932
  const jobs = this.cron.list(chatId);
876
933
  // No args — show numbered list with edit instructions
877
934
  if (!editArgs) {
878
935
  if (!jobs.length) {
879
- await this.bot.sendMessage(chatId, "No cron jobs to edit.");
936
+ await this.replyToChat(chatId, "No cron jobs to edit.", threadId);
880
937
  return;
881
938
  }
882
939
  const lines = jobs.map((j, i) => {
883
940
  const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
884
941
  return `#${i + 1} ${j.schedule} — "${short}"`;
885
942
  });
886
- await this.bot.sendMessage(chatId, `Cron jobs:\n${lines.join("\n")}\n\n` +
943
+ await this.replyToChat(chatId, `Cron jobs:\n${lines.join("\n")}\n\n` +
887
944
  "Edit options:\n" +
888
945
  "/cron edit <#> every <N><unit> <new prompt>\n" +
889
946
  "/cron edit <#> schedule every <N><unit>\n" +
890
- "/cron edit <#> prompt <new prompt>");
947
+ "/cron edit <#> prompt <new prompt>", threadId);
891
948
  return;
892
949
  }
893
950
  // Expect: <index> <rest>
894
951
  const indexMatch = editArgs.match(/^(\d+)\s+(.+)$/);
895
952
  if (!indexMatch) {
896
- await this.bot.sendMessage(chatId, "Usage: /cron edit <#> every <N><unit> <new prompt>");
953
+ await this.replyToChat(chatId, "Usage: /cron edit <#> every <N><unit> <new prompt>", threadId);
897
954
  return;
898
955
  }
899
956
  const index = parseInt(indexMatch[1], 10) - 1;
900
957
  if (index < 0 || index >= jobs.length) {
901
- await this.bot.sendMessage(chatId, `Invalid job number. Use /cron edit to see the list.`);
958
+ await this.replyToChat(chatId, `Invalid job number. Use /cron edit to see the list.`, threadId);
902
959
  return;
903
960
  }
904
961
  const job = jobs[index];
@@ -908,13 +965,13 @@ export class CcTgBot {
908
965
  const newSchedule = editCmd.slice("schedule ".length).trim();
909
966
  const result = this.cron.update(chatId, job.id, { schedule: newSchedule });
910
967
  if (result === null) {
911
- await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
968
+ await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
912
969
  }
913
970
  else if (result === false) {
914
- await this.bot.sendMessage(chatId, "Job not found.");
971
+ await this.replyToChat(chatId, "Job not found.", threadId);
915
972
  }
916
973
  else {
917
- await this.bot.sendMessage(chatId, `#${index + 1} schedule updated to ${newSchedule}.`);
974
+ await this.replyToChat(chatId, `#${index + 1} schedule updated to ${newSchedule}.`, threadId);
918
975
  }
919
976
  return;
920
977
  }
@@ -923,10 +980,10 @@ export class CcTgBot {
923
980
  const newPrompt = editCmd.slice("prompt ".length).trim();
924
981
  const result = this.cron.update(chatId, job.id, { prompt: newPrompt });
925
982
  if (result === false) {
926
- await this.bot.sendMessage(chatId, "Job not found.");
983
+ await this.replyToChat(chatId, "Job not found.", threadId);
927
984
  }
928
985
  else {
929
- await this.bot.sendMessage(chatId, `#${index + 1} prompt updated to "${newPrompt}".`);
986
+ await this.replyToChat(chatId, `#${index + 1} prompt updated to "${newPrompt}".`, threadId);
930
987
  }
931
988
  return;
932
989
  }
@@ -937,20 +994,20 @@ export class CcTgBot {
937
994
  const newPrompt = fullMatch[2];
938
995
  const result = this.cron.update(chatId, job.id, { schedule: newSchedule, prompt: newPrompt });
939
996
  if (result === null) {
940
- await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
997
+ await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
941
998
  }
942
999
  else if (result === false) {
943
- await this.bot.sendMessage(chatId, "Job not found.");
1000
+ await this.replyToChat(chatId, "Job not found.", threadId);
944
1001
  }
945
1002
  else {
946
- await this.bot.sendMessage(chatId, `#${index + 1} updated: ${newSchedule} — "${newPrompt}"`);
1003
+ await this.replyToChat(chatId, `#${index + 1} updated: ${newSchedule} — "${newPrompt}"`, threadId);
947
1004
  }
948
1005
  return;
949
1006
  }
950
- await this.bot.sendMessage(chatId, "Edit options:\n" +
1007
+ await this.replyToChat(chatId, "Edit options:\n" +
951
1008
  "/cron edit <#> every <N><unit> <new prompt>\n" +
952
1009
  "/cron edit <#> schedule every <N><unit>\n" +
953
- "/cron edit <#> prompt <new prompt>");
1010
+ "/cron edit <#> prompt <new prompt>", threadId);
954
1011
  }
955
1012
  /** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
956
1013
  findCcAgentPids() {
@@ -977,33 +1034,33 @@ export class CcTgBot {
977
1034
  }
978
1035
  return pids;
979
1036
  }
980
- async handleReloadMcp(chatId) {
981
- await this.bot.sendMessage(chatId, "Clearing npx cache and reloading MCP...");
1037
+ async handleReloadMcp(chatId, threadId) {
1038
+ await this.replyToChat(chatId, "Clearing npx cache and reloading MCP...", threadId);
982
1039
  try {
983
1040
  const home = process.env.HOME ?? "~";
984
1041
  execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
985
1042
  console.log("[mcp] cleared ~/.npm/_npx/");
986
1043
  }
987
1044
  catch (err) {
988
- await this.bot.sendMessage(chatId, `Warning: failed to clear npx cache: ${err.message}`);
1045
+ await this.replyToChat(chatId, `Warning: failed to clear npx cache: ${err.message}`, threadId);
989
1046
  }
990
1047
  const pids = this.killCcAgent();
991
1048
  if (pids.length === 0) {
992
- await this.bot.sendMessage(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.");
1049
+ await this.replyToChat(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.", threadId);
993
1050
  return;
994
1051
  }
995
- 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.`);
1052
+ 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);
996
1053
  }
997
- async handleMcpStatus(chatId) {
1054
+ async handleMcpStatus(chatId, threadId) {
998
1055
  try {
999
1056
  const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
1000
- await this.bot.sendMessage(chatId, `MCP server status:\n\n${output || "(no output)"}`);
1057
+ await this.replyToChat(chatId, `MCP server status:\n\n${output || "(no output)"}`, threadId);
1001
1058
  }
1002
1059
  catch (err) {
1003
- await this.bot.sendMessage(chatId, `Failed to run claude mcp list: ${err.message}`);
1060
+ await this.replyToChat(chatId, `Failed to run claude mcp list: ${err.message}`, threadId);
1004
1061
  }
1005
1062
  }
1006
- async handleMcpVersion(chatId) {
1063
+ async handleMcpVersion(chatId, threadId) {
1007
1064
  let npmVersion = "unknown";
1008
1065
  let cacheEntries = "(unavailable)";
1009
1066
  try {
@@ -1020,9 +1077,9 @@ export class CcTgBot {
1020
1077
  catch {
1021
1078
  cacheEntries = "(empty or not found)";
1022
1079
  }
1023
- await this.bot.sendMessage(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`);
1080
+ await this.replyToChat(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`, threadId);
1024
1081
  }
1025
- async handleClearNpxCache(chatId) {
1082
+ async handleClearNpxCache(chatId, threadId) {
1026
1083
  const home = process.env.HOME ?? "/tmp";
1027
1084
  const cleared = [];
1028
1085
  const failed = [];
@@ -1045,10 +1102,10 @@ export class CcTgBot {
1045
1102
  const clearNote = failed.length
1046
1103
  ? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
1047
1104
  : `Cleared: ${cleared.join(", ")}.`;
1048
- await this.bot.sendMessage(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`);
1105
+ await this.replyToChat(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`, threadId);
1049
1106
  }
1050
- async handleRestart(chatId) {
1051
- await this.bot.sendMessage(chatId, "Clearing cache and restarting... brb.");
1107
+ async handleRestart(chatId, threadId) {
1108
+ await this.replyToChat(chatId, "Clearing cache and restarting... brb.", threadId);
1052
1109
  await new Promise(resolve => setTimeout(resolve, 300));
1053
1110
  // Clear npm caches before restart so launchd brings up fresh version
1054
1111
  const home = process.env.HOME ?? "/tmp";
@@ -1059,45 +1116,48 @@ export class CcTgBot {
1059
1116
  catch { }
1060
1117
  }
1061
1118
  // Kill all active Claude sessions cleanly
1062
- for (const [cid] of this.sessions) {
1063
- this.killSession(cid);
1119
+ for (const session of this.sessions.values()) {
1120
+ this.stopTyping(session);
1121
+ session.claude.kill();
1064
1122
  }
1123
+ this.sessions.clear();
1065
1124
  await new Promise(resolve => setTimeout(resolve, 200));
1066
1125
  process.exit(0);
1067
1126
  }
1068
- async handleGetFile(chatId, text) {
1127
+ async handleGetFile(chatId, text, threadId) {
1069
1128
  const arg = text.slice("/get_file".length).trim();
1070
1129
  if (!arg) {
1071
- await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
1130
+ await this.replyToChat(chatId, "Usage: /get_file <path>", threadId);
1072
1131
  return;
1073
1132
  }
1074
1133
  const filePath = resolve(arg);
1075
1134
  const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
1076
1135
  const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
1077
1136
  if (!inSafeDir) {
1078
- await this.bot.sendMessage(chatId, "Access denied: path not in allowed directories");
1137
+ await this.replyToChat(chatId, "Access denied: path not in allowed directories", threadId);
1079
1138
  return;
1080
1139
  }
1081
1140
  if (!existsSync(filePath)) {
1082
- await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
1141
+ await this.replyToChat(chatId, `File not found: ${filePath}`, threadId);
1083
1142
  return;
1084
1143
  }
1085
1144
  if (!statSync(filePath).isFile()) {
1086
- await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
1145
+ await this.replyToChat(chatId, `Not a file: ${filePath}`, threadId);
1087
1146
  return;
1088
1147
  }
1089
1148
  if (this.isSensitiveFile(filePath)) {
1090
- await this.bot.sendMessage(chatId, "Access denied: sensitive file");
1149
+ await this.replyToChat(chatId, "Access denied: sensitive file", threadId);
1091
1150
  return;
1092
1151
  }
1093
1152
  const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
1094
1153
  const fileSize = statSync(filePath).size;
1095
1154
  if (fileSize > MAX_TG_FILE_BYTES) {
1096
1155
  const mb = (fileSize / (1024 * 1024)).toFixed(1);
1097
- await this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`);
1156
+ await this.replyToChat(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`, threadId);
1098
1157
  return;
1099
1158
  }
1100
- await this.bot.sendDocument(chatId, filePath);
1159
+ const docOpts = threadId ? { message_thread_id: threadId } : undefined;
1160
+ await this.bot.sendDocument(chatId, filePath, docOpts);
1101
1161
  }
1102
1162
  callCcAgentTool(toolName, args = {}) {
1103
1163
  return new Promise((resolve) => {
@@ -1170,12 +1230,13 @@ export class CcTgBot {
1170
1230
  proc.on("exit", () => { clearTimeout(timeout); done(null); });
1171
1231
  });
1172
1232
  }
1173
- killSession(chatId, keepCrons = true) {
1174
- const session = this.sessions.get(chatId);
1233
+ killSession(chatId, keepCrons = true, threadId) {
1234
+ const key = this.sessionKey(chatId, threadId);
1235
+ const session = this.sessions.get(key);
1175
1236
  if (session) {
1176
1237
  this.stopTyping(session);
1177
1238
  session.claude.kill();
1178
- this.sessions.delete(chatId);
1239
+ this.sessions.delete(key);
1179
1240
  }
1180
1241
  if (!keepCrons)
1181
1242
  this.cron.clearAll(chatId);
@@ -1185,9 +1246,11 @@ export class CcTgBot {
1185
1246
  }
1186
1247
  stop() {
1187
1248
  this.bot.stopPolling();
1188
- for (const [chatId] of this.sessions) {
1189
- this.killSession(chatId);
1249
+ for (const session of this.sessions.values()) {
1250
+ this.stopTyping(session);
1251
+ session.claude.kill();
1190
1252
  }
1253
+ this.sessions.clear();
1191
1254
  }
1192
1255
  }
1193
1256
  function buildPromptWithReplyContext(text, msg) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-tg",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
5
5
  "type": "module",
6
6
  "bin": {