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