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