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