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