@gonzih/cc-tg 0.9.21 → 0.9.23
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 +37 -4
- package/dist/bot.js +673 -355
- 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 +46 -0
- package/dist/notifier.js +253 -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,12 @@ 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" },
|
|
36
|
+
{ command: "agents", description: "Show running meta-agents and their live status" },
|
|
31
37
|
];
|
|
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
38
|
const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
|
|
48
39
|
const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
|
|
49
40
|
// Claude Sonnet 4.6 pricing (per 1M tokens)
|
|
@@ -79,30 +70,29 @@ function formatCostReport(cost) {
|
|
|
79
70
|
` Cache write: ${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`,
|
|
80
71
|
].join("\n");
|
|
81
72
|
}
|
|
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
73
|
function formatAgentCostSummary(text) {
|
|
87
74
|
try {
|
|
88
75
|
const data = JSON.parse(text);
|
|
89
76
|
const totalCost = (data.total_cost_usd ?? data.total_cost ?? 0);
|
|
90
|
-
const totalJobs = (data.total_jobs ?? data.job_count ?? 0);
|
|
91
77
|
const byRepo = (data.by_repo ?? []);
|
|
92
|
-
|
|
93
|
-
"
|
|
94
|
-
|
|
95
|
-
];
|
|
78
|
+
if (byRepo.length === 0) {
|
|
79
|
+
return "No cost data available yet.";
|
|
80
|
+
}
|
|
81
|
+
const lines = ["💰 Cost Summary", ""];
|
|
82
|
+
// Align repo names with right-padded costs
|
|
83
|
+
const maxLen = Math.max(...byRepo.map((e) => (e.repo ?? e.repository ?? "unknown").length));
|
|
96
84
|
for (const entry of byRepo) {
|
|
97
85
|
const repo = (entry.repo ?? entry.repository ?? "unknown");
|
|
98
86
|
const cost = (entry.cost_usd ?? entry.cost ?? 0);
|
|
99
|
-
const
|
|
100
|
-
lines.push(
|
|
87
|
+
const pad = " ".repeat(maxLen - repo.length + 3);
|
|
88
|
+
lines.push(`${repo}${pad}$${cost.toFixed(2)}`);
|
|
101
89
|
}
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push(`Total: $${totalCost.toFixed(2)}`);
|
|
102
92
|
return lines.join("\n");
|
|
103
93
|
}
|
|
104
94
|
catch {
|
|
105
|
-
return
|
|
95
|
+
return `💰 Cost Summary\n${text}`;
|
|
106
96
|
}
|
|
107
97
|
}
|
|
108
98
|
class CostStore {
|
|
@@ -169,12 +159,17 @@ export class CcTgBot {
|
|
|
169
159
|
sessions = new Map();
|
|
170
160
|
pendingRetries = new Map();
|
|
171
161
|
opts;
|
|
172
|
-
cron;
|
|
173
162
|
costStore;
|
|
174
163
|
botUsername = "";
|
|
175
164
|
botId = 0;
|
|
165
|
+
redis;
|
|
166
|
+
namespace;
|
|
167
|
+
lastActiveChatId;
|
|
168
|
+
cron;
|
|
176
169
|
constructor(opts) {
|
|
177
170
|
this.opts = opts;
|
|
171
|
+
this.redis = opts.redis;
|
|
172
|
+
this.namespace = opts.namespace ?? "default";
|
|
178
173
|
this.bot = new TelegramBot(opts.telegramToken, { polling: true });
|
|
179
174
|
this.bot.on("message", (msg) => this.handleTelegram(msg));
|
|
180
175
|
this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
|
|
@@ -183,11 +178,10 @@ export class CcTgBot {
|
|
|
183
178
|
this.botId = me.id;
|
|
184
179
|
console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
|
|
185
180
|
}).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
181
|
this.costStore = new CostStore(opts.cwd ?? process.cwd());
|
|
182
|
+
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt, _jobId, done) => {
|
|
183
|
+
this.runCronTask(chatId, prompt, done);
|
|
184
|
+
});
|
|
191
185
|
this.registerBotCommands();
|
|
192
186
|
console.log("cc-tg bot started");
|
|
193
187
|
console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
|
|
@@ -197,6 +191,55 @@ export class CcTgBot {
|
|
|
197
191
|
.then(() => console.log("[tg] bot commands registered"))
|
|
198
192
|
.catch((err) => console.error("[tg] setMyCommands failed:", err.message));
|
|
199
193
|
}
|
|
194
|
+
/** Write a message to the Redis chat log. Fire-and-forget — no-op if Redis is not configured. */
|
|
195
|
+
writeChatMessage(role, source, content, chatId) {
|
|
196
|
+
if (!this.redis)
|
|
197
|
+
return;
|
|
198
|
+
const msg = {
|
|
199
|
+
id: `${source}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
200
|
+
source,
|
|
201
|
+
role,
|
|
202
|
+
content,
|
|
203
|
+
timestamp: new Date().toISOString(),
|
|
204
|
+
chatId,
|
|
205
|
+
};
|
|
206
|
+
writeChatLog(this.redis, this.namespace, msg);
|
|
207
|
+
}
|
|
208
|
+
/** Returns the last chatId that sent a message — used by the chat bridge when no fixed chatId is configured. */
|
|
209
|
+
getLastActiveChatId() {
|
|
210
|
+
return this.lastActiveChatId;
|
|
211
|
+
}
|
|
212
|
+
/** Session key: "chatId:threadId" for topics, "chatId:main" for DMs/non-topic groups */
|
|
213
|
+
sessionKey(chatId, threadId) {
|
|
214
|
+
return `${chatId}:${threadId ?? 'main'}`;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Send a message back to the correct thread (or plain chat if no thread).
|
|
218
|
+
* When threadId is undefined, calls sendMessage with exactly 2 args to preserve
|
|
219
|
+
* backward-compatible call signatures (no extra options object).
|
|
220
|
+
*/
|
|
221
|
+
replyToChat(chatId, text, threadId, opts) {
|
|
222
|
+
if (threadId !== undefined) {
|
|
223
|
+
return this.bot.sendMessage(chatId, text, { ...opts, message_thread_id: threadId });
|
|
224
|
+
}
|
|
225
|
+
if (opts) {
|
|
226
|
+
return this.bot.sendMessage(chatId, text, opts);
|
|
227
|
+
}
|
|
228
|
+
return this.bot.sendMessage(chatId, text);
|
|
229
|
+
}
|
|
230
|
+
/** Parse THREAD_CWD_MAP env var — maps thread name or thread_id to a CWD path */
|
|
231
|
+
getThreadCwdMap() {
|
|
232
|
+
const raw = process.env.THREAD_CWD_MAP;
|
|
233
|
+
if (!raw)
|
|
234
|
+
return {};
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(raw);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
console.warn('[cc-tg] THREAD_CWD_MAP is not valid JSON, ignoring');
|
|
240
|
+
return {};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
200
243
|
isAllowed(userId) {
|
|
201
244
|
if (!this.opts.allowedUserIds?.length)
|
|
202
245
|
return true;
|
|
@@ -205,10 +248,20 @@ export class CcTgBot {
|
|
|
205
248
|
async handleTelegram(msg) {
|
|
206
249
|
const chatId = msg.chat.id;
|
|
207
250
|
const userId = msg.from?.id ?? chatId;
|
|
251
|
+
// Forum topic thread_id — undefined for DMs and non-topic group messages
|
|
252
|
+
const threadId = msg.message_thread_id;
|
|
253
|
+
// Thread name is available on the service message that creates a new topic.
|
|
254
|
+
// forum_topic_created is not in older @types/node-telegram-bot-api versions, so cast via unknown.
|
|
255
|
+
const rawMsg = msg;
|
|
256
|
+
const threadName = rawMsg.forum_topic_created
|
|
257
|
+
? rawMsg.forum_topic_created.name
|
|
258
|
+
: undefined;
|
|
208
259
|
if (!this.isAllowed(userId)) {
|
|
209
|
-
await this.
|
|
260
|
+
await this.replyToChat(chatId, "Not authorized.", threadId);
|
|
210
261
|
return;
|
|
211
262
|
}
|
|
263
|
+
// Track the last chat that sent us a message for the chat bridge
|
|
264
|
+
this.lastActiveChatId = chatId;
|
|
212
265
|
// Group chat handling
|
|
213
266
|
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
|
214
267
|
if (isGroup) {
|
|
@@ -227,17 +280,17 @@ export class CcTgBot {
|
|
|
227
280
|
}
|
|
228
281
|
// Voice message — transcribe then feed as text
|
|
229
282
|
if (msg.voice || msg.audio) {
|
|
230
|
-
await this.handleVoice(chatId, msg);
|
|
283
|
+
await this.handleVoice(chatId, msg, threadId, threadName);
|
|
231
284
|
return;
|
|
232
285
|
}
|
|
233
286
|
// Photo — send as base64 image content block to Claude
|
|
234
287
|
if (msg.photo?.length) {
|
|
235
|
-
await this.handlePhoto(chatId, msg);
|
|
288
|
+
await this.handlePhoto(chatId, msg, threadId, threadName);
|
|
236
289
|
return;
|
|
237
290
|
}
|
|
238
291
|
// Document — download to CWD/.cc-tg/uploads/, tell Claude the path
|
|
239
292
|
if (msg.document) {
|
|
240
|
-
await this.handleDocument(chatId, msg);
|
|
293
|
+
await this.handleDocument(chatId, msg, threadId, threadName);
|
|
241
294
|
return;
|
|
242
295
|
}
|
|
243
296
|
let text = msg.text?.trim();
|
|
@@ -247,68 +300,69 @@ export class CcTgBot {
|
|
|
247
300
|
if (this.botUsername) {
|
|
248
301
|
text = text.replace(new RegExp(`@${this.botUsername}\\s*`, "g"), "").trim();
|
|
249
302
|
}
|
|
303
|
+
const sessionKey = this.sessionKey(chatId, threadId);
|
|
250
304
|
// /start or /reset — kill existing session and ack
|
|
251
305
|
if (text === "/start" || text === "/reset") {
|
|
252
|
-
this.killSession(chatId);
|
|
253
|
-
await this.
|
|
306
|
+
this.killSession(chatId, true, threadId);
|
|
307
|
+
await this.replyToChat(chatId, "Session reset. Send a message to start.", threadId);
|
|
254
308
|
return;
|
|
255
309
|
}
|
|
256
310
|
// /stop — kill active session (interrupt running Claude task)
|
|
257
311
|
if (text === "/stop") {
|
|
258
|
-
const has = this.sessions.has(
|
|
259
|
-
this.killSession(chatId);
|
|
260
|
-
await this.
|
|
312
|
+
const has = this.sessions.has(sessionKey);
|
|
313
|
+
this.killSession(chatId, true, threadId);
|
|
314
|
+
await this.replyToChat(chatId, has ? "Stopped." : "No active session.", threadId);
|
|
261
315
|
return;
|
|
262
316
|
}
|
|
263
317
|
// /help — list all commands
|
|
264
318
|
if (text === "/help") {
|
|
265
319
|
const lines = BOT_COMMANDS.map((c) => `/${c.command} — ${c.description}`);
|
|
266
|
-
await this.
|
|
320
|
+
await this.replyToChat(chatId, lines.join("\n"), threadId);
|
|
267
321
|
return;
|
|
268
322
|
}
|
|
269
323
|
// /status
|
|
270
324
|
if (text === "/status") {
|
|
271
|
-
const has = this.sessions.has(
|
|
325
|
+
const has = this.sessions.has(sessionKey);
|
|
272
326
|
let status = has ? "Session active." : "No active session.";
|
|
273
327
|
const sleeping = this.pendingRetries.size;
|
|
274
328
|
if (sleeping > 0)
|
|
275
329
|
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);
|
|
330
|
+
await this.replyToChat(chatId, status, threadId);
|
|
282
331
|
return;
|
|
283
332
|
}
|
|
284
333
|
// /reload_mcp — kill cc-agent process so Claude Code auto-restarts it
|
|
285
334
|
if (text === "/reload_mcp") {
|
|
286
|
-
await this.handleReloadMcp(chatId);
|
|
335
|
+
await this.handleReloadMcp(chatId, threadId);
|
|
287
336
|
return;
|
|
288
337
|
}
|
|
289
338
|
// /mcp_status — run `claude mcp list` and show connection status
|
|
290
339
|
if (text === "/mcp_status") {
|
|
291
|
-
await this.handleMcpStatus(chatId);
|
|
340
|
+
await this.handleMcpStatus(chatId, threadId);
|
|
292
341
|
return;
|
|
293
342
|
}
|
|
294
343
|
// /mcp_version — show published npm version and cached npx entries
|
|
295
344
|
if (text === "/mcp_version") {
|
|
296
|
-
await this.handleMcpVersion(chatId);
|
|
345
|
+
await this.handleMcpVersion(chatId, threadId);
|
|
297
346
|
return;
|
|
298
347
|
}
|
|
299
348
|
// /clear_npx_cache — wipe ~/.npm/_npx/ then restart cc-agent
|
|
300
349
|
if (text === "/clear_npx_cache") {
|
|
301
|
-
await this.handleClearNpxCache(chatId);
|
|
350
|
+
await this.handleClearNpxCache(chatId, threadId);
|
|
302
351
|
return;
|
|
303
352
|
}
|
|
304
353
|
// /restart — restart the bot process in-place
|
|
305
354
|
if (text === "/restart") {
|
|
306
|
-
await this.handleRestart(chatId);
|
|
355
|
+
await this.handleRestart(chatId, threadId);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
|
|
359
|
+
if (text.startsWith("/cron")) {
|
|
360
|
+
await this.handleCron(chatId, text, threadId);
|
|
307
361
|
return;
|
|
308
362
|
}
|
|
309
363
|
// /get_file <path> — send a file from the server to the user
|
|
310
364
|
if (text.startsWith("/get_file")) {
|
|
311
|
-
await this.handleGetFile(chatId, text);
|
|
365
|
+
await this.handleGetFile(chatId, text, threadId);
|
|
312
366
|
return;
|
|
313
367
|
}
|
|
314
368
|
// /cost — show session token usage and cost
|
|
@@ -324,85 +378,242 @@ export class CcTgBot {
|
|
|
324
378
|
catch (err) {
|
|
325
379
|
console.error("[cost] cc-agent cost_summary failed:", err.message);
|
|
326
380
|
}
|
|
327
|
-
await this.
|
|
381
|
+
await this.replyToChat(chatId, reply, threadId);
|
|
328
382
|
return;
|
|
329
383
|
}
|
|
330
|
-
|
|
384
|
+
// /skills — list available Claude skills from ~/.claude/skills/
|
|
385
|
+
if (text === "/skills") {
|
|
386
|
+
await this.replyToChat(chatId, listSkills(), threadId);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// /voice_retry — retry failed voice message transcriptions
|
|
390
|
+
if (text === "/voice_retry") {
|
|
391
|
+
await this.handleVoiceRetry(chatId, threadId);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// /drivers — list available agent drivers via cc-agent MCP
|
|
395
|
+
if (text === "/drivers") {
|
|
396
|
+
await this.handleDrivers(chatId, threadId);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// /agents — show running meta-agents and their live status
|
|
400
|
+
if (text === "/agents") {
|
|
401
|
+
await this.handleAgents(chatId, threadId);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const session = this.getOrCreateSession(chatId, threadId, threadName);
|
|
331
405
|
try {
|
|
332
|
-
const
|
|
406
|
+
const enriched = await enrichPromptWithUrls(text);
|
|
407
|
+
const prompt = buildPromptWithReplyContext(enriched, msg);
|
|
333
408
|
session.currentPrompt = prompt;
|
|
334
409
|
session.claude.sendPrompt(prompt);
|
|
335
410
|
this.startTyping(chatId, session);
|
|
411
|
+
this.writeChatMessage("user", "telegram", text, chatId);
|
|
336
412
|
}
|
|
337
413
|
catch (err) {
|
|
338
|
-
await this.
|
|
339
|
-
this.killSession(chatId);
|
|
414
|
+
await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
|
|
415
|
+
this.killSession(chatId, true, threadId);
|
|
340
416
|
}
|
|
341
417
|
}
|
|
342
|
-
|
|
418
|
+
/**
|
|
419
|
+
* Feed a text message into the active Claude session for the given chat.
|
|
420
|
+
* Called by the notifier when a UI message arrives via Redis pub/sub.
|
|
421
|
+
*/
|
|
422
|
+
async handleUserMessage(chatId, text) {
|
|
423
|
+
const session = this.getOrCreateSession(chatId);
|
|
424
|
+
try {
|
|
425
|
+
const enriched = await enrichPromptWithUrls(text);
|
|
426
|
+
session.currentPrompt = enriched;
|
|
427
|
+
session.claude.sendPrompt(enriched);
|
|
428
|
+
this.startTyping(chatId, session);
|
|
429
|
+
this.writeChatMessage("user", "ui", text, chatId);
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`);
|
|
433
|
+
this.killSession(chatId, true);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async handleVoice(chatId, msg, threadId, threadName) {
|
|
343
437
|
const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
|
|
344
438
|
if (!fileId)
|
|
345
439
|
return;
|
|
346
440
|
console.log(`[voice:${chatId}] received voice message, transcribing...`);
|
|
347
|
-
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
441
|
+
this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
|
|
442
|
+
// Store in Redis before transcription so we can retry on failure
|
|
443
|
+
const pendingEntry = JSON.stringify({
|
|
444
|
+
file_id: fileId,
|
|
445
|
+
chat_id: chatId,
|
|
446
|
+
message_id: msg.message_id,
|
|
447
|
+
timestamp: Date.now(),
|
|
448
|
+
});
|
|
449
|
+
if (this.redis) {
|
|
450
|
+
await this.redis.rpush("voice:pending", pendingEntry).catch((err) => console.warn("[voice] redis rpush voice:pending failed:", err.message));
|
|
451
|
+
}
|
|
348
452
|
try {
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
return transcribeVoice(fileLink);
|
|
352
|
-
}, 3, [2000, 5000]);
|
|
453
|
+
const fileLink = await this.bot.getFileLink(fileId);
|
|
454
|
+
const transcript = await transcribeVoice(fileLink);
|
|
353
455
|
console.log(`[voice:${chatId}] transcribed: ${transcript}`);
|
|
456
|
+
// Remove from pending on success
|
|
457
|
+
if (this.redis) {
|
|
458
|
+
await this.redis.lrem("voice:pending", 0, pendingEntry).catch((err) => console.warn("[voice] redis lrem voice:pending failed:", err.message));
|
|
459
|
+
}
|
|
354
460
|
if (!transcript || transcript === "[empty transcription]") {
|
|
355
|
-
await this.
|
|
461
|
+
await this.replyToChat(chatId, "Could not transcribe voice message.", threadId);
|
|
356
462
|
return;
|
|
357
463
|
}
|
|
358
464
|
// Feed transcript into Claude as if user typed it
|
|
359
|
-
const session = this.getOrCreateSession(chatId);
|
|
465
|
+
const session = this.getOrCreateSession(chatId, threadId, threadName);
|
|
360
466
|
try {
|
|
361
467
|
const prompt = buildPromptWithReplyContext(transcript, msg);
|
|
468
|
+
this.writeChatMessage("user", "telegram", transcript, chatId);
|
|
362
469
|
session.currentPrompt = prompt;
|
|
363
470
|
session.claude.sendPrompt(prompt);
|
|
364
471
|
this.startTyping(chatId, session);
|
|
365
472
|
}
|
|
366
473
|
catch (err) {
|
|
367
|
-
await this.
|
|
368
|
-
this.killSession(chatId);
|
|
474
|
+
await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
|
|
475
|
+
this.killSession(chatId, true, threadId);
|
|
369
476
|
}
|
|
370
477
|
}
|
|
371
478
|
catch (err) {
|
|
372
|
-
const errMsg = err
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
479
|
+
const errMsg = err.message;
|
|
480
|
+
console.error(`[voice:${chatId}] error:`, errMsg);
|
|
481
|
+
// Push to voice:failed on failure (entry stays in voice:pending for retry)
|
|
482
|
+
if (this.redis) {
|
|
483
|
+
const failedEntry = JSON.stringify({
|
|
484
|
+
file_id: fileId,
|
|
485
|
+
chat_id: chatId,
|
|
486
|
+
message_id: msg.message_id,
|
|
487
|
+
timestamp: Date.now(),
|
|
488
|
+
error: errMsg,
|
|
489
|
+
failed_at: Date.now(),
|
|
490
|
+
});
|
|
491
|
+
this.redis.rpush("voice:failed", failedEntry)
|
|
492
|
+
.then(() => this.redis.expire("voice:failed", 48 * 60 * 60))
|
|
493
|
+
.catch((redisErr) => console.warn("[voice] redis write voice:failed failed:", redisErr.message));
|
|
494
|
+
}
|
|
495
|
+
// User-friendly error messages
|
|
496
|
+
let userMsg;
|
|
497
|
+
if (errMsg.includes("whisper-cpp not found") || errMsg.includes("whisper not found")) {
|
|
498
|
+
userMsg = "Voice transcription unavailable — whisper-cpp not installed";
|
|
499
|
+
}
|
|
500
|
+
else if (errMsg.includes("No whisper model found")) {
|
|
501
|
+
userMsg = "Voice transcription unavailable — no whisper model found";
|
|
502
|
+
}
|
|
503
|
+
else if (errMsg.includes("HTTP") && errMsg.includes("downloading")) {
|
|
504
|
+
userMsg = "Could not download voice file from Telegram";
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
userMsg = `Voice transcription failed: ${errMsg}`;
|
|
508
|
+
}
|
|
509
|
+
await this.replyToChat(chatId, userMsg, threadId);
|
|
378
510
|
}
|
|
379
511
|
}
|
|
380
|
-
async
|
|
512
|
+
async handleVoiceRetry(chatId, threadId) {
|
|
513
|
+
if (!this.redis) {
|
|
514
|
+
await this.replyToChat(chatId, "Redis not configured — voice retry unavailable.", threadId);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const [pendingRaw, failedRaw] = await Promise.all([
|
|
518
|
+
this.redis.lrange("voice:pending", 0, -1).catch(() => []),
|
|
519
|
+
this.redis.lrange("voice:failed", 0, -1).catch(() => []),
|
|
520
|
+
]);
|
|
521
|
+
// Deduplicate by file_id across both lists
|
|
522
|
+
const allEntries = new Map();
|
|
523
|
+
for (const raw of [...pendingRaw, ...failedRaw]) {
|
|
524
|
+
try {
|
|
525
|
+
const entry = JSON.parse(raw);
|
|
526
|
+
if (entry.file_id)
|
|
527
|
+
allEntries.set(entry.file_id, entry);
|
|
528
|
+
}
|
|
529
|
+
catch { /* skip malformed entries */ }
|
|
530
|
+
}
|
|
531
|
+
if (allEntries.size === 0) {
|
|
532
|
+
await this.replyToChat(chatId, "No pending voice messages to retry.", threadId);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
await this.replyToChat(chatId, `Retrying ${allEntries.size} voice message(s)...`, threadId);
|
|
536
|
+
let succeeded = 0;
|
|
537
|
+
let failed = 0;
|
|
538
|
+
const errors = [];
|
|
539
|
+
for (const [fileId, entry] of allEntries) {
|
|
540
|
+
try {
|
|
541
|
+
const fileLink = await this.bot.getFileLink(fileId);
|
|
542
|
+
const transcript = await transcribeVoice(fileLink);
|
|
543
|
+
if (transcript && transcript !== "[empty transcription]") {
|
|
544
|
+
const session = this.getOrCreateSession(entry.chat_id, threadId, undefined);
|
|
545
|
+
session.claude.sendPrompt(transcript);
|
|
546
|
+
this.writeChatMessage("user", "telegram", transcript, entry.chat_id);
|
|
547
|
+
// Remove from both lists
|
|
548
|
+
const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
|
|
549
|
+
const matchFailed = failedRaw.find((r) => r.includes(`"${fileId}"`));
|
|
550
|
+
if (matchPending)
|
|
551
|
+
await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
|
|
552
|
+
if (matchFailed)
|
|
553
|
+
await this.redis.lrem("voice:failed", 0, matchFailed).catch(() => { });
|
|
554
|
+
succeeded++;
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
failed++;
|
|
558
|
+
errors.push(`${fileId}: empty transcription`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
const errMsg = err.message;
|
|
563
|
+
failed++;
|
|
564
|
+
errors.push(`${fileId}: ${errMsg}`);
|
|
565
|
+
// Permanently unretryable (expired Telegram link) — remove from voice:pending
|
|
566
|
+
if (errMsg.includes("Bad Request") || errMsg.includes("file_id")) {
|
|
567
|
+
const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
|
|
568
|
+
if (matchPending)
|
|
569
|
+
await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Purge stale entries from voice:pending older than 48h
|
|
574
|
+
const staleThreshold = 48 * 60 * 60 * 1000;
|
|
575
|
+
let purged = 0;
|
|
576
|
+
for (const raw of pendingRaw) {
|
|
577
|
+
try {
|
|
578
|
+
const entry = JSON.parse(raw);
|
|
579
|
+
if (entry.timestamp && Date.now() - entry.timestamp > staleThreshold) {
|
|
580
|
+
await this.redis.lrem("voice:pending", 0, raw).catch(() => { });
|
|
581
|
+
purged++;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
catch { /* skip malformed entries */ }
|
|
585
|
+
}
|
|
586
|
+
const lines = [`Voice retry complete: ${succeeded} succeeded, ${failed} failed, ${purged} stale entries purged.`];
|
|
587
|
+
if (errors.length > 0)
|
|
588
|
+
lines.push(...errors.map((e) => `• ${e}`));
|
|
589
|
+
await this.replyToChat(chatId, lines.join("\n"), threadId);
|
|
590
|
+
}
|
|
591
|
+
async handlePhoto(chatId, msg, threadId, threadName) {
|
|
381
592
|
// Pick highest resolution photo
|
|
382
593
|
const photos = msg.photo;
|
|
383
594
|
const best = photos[photos.length - 1];
|
|
384
595
|
const caption = msg.caption?.trim();
|
|
385
596
|
console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
|
|
386
|
-
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
597
|
+
this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
|
|
387
598
|
try {
|
|
388
599
|
const fileLink = await this.bot.getFileLink(best.file_id);
|
|
389
600
|
const imageData = await fetchAsBase64(fileLink);
|
|
390
601
|
// Telegram photos are always JPEG
|
|
391
|
-
const session = this.getOrCreateSession(chatId);
|
|
602
|
+
const session = this.getOrCreateSession(chatId, threadId, threadName);
|
|
392
603
|
session.claude.sendImage(imageData, "image/jpeg", caption);
|
|
393
604
|
this.startTyping(chatId, session);
|
|
394
605
|
}
|
|
395
606
|
catch (err) {
|
|
396
607
|
console.error(`[photo:${chatId}] error:`, err.message);
|
|
397
|
-
await this.
|
|
608
|
+
await this.replyToChat(chatId, `Failed to process image: ${err.message}`, threadId);
|
|
398
609
|
}
|
|
399
610
|
}
|
|
400
|
-
async handleDocument(chatId, msg) {
|
|
611
|
+
async handleDocument(chatId, msg, threadId, threadName) {
|
|
401
612
|
const doc = msg.document;
|
|
402
613
|
const caption = msg.caption?.trim();
|
|
403
614
|
const fileName = doc.file_name ?? `file_${doc.file_id}`;
|
|
404
615
|
console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
|
|
405
|
-
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
616
|
+
this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
|
|
406
617
|
try {
|
|
407
618
|
const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
|
|
408
619
|
mkdirSync(uploadsDir, { recursive: true });
|
|
@@ -413,22 +624,34 @@ export class CcTgBot {
|
|
|
413
624
|
const prompt = caption
|
|
414
625
|
? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
|
|
415
626
|
: `ATTACHMENTS: [${fileName}](${destPath})`;
|
|
416
|
-
const session = this.getOrCreateSession(chatId);
|
|
627
|
+
const session = this.getOrCreateSession(chatId, threadId, threadName);
|
|
417
628
|
session.claude.sendPrompt(prompt);
|
|
418
629
|
this.startTyping(chatId, session);
|
|
419
630
|
}
|
|
420
631
|
catch (err) {
|
|
421
632
|
console.error(`[doc:${chatId}] error:`, err.message);
|
|
422
|
-
await this.
|
|
633
|
+
await this.replyToChat(chatId, `Failed to receive document: ${err.message}`, threadId);
|
|
423
634
|
}
|
|
424
635
|
}
|
|
425
|
-
getOrCreateSession(chatId) {
|
|
426
|
-
const
|
|
636
|
+
getOrCreateSession(chatId, threadId, threadName) {
|
|
637
|
+
const key = this.sessionKey(chatId, threadId);
|
|
638
|
+
const existing = this.sessions.get(key);
|
|
427
639
|
if (existing && !existing.claude.exited)
|
|
428
640
|
return existing;
|
|
641
|
+
// Determine CWD for this thread — check THREAD_CWD_MAP by name then by ID
|
|
642
|
+
let sessionCwd = this.opts.cwd;
|
|
643
|
+
const threadCwdMap = this.getThreadCwdMap();
|
|
644
|
+
if (threadName && threadCwdMap[threadName]) {
|
|
645
|
+
sessionCwd = threadCwdMap[threadName];
|
|
646
|
+
console.log(`[cc-tg] thread "${threadName}" → cwd: ${sessionCwd}`);
|
|
647
|
+
}
|
|
648
|
+
else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
|
|
649
|
+
sessionCwd = threadCwdMap[String(threadId)];
|
|
650
|
+
console.log(`[cc-tg] thread ${threadId} → cwd: ${sessionCwd}`);
|
|
651
|
+
}
|
|
429
652
|
const claude = new ClaudeProcess({
|
|
430
|
-
cwd:
|
|
431
|
-
token: this.opts.claudeToken,
|
|
653
|
+
cwd: sessionCwd,
|
|
654
|
+
token: getCurrentToken() || this.opts.claudeToken,
|
|
432
655
|
});
|
|
433
656
|
const session = {
|
|
434
657
|
claude,
|
|
@@ -438,6 +661,7 @@ export class CcTgBot {
|
|
|
438
661
|
writtenFiles: new Set(),
|
|
439
662
|
currentPrompt: "",
|
|
440
663
|
isRetry: false,
|
|
664
|
+
threadId,
|
|
441
665
|
};
|
|
442
666
|
claude.on("usage", (usage) => {
|
|
443
667
|
this.costStore.addUsage(chatId, usage);
|
|
@@ -446,33 +670,47 @@ export class CcTgBot {
|
|
|
446
670
|
// Verbose logging — log every message type and subtype
|
|
447
671
|
const subtype = msg.payload.subtype ?? "";
|
|
448
672
|
const toolName = this.extractToolName(msg);
|
|
449
|
-
const logParts = [`[claude:${
|
|
673
|
+
const logParts = [`[claude:${key}] msg=${msg.type}`];
|
|
450
674
|
if (subtype)
|
|
451
675
|
logParts.push(`subtype=${subtype}`);
|
|
452
676
|
if (toolName)
|
|
453
677
|
logParts.push(`tool=${toolName}`);
|
|
454
678
|
console.log(logParts.join(" "));
|
|
455
679
|
// Track files written by Write/Edit tool calls
|
|
456
|
-
this.trackWrittenFiles(msg, session,
|
|
680
|
+
this.trackWrittenFiles(msg, session, sessionCwd);
|
|
681
|
+
// Publish tool call events to the chat log
|
|
682
|
+
if (msg.type === "assistant") {
|
|
683
|
+
const message = msg.payload.message;
|
|
684
|
+
const content = message?.content;
|
|
685
|
+
if (Array.isArray(content)) {
|
|
686
|
+
for (const block of content) {
|
|
687
|
+
if (block.type !== "tool_use")
|
|
688
|
+
continue;
|
|
689
|
+
const name = block.name;
|
|
690
|
+
const input = block.input;
|
|
691
|
+
this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {})}`, chatId);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
457
695
|
this.handleClaudeMessage(chatId, session, msg);
|
|
458
696
|
});
|
|
459
697
|
claude.on("stderr", (data) => {
|
|
460
698
|
const line = data.trim();
|
|
461
699
|
if (line)
|
|
462
|
-
console.error(`[claude:${
|
|
700
|
+
console.error(`[claude:${key}:stderr]`, line);
|
|
463
701
|
});
|
|
464
702
|
claude.on("exit", (code) => {
|
|
465
|
-
console.log(`[claude:${
|
|
703
|
+
console.log(`[claude:${key}] exited code=${code}`);
|
|
466
704
|
this.stopTyping(session);
|
|
467
|
-
this.sessions.delete(
|
|
705
|
+
this.sessions.delete(key);
|
|
468
706
|
});
|
|
469
707
|
claude.on("error", (err) => {
|
|
470
|
-
console.error(`[claude:${
|
|
708
|
+
console.error(`[claude:${key}] process error: ${err.message}`);
|
|
471
709
|
this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
|
|
472
710
|
this.stopTyping(session);
|
|
473
|
-
this.sessions.delete(
|
|
711
|
+
this.sessions.delete(key);
|
|
474
712
|
});
|
|
475
|
-
this.sessions.set(
|
|
713
|
+
this.sessions.set(key, session);
|
|
476
714
|
return session;
|
|
477
715
|
}
|
|
478
716
|
handleClaudeMessage(chatId, session, msg) {
|
|
@@ -488,33 +726,58 @@ export class CcTgBot {
|
|
|
488
726
|
// Check for usage/rate limit signals before forwarding to Telegram
|
|
489
727
|
const sig = detectUsageLimit(text);
|
|
490
728
|
if (sig.detected) {
|
|
729
|
+
const threadId = session.threadId;
|
|
730
|
+
const retryKey = this.sessionKey(chatId, threadId);
|
|
491
731
|
const lastPrompt = session.currentPrompt;
|
|
492
|
-
const prevRetry = this.pendingRetries.get(
|
|
732
|
+
const prevRetry = this.pendingRetries.get(retryKey);
|
|
493
733
|
const attempt = (prevRetry?.attempt ?? 0) + 1;
|
|
494
734
|
if (prevRetry)
|
|
495
735
|
clearTimeout(prevRetry.timer);
|
|
496
|
-
this.
|
|
497
|
-
this.killSession(chatId);
|
|
736
|
+
this.replyToChat(chatId, sig.humanMessage, threadId).catch(() => { });
|
|
737
|
+
this.killSession(chatId, true, threadId);
|
|
738
|
+
// Token rotation: if this is a usage_exhausted signal and we have multiple
|
|
739
|
+
// tokens, rotate to the next one and retry immediately instead of sleeping.
|
|
740
|
+
// Only rotate if we haven't yet cycled through all tokens (attempt <= count-1).
|
|
741
|
+
if (sig.reason === "usage_exhausted" && getTokenCount() > 1 && attempt <= getTokenCount() - 1) {
|
|
742
|
+
const prevIdx = getTokenIndex();
|
|
743
|
+
rotateToken();
|
|
744
|
+
const newIdx = getTokenIndex();
|
|
745
|
+
const total = getTokenCount();
|
|
746
|
+
console.log(`[cc-tg] Token ${prevIdx + 1}/${total} exhausted, rotating to token ${newIdx + 1}/${total}`);
|
|
747
|
+
this.replyToChat(chatId, `🔄 Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`, threadId).catch(() => { });
|
|
748
|
+
this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
|
|
749
|
+
try {
|
|
750
|
+
const retrySession = this.getOrCreateSession(chatId, threadId);
|
|
751
|
+
retrySession.currentPrompt = lastPrompt;
|
|
752
|
+
retrySession.isRetry = true;
|
|
753
|
+
retrySession.claude.sendPrompt(lastPrompt);
|
|
754
|
+
this.startTyping(chatId, retrySession);
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
this.replyToChat(chatId, `❌ Failed to retry with rotated token: ${err.message}`, threadId).catch(() => { });
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
498
761
|
if (attempt > 3) {
|
|
499
|
-
this.
|
|
500
|
-
this.pendingRetries.delete(
|
|
762
|
+
this.replyToChat(chatId, "❌ Claude usage limit persists after 3 retries. Please try again later.", threadId).catch(() => { });
|
|
763
|
+
this.pendingRetries.delete(retryKey);
|
|
501
764
|
return;
|
|
502
765
|
}
|
|
503
|
-
console.log(`[usage-limit:${
|
|
766
|
+
console.log(`[usage-limit:${retryKey}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
|
|
504
767
|
const timer = setTimeout(() => {
|
|
505
|
-
this.pendingRetries.delete(
|
|
768
|
+
this.pendingRetries.delete(retryKey);
|
|
506
769
|
try {
|
|
507
|
-
const retrySession = this.getOrCreateSession(chatId);
|
|
770
|
+
const retrySession = this.getOrCreateSession(chatId, threadId);
|
|
508
771
|
retrySession.currentPrompt = lastPrompt;
|
|
509
772
|
retrySession.isRetry = true;
|
|
510
773
|
retrySession.claude.sendPrompt(lastPrompt);
|
|
511
774
|
this.startTyping(chatId, retrySession);
|
|
512
775
|
}
|
|
513
776
|
catch (err) {
|
|
514
|
-
this.
|
|
777
|
+
this.replyToChat(chatId, `❌ Failed to retry: ${err.message}`, threadId).catch(() => { });
|
|
515
778
|
}
|
|
516
779
|
}, sig.retryAfterMs);
|
|
517
|
-
this.pendingRetries.set(
|
|
780
|
+
this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer });
|
|
518
781
|
return;
|
|
519
782
|
}
|
|
520
783
|
// Accumulate text and debounce — Claude streams chunks rapidly
|
|
@@ -526,9 +789,11 @@ export class CcTgBot {
|
|
|
526
789
|
startTyping(chatId, session) {
|
|
527
790
|
this.stopTyping(session);
|
|
528
791
|
// Send immediately, then keep alive every 4s
|
|
529
|
-
|
|
792
|
+
// Pass message_thread_id so typing appears in the correct forum topic thread
|
|
793
|
+
const threadOpts = session.threadId !== undefined ? { message_thread_id: session.threadId } : undefined;
|
|
794
|
+
this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
|
|
530
795
|
session.typingTimer = setInterval(() => {
|
|
531
|
-
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
796
|
+
this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
|
|
532
797
|
}, TYPING_INTERVAL_MS);
|
|
533
798
|
}
|
|
534
799
|
stopTyping(session) {
|
|
@@ -543,15 +808,17 @@ export class CcTgBot {
|
|
|
543
808
|
session.flushTimer = null;
|
|
544
809
|
if (!raw)
|
|
545
810
|
return;
|
|
811
|
+
this.writeChatMessage("assistant", "cc-tg", raw, chatId);
|
|
546
812
|
const text = session.isRetry ? `✅ Claude is back!\n\n${raw}` : raw;
|
|
547
813
|
session.isRetry = false;
|
|
548
|
-
// Format for Telegram
|
|
814
|
+
// Format for Telegram HTML and split if needed (max 4096 chars)
|
|
549
815
|
const formatted = formatForTelegram(text);
|
|
550
816
|
const chunks = splitLongMessage(formatted);
|
|
817
|
+
const threadId = session.threadId;
|
|
551
818
|
for (const chunk of chunks) {
|
|
552
|
-
this.
|
|
553
|
-
//
|
|
554
|
-
this.
|
|
819
|
+
this.replyToChat(chatId, chunk, threadId, { parse_mode: "HTML" }).catch(() => {
|
|
820
|
+
// HTML parse failed — retry as plain text
|
|
821
|
+
this.replyToChat(chatId, chunk, threadId).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
|
|
555
822
|
});
|
|
556
823
|
}
|
|
557
824
|
// Hybrid file upload: find files mentioned in result text that Claude actually wrote
|
|
@@ -724,11 +991,12 @@ export class CcTgBot {
|
|
|
724
991
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
725
992
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
726
993
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
727
|
-
this.
|
|
994
|
+
this.replyToChat(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`, session.threadId).catch(() => { });
|
|
728
995
|
continue;
|
|
729
996
|
}
|
|
730
997
|
console.log(`[claude:files] uploading to telegram: ${filePath}`);
|
|
731
|
-
|
|
998
|
+
const docOpts = session.threadId ? { message_thread_id: session.threadId } : undefined;
|
|
999
|
+
this.bot.sendDocument(chatId, filePath, docOpts).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
|
|
732
1000
|
}
|
|
733
1001
|
// Clear written files for next turn
|
|
734
1002
|
session.writtenFiles.clear();
|
|
@@ -743,203 +1011,6 @@ export class CcTgBot {
|
|
|
743
1011
|
const toolUse = content.find((b) => b.type === "tool_use");
|
|
744
1012
|
return toolUse?.name ?? "";
|
|
745
1013
|
}
|
|
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
1014
|
/** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
|
|
944
1015
|
findCcAgentPids() {
|
|
945
1016
|
try {
|
|
@@ -965,25 +1036,33 @@ export class CcTgBot {
|
|
|
965
1036
|
}
|
|
966
1037
|
return pids;
|
|
967
1038
|
}
|
|
968
|
-
async handleReloadMcp(chatId) {
|
|
969
|
-
await this.
|
|
1039
|
+
async handleReloadMcp(chatId, threadId) {
|
|
1040
|
+
await this.replyToChat(chatId, "Clearing npx cache and reloading MCP...", threadId);
|
|
1041
|
+
try {
|
|
1042
|
+
const home = process.env.HOME ?? "~";
|
|
1043
|
+
execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
|
|
1044
|
+
console.log("[mcp] cleared ~/.npm/_npx/");
|
|
1045
|
+
}
|
|
1046
|
+
catch (err) {
|
|
1047
|
+
await this.replyToChat(chatId, `Warning: failed to clear npx cache: ${err.message}`, threadId);
|
|
1048
|
+
}
|
|
970
1049
|
const pids = this.killCcAgent();
|
|
971
1050
|
if (pids.length === 0) {
|
|
972
|
-
await this.
|
|
1051
|
+
await this.replyToChat(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.", threadId);
|
|
973
1052
|
return;
|
|
974
1053
|
}
|
|
975
|
-
await this.
|
|
1054
|
+
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);
|
|
976
1055
|
}
|
|
977
|
-
async handleMcpStatus(chatId) {
|
|
1056
|
+
async handleMcpStatus(chatId, threadId) {
|
|
978
1057
|
try {
|
|
979
1058
|
const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
|
|
980
|
-
await this.
|
|
1059
|
+
await this.replyToChat(chatId, `MCP server status:\n\n${output || "(no output)"}`, threadId);
|
|
981
1060
|
}
|
|
982
1061
|
catch (err) {
|
|
983
|
-
await this.
|
|
1062
|
+
await this.replyToChat(chatId, `Failed to run claude mcp list: ${err.message}`, threadId);
|
|
984
1063
|
}
|
|
985
1064
|
}
|
|
986
|
-
async handleMcpVersion(chatId) {
|
|
1065
|
+
async handleMcpVersion(chatId, threadId) {
|
|
987
1066
|
let npmVersion = "unknown";
|
|
988
1067
|
let cacheEntries = "(unavailable)";
|
|
989
1068
|
try {
|
|
@@ -1000,18 +1079,14 @@ export class CcTgBot {
|
|
|
1000
1079
|
catch {
|
|
1001
1080
|
cacheEntries = "(empty or not found)";
|
|
1002
1081
|
}
|
|
1003
|
-
await this.
|
|
1082
|
+
await this.replyToChat(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`, threadId);
|
|
1004
1083
|
}
|
|
1005
|
-
async handleClearNpxCache(chatId) {
|
|
1084
|
+
async handleClearNpxCache(chatId, threadId) {
|
|
1006
1085
|
const home = process.env.HOME ?? "/tmp";
|
|
1007
|
-
// Use the isolated npm cache dir set in the plist (npm_config_cache), not hardcoded ~/.npm
|
|
1008
|
-
const npmBase = process.env.npm_config_cache
|
|
1009
|
-
? join(process.env.npm_config_cache, "..")
|
|
1010
|
-
: `${home}/.npm`;
|
|
1011
1086
|
const cleared = [];
|
|
1012
1087
|
const failed = [];
|
|
1013
1088
|
// Clear both npx execution cache and full npm package cache
|
|
1014
|
-
for (const dir of [`${
|
|
1089
|
+
for (const dir of [`${home}/.npm/_npx`, `${home}/.npm/cache`]) {
|
|
1015
1090
|
try {
|
|
1016
1091
|
execSync(`rm -rf "${dir}"`, { encoding: "utf8", shell: "/bin/sh" });
|
|
1017
1092
|
cleared.push(dir.replace(home, "~"));
|
|
@@ -1029,73 +1104,233 @@ export class CcTgBot {
|
|
|
1029
1104
|
const clearNote = failed.length
|
|
1030
1105
|
? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
|
|
1031
1106
|
: `Cleared: ${cleared.join(", ")}.`;
|
|
1032
|
-
await this.
|
|
1107
|
+
await this.replyToChat(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`, threadId);
|
|
1033
1108
|
}
|
|
1034
|
-
async handleRestart(chatId) {
|
|
1035
|
-
await this.
|
|
1109
|
+
async handleRestart(chatId, threadId) {
|
|
1110
|
+
await this.replyToChat(chatId, "Clearing cache and restarting... brb.", threadId);
|
|
1036
1111
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1112
|
+
// Clear npm caches before restart so launchd brings up fresh version
|
|
1113
|
+
const home = process.env.HOME ?? "/tmp";
|
|
1114
|
+
for (const dir of [`${home}/.npm/_npx`, `${home}/.npm/cache`]) {
|
|
1115
|
+
try {
|
|
1116
|
+
execSync(`rm -rf "${dir}"`, { shell: "/bin/sh" });
|
|
1117
|
+
}
|
|
1118
|
+
catch { }
|
|
1119
|
+
}
|
|
1037
1120
|
// Kill all active Claude sessions cleanly
|
|
1038
|
-
for (const
|
|
1039
|
-
this.
|
|
1121
|
+
for (const session of this.sessions.values()) {
|
|
1122
|
+
this.stopTyping(session);
|
|
1123
|
+
session.claude.kill();
|
|
1040
1124
|
}
|
|
1125
|
+
this.sessions.clear();
|
|
1041
1126
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1042
1127
|
process.exit(0);
|
|
1043
1128
|
}
|
|
1044
|
-
async
|
|
1129
|
+
async handleCron(chatId, text, threadId) {
|
|
1130
|
+
const args = text.slice("/cron".length).trim();
|
|
1131
|
+
if (args === "list" || args === "") {
|
|
1132
|
+
const jobs = this.cron.list(chatId);
|
|
1133
|
+
if (!jobs.length) {
|
|
1134
|
+
await this.replyToChat(chatId, "No cron jobs.", threadId);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const lines = jobs.map((j, i) => {
|
|
1138
|
+
const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
|
|
1139
|
+
return `#${i + 1} ${j.schedule} — "${short}"`;
|
|
1140
|
+
});
|
|
1141
|
+
await this.replyToChat(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`, threadId);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (args === "clear") {
|
|
1145
|
+
const n = this.cron.clearAll(chatId);
|
|
1146
|
+
await this.replyToChat(chatId, `Cleared ${n} cron job(s).`, threadId);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
if (args.startsWith("remove ")) {
|
|
1150
|
+
const id = args.slice("remove ".length).trim();
|
|
1151
|
+
const ok = this.cron.remove(chatId, id);
|
|
1152
|
+
await this.replyToChat(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`, threadId);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
1156
|
+
if (!scheduleMatch) {
|
|
1157
|
+
await this.replyToChat(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron remove <id>\n/cron clear", threadId);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
const schedule = scheduleMatch[1];
|
|
1161
|
+
const prompt = scheduleMatch[2];
|
|
1162
|
+
const job = this.cron.add(chatId, schedule, prompt);
|
|
1163
|
+
if (!job) {
|
|
1164
|
+
await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
await this.replyToChat(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`, threadId);
|
|
1168
|
+
}
|
|
1169
|
+
runCronTask(chatId, prompt, done = () => { }) {
|
|
1170
|
+
const cronProcess = new ClaudeProcess({ cwd: this.opts.cwd ?? process.cwd() });
|
|
1171
|
+
cronProcess.sendPrompt(prompt);
|
|
1172
|
+
cronProcess.on("message", (msg) => {
|
|
1173
|
+
const result = extractText(msg);
|
|
1174
|
+
if (result) {
|
|
1175
|
+
const formatted = formatForTelegram(`🕐 ${result}`);
|
|
1176
|
+
const chunks = splitLongMessage(formatted);
|
|
1177
|
+
for (const chunk of chunks) {
|
|
1178
|
+
this.replyToChat(chatId, chunk).catch((err) => console.error("[cron] send failed:", err.message));
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
cronProcess.on("exit", () => done());
|
|
1183
|
+
}
|
|
1184
|
+
async handleGetFile(chatId, text, threadId) {
|
|
1045
1185
|
const arg = text.slice("/get_file".length).trim();
|
|
1046
1186
|
if (!arg) {
|
|
1047
|
-
await this.
|
|
1187
|
+
await this.replyToChat(chatId, "Usage: /get_file <path>", threadId);
|
|
1048
1188
|
return;
|
|
1049
1189
|
}
|
|
1050
1190
|
const filePath = resolve(arg);
|
|
1051
1191
|
const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
|
|
1052
1192
|
const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
|
|
1053
1193
|
if (!inSafeDir) {
|
|
1054
|
-
await this.
|
|
1194
|
+
await this.replyToChat(chatId, "Access denied: path not in allowed directories", threadId);
|
|
1055
1195
|
return;
|
|
1056
1196
|
}
|
|
1057
1197
|
if (!existsSync(filePath)) {
|
|
1058
|
-
await this.
|
|
1198
|
+
await this.replyToChat(chatId, `File not found: ${filePath}`, threadId);
|
|
1059
1199
|
return;
|
|
1060
1200
|
}
|
|
1061
1201
|
if (!statSync(filePath).isFile()) {
|
|
1062
|
-
await this.
|
|
1202
|
+
await this.replyToChat(chatId, `Not a file: ${filePath}`, threadId);
|
|
1063
1203
|
return;
|
|
1064
1204
|
}
|
|
1065
1205
|
if (this.isSensitiveFile(filePath)) {
|
|
1066
|
-
await this.
|
|
1206
|
+
await this.replyToChat(chatId, "Access denied: sensitive file", threadId);
|
|
1067
1207
|
return;
|
|
1068
1208
|
}
|
|
1069
1209
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
1070
1210
|
const fileSize = statSync(filePath).size;
|
|
1071
1211
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
1072
1212
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
1073
|
-
await this.
|
|
1213
|
+
await this.replyToChat(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`, threadId);
|
|
1074
1214
|
return;
|
|
1075
1215
|
}
|
|
1076
|
-
|
|
1216
|
+
const docOpts = threadId ? { message_thread_id: threadId } : undefined;
|
|
1217
|
+
await this.bot.sendDocument(chatId, filePath, docOpts);
|
|
1218
|
+
}
|
|
1219
|
+
async handleDrivers(chatId, threadId) {
|
|
1220
|
+
try {
|
|
1221
|
+
const raw = await this.callCcAgentTool("list_drivers");
|
|
1222
|
+
if (!raw) {
|
|
1223
|
+
await this.replyToChat(chatId, "No drivers available or cc-agent did not respond.", threadId);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
// Try to pretty-print JSON array/object, fall back to raw string
|
|
1227
|
+
let reply;
|
|
1228
|
+
try {
|
|
1229
|
+
const data = JSON.parse(raw);
|
|
1230
|
+
if (Array.isArray(data)) {
|
|
1231
|
+
const current = process.env.CC_AGENT_DEFAULT_DRIVER || "claude";
|
|
1232
|
+
const lines = data.map((d) => d === current ? `• ${d} (default)` : `• ${d}`);
|
|
1233
|
+
reply = `Available drivers:\n${lines.join("\n")}`;
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
reply = `Available drivers:\n${raw}`;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
catch {
|
|
1240
|
+
reply = `Available drivers:\n${raw}`;
|
|
1241
|
+
}
|
|
1242
|
+
await this.replyToChat(chatId, reply, threadId);
|
|
1243
|
+
}
|
|
1244
|
+
catch (err) {
|
|
1245
|
+
await this.replyToChat(chatId, `Failed to list drivers: ${err.message}`, threadId);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
async handleAgents(chatId, threadId) {
|
|
1249
|
+
if (!this.redis) {
|
|
1250
|
+
await this.replyToChat(chatId, "Redis not configured — agents status unavailable.", threadId);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
try {
|
|
1254
|
+
// Scan for all meta-agent status keys
|
|
1255
|
+
const keys = [];
|
|
1256
|
+
let cursor = "0";
|
|
1257
|
+
do {
|
|
1258
|
+
const [nextCursor, found] = await this.redis.scan(cursor, "MATCH", "cca:meta-agent:status:*", "COUNT", 100);
|
|
1259
|
+
cursor = nextCursor;
|
|
1260
|
+
keys.push(...found);
|
|
1261
|
+
} while (cursor !== "0");
|
|
1262
|
+
if (keys.length === 0) {
|
|
1263
|
+
await this.replyToChat(chatId, "No active meta-agents.", threadId);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
const statuses = await Promise.all(keys.sort().map(async (key) => ({ key, raw: await this.redis.get(key) })));
|
|
1267
|
+
const lines = ["🤖 Active Agents", ""];
|
|
1268
|
+
for (const { key, raw } of statuses) {
|
|
1269
|
+
const namespace = key.replace("cca:meta-agent:status:", "");
|
|
1270
|
+
if (!raw) {
|
|
1271
|
+
lines.push(`${namespace} — status unknown`);
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
try {
|
|
1275
|
+
const status = JSON.parse(raw);
|
|
1276
|
+
const state = status.status ?? "unknown";
|
|
1277
|
+
const turns = status.turn ?? status.turn_count ?? 0;
|
|
1278
|
+
const tool = status.current_tool;
|
|
1279
|
+
const lastActivity = status.last_activity ?? status.updated_at;
|
|
1280
|
+
let ageStr = "";
|
|
1281
|
+
if (lastActivity) {
|
|
1282
|
+
const ageSec = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
1283
|
+
if (ageSec < 60)
|
|
1284
|
+
ageStr = `${ageSec}s ago`;
|
|
1285
|
+
else if (ageSec < 3600)
|
|
1286
|
+
ageStr = `${Math.floor(ageSec / 60)}m ago`;
|
|
1287
|
+
else
|
|
1288
|
+
ageStr = `${Math.floor(ageSec / 3600)}h ago`;
|
|
1289
|
+
}
|
|
1290
|
+
let statusDesc;
|
|
1291
|
+
if (state === "running" && tool) {
|
|
1292
|
+
statusDesc = `typing... (turn ${turns})`;
|
|
1293
|
+
}
|
|
1294
|
+
else if (state === "running") {
|
|
1295
|
+
statusDesc = `running (turn ${turns}${ageStr ? `, ${ageStr}` : ""})`;
|
|
1296
|
+
}
|
|
1297
|
+
else {
|
|
1298
|
+
statusDesc = `idle (turn ${turns}${ageStr ? `, ${ageStr}` : ""})`;
|
|
1299
|
+
}
|
|
1300
|
+
lines.push(`${namespace} — ${statusDesc}`);
|
|
1301
|
+
}
|
|
1302
|
+
catch {
|
|
1303
|
+
lines.push(`${namespace} — status unknown`);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
await this.replyToChat(chatId, lines.join("\n"), threadId);
|
|
1307
|
+
}
|
|
1308
|
+
catch (err) {
|
|
1309
|
+
await this.replyToChat(chatId, `Failed to get agents status: ${err.message}`, threadId);
|
|
1310
|
+
}
|
|
1077
1311
|
}
|
|
1078
1312
|
callCcAgentTool(toolName, args = {}) {
|
|
1313
|
+
// For spawn tools, pass through the configured driver and model
|
|
1314
|
+
const spawnTools = new Set(["spawn_agent", "spawn_from_profile"]);
|
|
1315
|
+
if (spawnTools.has(toolName)) {
|
|
1316
|
+
const driver = process.env.CC_AGENT_DEFAULT_DRIVER || "claude";
|
|
1317
|
+
const model = process.env.CC_AGENT_DEFAULT_MODEL || undefined;
|
|
1318
|
+
args = { agent_driver: driver, ...(model ? { agent_model: model } : {}), ...args };
|
|
1319
|
+
}
|
|
1079
1320
|
return new Promise((resolve) => {
|
|
1080
1321
|
let settled = false;
|
|
1081
|
-
let procRef = null;
|
|
1082
1322
|
const done = (val) => {
|
|
1083
1323
|
if (!settled) {
|
|
1084
1324
|
settled = true;
|
|
1085
|
-
try {
|
|
1086
|
-
procRef?.kill();
|
|
1087
|
-
}
|
|
1088
|
-
catch { }
|
|
1089
1325
|
resolve(val);
|
|
1090
1326
|
}
|
|
1091
1327
|
};
|
|
1092
1328
|
let proc;
|
|
1093
1329
|
try {
|
|
1094
|
-
proc = spawn("npx", ["
|
|
1330
|
+
proc = spawn("npx", ["-y", "@gonzih/cc-agent@latest"], {
|
|
1095
1331
|
env: { ...process.env },
|
|
1096
1332
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1097
1333
|
});
|
|
1098
|
-
procRef = proc;
|
|
1099
1334
|
}
|
|
1100
1335
|
catch (err) {
|
|
1101
1336
|
console.error("[mcp] failed to spawn cc-agent:", err.message);
|
|
@@ -1152,21 +1387,25 @@ export class CcTgBot {
|
|
|
1152
1387
|
proc.on("exit", () => { clearTimeout(timeout); done(null); });
|
|
1153
1388
|
});
|
|
1154
1389
|
}
|
|
1155
|
-
killSession(chatId,
|
|
1156
|
-
const
|
|
1390
|
+
killSession(chatId, _keepCrons = true, threadId) {
|
|
1391
|
+
const key = this.sessionKey(chatId, threadId);
|
|
1392
|
+
const session = this.sessions.get(key);
|
|
1157
1393
|
if (session) {
|
|
1158
1394
|
this.stopTyping(session);
|
|
1159
1395
|
session.claude.kill();
|
|
1160
|
-
this.sessions.delete(
|
|
1396
|
+
this.sessions.delete(key);
|
|
1161
1397
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1398
|
+
}
|
|
1399
|
+
getMe() {
|
|
1400
|
+
return this.bot.getMe();
|
|
1164
1401
|
}
|
|
1165
1402
|
stop() {
|
|
1166
1403
|
this.bot.stopPolling();
|
|
1167
|
-
for (const
|
|
1168
|
-
this.
|
|
1404
|
+
for (const session of this.sessions.values()) {
|
|
1405
|
+
this.stopTyping(session);
|
|
1406
|
+
session.claude.kill();
|
|
1169
1407
|
}
|
|
1408
|
+
this.sessions.clear();
|
|
1170
1409
|
}
|
|
1171
1410
|
}
|
|
1172
1411
|
function buildPromptWithReplyContext(text, msg) {
|
|
@@ -1205,6 +1444,85 @@ function downloadToFile(url, destPath) {
|
|
|
1205
1444
|
}).on("error", reject);
|
|
1206
1445
|
});
|
|
1207
1446
|
}
|
|
1447
|
+
/** Fetch URL via Jina Reader and return first maxChars characters */
|
|
1448
|
+
function fetchUrlViaJina(url, maxChars = 2000) {
|
|
1449
|
+
const jinaUrl = `https://r.jina.ai/${url}`;
|
|
1450
|
+
return new Promise((resolve, reject) => {
|
|
1451
|
+
https.get(jinaUrl, (res) => {
|
|
1452
|
+
const chunks = [];
|
|
1453
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
1454
|
+
res.on("end", () => {
|
|
1455
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
1456
|
+
resolve(text.slice(0, maxChars));
|
|
1457
|
+
});
|
|
1458
|
+
res.on("error", reject);
|
|
1459
|
+
}).on("error", reject);
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
/** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
|
|
1463
|
+
export async function enrichPromptWithUrls(text) {
|
|
1464
|
+
const urlRegex = /https?:\/\/[^\s]+/g;
|
|
1465
|
+
const urls = text.match(urlRegex);
|
|
1466
|
+
if (!urls || urls.length === 0)
|
|
1467
|
+
return text;
|
|
1468
|
+
const prefixes = [];
|
|
1469
|
+
for (const url of urls) {
|
|
1470
|
+
// Skip jina.ai URLs to avoid recursion
|
|
1471
|
+
if (url.includes("r.jina.ai"))
|
|
1472
|
+
continue;
|
|
1473
|
+
try {
|
|
1474
|
+
const content = await fetchUrlViaJina(url);
|
|
1475
|
+
if (content.trim()) {
|
|
1476
|
+
prefixes.push(`[Web content from ${url}]:\n${content}`);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
catch (err) {
|
|
1480
|
+
console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (prefixes.length === 0)
|
|
1484
|
+
return text;
|
|
1485
|
+
return prefixes.join("\n\n") + "\n\n" + text;
|
|
1486
|
+
}
|
|
1487
|
+
/** Parse frontmatter description from a skill markdown file */
|
|
1488
|
+
function parseSkillDescription(content) {
|
|
1489
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
1490
|
+
if (!match)
|
|
1491
|
+
return null;
|
|
1492
|
+
const frontmatter = match[1];
|
|
1493
|
+
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
1494
|
+
return descMatch ? descMatch[1].trim() : null;
|
|
1495
|
+
}
|
|
1496
|
+
/** List available skills from ~/.claude/skills/ */
|
|
1497
|
+
export function listSkills() {
|
|
1498
|
+
const skillsDir = join(os.homedir(), ".claude", "skills");
|
|
1499
|
+
if (!existsSync(skillsDir)) {
|
|
1500
|
+
return "No skills directory found at ~/.claude/skills/";
|
|
1501
|
+
}
|
|
1502
|
+
let files;
|
|
1503
|
+
try {
|
|
1504
|
+
files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
|
|
1505
|
+
}
|
|
1506
|
+
catch {
|
|
1507
|
+
return "Could not read skills directory.";
|
|
1508
|
+
}
|
|
1509
|
+
if (files.length === 0) {
|
|
1510
|
+
return "No skills found in ~/.claude/skills/";
|
|
1511
|
+
}
|
|
1512
|
+
const lines = ["Available skills:"];
|
|
1513
|
+
for (const file of files.sort()) {
|
|
1514
|
+
const name = "/" + file.replace(/\.md$/, "");
|
|
1515
|
+
try {
|
|
1516
|
+
const content = readFileSync(join(skillsDir, file), "utf8");
|
|
1517
|
+
const description = parseSkillDescription(content);
|
|
1518
|
+
lines.push(description ? `${name} — ${description}` : name);
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
lines.push(name);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return lines.join("\n");
|
|
1525
|
+
}
|
|
1208
1526
|
export function splitMessage(text, maxLen = 4096) {
|
|
1209
1527
|
if (text.length <= maxLen)
|
|
1210
1528
|
return [text];
|