@gonzih/cc-tg 0.9.19 → 0.9.21
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 +347 -550
- 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);
|
|
327
|
+
await this.bot.sendMessage(chatId, reply);
|
|
382
328
|
return;
|
|
383
329
|
}
|
|
384
|
-
|
|
385
|
-
if (text === "/voice_retry") {
|
|
386
|
-
await this.handleVoiceRetry(chatId, threadId);
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
const session = this.getOrCreateSession(chatId, threadId, threadName);
|
|
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
336
|
}
|
|
398
337
|
catch (err) {
|
|
399
|
-
await this.
|
|
400
|
-
this.killSession(chatId
|
|
338
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
339
|
+
this.killSession(chatId);
|
|
401
340
|
}
|
|
402
341
|
}
|
|
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) {
|
|
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,25 @@ export class CcTgBot {
|
|
|
1021
965
|
}
|
|
1022
966
|
return pids;
|
|
1023
967
|
}
|
|
1024
|
-
async handleReloadMcp(chatId
|
|
1025
|
-
await this.
|
|
1026
|
-
try {
|
|
1027
|
-
const home = process.env.HOME ?? "~";
|
|
1028
|
-
execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
|
|
1029
|
-
console.log("[mcp] cleared ~/.npm/_npx/");
|
|
1030
|
-
}
|
|
1031
|
-
catch (err) {
|
|
1032
|
-
await this.replyToChat(chatId, `Warning: failed to clear npx cache: ${err.message}`, threadId);
|
|
1033
|
-
}
|
|
968
|
+
async handleReloadMcp(chatId) {
|
|
969
|
+
await this.bot.sendMessage(chatId, "Reloading MCP...");
|
|
1034
970
|
const pids = this.killCcAgent();
|
|
1035
971
|
if (pids.length === 0) {
|
|
1036
|
-
await this.
|
|
972
|
+
await this.bot.sendMessage(chatId, "No cc-agent process found — MCP will start fresh on the next agent call.");
|
|
1037
973
|
return;
|
|
1038
974
|
}
|
|
1039
|
-
await this.
|
|
975
|
+
await this.bot.sendMessage(chatId, `Sent SIGTERM to cc-agent (pid${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}).\nMCP restarted. New process will load on next agent call.`);
|
|
1040
976
|
}
|
|
1041
|
-
async handleMcpStatus(chatId
|
|
977
|
+
async handleMcpStatus(chatId) {
|
|
1042
978
|
try {
|
|
1043
979
|
const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
|
|
1044
|
-
await this.
|
|
980
|
+
await this.bot.sendMessage(chatId, `MCP server status:\n\n${output || "(no output)"}`);
|
|
1045
981
|
}
|
|
1046
982
|
catch (err) {
|
|
1047
|
-
await this.
|
|
983
|
+
await this.bot.sendMessage(chatId, `Failed to run claude mcp list: ${err.message}`);
|
|
1048
984
|
}
|
|
1049
985
|
}
|
|
1050
|
-
async handleMcpVersion(chatId
|
|
986
|
+
async handleMcpVersion(chatId) {
|
|
1051
987
|
let npmVersion = "unknown";
|
|
1052
988
|
let cacheEntries = "(unavailable)";
|
|
1053
989
|
try {
|
|
@@ -1064,14 +1000,18 @@ export class CcTgBot {
|
|
|
1064
1000
|
catch {
|
|
1065
1001
|
cacheEntries = "(empty or not found)";
|
|
1066
1002
|
}
|
|
1067
|
-
await this.
|
|
1003
|
+
await this.bot.sendMessage(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`);
|
|
1068
1004
|
}
|
|
1069
|
-
async handleClearNpxCache(chatId
|
|
1005
|
+
async handleClearNpxCache(chatId) {
|
|
1070
1006
|
const home = process.env.HOME ?? "/tmp";
|
|
1007
|
+
// Use the isolated npm cache dir set in the plist (npm_config_cache), not hardcoded ~/.npm
|
|
1008
|
+
const npmBase = process.env.npm_config_cache
|
|
1009
|
+
? join(process.env.npm_config_cache, "..")
|
|
1010
|
+
: `${home}/.npm`;
|
|
1071
1011
|
const cleared = [];
|
|
1072
1012
|
const failed = [];
|
|
1073
1013
|
// Clear both npx execution cache and full npm package cache
|
|
1074
|
-
for (const dir of [`${
|
|
1014
|
+
for (const dir of [`${npmBase}/_npx`, `${npmBase}/cache`]) {
|
|
1075
1015
|
try {
|
|
1076
1016
|
execSync(`rm -rf "${dir}"`, { encoding: "utf8", shell: "/bin/sh" });
|
|
1077
1017
|
cleared.push(dir.replace(home, "~"));
|
|
@@ -1089,133 +1029,73 @@ export class CcTgBot {
|
|
|
1089
1029
|
const clearNote = failed.length
|
|
1090
1030
|
? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
|
|
1091
1031
|
: `Cleared: ${cleared.join(", ")}.`;
|
|
1092
|
-
await this.
|
|
1032
|
+
await this.bot.sendMessage(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`);
|
|
1093
1033
|
}
|
|
1094
|
-
async handleRestart(chatId
|
|
1095
|
-
await this.
|
|
1034
|
+
async handleRestart(chatId) {
|
|
1035
|
+
await this.bot.sendMessage(chatId, "Restarting... brb.");
|
|
1096
1036
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1097
|
-
// Clear npm caches before restart so launchd brings up fresh version
|
|
1098
|
-
const home = process.env.HOME ?? "/tmp";
|
|
1099
|
-
for (const dir of [`${home}/.npm/_npx`, `${home}/.npm/cache`]) {
|
|
1100
|
-
try {
|
|
1101
|
-
execSync(`rm -rf "${dir}"`, { shell: "/bin/sh" });
|
|
1102
|
-
}
|
|
1103
|
-
catch { }
|
|
1104
|
-
}
|
|
1105
1037
|
// Kill all active Claude sessions cleanly
|
|
1106
|
-
for (const
|
|
1107
|
-
this.
|
|
1108
|
-
session.claude.kill();
|
|
1038
|
+
for (const [cid] of this.sessions) {
|
|
1039
|
+
this.killSession(cid);
|
|
1109
1040
|
}
|
|
1110
|
-
this.sessions.clear();
|
|
1111
1041
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1112
1042
|
process.exit(0);
|
|
1113
1043
|
}
|
|
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) {
|
|
1044
|
+
async handleGetFile(chatId, text) {
|
|
1170
1045
|
const arg = text.slice("/get_file".length).trim();
|
|
1171
1046
|
if (!arg) {
|
|
1172
|
-
await this.
|
|
1047
|
+
await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
|
|
1173
1048
|
return;
|
|
1174
1049
|
}
|
|
1175
1050
|
const filePath = resolve(arg);
|
|
1176
1051
|
const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
|
|
1177
1052
|
const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
|
|
1178
1053
|
if (!inSafeDir) {
|
|
1179
|
-
await this.
|
|
1054
|
+
await this.bot.sendMessage(chatId, "Access denied: path not in allowed directories");
|
|
1180
1055
|
return;
|
|
1181
1056
|
}
|
|
1182
1057
|
if (!existsSync(filePath)) {
|
|
1183
|
-
await this.
|
|
1058
|
+
await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
|
|
1184
1059
|
return;
|
|
1185
1060
|
}
|
|
1186
1061
|
if (!statSync(filePath).isFile()) {
|
|
1187
|
-
await this.
|
|
1062
|
+
await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
|
|
1188
1063
|
return;
|
|
1189
1064
|
}
|
|
1190
1065
|
if (this.isSensitiveFile(filePath)) {
|
|
1191
|
-
await this.
|
|
1066
|
+
await this.bot.sendMessage(chatId, "Access denied: sensitive file");
|
|
1192
1067
|
return;
|
|
1193
1068
|
}
|
|
1194
1069
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
1195
1070
|
const fileSize = statSync(filePath).size;
|
|
1196
1071
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
1197
1072
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
1198
|
-
await this.
|
|
1073
|
+
await this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`);
|
|
1199
1074
|
return;
|
|
1200
1075
|
}
|
|
1201
|
-
|
|
1202
|
-
await this.bot.sendDocument(chatId, filePath, docOpts);
|
|
1076
|
+
await this.bot.sendDocument(chatId, filePath);
|
|
1203
1077
|
}
|
|
1204
1078
|
callCcAgentTool(toolName, args = {}) {
|
|
1205
1079
|
return new Promise((resolve) => {
|
|
1206
1080
|
let settled = false;
|
|
1081
|
+
let procRef = null;
|
|
1207
1082
|
const done = (val) => {
|
|
1208
1083
|
if (!settled) {
|
|
1209
1084
|
settled = true;
|
|
1085
|
+
try {
|
|
1086
|
+
procRef?.kill();
|
|
1087
|
+
}
|
|
1088
|
+
catch { }
|
|
1210
1089
|
resolve(val);
|
|
1211
1090
|
}
|
|
1212
1091
|
};
|
|
1213
1092
|
let proc;
|
|
1214
1093
|
try {
|
|
1215
|
-
proc = spawn("npx", ["-y", "@gonzih/cc-agent@latest"], {
|
|
1094
|
+
proc = spawn("npx", ["--prefer-online", "-y", "@gonzih/cc-agent@latest"], {
|
|
1216
1095
|
env: { ...process.env },
|
|
1217
1096
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1218
1097
|
});
|
|
1098
|
+
procRef = proc;
|
|
1219
1099
|
}
|
|
1220
1100
|
catch (err) {
|
|
1221
1101
|
console.error("[mcp] failed to spawn cc-agent:", err.message);
|
|
@@ -1272,25 +1152,21 @@ export class CcTgBot {
|
|
|
1272
1152
|
proc.on("exit", () => { clearTimeout(timeout); done(null); });
|
|
1273
1153
|
});
|
|
1274
1154
|
}
|
|
1275
|
-
killSession(chatId,
|
|
1276
|
-
const
|
|
1277
|
-
const session = this.sessions.get(key);
|
|
1155
|
+
killSession(chatId, keepCrons = true) {
|
|
1156
|
+
const session = this.sessions.get(chatId);
|
|
1278
1157
|
if (session) {
|
|
1279
1158
|
this.stopTyping(session);
|
|
1280
1159
|
session.claude.kill();
|
|
1281
|
-
this.sessions.delete(
|
|
1160
|
+
this.sessions.delete(chatId);
|
|
1282
1161
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
return this.bot.getMe();
|
|
1162
|
+
if (!keepCrons)
|
|
1163
|
+
this.cron.clearAll(chatId);
|
|
1286
1164
|
}
|
|
1287
1165
|
stop() {
|
|
1288
1166
|
this.bot.stopPolling();
|
|
1289
|
-
for (const
|
|
1290
|
-
this.
|
|
1291
|
-
session.claude.kill();
|
|
1167
|
+
for (const [chatId] of this.sessions) {
|
|
1168
|
+
this.killSession(chatId);
|
|
1292
1169
|
}
|
|
1293
|
-
this.sessions.clear();
|
|
1294
1170
|
}
|
|
1295
1171
|
}
|
|
1296
1172
|
function buildPromptWithReplyContext(text, msg) {
|
|
@@ -1329,85 +1205,6 @@ function downloadToFile(url, destPath) {
|
|
|
1329
1205
|
}).on("error", reject);
|
|
1330
1206
|
});
|
|
1331
1207
|
}
|
|
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
1208
|
export function splitMessage(text, maxLen = 4096) {
|
|
1412
1209
|
if (text.length <= maxLen)
|
|
1413
1210
|
return [text];
|