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