@gonzih/cc-tg 0.9.19 → 0.9.20
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 +0 -36
- package/dist/bot.d.ts +4 -35
- package/dist/bot.js +356 -538
- package/dist/cron.d.ts +1 -7
- package/dist/cron.js +3 -24
- package/dist/formatter.d.ts +12 -14
- package/dist/formatter.js +36 -72
- package/dist/index.js +21 -77
- package/dist/usage-limit.js +3 -2
- package/dist/voice.js +34 -29
- package/package.json +3 -4
- package/dist/notifier.d.ts +0 -37
- package/dist/notifier.js +0 -209
- package/dist/tokens.d.ts +0 -22
- package/dist/tokens.js +0 -56
package/dist/bot.js
CHANGED
|
@@ -11,17 +11,16 @@ import https from "https";
|
|
|
11
11
|
import http from "http";
|
|
12
12
|
import { ClaudeProcess, extractText } from "./claude.js";
|
|
13
13
|
import { transcribeVoice, isVoiceAvailable } from "./voice.js";
|
|
14
|
+
import { CronManager } from "./cron.js";
|
|
14
15
|
import { formatForTelegram, splitLongMessage } from "./formatter.js";
|
|
15
16
|
import { detectUsageLimit } from "./usage-limit.js";
|
|
16
|
-
import { getCurrentToken, rotateToken, getTokenIndex, getTokenCount } from "./tokens.js";
|
|
17
|
-
import { writeChatLog } from "./notifier.js";
|
|
18
|
-
import { CronManager } from "./cron.js";
|
|
19
17
|
const BOT_COMMANDS = [
|
|
20
18
|
{ command: "start", description: "Reset session and start fresh" },
|
|
21
19
|
{ command: "reset", description: "Reset Claude session" },
|
|
22
20
|
{ command: "stop", description: "Stop the current Claude task" },
|
|
23
21
|
{ command: "status", description: "Check if a session is active" },
|
|
24
22
|
{ command: "help", description: "Show all available commands" },
|
|
23
|
+
{ command: "cron", description: "Manage cron jobs — add/list/edit/remove/clear" },
|
|
25
24
|
{ command: "reload_mcp", description: "Restart the cc-agent MCP server process" },
|
|
26
25
|
{ command: "mcp_status", description: "Check MCP server connection status" },
|
|
27
26
|
{ command: "mcp_version", description: "Show cc-agent npm version and npx cache info" },
|
|
@@ -29,10 +28,22 @@ const BOT_COMMANDS = [
|
|
|
29
28
|
{ command: "restart", description: "Restart the bot process in-place" },
|
|
30
29
|
{ command: "get_file", description: "Send a file from the server to this chat" },
|
|
31
30
|
{ command: "cost", description: "Show session token usage and cost" },
|
|
32
|
-
{ command: "skills", description: "List available Claude skills with descriptions" },
|
|
33
|
-
{ command: "cron", description: "Manage cron jobs — add/list/edit/remove/clear" },
|
|
34
|
-
{ command: "voice_retry", description: "Retry failed voice message transcriptions" },
|
|
35
31
|
];
|
|
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
|
+
}
|
|
36
47
|
const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
|
|
37
48
|
const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
|
|
38
49
|
// Claude Sonnet 4.6 pricing (per 1M tokens)
|
|
@@ -68,6 +79,10 @@ function formatCostReport(cost) {
|
|
|
68
79
|
` Cache write: ${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`,
|
|
69
80
|
].join("\n");
|
|
70
81
|
}
|
|
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
|
+
}
|
|
71
86
|
function formatAgentCostSummary(text) {
|
|
72
87
|
try {
|
|
73
88
|
const data = JSON.parse(text);
|
|
@@ -154,17 +169,12 @@ export class CcTgBot {
|
|
|
154
169
|
sessions = new Map();
|
|
155
170
|
pendingRetries = new Map();
|
|
156
171
|
opts;
|
|
172
|
+
cron;
|
|
157
173
|
costStore;
|
|
158
174
|
botUsername = "";
|
|
159
175
|
botId = 0;
|
|
160
|
-
redis;
|
|
161
|
-
namespace;
|
|
162
|
-
lastActiveChatId;
|
|
163
|
-
cron;
|
|
164
176
|
constructor(opts) {
|
|
165
177
|
this.opts = opts;
|
|
166
|
-
this.redis = opts.redis;
|
|
167
|
-
this.namespace = opts.namespace ?? "default";
|
|
168
178
|
this.bot = new TelegramBot(opts.telegramToken, { polling: true });
|
|
169
179
|
this.bot.on("message", (msg) => this.handleTelegram(msg));
|
|
170
180
|
this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
|
|
@@ -173,10 +183,11 @@ export class CcTgBot {
|
|
|
173
183
|
this.botId = me.id;
|
|
174
184
|
console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
|
|
175
185
|
}).catch((err) => console.error("[tg] getMe failed:", err.message));
|
|
176
|
-
|
|
177
|
-
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt
|
|
178
|
-
this.runCronTask(chatId, prompt
|
|
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);
|
|
179
189
|
});
|
|
190
|
+
this.costStore = new CostStore(opts.cwd ?? process.cwd());
|
|
180
191
|
this.registerBotCommands();
|
|
181
192
|
console.log("cc-tg bot started");
|
|
182
193
|
console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
|
|
@@ -186,55 +197,6 @@ export class CcTgBot {
|
|
|
186
197
|
.then(() => console.log("[tg] bot commands registered"))
|
|
187
198
|
.catch((err) => console.error("[tg] setMyCommands failed:", err.message));
|
|
188
199
|
}
|
|
189
|
-
/** Write a message to the Redis chat log. Fire-and-forget — no-op if Redis is not configured. */
|
|
190
|
-
writeChatMessage(role, source, content, chatId) {
|
|
191
|
-
if (!this.redis)
|
|
192
|
-
return;
|
|
193
|
-
const msg = {
|
|
194
|
-
id: `${source}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
195
|
-
source,
|
|
196
|
-
role,
|
|
197
|
-
content,
|
|
198
|
-
timestamp: new Date().toISOString(),
|
|
199
|
-
chatId,
|
|
200
|
-
};
|
|
201
|
-
writeChatLog(this.redis, this.namespace, msg);
|
|
202
|
-
}
|
|
203
|
-
/** Returns the last chatId that sent a message — used by the chat bridge when no fixed chatId is configured. */
|
|
204
|
-
getLastActiveChatId() {
|
|
205
|
-
return this.lastActiveChatId;
|
|
206
|
-
}
|
|
207
|
-
/** Session key: "chatId:threadId" for topics, "chatId:main" for DMs/non-topic groups */
|
|
208
|
-
sessionKey(chatId, threadId) {
|
|
209
|
-
return `${chatId}:${threadId ?? 'main'}`;
|
|
210
|
-
}
|
|
211
|
-
/**
|
|
212
|
-
* Send a message back to the correct thread (or plain chat if no thread).
|
|
213
|
-
* When threadId is undefined, calls sendMessage with exactly 2 args to preserve
|
|
214
|
-
* backward-compatible call signatures (no extra options object).
|
|
215
|
-
*/
|
|
216
|
-
replyToChat(chatId, text, threadId, opts) {
|
|
217
|
-
if (threadId !== undefined) {
|
|
218
|
-
return this.bot.sendMessage(chatId, text, { ...opts, message_thread_id: threadId });
|
|
219
|
-
}
|
|
220
|
-
if (opts) {
|
|
221
|
-
return this.bot.sendMessage(chatId, text, opts);
|
|
222
|
-
}
|
|
223
|
-
return this.bot.sendMessage(chatId, text);
|
|
224
|
-
}
|
|
225
|
-
/** Parse THREAD_CWD_MAP env var — maps thread name or thread_id to a CWD path */
|
|
226
|
-
getThreadCwdMap() {
|
|
227
|
-
const raw = process.env.THREAD_CWD_MAP;
|
|
228
|
-
if (!raw)
|
|
229
|
-
return {};
|
|
230
|
-
try {
|
|
231
|
-
return JSON.parse(raw);
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
console.warn('[cc-tg] THREAD_CWD_MAP is not valid JSON, ignoring');
|
|
235
|
-
return {};
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
200
|
isAllowed(userId) {
|
|
239
201
|
if (!this.opts.allowedUserIds?.length)
|
|
240
202
|
return true;
|
|
@@ -243,20 +205,10 @@ export class CcTgBot {
|
|
|
243
205
|
async handleTelegram(msg) {
|
|
244
206
|
const chatId = msg.chat.id;
|
|
245
207
|
const userId = msg.from?.id ?? chatId;
|
|
246
|
-
// Forum topic thread_id — undefined for DMs and non-topic group messages
|
|
247
|
-
const threadId = msg.message_thread_id;
|
|
248
|
-
// Thread name is available on the service message that creates a new topic.
|
|
249
|
-
// forum_topic_created is not in older @types/node-telegram-bot-api versions, so cast via unknown.
|
|
250
|
-
const rawMsg = msg;
|
|
251
|
-
const threadName = rawMsg.forum_topic_created
|
|
252
|
-
? rawMsg.forum_topic_created.name
|
|
253
|
-
: undefined;
|
|
254
208
|
if (!this.isAllowed(userId)) {
|
|
255
|
-
await this.
|
|
209
|
+
await this.bot.sendMessage(chatId, "Not authorized.");
|
|
256
210
|
return;
|
|
257
211
|
}
|
|
258
|
-
// Track the last chat that sent us a message for the chat bridge
|
|
259
|
-
this.lastActiveChatId = chatId;
|
|
260
212
|
// Group chat handling
|
|
261
213
|
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
|
262
214
|
if (isGroup) {
|
|
@@ -275,17 +227,17 @@ export class CcTgBot {
|
|
|
275
227
|
}
|
|
276
228
|
// Voice message — transcribe then feed as text
|
|
277
229
|
if (msg.voice || msg.audio) {
|
|
278
|
-
await this.handleVoice(chatId, msg
|
|
230
|
+
await this.handleVoice(chatId, msg);
|
|
279
231
|
return;
|
|
280
232
|
}
|
|
281
233
|
// Photo — send as base64 image content block to Claude
|
|
282
234
|
if (msg.photo?.length) {
|
|
283
|
-
await this.handlePhoto(chatId, msg
|
|
235
|
+
await this.handlePhoto(chatId, msg);
|
|
284
236
|
return;
|
|
285
237
|
}
|
|
286
238
|
// Document — download to CWD/.cc-tg/uploads/, tell Claude the path
|
|
287
239
|
if (msg.document) {
|
|
288
|
-
await this.handleDocument(chatId, msg
|
|
240
|
+
await this.handleDocument(chatId, msg);
|
|
289
241
|
return;
|
|
290
242
|
}
|
|
291
243
|
let text = msg.text?.trim();
|
|
@@ -295,69 +247,68 @@ export class CcTgBot {
|
|
|
295
247
|
if (this.botUsername) {
|
|
296
248
|
text = text.replace(new RegExp(`@${this.botUsername}\\s*`, "g"), "").trim();
|
|
297
249
|
}
|
|
298
|
-
const sessionKey = this.sessionKey(chatId, threadId);
|
|
299
250
|
// /start or /reset — kill existing session and ack
|
|
300
251
|
if (text === "/start" || text === "/reset") {
|
|
301
|
-
this.killSession(chatId
|
|
302
|
-
await this.
|
|
252
|
+
this.killSession(chatId);
|
|
253
|
+
await this.bot.sendMessage(chatId, "Session reset. Send a message to start.");
|
|
303
254
|
return;
|
|
304
255
|
}
|
|
305
256
|
// /stop — kill active session (interrupt running Claude task)
|
|
306
257
|
if (text === "/stop") {
|
|
307
|
-
const has = this.sessions.has(
|
|
308
|
-
this.killSession(chatId
|
|
309
|
-
await this.
|
|
258
|
+
const has = this.sessions.has(chatId);
|
|
259
|
+
this.killSession(chatId);
|
|
260
|
+
await this.bot.sendMessage(chatId, has ? "Stopped." : "No active session.");
|
|
310
261
|
return;
|
|
311
262
|
}
|
|
312
263
|
// /help — list all commands
|
|
313
264
|
if (text === "/help") {
|
|
314
265
|
const lines = BOT_COMMANDS.map((c) => `/${c.command} — ${c.description}`);
|
|
315
|
-
await this.
|
|
266
|
+
await this.bot.sendMessage(chatId, lines.join("\n"));
|
|
316
267
|
return;
|
|
317
268
|
}
|
|
318
269
|
// /status
|
|
319
270
|
if (text === "/status") {
|
|
320
|
-
const has = this.sessions.has(
|
|
271
|
+
const has = this.sessions.has(chatId);
|
|
321
272
|
let status = has ? "Session active." : "No active session.";
|
|
322
273
|
const sleeping = this.pendingRetries.size;
|
|
323
274
|
if (sleeping > 0)
|
|
324
275
|
status += `\n⏸ ${sleeping} request(s) sleeping (usage limit).`;
|
|
325
|
-
await this.
|
|
276
|
+
await this.bot.sendMessage(chatId, status);
|
|
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
282
|
return;
|
|
327
283
|
}
|
|
328
284
|
// /reload_mcp — kill cc-agent process so Claude Code auto-restarts it
|
|
329
285
|
if (text === "/reload_mcp") {
|
|
330
|
-
await this.handleReloadMcp(chatId
|
|
286
|
+
await this.handleReloadMcp(chatId);
|
|
331
287
|
return;
|
|
332
288
|
}
|
|
333
289
|
// /mcp_status — run `claude mcp list` and show connection status
|
|
334
290
|
if (text === "/mcp_status") {
|
|
335
|
-
await this.handleMcpStatus(chatId
|
|
291
|
+
await this.handleMcpStatus(chatId);
|
|
336
292
|
return;
|
|
337
293
|
}
|
|
338
294
|
// /mcp_version — show published npm version and cached npx entries
|
|
339
295
|
if (text === "/mcp_version") {
|
|
340
|
-
await this.handleMcpVersion(chatId
|
|
296
|
+
await this.handleMcpVersion(chatId);
|
|
341
297
|
return;
|
|
342
298
|
}
|
|
343
299
|
// /clear_npx_cache — wipe ~/.npm/_npx/ then restart cc-agent
|
|
344
300
|
if (text === "/clear_npx_cache") {
|
|
345
|
-
await this.handleClearNpxCache(chatId
|
|
301
|
+
await this.handleClearNpxCache(chatId);
|
|
346
302
|
return;
|
|
347
303
|
}
|
|
348
304
|
// /restart — restart the bot process in-place
|
|
349
305
|
if (text === "/restart") {
|
|
350
|
-
await this.handleRestart(chatId
|
|
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);
|
|
306
|
+
await this.handleRestart(chatId);
|
|
356
307
|
return;
|
|
357
308
|
}
|
|
358
309
|
// /get_file <path> — send a file from the server to the user
|
|
359
310
|
if (text.startsWith("/get_file")) {
|
|
360
|
-
await this.handleGetFile(chatId, text
|
|
311
|
+
await this.handleGetFile(chatId, text);
|
|
361
312
|
return;
|
|
362
313
|
}
|
|
363
314
|
// /cost — show session token usage and cost
|
|
@@ -373,232 +324,85 @@ export class CcTgBot {
|
|
|
373
324
|
catch (err) {
|
|
374
325
|
console.error("[cost] cc-agent cost_summary failed:", err.message);
|
|
375
326
|
}
|
|
376
|
-
await this.
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
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);
|
|
327
|
+
await this.bot.sendMessage(chatId, reply);
|
|
387
328
|
return;
|
|
388
329
|
}
|
|
389
|
-
const session = this.getOrCreateSession(chatId
|
|
330
|
+
const session = this.getOrCreateSession(chatId);
|
|
390
331
|
try {
|
|
391
|
-
const
|
|
392
|
-
const prompt = buildPromptWithReplyContext(enriched, msg);
|
|
332
|
+
const prompt = buildPromptWithReplyContext(text, msg);
|
|
393
333
|
session.currentPrompt = prompt;
|
|
394
334
|
session.claude.sendPrompt(prompt);
|
|
395
335
|
this.startTyping(chatId, session);
|
|
396
|
-
this.writeChatMessage("user", "telegram", text, chatId);
|
|
397
|
-
}
|
|
398
|
-
catch (err) {
|
|
399
|
-
await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
|
|
400
|
-
this.killSession(chatId, true, threadId);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
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
336
|
}
|
|
416
337
|
catch (err) {
|
|
417
|
-
await this.
|
|
418
|
-
this.killSession(chatId
|
|
338
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
339
|
+
this.killSession(chatId);
|
|
419
340
|
}
|
|
420
341
|
}
|
|
421
|
-
async handleVoice(chatId, msg
|
|
342
|
+
async handleVoice(chatId, msg) {
|
|
422
343
|
const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
|
|
423
344
|
if (!fileId)
|
|
424
345
|
return;
|
|
425
346
|
console.log(`[voice:${chatId}] received voice message, transcribing...`);
|
|
426
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
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
|
-
}
|
|
347
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
437
348
|
try {
|
|
438
|
-
const
|
|
439
|
-
|
|
349
|
+
const transcript = await withRetry(async () => {
|
|
350
|
+
const fileLink = await this.bot.getFileLink(fileId);
|
|
351
|
+
return transcribeVoice(fileLink);
|
|
352
|
+
}, 3, [2000, 5000]);
|
|
440
353
|
console.log(`[voice:${chatId}] transcribed: ${transcript}`);
|
|
441
|
-
// Remove from pending on success
|
|
442
|
-
if (this.redis) {
|
|
443
|
-
await this.redis.lrem("voice:pending", 0, pendingEntry).catch((err) => console.warn("[voice] redis lrem voice:pending failed:", err.message));
|
|
444
|
-
}
|
|
445
354
|
if (!transcript || transcript === "[empty transcription]") {
|
|
446
|
-
await this.
|
|
355
|
+
await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
|
|
447
356
|
return;
|
|
448
357
|
}
|
|
449
358
|
// Feed transcript into Claude as if user typed it
|
|
450
|
-
const session = this.getOrCreateSession(chatId
|
|
359
|
+
const session = this.getOrCreateSession(chatId);
|
|
451
360
|
try {
|
|
452
361
|
const prompt = buildPromptWithReplyContext(transcript, msg);
|
|
453
|
-
this.writeChatMessage("user", "telegram", transcript, chatId);
|
|
454
362
|
session.currentPrompt = prompt;
|
|
455
363
|
session.claude.sendPrompt(prompt);
|
|
456
364
|
this.startTyping(chatId, session);
|
|
457
365
|
}
|
|
458
366
|
catch (err) {
|
|
459
|
-
await this.
|
|
460
|
-
this.killSession(chatId
|
|
367
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
368
|
+
this.killSession(chatId);
|
|
461
369
|
}
|
|
462
370
|
}
|
|
463
371
|
catch (err) {
|
|
464
|
-
const errMsg = err
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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);
|
|
372
|
+
const errMsg = err instanceof Error
|
|
373
|
+
? (err.message || err.toString() || `signal: ${err.signal || 'unknown'}`)
|
|
374
|
+
: String(err);
|
|
375
|
+
const stderr = err.stderr ? ` | stderr: ${err.stderr.slice(0, 200)}` : '';
|
|
376
|
+
console.error(`[voice:${chatId}] error:`, errMsg, stderr);
|
|
377
|
+
await this.bot.sendMessage(chatId, `Voice transcription failed: ${errMsg}${stderr}`);
|
|
495
378
|
}
|
|
496
379
|
}
|
|
497
|
-
async
|
|
498
|
-
if (!this.redis) {
|
|
499
|
-
await this.replyToChat(chatId, "Redis not configured — voice retry unavailable.", threadId);
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
const [pendingRaw, failedRaw] = await Promise.all([
|
|
503
|
-
this.redis.lrange("voice:pending", 0, -1).catch(() => []),
|
|
504
|
-
this.redis.lrange("voice:failed", 0, -1).catch(() => []),
|
|
505
|
-
]);
|
|
506
|
-
// Deduplicate by file_id across both lists
|
|
507
|
-
const allEntries = new Map();
|
|
508
|
-
for (const raw of [...pendingRaw, ...failedRaw]) {
|
|
509
|
-
try {
|
|
510
|
-
const entry = JSON.parse(raw);
|
|
511
|
-
if (entry.file_id)
|
|
512
|
-
allEntries.set(entry.file_id, entry);
|
|
513
|
-
}
|
|
514
|
-
catch { /* skip malformed entries */ }
|
|
515
|
-
}
|
|
516
|
-
if (allEntries.size === 0) {
|
|
517
|
-
await this.replyToChat(chatId, "No pending voice messages to retry.", threadId);
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
await this.replyToChat(chatId, `Retrying ${allEntries.size} voice message(s)...`, threadId);
|
|
521
|
-
let succeeded = 0;
|
|
522
|
-
let failed = 0;
|
|
523
|
-
const errors = [];
|
|
524
|
-
for (const [fileId, entry] of allEntries) {
|
|
525
|
-
try {
|
|
526
|
-
const fileLink = await this.bot.getFileLink(fileId);
|
|
527
|
-
const transcript = await transcribeVoice(fileLink);
|
|
528
|
-
if (transcript && transcript !== "[empty transcription]") {
|
|
529
|
-
const session = this.getOrCreateSession(entry.chat_id, threadId, undefined);
|
|
530
|
-
session.claude.sendPrompt(transcript);
|
|
531
|
-
this.writeChatMessage("user", "telegram", transcript, entry.chat_id);
|
|
532
|
-
// Remove from both lists
|
|
533
|
-
const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
|
|
534
|
-
const matchFailed = failedRaw.find((r) => r.includes(`"${fileId}"`));
|
|
535
|
-
if (matchPending)
|
|
536
|
-
await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
|
|
537
|
-
if (matchFailed)
|
|
538
|
-
await this.redis.lrem("voice:failed", 0, matchFailed).catch(() => { });
|
|
539
|
-
succeeded++;
|
|
540
|
-
}
|
|
541
|
-
else {
|
|
542
|
-
failed++;
|
|
543
|
-
errors.push(`${fileId}: empty transcription`);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
catch (err) {
|
|
547
|
-
const errMsg = err.message;
|
|
548
|
-
failed++;
|
|
549
|
-
errors.push(`${fileId}: ${errMsg}`);
|
|
550
|
-
// Permanently unretryable (expired Telegram link) — remove from voice:pending
|
|
551
|
-
if (errMsg.includes("Bad Request") || errMsg.includes("file_id")) {
|
|
552
|
-
const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
|
|
553
|
-
if (matchPending)
|
|
554
|
-
await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
// Purge stale entries from voice:pending older than 48h
|
|
559
|
-
const staleThreshold = 48 * 60 * 60 * 1000;
|
|
560
|
-
let purged = 0;
|
|
561
|
-
for (const raw of pendingRaw) {
|
|
562
|
-
try {
|
|
563
|
-
const entry = JSON.parse(raw);
|
|
564
|
-
if (entry.timestamp && Date.now() - entry.timestamp > staleThreshold) {
|
|
565
|
-
await this.redis.lrem("voice:pending", 0, raw).catch(() => { });
|
|
566
|
-
purged++;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
catch { /* skip malformed entries */ }
|
|
570
|
-
}
|
|
571
|
-
const lines = [`Voice retry complete: ${succeeded} succeeded, ${failed} failed, ${purged} stale entries purged.`];
|
|
572
|
-
if (errors.length > 0)
|
|
573
|
-
lines.push(...errors.map((e) => `• ${e}`));
|
|
574
|
-
await this.replyToChat(chatId, lines.join("\n"), threadId);
|
|
575
|
-
}
|
|
576
|
-
async handlePhoto(chatId, msg, threadId, threadName) {
|
|
380
|
+
async handlePhoto(chatId, msg) {
|
|
577
381
|
// Pick highest resolution photo
|
|
578
382
|
const photos = msg.photo;
|
|
579
383
|
const best = photos[photos.length - 1];
|
|
580
384
|
const caption = msg.caption?.trim();
|
|
581
385
|
console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
|
|
582
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
386
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
583
387
|
try {
|
|
584
388
|
const fileLink = await this.bot.getFileLink(best.file_id);
|
|
585
389
|
const imageData = await fetchAsBase64(fileLink);
|
|
586
390
|
// Telegram photos are always JPEG
|
|
587
|
-
const session = this.getOrCreateSession(chatId
|
|
391
|
+
const session = this.getOrCreateSession(chatId);
|
|
588
392
|
session.claude.sendImage(imageData, "image/jpeg", caption);
|
|
589
393
|
this.startTyping(chatId, session);
|
|
590
394
|
}
|
|
591
395
|
catch (err) {
|
|
592
396
|
console.error(`[photo:${chatId}] error:`, err.message);
|
|
593
|
-
await this.
|
|
397
|
+
await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
|
|
594
398
|
}
|
|
595
399
|
}
|
|
596
|
-
async handleDocument(chatId, msg
|
|
400
|
+
async handleDocument(chatId, msg) {
|
|
597
401
|
const doc = msg.document;
|
|
598
402
|
const caption = msg.caption?.trim();
|
|
599
403
|
const fileName = doc.file_name ?? `file_${doc.file_id}`;
|
|
600
404
|
console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
|
|
601
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
405
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
602
406
|
try {
|
|
603
407
|
const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
|
|
604
408
|
mkdirSync(uploadsDir, { recursive: true });
|
|
@@ -609,34 +413,22 @@ export class CcTgBot {
|
|
|
609
413
|
const prompt = caption
|
|
610
414
|
? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
|
|
611
415
|
: `ATTACHMENTS: [${fileName}](${destPath})`;
|
|
612
|
-
const session = this.getOrCreateSession(chatId
|
|
416
|
+
const session = this.getOrCreateSession(chatId);
|
|
613
417
|
session.claude.sendPrompt(prompt);
|
|
614
418
|
this.startTyping(chatId, session);
|
|
615
419
|
}
|
|
616
420
|
catch (err) {
|
|
617
421
|
console.error(`[doc:${chatId}] error:`, err.message);
|
|
618
|
-
await this.
|
|
422
|
+
await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
|
|
619
423
|
}
|
|
620
424
|
}
|
|
621
|
-
getOrCreateSession(chatId
|
|
622
|
-
const
|
|
623
|
-
const existing = this.sessions.get(key);
|
|
425
|
+
getOrCreateSession(chatId) {
|
|
426
|
+
const existing = this.sessions.get(chatId);
|
|
624
427
|
if (existing && !existing.claude.exited)
|
|
625
428
|
return existing;
|
|
626
|
-
// Determine CWD for this thread — check THREAD_CWD_MAP by name then by ID
|
|
627
|
-
let sessionCwd = this.opts.cwd;
|
|
628
|
-
const threadCwdMap = this.getThreadCwdMap();
|
|
629
|
-
if (threadName && threadCwdMap[threadName]) {
|
|
630
|
-
sessionCwd = threadCwdMap[threadName];
|
|
631
|
-
console.log(`[cc-tg] thread "${threadName}" → cwd: ${sessionCwd}`);
|
|
632
|
-
}
|
|
633
|
-
else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
|
|
634
|
-
sessionCwd = threadCwdMap[String(threadId)];
|
|
635
|
-
console.log(`[cc-tg] thread ${threadId} → cwd: ${sessionCwd}`);
|
|
636
|
-
}
|
|
637
429
|
const claude = new ClaudeProcess({
|
|
638
|
-
cwd:
|
|
639
|
-
token:
|
|
430
|
+
cwd: this.opts.cwd,
|
|
431
|
+
token: this.opts.claudeToken,
|
|
640
432
|
});
|
|
641
433
|
const session = {
|
|
642
434
|
claude,
|
|
@@ -646,7 +438,6 @@ export class CcTgBot {
|
|
|
646
438
|
writtenFiles: new Set(),
|
|
647
439
|
currentPrompt: "",
|
|
648
440
|
isRetry: false,
|
|
649
|
-
threadId,
|
|
650
441
|
};
|
|
651
442
|
claude.on("usage", (usage) => {
|
|
652
443
|
this.costStore.addUsage(chatId, usage);
|
|
@@ -655,47 +446,33 @@ export class CcTgBot {
|
|
|
655
446
|
// Verbose logging — log every message type and subtype
|
|
656
447
|
const subtype = msg.payload.subtype ?? "";
|
|
657
448
|
const toolName = this.extractToolName(msg);
|
|
658
|
-
const logParts = [`[claude:${
|
|
449
|
+
const logParts = [`[claude:${chatId}] msg=${msg.type}`];
|
|
659
450
|
if (subtype)
|
|
660
451
|
logParts.push(`subtype=${subtype}`);
|
|
661
452
|
if (toolName)
|
|
662
453
|
logParts.push(`tool=${toolName}`);
|
|
663
454
|
console.log(logParts.join(" "));
|
|
664
455
|
// Track files written by Write/Edit tool calls
|
|
665
|
-
this.trackWrittenFiles(msg, session,
|
|
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
|
-
}
|
|
456
|
+
this.trackWrittenFiles(msg, session, this.opts.cwd);
|
|
680
457
|
this.handleClaudeMessage(chatId, session, msg);
|
|
681
458
|
});
|
|
682
459
|
claude.on("stderr", (data) => {
|
|
683
460
|
const line = data.trim();
|
|
684
461
|
if (line)
|
|
685
|
-
console.error(`[claude:${
|
|
462
|
+
console.error(`[claude:${chatId}:stderr]`, line);
|
|
686
463
|
});
|
|
687
464
|
claude.on("exit", (code) => {
|
|
688
|
-
console.log(`[claude:${
|
|
465
|
+
console.log(`[claude:${chatId}] exited code=${code}`);
|
|
689
466
|
this.stopTyping(session);
|
|
690
|
-
this.sessions.delete(
|
|
467
|
+
this.sessions.delete(chatId);
|
|
691
468
|
});
|
|
692
469
|
claude.on("error", (err) => {
|
|
693
|
-
console.error(`[claude:${
|
|
470
|
+
console.error(`[claude:${chatId}] process error: ${err.message}`);
|
|
694
471
|
this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
|
|
695
472
|
this.stopTyping(session);
|
|
696
|
-
this.sessions.delete(
|
|
473
|
+
this.sessions.delete(chatId);
|
|
697
474
|
});
|
|
698
|
-
this.sessions.set(
|
|
475
|
+
this.sessions.set(chatId, session);
|
|
699
476
|
return session;
|
|
700
477
|
}
|
|
701
478
|
handleClaudeMessage(chatId, session, msg) {
|
|
@@ -711,58 +488,33 @@ export class CcTgBot {
|
|
|
711
488
|
// Check for usage/rate limit signals before forwarding to Telegram
|
|
712
489
|
const sig = detectUsageLimit(text);
|
|
713
490
|
if (sig.detected) {
|
|
714
|
-
const threadId = session.threadId;
|
|
715
|
-
const retryKey = this.sessionKey(chatId, threadId);
|
|
716
491
|
const lastPrompt = session.currentPrompt;
|
|
717
|
-
const prevRetry = this.pendingRetries.get(
|
|
492
|
+
const prevRetry = this.pendingRetries.get(chatId);
|
|
718
493
|
const attempt = (prevRetry?.attempt ?? 0) + 1;
|
|
719
494
|
if (prevRetry)
|
|
720
495
|
clearTimeout(prevRetry.timer);
|
|
721
|
-
this.
|
|
722
|
-
this.killSession(chatId
|
|
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
|
-
}
|
|
496
|
+
this.bot.sendMessage(chatId, sig.humanMessage).catch(() => { });
|
|
497
|
+
this.killSession(chatId);
|
|
746
498
|
if (attempt > 3) {
|
|
747
|
-
this.
|
|
748
|
-
this.pendingRetries.delete(
|
|
499
|
+
this.bot.sendMessage(chatId, "❌ Claude usage limit persists after 3 retries. Please try again later.").catch(() => { });
|
|
500
|
+
this.pendingRetries.delete(chatId);
|
|
749
501
|
return;
|
|
750
502
|
}
|
|
751
|
-
console.log(`[usage-limit:${
|
|
503
|
+
console.log(`[usage-limit:${chatId}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
|
|
752
504
|
const timer = setTimeout(() => {
|
|
753
|
-
this.pendingRetries.delete(
|
|
505
|
+
this.pendingRetries.delete(chatId);
|
|
754
506
|
try {
|
|
755
|
-
const retrySession = this.getOrCreateSession(chatId
|
|
507
|
+
const retrySession = this.getOrCreateSession(chatId);
|
|
756
508
|
retrySession.currentPrompt = lastPrompt;
|
|
757
509
|
retrySession.isRetry = true;
|
|
758
510
|
retrySession.claude.sendPrompt(lastPrompt);
|
|
759
511
|
this.startTyping(chatId, retrySession);
|
|
760
512
|
}
|
|
761
513
|
catch (err) {
|
|
762
|
-
this.
|
|
514
|
+
this.bot.sendMessage(chatId, `❌ Failed to retry: ${err.message}`).catch(() => { });
|
|
763
515
|
}
|
|
764
516
|
}, sig.retryAfterMs);
|
|
765
|
-
this.pendingRetries.set(
|
|
517
|
+
this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer });
|
|
766
518
|
return;
|
|
767
519
|
}
|
|
768
520
|
// Accumulate text and debounce — Claude streams chunks rapidly
|
|
@@ -774,11 +526,9 @@ export class CcTgBot {
|
|
|
774
526
|
startTyping(chatId, session) {
|
|
775
527
|
this.stopTyping(session);
|
|
776
528
|
// Send immediately, then keep alive every 4s
|
|
777
|
-
|
|
778
|
-
const threadOpts = session.threadId !== undefined ? { message_thread_id: session.threadId } : undefined;
|
|
779
|
-
this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
|
|
529
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
780
530
|
session.typingTimer = setInterval(() => {
|
|
781
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
531
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
782
532
|
}, TYPING_INTERVAL_MS);
|
|
783
533
|
}
|
|
784
534
|
stopTyping(session) {
|
|
@@ -793,17 +543,15 @@ export class CcTgBot {
|
|
|
793
543
|
session.flushTimer = null;
|
|
794
544
|
if (!raw)
|
|
795
545
|
return;
|
|
796
|
-
this.writeChatMessage("assistant", "cc-tg", raw, chatId);
|
|
797
546
|
const text = session.isRetry ? `✅ Claude is back!\n\n${raw}` : raw;
|
|
798
547
|
session.isRetry = false;
|
|
799
|
-
// Format for Telegram
|
|
548
|
+
// Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
|
|
800
549
|
const formatted = formatForTelegram(text);
|
|
801
550
|
const chunks = splitLongMessage(formatted);
|
|
802
|
-
const threadId = session.threadId;
|
|
803
551
|
for (const chunk of chunks) {
|
|
804
|
-
this.
|
|
805
|
-
//
|
|
806
|
-
this.
|
|
552
|
+
this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" }).catch(() => {
|
|
553
|
+
// MarkdownV2 parse failed — retry as plain text
|
|
554
|
+
this.bot.sendMessage(chatId, chunk).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
|
|
807
555
|
});
|
|
808
556
|
}
|
|
809
557
|
// Hybrid file upload: find files mentioned in result text that Claude actually wrote
|
|
@@ -976,12 +724,11 @@ export class CcTgBot {
|
|
|
976
724
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
977
725
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
978
726
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
979
|
-
this.
|
|
727
|
+
this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`).catch(() => { });
|
|
980
728
|
continue;
|
|
981
729
|
}
|
|
982
730
|
console.log(`[claude:files] uploading to telegram: ${filePath}`);
|
|
983
|
-
|
|
984
|
-
this.bot.sendDocument(chatId, filePath, docOpts).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
|
|
731
|
+
this.bot.sendDocument(chatId, filePath).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
|
|
985
732
|
}
|
|
986
733
|
// Clear written files for next turn
|
|
987
734
|
session.writtenFiles.clear();
|
|
@@ -996,6 +743,203 @@ export class CcTgBot {
|
|
|
996
743
|
const toolUse = content.find((b) => b.type === "tool_use");
|
|
997
744
|
return toolUse?.name ?? "";
|
|
998
745
|
}
|
|
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
|
+
}
|
|
999
943
|
/** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
|
|
1000
944
|
findCcAgentPids() {
|
|
1001
945
|
try {
|
|
@@ -1021,33 +965,34 @@ export class CcTgBot {
|
|
|
1021
965
|
}
|
|
1022
966
|
return pids;
|
|
1023
967
|
}
|
|
1024
|
-
async handleReloadMcp(chatId
|
|
1025
|
-
await this.
|
|
968
|
+
async handleReloadMcp(chatId) {
|
|
969
|
+
await this.bot.sendMessage(chatId, "Clearing npx cache and reloading MCP...");
|
|
1026
970
|
try {
|
|
1027
971
|
const home = process.env.HOME ?? "~";
|
|
1028
|
-
|
|
1029
|
-
|
|
972
|
+
const npmBase = process.env.npm_config_cache ? join(process.env.npm_config_cache, "..") : `${home}/.npm`;
|
|
973
|
+
execSync(`rm -rf "${npmBase}/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
|
|
974
|
+
console.log(`[mcp] cleared ${npmBase}/_npx/`);
|
|
1030
975
|
}
|
|
1031
976
|
catch (err) {
|
|
1032
|
-
await this.
|
|
977
|
+
await this.bot.sendMessage(chatId, `Warning: failed to clear npx cache: ${err.message}`);
|
|
1033
978
|
}
|
|
1034
979
|
const pids = this.killCcAgent();
|
|
1035
980
|
if (pids.length === 0) {
|
|
1036
|
-
await this.
|
|
981
|
+
await this.bot.sendMessage(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.");
|
|
1037
982
|
return;
|
|
1038
983
|
}
|
|
1039
|
-
await this.
|
|
984
|
+
await this.bot.sendMessage(chatId, `NPX cache cleared. Sent SIGTERM to cc-agent (pid${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}).\nMCP restarted. New process will load on next agent call.`);
|
|
1040
985
|
}
|
|
1041
|
-
async handleMcpStatus(chatId
|
|
986
|
+
async handleMcpStatus(chatId) {
|
|
1042
987
|
try {
|
|
1043
988
|
const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
|
|
1044
|
-
await this.
|
|
989
|
+
await this.bot.sendMessage(chatId, `MCP server status:\n\n${output || "(no output)"}`);
|
|
1045
990
|
}
|
|
1046
991
|
catch (err) {
|
|
1047
|
-
await this.
|
|
992
|
+
await this.bot.sendMessage(chatId, `Failed to run claude mcp list: ${err.message}`);
|
|
1048
993
|
}
|
|
1049
994
|
}
|
|
1050
|
-
async handleMcpVersion(chatId
|
|
995
|
+
async handleMcpVersion(chatId) {
|
|
1051
996
|
let npmVersion = "unknown";
|
|
1052
997
|
let cacheEntries = "(unavailable)";
|
|
1053
998
|
try {
|
|
@@ -1064,14 +1009,18 @@ export class CcTgBot {
|
|
|
1064
1009
|
catch {
|
|
1065
1010
|
cacheEntries = "(empty or not found)";
|
|
1066
1011
|
}
|
|
1067
|
-
await this.
|
|
1012
|
+
await this.bot.sendMessage(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`);
|
|
1068
1013
|
}
|
|
1069
|
-
async handleClearNpxCache(chatId
|
|
1014
|
+
async handleClearNpxCache(chatId) {
|
|
1070
1015
|
const home = process.env.HOME ?? "/tmp";
|
|
1016
|
+
// Use the isolated npm cache dir set in the plist (npm_config_cache), not hardcoded ~/.npm
|
|
1017
|
+
const npmBase = process.env.npm_config_cache
|
|
1018
|
+
? join(process.env.npm_config_cache, "..")
|
|
1019
|
+
: `${home}/.npm`;
|
|
1071
1020
|
const cleared = [];
|
|
1072
1021
|
const failed = [];
|
|
1073
1022
|
// Clear both npx execution cache and full npm package cache
|
|
1074
|
-
for (const dir of [`${
|
|
1023
|
+
for (const dir of [`${npmBase}/_npx`, `${npmBase}/cache`]) {
|
|
1075
1024
|
try {
|
|
1076
1025
|
execSync(`rm -rf "${dir}"`, { encoding: "utf8", shell: "/bin/sh" });
|
|
1077
1026
|
cleared.push(dir.replace(home, "~"));
|
|
@@ -1089,133 +1038,85 @@ export class CcTgBot {
|
|
|
1089
1038
|
const clearNote = failed.length
|
|
1090
1039
|
? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
|
|
1091
1040
|
: `Cleared: ${cleared.join(", ")}.`;
|
|
1092
|
-
await this.
|
|
1041
|
+
await this.bot.sendMessage(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`);
|
|
1093
1042
|
}
|
|
1094
|
-
async handleRestart(chatId
|
|
1095
|
-
await this.
|
|
1043
|
+
async handleRestart(chatId) {
|
|
1044
|
+
await this.bot.sendMessage(chatId, "Clearing cache and restarting... brb.");
|
|
1096
1045
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1097
1046
|
// Clear npm caches before restart so launchd brings up fresh version
|
|
1047
|
+
// Use isolated npm_config_cache path from plist, not hardcoded ~/.npm
|
|
1098
1048
|
const home = process.env.HOME ?? "/tmp";
|
|
1099
|
-
|
|
1049
|
+
const npmBase = process.env.npm_config_cache
|
|
1050
|
+
? join(process.env.npm_config_cache, "..")
|
|
1051
|
+
: `${home}/.npm`;
|
|
1052
|
+
for (const dir of [`${npmBase}/_npx`, `${npmBase}/cache`]) {
|
|
1100
1053
|
try {
|
|
1101
1054
|
execSync(`rm -rf "${dir}"`, { shell: "/bin/sh" });
|
|
1102
1055
|
}
|
|
1103
1056
|
catch { }
|
|
1104
1057
|
}
|
|
1105
1058
|
// Kill all active Claude sessions cleanly
|
|
1106
|
-
for (const
|
|
1107
|
-
this.
|
|
1108
|
-
session.claude.kill();
|
|
1059
|
+
for (const [cid] of this.sessions) {
|
|
1060
|
+
this.killSession(cid);
|
|
1109
1061
|
}
|
|
1110
|
-
this.sessions.clear();
|
|
1111
1062
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1112
1063
|
process.exit(0);
|
|
1113
1064
|
}
|
|
1114
|
-
async
|
|
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) {
|
|
1065
|
+
async handleGetFile(chatId, text) {
|
|
1170
1066
|
const arg = text.slice("/get_file".length).trim();
|
|
1171
1067
|
if (!arg) {
|
|
1172
|
-
await this.
|
|
1068
|
+
await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
|
|
1173
1069
|
return;
|
|
1174
1070
|
}
|
|
1175
1071
|
const filePath = resolve(arg);
|
|
1176
1072
|
const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
|
|
1177
1073
|
const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
|
|
1178
1074
|
if (!inSafeDir) {
|
|
1179
|
-
await this.
|
|
1075
|
+
await this.bot.sendMessage(chatId, "Access denied: path not in allowed directories");
|
|
1180
1076
|
return;
|
|
1181
1077
|
}
|
|
1182
1078
|
if (!existsSync(filePath)) {
|
|
1183
|
-
await this.
|
|
1079
|
+
await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
|
|
1184
1080
|
return;
|
|
1185
1081
|
}
|
|
1186
1082
|
if (!statSync(filePath).isFile()) {
|
|
1187
|
-
await this.
|
|
1083
|
+
await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
|
|
1188
1084
|
return;
|
|
1189
1085
|
}
|
|
1190
1086
|
if (this.isSensitiveFile(filePath)) {
|
|
1191
|
-
await this.
|
|
1087
|
+
await this.bot.sendMessage(chatId, "Access denied: sensitive file");
|
|
1192
1088
|
return;
|
|
1193
1089
|
}
|
|
1194
1090
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
1195
1091
|
const fileSize = statSync(filePath).size;
|
|
1196
1092
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
1197
1093
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
1198
|
-
await this.
|
|
1094
|
+
await this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`);
|
|
1199
1095
|
return;
|
|
1200
1096
|
}
|
|
1201
|
-
|
|
1202
|
-
await this.bot.sendDocument(chatId, filePath, docOpts);
|
|
1097
|
+
await this.bot.sendDocument(chatId, filePath);
|
|
1203
1098
|
}
|
|
1204
1099
|
callCcAgentTool(toolName, args = {}) {
|
|
1205
1100
|
return new Promise((resolve) => {
|
|
1206
1101
|
let settled = false;
|
|
1102
|
+
let procRef = null;
|
|
1207
1103
|
const done = (val) => {
|
|
1208
1104
|
if (!settled) {
|
|
1209
1105
|
settled = true;
|
|
1106
|
+
try {
|
|
1107
|
+
procRef?.kill();
|
|
1108
|
+
}
|
|
1109
|
+
catch { }
|
|
1210
1110
|
resolve(val);
|
|
1211
1111
|
}
|
|
1212
1112
|
};
|
|
1213
1113
|
let proc;
|
|
1214
1114
|
try {
|
|
1215
|
-
proc = spawn("npx", ["-y", "@gonzih/cc-agent@latest"], {
|
|
1115
|
+
proc = spawn("npx", ["--prefer-online", "-y", "@gonzih/cc-agent@latest"], {
|
|
1216
1116
|
env: { ...process.env },
|
|
1217
1117
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1218
1118
|
});
|
|
1119
|
+
procRef = proc;
|
|
1219
1120
|
}
|
|
1220
1121
|
catch (err) {
|
|
1221
1122
|
console.error("[mcp] failed to spawn cc-agent:", err.message);
|
|
@@ -1272,25 +1173,21 @@ export class CcTgBot {
|
|
|
1272
1173
|
proc.on("exit", () => { clearTimeout(timeout); done(null); });
|
|
1273
1174
|
});
|
|
1274
1175
|
}
|
|
1275
|
-
killSession(chatId,
|
|
1276
|
-
const
|
|
1277
|
-
const session = this.sessions.get(key);
|
|
1176
|
+
killSession(chatId, keepCrons = true) {
|
|
1177
|
+
const session = this.sessions.get(chatId);
|
|
1278
1178
|
if (session) {
|
|
1279
1179
|
this.stopTyping(session);
|
|
1280
1180
|
session.claude.kill();
|
|
1281
|
-
this.sessions.delete(
|
|
1181
|
+
this.sessions.delete(chatId);
|
|
1282
1182
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
return this.bot.getMe();
|
|
1183
|
+
if (!keepCrons)
|
|
1184
|
+
this.cron.clearAll(chatId);
|
|
1286
1185
|
}
|
|
1287
1186
|
stop() {
|
|
1288
1187
|
this.bot.stopPolling();
|
|
1289
|
-
for (const
|
|
1290
|
-
this.
|
|
1291
|
-
session.claude.kill();
|
|
1188
|
+
for (const [chatId] of this.sessions) {
|
|
1189
|
+
this.killSession(chatId);
|
|
1292
1190
|
}
|
|
1293
|
-
this.sessions.clear();
|
|
1294
1191
|
}
|
|
1295
1192
|
}
|
|
1296
1193
|
function buildPromptWithReplyContext(text, msg) {
|
|
@@ -1329,85 +1226,6 @@ function downloadToFile(url, destPath) {
|
|
|
1329
1226
|
}).on("error", reject);
|
|
1330
1227
|
});
|
|
1331
1228
|
}
|
|
1332
|
-
/** Fetch URL via Jina Reader and return first maxChars characters */
|
|
1333
|
-
function fetchUrlViaJina(url, maxChars = 2000) {
|
|
1334
|
-
const jinaUrl = `https://r.jina.ai/${url}`;
|
|
1335
|
-
return new Promise((resolve, reject) => {
|
|
1336
|
-
https.get(jinaUrl, (res) => {
|
|
1337
|
-
const chunks = [];
|
|
1338
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
1339
|
-
res.on("end", () => {
|
|
1340
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
1341
|
-
resolve(text.slice(0, maxChars));
|
|
1342
|
-
});
|
|
1343
|
-
res.on("error", reject);
|
|
1344
|
-
}).on("error", reject);
|
|
1345
|
-
});
|
|
1346
|
-
}
|
|
1347
|
-
/** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
|
|
1348
|
-
export async function enrichPromptWithUrls(text) {
|
|
1349
|
-
const urlRegex = /https?:\/\/[^\s]+/g;
|
|
1350
|
-
const urls = text.match(urlRegex);
|
|
1351
|
-
if (!urls || urls.length === 0)
|
|
1352
|
-
return text;
|
|
1353
|
-
const prefixes = [];
|
|
1354
|
-
for (const url of urls) {
|
|
1355
|
-
// Skip jina.ai URLs to avoid recursion
|
|
1356
|
-
if (url.includes("r.jina.ai"))
|
|
1357
|
-
continue;
|
|
1358
|
-
try {
|
|
1359
|
-
const content = await fetchUrlViaJina(url);
|
|
1360
|
-
if (content.trim()) {
|
|
1361
|
-
prefixes.push(`[Web content from ${url}]:\n${content}`);
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
catch (err) {
|
|
1365
|
-
console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
if (prefixes.length === 0)
|
|
1369
|
-
return text;
|
|
1370
|
-
return prefixes.join("\n\n") + "\n\n" + text;
|
|
1371
|
-
}
|
|
1372
|
-
/** Parse frontmatter description from a skill markdown file */
|
|
1373
|
-
function parseSkillDescription(content) {
|
|
1374
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
1375
|
-
if (!match)
|
|
1376
|
-
return null;
|
|
1377
|
-
const frontmatter = match[1];
|
|
1378
|
-
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
1379
|
-
return descMatch ? descMatch[1].trim() : null;
|
|
1380
|
-
}
|
|
1381
|
-
/** List available skills from ~/.claude/skills/ */
|
|
1382
|
-
export function listSkills() {
|
|
1383
|
-
const skillsDir = join(os.homedir(), ".claude", "skills");
|
|
1384
|
-
if (!existsSync(skillsDir)) {
|
|
1385
|
-
return "No skills directory found at ~/.claude/skills/";
|
|
1386
|
-
}
|
|
1387
|
-
let files;
|
|
1388
|
-
try {
|
|
1389
|
-
files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
|
|
1390
|
-
}
|
|
1391
|
-
catch {
|
|
1392
|
-
return "Could not read skills directory.";
|
|
1393
|
-
}
|
|
1394
|
-
if (files.length === 0) {
|
|
1395
|
-
return "No skills found in ~/.claude/skills/";
|
|
1396
|
-
}
|
|
1397
|
-
const lines = ["Available skills:"];
|
|
1398
|
-
for (const file of files.sort()) {
|
|
1399
|
-
const name = "/" + file.replace(/\.md$/, "");
|
|
1400
|
-
try {
|
|
1401
|
-
const content = readFileSync(join(skillsDir, file), "utf8");
|
|
1402
|
-
const description = parseSkillDescription(content);
|
|
1403
|
-
lines.push(description ? `${name} — ${description}` : name);
|
|
1404
|
-
}
|
|
1405
|
-
catch {
|
|
1406
|
-
lines.push(name);
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
return lines.join("\n");
|
|
1410
|
-
}
|
|
1411
1229
|
export function splitMessage(text, maxLen = 4096) {
|
|
1412
1230
|
if (text.length <= maxLen)
|
|
1413
1231
|
return [text];
|