@gonzih/cc-tg 0.9.0 ā 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -36
- package/dist/bot.d.ts +4 -28
- package/dist/bot.js +312 -328
- package/dist/cron.d.ts +33 -0
- package/dist/cron.js +127 -0
- package/dist/formatter.d.ts +12 -14
- package/dist/formatter.js +36 -72
- package/dist/index.js +3 -58
- package/dist/usage-limit.js +3 -2
- package/package.json +3 -4
- package/dist/notifier.d.ts +0 -36
- package/dist/notifier.js +0 -114
- package/dist/tokens.d.ts +0 -22
- package/dist/tokens.js +0 -56
package/dist/bot.js
CHANGED
|
@@ -11,16 +11,16 @@ import https from "https";
|
|
|
11
11
|
import http from "http";
|
|
12
12
|
import { ClaudeProcess, extractText } from "./claude.js";
|
|
13
13
|
import { transcribeVoice, isVoiceAvailable } from "./voice.js";
|
|
14
|
+
import { CronManager } from "./cron.js";
|
|
14
15
|
import { formatForTelegram, splitLongMessage } from "./formatter.js";
|
|
15
16
|
import { detectUsageLimit } from "./usage-limit.js";
|
|
16
|
-
import { getCurrentToken, rotateToken, getTokenIndex, getTokenCount } from "./tokens.js";
|
|
17
|
-
import { writeChatLog } from "./notifier.js";
|
|
18
17
|
const BOT_COMMANDS = [
|
|
19
18
|
{ command: "start", description: "Reset session and start fresh" },
|
|
20
19
|
{ command: "reset", description: "Reset Claude session" },
|
|
21
20
|
{ command: "stop", description: "Stop the current Claude task" },
|
|
22
21
|
{ command: "status", description: "Check if a session is active" },
|
|
23
22
|
{ command: "help", description: "Show all available commands" },
|
|
23
|
+
{ command: "cron", description: "Manage cron jobs ā add/list/edit/remove/clear" },
|
|
24
24
|
{ command: "reload_mcp", description: "Restart the cc-agent MCP server process" },
|
|
25
25
|
{ command: "mcp_status", description: "Check MCP server connection status" },
|
|
26
26
|
{ command: "mcp_version", description: "Show cc-agent npm version and npx cache info" },
|
|
@@ -28,7 +28,6 @@ const BOT_COMMANDS = [
|
|
|
28
28
|
{ command: "restart", description: "Restart the bot process in-place" },
|
|
29
29
|
{ command: "get_file", description: "Send a file from the server to this chat" },
|
|
30
30
|
{ command: "cost", description: "Show session token usage and cost" },
|
|
31
|
-
{ command: "skills", description: "List available Claude skills with descriptions" },
|
|
32
31
|
];
|
|
33
32
|
const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
|
|
34
33
|
const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
|
|
@@ -65,6 +64,10 @@ function formatCostReport(cost) {
|
|
|
65
64
|
` Cache write: ${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`,
|
|
66
65
|
].join("\n");
|
|
67
66
|
}
|
|
67
|
+
function formatCronCostFooter(usage) {
|
|
68
|
+
const cost = computeCostUsd(usage);
|
|
69
|
+
return `\nš° Cron cost: $${cost.toFixed(4)} (${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out tokens)`;
|
|
70
|
+
}
|
|
68
71
|
function formatAgentCostSummary(text) {
|
|
69
72
|
try {
|
|
70
73
|
const data = JSON.parse(text);
|
|
@@ -151,15 +154,12 @@ export class CcTgBot {
|
|
|
151
154
|
sessions = new Map();
|
|
152
155
|
pendingRetries = new Map();
|
|
153
156
|
opts;
|
|
157
|
+
cron;
|
|
154
158
|
costStore;
|
|
155
159
|
botUsername = "";
|
|
156
160
|
botId = 0;
|
|
157
|
-
redis;
|
|
158
|
-
namespace;
|
|
159
161
|
constructor(opts) {
|
|
160
162
|
this.opts = opts;
|
|
161
|
-
this.redis = opts.redis;
|
|
162
|
-
this.namespace = opts.namespace ?? "default";
|
|
163
163
|
this.bot = new TelegramBot(opts.telegramToken, { polling: true });
|
|
164
164
|
this.bot.on("message", (msg) => this.handleTelegram(msg));
|
|
165
165
|
this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
|
|
@@ -168,6 +168,10 @@ export class CcTgBot {
|
|
|
168
168
|
this.botId = me.id;
|
|
169
169
|
console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
|
|
170
170
|
}).catch((err) => console.error("[tg] getMe failed:", err.message));
|
|
171
|
+
// Cron manager ā fires each task into an isolated ClaudeProcess
|
|
172
|
+
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt) => {
|
|
173
|
+
this.runCronTask(chatId, prompt);
|
|
174
|
+
});
|
|
171
175
|
this.costStore = new CostStore(opts.cwd ?? process.cwd());
|
|
172
176
|
this.registerBotCommands();
|
|
173
177
|
console.log("cc-tg bot started");
|
|
@@ -178,51 +182,6 @@ export class CcTgBot {
|
|
|
178
182
|
.then(() => console.log("[tg] bot commands registered"))
|
|
179
183
|
.catch((err) => console.error("[tg] setMyCommands failed:", err.message));
|
|
180
184
|
}
|
|
181
|
-
/** Write a message to the Redis chat log. Fire-and-forget ā no-op if Redis is not configured. */
|
|
182
|
-
writeChatMessage(role, source, content, chatId) {
|
|
183
|
-
if (!this.redis)
|
|
184
|
-
return;
|
|
185
|
-
const msg = {
|
|
186
|
-
id: `${source}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
187
|
-
source,
|
|
188
|
-
role,
|
|
189
|
-
content,
|
|
190
|
-
timestamp: new Date().toISOString(),
|
|
191
|
-
chatId,
|
|
192
|
-
};
|
|
193
|
-
writeChatLog(this.redis, this.namespace, msg);
|
|
194
|
-
}
|
|
195
|
-
/** Session key: "chatId:threadId" for topics, "chatId:main" for DMs/non-topic groups */
|
|
196
|
-
sessionKey(chatId, threadId) {
|
|
197
|
-
return `${chatId}:${threadId ?? 'main'}`;
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* Send a message back to the correct thread (or plain chat if no thread).
|
|
201
|
-
* When threadId is undefined, calls sendMessage with exactly 2 args to preserve
|
|
202
|
-
* backward-compatible call signatures (no extra options object).
|
|
203
|
-
*/
|
|
204
|
-
replyToChat(chatId, text, threadId, opts) {
|
|
205
|
-
if (threadId !== undefined) {
|
|
206
|
-
return this.bot.sendMessage(chatId, text, { ...opts, message_thread_id: threadId });
|
|
207
|
-
}
|
|
208
|
-
if (opts) {
|
|
209
|
-
return this.bot.sendMessage(chatId, text, opts);
|
|
210
|
-
}
|
|
211
|
-
return this.bot.sendMessage(chatId, text);
|
|
212
|
-
}
|
|
213
|
-
/** Parse THREAD_CWD_MAP env var ā maps thread name or thread_id to a CWD path */
|
|
214
|
-
getThreadCwdMap() {
|
|
215
|
-
const raw = process.env.THREAD_CWD_MAP;
|
|
216
|
-
if (!raw)
|
|
217
|
-
return {};
|
|
218
|
-
try {
|
|
219
|
-
return JSON.parse(raw);
|
|
220
|
-
}
|
|
221
|
-
catch {
|
|
222
|
-
console.warn('[cc-tg] THREAD_CWD_MAP is not valid JSON, ignoring');
|
|
223
|
-
return {};
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
185
|
isAllowed(userId) {
|
|
227
186
|
if (!this.opts.allowedUserIds?.length)
|
|
228
187
|
return true;
|
|
@@ -231,16 +190,8 @@ export class CcTgBot {
|
|
|
231
190
|
async handleTelegram(msg) {
|
|
232
191
|
const chatId = msg.chat.id;
|
|
233
192
|
const userId = msg.from?.id ?? chatId;
|
|
234
|
-
// Forum topic thread_id ā undefined for DMs and non-topic group messages
|
|
235
|
-
const threadId = msg.message_thread_id;
|
|
236
|
-
// Thread name is available on the service message that creates a new topic.
|
|
237
|
-
// forum_topic_created is not in older @types/node-telegram-bot-api versions, so cast via unknown.
|
|
238
|
-
const rawMsg = msg;
|
|
239
|
-
const threadName = rawMsg.forum_topic_created
|
|
240
|
-
? rawMsg.forum_topic_created.name
|
|
241
|
-
: undefined;
|
|
242
193
|
if (!this.isAllowed(userId)) {
|
|
243
|
-
await this.
|
|
194
|
+
await this.bot.sendMessage(chatId, "Not authorized.");
|
|
244
195
|
return;
|
|
245
196
|
}
|
|
246
197
|
// Group chat handling
|
|
@@ -261,17 +212,17 @@ export class CcTgBot {
|
|
|
261
212
|
}
|
|
262
213
|
// Voice message ā transcribe then feed as text
|
|
263
214
|
if (msg.voice || msg.audio) {
|
|
264
|
-
await this.handleVoice(chatId, msg
|
|
215
|
+
await this.handleVoice(chatId, msg);
|
|
265
216
|
return;
|
|
266
217
|
}
|
|
267
218
|
// Photo ā send as base64 image content block to Claude
|
|
268
219
|
if (msg.photo?.length) {
|
|
269
|
-
await this.handlePhoto(chatId, msg
|
|
220
|
+
await this.handlePhoto(chatId, msg);
|
|
270
221
|
return;
|
|
271
222
|
}
|
|
272
223
|
// Document ā download to CWD/.cc-tg/uploads/, tell Claude the path
|
|
273
224
|
if (msg.document) {
|
|
274
|
-
await this.handleDocument(chatId, msg
|
|
225
|
+
await this.handleDocument(chatId, msg);
|
|
275
226
|
return;
|
|
276
227
|
}
|
|
277
228
|
let text = msg.text?.trim();
|
|
@@ -281,64 +232,68 @@ export class CcTgBot {
|
|
|
281
232
|
if (this.botUsername) {
|
|
282
233
|
text = text.replace(new RegExp(`@${this.botUsername}\\s*`, "g"), "").trim();
|
|
283
234
|
}
|
|
284
|
-
const sessionKey = this.sessionKey(chatId, threadId);
|
|
285
235
|
// /start or /reset ā kill existing session and ack
|
|
286
236
|
if (text === "/start" || text === "/reset") {
|
|
287
|
-
this.killSession(chatId
|
|
288
|
-
await this.
|
|
237
|
+
this.killSession(chatId);
|
|
238
|
+
await this.bot.sendMessage(chatId, "Session reset. Send a message to start.");
|
|
289
239
|
return;
|
|
290
240
|
}
|
|
291
241
|
// /stop ā kill active session (interrupt running Claude task)
|
|
292
242
|
if (text === "/stop") {
|
|
293
|
-
const has = this.sessions.has(
|
|
294
|
-
this.killSession(chatId
|
|
295
|
-
await this.
|
|
243
|
+
const has = this.sessions.has(chatId);
|
|
244
|
+
this.killSession(chatId);
|
|
245
|
+
await this.bot.sendMessage(chatId, has ? "Stopped." : "No active session.");
|
|
296
246
|
return;
|
|
297
247
|
}
|
|
298
248
|
// /help ā list all commands
|
|
299
249
|
if (text === "/help") {
|
|
300
250
|
const lines = BOT_COMMANDS.map((c) => `/${c.command} ā ${c.description}`);
|
|
301
|
-
await this.
|
|
251
|
+
await this.bot.sendMessage(chatId, lines.join("\n"));
|
|
302
252
|
return;
|
|
303
253
|
}
|
|
304
254
|
// /status
|
|
305
255
|
if (text === "/status") {
|
|
306
|
-
const has = this.sessions.has(
|
|
256
|
+
const has = this.sessions.has(chatId);
|
|
307
257
|
let status = has ? "Session active." : "No active session.";
|
|
308
258
|
const sleeping = this.pendingRetries.size;
|
|
309
259
|
if (sleeping > 0)
|
|
310
260
|
status += `\nāø ${sleeping} request(s) sleeping (usage limit).`;
|
|
311
|
-
await this.
|
|
261
|
+
await this.bot.sendMessage(chatId, status);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
|
|
265
|
+
if (text.startsWith("/cron")) {
|
|
266
|
+
await this.handleCron(chatId, text);
|
|
312
267
|
return;
|
|
313
268
|
}
|
|
314
269
|
// /reload_mcp ā kill cc-agent process so Claude Code auto-restarts it
|
|
315
270
|
if (text === "/reload_mcp") {
|
|
316
|
-
await this.handleReloadMcp(chatId
|
|
271
|
+
await this.handleReloadMcp(chatId);
|
|
317
272
|
return;
|
|
318
273
|
}
|
|
319
274
|
// /mcp_status ā run `claude mcp list` and show connection status
|
|
320
275
|
if (text === "/mcp_status") {
|
|
321
|
-
await this.handleMcpStatus(chatId
|
|
276
|
+
await this.handleMcpStatus(chatId);
|
|
322
277
|
return;
|
|
323
278
|
}
|
|
324
279
|
// /mcp_version ā show published npm version and cached npx entries
|
|
325
280
|
if (text === "/mcp_version") {
|
|
326
|
-
await this.handleMcpVersion(chatId
|
|
281
|
+
await this.handleMcpVersion(chatId);
|
|
327
282
|
return;
|
|
328
283
|
}
|
|
329
284
|
// /clear_npx_cache ā wipe ~/.npm/_npx/ then restart cc-agent
|
|
330
285
|
if (text === "/clear_npx_cache") {
|
|
331
|
-
await this.handleClearNpxCache(chatId
|
|
286
|
+
await this.handleClearNpxCache(chatId);
|
|
332
287
|
return;
|
|
333
288
|
}
|
|
334
289
|
// /restart ā restart the bot process in-place
|
|
335
290
|
if (text === "/restart") {
|
|
336
|
-
await this.handleRestart(chatId
|
|
291
|
+
await this.handleRestart(chatId);
|
|
337
292
|
return;
|
|
338
293
|
}
|
|
339
294
|
// /get_file <path> ā send a file from the server to the user
|
|
340
295
|
if (text.startsWith("/get_file")) {
|
|
341
|
-
await this.handleGetFile(chatId, text
|
|
296
|
+
await this.handleGetFile(chatId, text);
|
|
342
297
|
return;
|
|
343
298
|
}
|
|
344
299
|
// /cost ā show session token usage and cost
|
|
@@ -354,62 +309,37 @@ export class CcTgBot {
|
|
|
354
309
|
catch (err) {
|
|
355
310
|
console.error("[cost] cc-agent cost_summary failed:", err.message);
|
|
356
311
|
}
|
|
357
|
-
await this.
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
// /skills ā list available Claude skills from ~/.claude/skills/
|
|
361
|
-
if (text === "/skills") {
|
|
362
|
-
await this.replyToChat(chatId, listSkills(), threadId);
|
|
312
|
+
await this.bot.sendMessage(chatId, reply);
|
|
363
313
|
return;
|
|
364
314
|
}
|
|
365
|
-
const session = this.getOrCreateSession(chatId
|
|
315
|
+
const session = this.getOrCreateSession(chatId);
|
|
366
316
|
try {
|
|
367
|
-
const
|
|
368
|
-
const prompt = buildPromptWithReplyContext(enriched, msg);
|
|
317
|
+
const prompt = buildPromptWithReplyContext(text, msg);
|
|
369
318
|
session.currentPrompt = prompt;
|
|
370
319
|
session.claude.sendPrompt(prompt);
|
|
371
320
|
this.startTyping(chatId, session);
|
|
372
|
-
this.writeChatMessage("user", "telegram", text, chatId);
|
|
373
|
-
}
|
|
374
|
-
catch (err) {
|
|
375
|
-
await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
|
|
376
|
-
this.killSession(chatId, true, threadId);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
/**
|
|
380
|
-
* Feed a text message into the active Claude session for the given chat.
|
|
381
|
-
* Called by the notifier when a UI message arrives via Redis pub/sub.
|
|
382
|
-
*/
|
|
383
|
-
async handleUserMessage(chatId, text) {
|
|
384
|
-
const session = this.getOrCreateSession(chatId);
|
|
385
|
-
try {
|
|
386
|
-
const enriched = await enrichPromptWithUrls(text);
|
|
387
|
-
session.currentPrompt = enriched;
|
|
388
|
-
session.claude.sendPrompt(enriched);
|
|
389
|
-
this.startTyping(chatId, session);
|
|
390
|
-
this.writeChatMessage("user", "ui", text, chatId);
|
|
391
321
|
}
|
|
392
322
|
catch (err) {
|
|
393
|
-
await this.
|
|
394
|
-
this.killSession(chatId
|
|
323
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
324
|
+
this.killSession(chatId);
|
|
395
325
|
}
|
|
396
326
|
}
|
|
397
|
-
async handleVoice(chatId, msg
|
|
327
|
+
async handleVoice(chatId, msg) {
|
|
398
328
|
const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
|
|
399
329
|
if (!fileId)
|
|
400
330
|
return;
|
|
401
331
|
console.log(`[voice:${chatId}] received voice message, transcribing...`);
|
|
402
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
332
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
403
333
|
try {
|
|
404
334
|
const fileLink = await this.bot.getFileLink(fileId);
|
|
405
335
|
const transcript = await transcribeVoice(fileLink);
|
|
406
336
|
console.log(`[voice:${chatId}] transcribed: ${transcript}`);
|
|
407
337
|
if (!transcript || transcript === "[empty transcription]") {
|
|
408
|
-
await this.
|
|
338
|
+
await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
|
|
409
339
|
return;
|
|
410
340
|
}
|
|
411
341
|
// Feed transcript into Claude as if user typed it
|
|
412
|
-
const session = this.getOrCreateSession(chatId
|
|
342
|
+
const session = this.getOrCreateSession(chatId);
|
|
413
343
|
try {
|
|
414
344
|
const prompt = buildPromptWithReplyContext(transcript, msg);
|
|
415
345
|
session.currentPrompt = prompt;
|
|
@@ -417,41 +347,41 @@ export class CcTgBot {
|
|
|
417
347
|
this.startTyping(chatId, session);
|
|
418
348
|
}
|
|
419
349
|
catch (err) {
|
|
420
|
-
await this.
|
|
421
|
-
this.killSession(chatId
|
|
350
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
351
|
+
this.killSession(chatId);
|
|
422
352
|
}
|
|
423
353
|
}
|
|
424
354
|
catch (err) {
|
|
425
355
|
console.error(`[voice:${chatId}] error:`, err.message);
|
|
426
|
-
await this.
|
|
356
|
+
await this.bot.sendMessage(chatId, `Voice transcription failed: ${err.message}`);
|
|
427
357
|
}
|
|
428
358
|
}
|
|
429
|
-
async handlePhoto(chatId, msg
|
|
359
|
+
async handlePhoto(chatId, msg) {
|
|
430
360
|
// Pick highest resolution photo
|
|
431
361
|
const photos = msg.photo;
|
|
432
362
|
const best = photos[photos.length - 1];
|
|
433
363
|
const caption = msg.caption?.trim();
|
|
434
364
|
console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
|
|
435
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
365
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
436
366
|
try {
|
|
437
367
|
const fileLink = await this.bot.getFileLink(best.file_id);
|
|
438
368
|
const imageData = await fetchAsBase64(fileLink);
|
|
439
369
|
// Telegram photos are always JPEG
|
|
440
|
-
const session = this.getOrCreateSession(chatId
|
|
370
|
+
const session = this.getOrCreateSession(chatId);
|
|
441
371
|
session.claude.sendImage(imageData, "image/jpeg", caption);
|
|
442
372
|
this.startTyping(chatId, session);
|
|
443
373
|
}
|
|
444
374
|
catch (err) {
|
|
445
375
|
console.error(`[photo:${chatId}] error:`, err.message);
|
|
446
|
-
await this.
|
|
376
|
+
await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
|
|
447
377
|
}
|
|
448
378
|
}
|
|
449
|
-
async handleDocument(chatId, msg
|
|
379
|
+
async handleDocument(chatId, msg) {
|
|
450
380
|
const doc = msg.document;
|
|
451
381
|
const caption = msg.caption?.trim();
|
|
452
382
|
const fileName = doc.file_name ?? `file_${doc.file_id}`;
|
|
453
383
|
console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
|
|
454
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
384
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
455
385
|
try {
|
|
456
386
|
const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
|
|
457
387
|
mkdirSync(uploadsDir, { recursive: true });
|
|
@@ -462,34 +392,22 @@ export class CcTgBot {
|
|
|
462
392
|
const prompt = caption
|
|
463
393
|
? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
|
|
464
394
|
: `ATTACHMENTS: [${fileName}](${destPath})`;
|
|
465
|
-
const session = this.getOrCreateSession(chatId
|
|
395
|
+
const session = this.getOrCreateSession(chatId);
|
|
466
396
|
session.claude.sendPrompt(prompt);
|
|
467
397
|
this.startTyping(chatId, session);
|
|
468
398
|
}
|
|
469
399
|
catch (err) {
|
|
470
400
|
console.error(`[doc:${chatId}] error:`, err.message);
|
|
471
|
-
await this.
|
|
401
|
+
await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
|
|
472
402
|
}
|
|
473
403
|
}
|
|
474
|
-
getOrCreateSession(chatId
|
|
475
|
-
const
|
|
476
|
-
const existing = this.sessions.get(key);
|
|
404
|
+
getOrCreateSession(chatId) {
|
|
405
|
+
const existing = this.sessions.get(chatId);
|
|
477
406
|
if (existing && !existing.claude.exited)
|
|
478
407
|
return existing;
|
|
479
|
-
// Determine CWD for this thread ā check THREAD_CWD_MAP by name then by ID
|
|
480
|
-
let sessionCwd = this.opts.cwd;
|
|
481
|
-
const threadCwdMap = this.getThreadCwdMap();
|
|
482
|
-
if (threadName && threadCwdMap[threadName]) {
|
|
483
|
-
sessionCwd = threadCwdMap[threadName];
|
|
484
|
-
console.log(`[cc-tg] thread "${threadName}" ā cwd: ${sessionCwd}`);
|
|
485
|
-
}
|
|
486
|
-
else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
|
|
487
|
-
sessionCwd = threadCwdMap[String(threadId)];
|
|
488
|
-
console.log(`[cc-tg] thread ${threadId} ā cwd: ${sessionCwd}`);
|
|
489
|
-
}
|
|
490
408
|
const claude = new ClaudeProcess({
|
|
491
|
-
cwd:
|
|
492
|
-
token:
|
|
409
|
+
cwd: this.opts.cwd,
|
|
410
|
+
token: this.opts.claudeToken,
|
|
493
411
|
});
|
|
494
412
|
const session = {
|
|
495
413
|
claude,
|
|
@@ -499,7 +417,6 @@ export class CcTgBot {
|
|
|
499
417
|
writtenFiles: new Set(),
|
|
500
418
|
currentPrompt: "",
|
|
501
419
|
isRetry: false,
|
|
502
|
-
threadId,
|
|
503
420
|
};
|
|
504
421
|
claude.on("usage", (usage) => {
|
|
505
422
|
this.costStore.addUsage(chatId, usage);
|
|
@@ -508,47 +425,33 @@ export class CcTgBot {
|
|
|
508
425
|
// Verbose logging ā log every message type and subtype
|
|
509
426
|
const subtype = msg.payload.subtype ?? "";
|
|
510
427
|
const toolName = this.extractToolName(msg);
|
|
511
|
-
const logParts = [`[claude:${
|
|
428
|
+
const logParts = [`[claude:${chatId}] msg=${msg.type}`];
|
|
512
429
|
if (subtype)
|
|
513
430
|
logParts.push(`subtype=${subtype}`);
|
|
514
431
|
if (toolName)
|
|
515
432
|
logParts.push(`tool=${toolName}`);
|
|
516
433
|
console.log(logParts.join(" "));
|
|
517
434
|
// Track files written by Write/Edit tool calls
|
|
518
|
-
this.trackWrittenFiles(msg, session,
|
|
519
|
-
// Publish tool call events to the chat log
|
|
520
|
-
if (msg.type === "assistant") {
|
|
521
|
-
const message = msg.payload.message;
|
|
522
|
-
const content = message?.content;
|
|
523
|
-
if (Array.isArray(content)) {
|
|
524
|
-
for (const block of content) {
|
|
525
|
-
if (block.type !== "tool_use")
|
|
526
|
-
continue;
|
|
527
|
-
const name = block.name;
|
|
528
|
-
const input = block.input;
|
|
529
|
-
this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {}).slice(0, 120)}`, chatId);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
435
|
+
this.trackWrittenFiles(msg, session, this.opts.cwd);
|
|
533
436
|
this.handleClaudeMessage(chatId, session, msg);
|
|
534
437
|
});
|
|
535
438
|
claude.on("stderr", (data) => {
|
|
536
439
|
const line = data.trim();
|
|
537
440
|
if (line)
|
|
538
|
-
console.error(`[claude:${
|
|
441
|
+
console.error(`[claude:${chatId}:stderr]`, line);
|
|
539
442
|
});
|
|
540
443
|
claude.on("exit", (code) => {
|
|
541
|
-
console.log(`[claude:${
|
|
444
|
+
console.log(`[claude:${chatId}] exited code=${code}`);
|
|
542
445
|
this.stopTyping(session);
|
|
543
|
-
this.sessions.delete(
|
|
446
|
+
this.sessions.delete(chatId);
|
|
544
447
|
});
|
|
545
448
|
claude.on("error", (err) => {
|
|
546
|
-
console.error(`[claude:${
|
|
449
|
+
console.error(`[claude:${chatId}] process error: ${err.message}`);
|
|
547
450
|
this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
|
|
548
451
|
this.stopTyping(session);
|
|
549
|
-
this.sessions.delete(
|
|
452
|
+
this.sessions.delete(chatId);
|
|
550
453
|
});
|
|
551
|
-
this.sessions.set(
|
|
454
|
+
this.sessions.set(chatId, session);
|
|
552
455
|
return session;
|
|
553
456
|
}
|
|
554
457
|
handleClaudeMessage(chatId, session, msg) {
|
|
@@ -564,58 +467,33 @@ export class CcTgBot {
|
|
|
564
467
|
// Check for usage/rate limit signals before forwarding to Telegram
|
|
565
468
|
const sig = detectUsageLimit(text);
|
|
566
469
|
if (sig.detected) {
|
|
567
|
-
const threadId = session.threadId;
|
|
568
|
-
const retryKey = this.sessionKey(chatId, threadId);
|
|
569
470
|
const lastPrompt = session.currentPrompt;
|
|
570
|
-
const prevRetry = this.pendingRetries.get(
|
|
471
|
+
const prevRetry = this.pendingRetries.get(chatId);
|
|
571
472
|
const attempt = (prevRetry?.attempt ?? 0) + 1;
|
|
572
473
|
if (prevRetry)
|
|
573
474
|
clearTimeout(prevRetry.timer);
|
|
574
|
-
this.
|
|
575
|
-
this.killSession(chatId
|
|
576
|
-
// Token rotation: if this is a usage_exhausted signal and we have multiple
|
|
577
|
-
// tokens, rotate to the next one and retry immediately instead of sleeping.
|
|
578
|
-
// Only rotate if we haven't yet cycled through all tokens (attempt <= count-1).
|
|
579
|
-
if (sig.reason === "usage_exhausted" && getTokenCount() > 1 && attempt <= getTokenCount() - 1) {
|
|
580
|
-
const prevIdx = getTokenIndex();
|
|
581
|
-
rotateToken();
|
|
582
|
-
const newIdx = getTokenIndex();
|
|
583
|
-
const total = getTokenCount();
|
|
584
|
-
console.log(`[cc-tg] Token ${prevIdx + 1}/${total} exhausted, rotating to token ${newIdx + 1}/${total}`);
|
|
585
|
-
this.replyToChat(chatId, `š Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`, threadId).catch(() => { });
|
|
586
|
-
this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
|
|
587
|
-
try {
|
|
588
|
-
const retrySession = this.getOrCreateSession(chatId, threadId);
|
|
589
|
-
retrySession.currentPrompt = lastPrompt;
|
|
590
|
-
retrySession.isRetry = true;
|
|
591
|
-
retrySession.claude.sendPrompt(lastPrompt);
|
|
592
|
-
this.startTyping(chatId, retrySession);
|
|
593
|
-
}
|
|
594
|
-
catch (err) {
|
|
595
|
-
this.replyToChat(chatId, `ā Failed to retry with rotated token: ${err.message}`, threadId).catch(() => { });
|
|
596
|
-
}
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
475
|
+
this.bot.sendMessage(chatId, sig.humanMessage).catch(() => { });
|
|
476
|
+
this.killSession(chatId);
|
|
599
477
|
if (attempt > 3) {
|
|
600
|
-
this.
|
|
601
|
-
this.pendingRetries.delete(
|
|
478
|
+
this.bot.sendMessage(chatId, "ā Claude usage limit persists after 3 retries. Please try again later.").catch(() => { });
|
|
479
|
+
this.pendingRetries.delete(chatId);
|
|
602
480
|
return;
|
|
603
481
|
}
|
|
604
|
-
console.log(`[usage-limit:${
|
|
482
|
+
console.log(`[usage-limit:${chatId}] ${sig.reason} ā scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
|
|
605
483
|
const timer = setTimeout(() => {
|
|
606
|
-
this.pendingRetries.delete(
|
|
484
|
+
this.pendingRetries.delete(chatId);
|
|
607
485
|
try {
|
|
608
|
-
const retrySession = this.getOrCreateSession(chatId
|
|
486
|
+
const retrySession = this.getOrCreateSession(chatId);
|
|
609
487
|
retrySession.currentPrompt = lastPrompt;
|
|
610
488
|
retrySession.isRetry = true;
|
|
611
489
|
retrySession.claude.sendPrompt(lastPrompt);
|
|
612
490
|
this.startTyping(chatId, retrySession);
|
|
613
491
|
}
|
|
614
492
|
catch (err) {
|
|
615
|
-
this.
|
|
493
|
+
this.bot.sendMessage(chatId, `ā Failed to retry: ${err.message}`).catch(() => { });
|
|
616
494
|
}
|
|
617
495
|
}, sig.retryAfterMs);
|
|
618
|
-
this.pendingRetries.set(
|
|
496
|
+
this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer });
|
|
619
497
|
return;
|
|
620
498
|
}
|
|
621
499
|
// Accumulate text and debounce ā Claude streams chunks rapidly
|
|
@@ -627,11 +505,9 @@ export class CcTgBot {
|
|
|
627
505
|
startTyping(chatId, session) {
|
|
628
506
|
this.stopTyping(session);
|
|
629
507
|
// Send immediately, then keep alive every 4s
|
|
630
|
-
|
|
631
|
-
const threadOpts = session.threadId !== undefined ? { message_thread_id: session.threadId } : undefined;
|
|
632
|
-
this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
|
|
508
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
633
509
|
session.typingTimer = setInterval(() => {
|
|
634
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
510
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
635
511
|
}, TYPING_INTERVAL_MS);
|
|
636
512
|
}
|
|
637
513
|
stopTyping(session) {
|
|
@@ -646,17 +522,15 @@ export class CcTgBot {
|
|
|
646
522
|
session.flushTimer = null;
|
|
647
523
|
if (!raw)
|
|
648
524
|
return;
|
|
649
|
-
this.writeChatMessage("assistant", "cc-tg", raw, chatId);
|
|
650
525
|
const text = session.isRetry ? `ā
Claude is back!\n\n${raw}` : raw;
|
|
651
526
|
session.isRetry = false;
|
|
652
|
-
// Format for Telegram
|
|
527
|
+
// Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
|
|
653
528
|
const formatted = formatForTelegram(text);
|
|
654
529
|
const chunks = splitLongMessage(formatted);
|
|
655
|
-
const threadId = session.threadId;
|
|
656
530
|
for (const chunk of chunks) {
|
|
657
|
-
this.
|
|
658
|
-
//
|
|
659
|
-
this.
|
|
531
|
+
this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" }).catch(() => {
|
|
532
|
+
// MarkdownV2 parse failed ā retry as plain text
|
|
533
|
+
this.bot.sendMessage(chatId, chunk).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
|
|
660
534
|
});
|
|
661
535
|
}
|
|
662
536
|
// Hybrid file upload: find files mentioned in result text that Claude actually wrote
|
|
@@ -829,12 +703,11 @@ export class CcTgBot {
|
|
|
829
703
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
830
704
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
831
705
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
832
|
-
this.
|
|
706
|
+
this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`).catch(() => { });
|
|
833
707
|
continue;
|
|
834
708
|
}
|
|
835
709
|
console.log(`[claude:files] uploading to telegram: ${filePath}`);
|
|
836
|
-
|
|
837
|
-
this.bot.sendDocument(chatId, filePath, docOpts).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
|
|
710
|
+
this.bot.sendDocument(chatId, filePath).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
|
|
838
711
|
}
|
|
839
712
|
// Clear written files for next turn
|
|
840
713
|
session.writtenFiles.clear();
|
|
@@ -849,6 +722,203 @@ export class CcTgBot {
|
|
|
849
722
|
const toolUse = content.find((b) => b.type === "tool_use");
|
|
850
723
|
return toolUse?.name ?? "";
|
|
851
724
|
}
|
|
725
|
+
runCronTask(chatId, prompt) {
|
|
726
|
+
// Fresh isolated Claude session ā never touches main conversation
|
|
727
|
+
const cronProcess = new ClaudeProcess({
|
|
728
|
+
cwd: this.opts.cwd,
|
|
729
|
+
token: this.opts.claudeToken,
|
|
730
|
+
});
|
|
731
|
+
const taskPrompt = [
|
|
732
|
+
"You are handling a scheduled background task.",
|
|
733
|
+
"This is NOT part of the user's ongoing conversation.",
|
|
734
|
+
"Be concise. Report results only. No greetings or pleasantries.",
|
|
735
|
+
"If there is nothing to report, say so in one sentence.",
|
|
736
|
+
"",
|
|
737
|
+
`SCHEDULED TASK: ${prompt}`,
|
|
738
|
+
].join("\n");
|
|
739
|
+
let output = "";
|
|
740
|
+
const cronUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
741
|
+
cronProcess.on("usage", (usage) => {
|
|
742
|
+
cronUsage.inputTokens += usage.inputTokens;
|
|
743
|
+
cronUsage.outputTokens += usage.outputTokens;
|
|
744
|
+
cronUsage.cacheReadTokens += usage.cacheReadTokens;
|
|
745
|
+
cronUsage.cacheWriteTokens += usage.cacheWriteTokens;
|
|
746
|
+
});
|
|
747
|
+
cronProcess.on("message", (msg) => {
|
|
748
|
+
if (msg.type === "result") {
|
|
749
|
+
const text = extractText(msg);
|
|
750
|
+
if (text)
|
|
751
|
+
output += text;
|
|
752
|
+
const result = output.trim();
|
|
753
|
+
if (result) {
|
|
754
|
+
let footer = "";
|
|
755
|
+
try {
|
|
756
|
+
footer = formatCronCostFooter(cronUsage);
|
|
757
|
+
}
|
|
758
|
+
catch (err) {
|
|
759
|
+
console.error(`[cron] cost footer error:`, err.message);
|
|
760
|
+
}
|
|
761
|
+
const cronFormatted = formatForTelegram(`š ${result}${footer}`);
|
|
762
|
+
const chunks = splitLongMessage(cronFormatted);
|
|
763
|
+
(async () => {
|
|
764
|
+
for (const chunk of chunks) {
|
|
765
|
+
try {
|
|
766
|
+
await this.bot.sendMessage(chatId, chunk, { parse_mode: "MarkdownV2" });
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
// MarkdownV2 parse failed ā retry as plain text
|
|
770
|
+
try {
|
|
771
|
+
await this.bot.sendMessage(chatId, chunk);
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
console.error(`[cron] failed to send result to chat=${chatId}:`, err.message);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
})();
|
|
779
|
+
}
|
|
780
|
+
cronProcess.kill();
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
cronProcess.on("error", (err) => {
|
|
784
|
+
console.error(`[cron] task error for chat=${chatId}:`, err.message);
|
|
785
|
+
cronProcess.kill();
|
|
786
|
+
});
|
|
787
|
+
cronProcess.on("exit", () => {
|
|
788
|
+
console.log(`[cron] task complete for chat=${chatId}`);
|
|
789
|
+
});
|
|
790
|
+
cronProcess.sendPrompt(taskPrompt);
|
|
791
|
+
}
|
|
792
|
+
async handleCron(chatId, text) {
|
|
793
|
+
const args = text.slice("/cron".length).trim();
|
|
794
|
+
// /cron list
|
|
795
|
+
if (args === "list" || args === "") {
|
|
796
|
+
const jobs = this.cron.list(chatId);
|
|
797
|
+
if (!jobs.length) {
|
|
798
|
+
await this.bot.sendMessage(chatId, "No cron jobs.");
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
const lines = jobs.map((j, i) => {
|
|
802
|
+
const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "ā¦" : j.prompt;
|
|
803
|
+
return `#${i + 1} ${j.schedule} ā "${short}"`;
|
|
804
|
+
});
|
|
805
|
+
await this.bot.sendMessage(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
// /cron clear
|
|
809
|
+
if (args === "clear") {
|
|
810
|
+
const n = this.cron.clearAll(chatId);
|
|
811
|
+
await this.bot.sendMessage(chatId, `Cleared ${n} cron job(s).`);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
// /cron remove <id>
|
|
815
|
+
if (args.startsWith("remove ")) {
|
|
816
|
+
const id = args.slice("remove ".length).trim();
|
|
817
|
+
const ok = this.cron.remove(chatId, id);
|
|
818
|
+
await this.bot.sendMessage(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
// /cron edit [<#> ...]
|
|
822
|
+
if (args === "edit" || args.startsWith("edit ")) {
|
|
823
|
+
await this.handleCronEdit(chatId, args.slice("edit".length).trim());
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
// /cron every 1h <prompt>
|
|
827
|
+
const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
828
|
+
if (!scheduleMatch) {
|
|
829
|
+
await this.bot.sendMessage(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron edit\n/cron remove <id>\n/cron clear");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const schedule = scheduleMatch[1];
|
|
833
|
+
const prompt = scheduleMatch[2];
|
|
834
|
+
const job = this.cron.add(chatId, schedule, prompt);
|
|
835
|
+
if (!job) {
|
|
836
|
+
await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
await this.bot.sendMessage(chatId, `Cron set [${job.id}]: ${schedule} ā "${prompt}"`);
|
|
840
|
+
}
|
|
841
|
+
async handleCronEdit(chatId, editArgs) {
|
|
842
|
+
const jobs = this.cron.list(chatId);
|
|
843
|
+
// No args ā show numbered list with edit instructions
|
|
844
|
+
if (!editArgs) {
|
|
845
|
+
if (!jobs.length) {
|
|
846
|
+
await this.bot.sendMessage(chatId, "No cron jobs to edit.");
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const lines = jobs.map((j, i) => {
|
|
850
|
+
const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "ā¦" : j.prompt;
|
|
851
|
+
return `#${i + 1} ${j.schedule} ā "${short}"`;
|
|
852
|
+
});
|
|
853
|
+
await this.bot.sendMessage(chatId, `Cron jobs:\n${lines.join("\n")}\n\n` +
|
|
854
|
+
"Edit options:\n" +
|
|
855
|
+
"/cron edit <#> every <N><unit> <new prompt>\n" +
|
|
856
|
+
"/cron edit <#> schedule every <N><unit>\n" +
|
|
857
|
+
"/cron edit <#> prompt <new prompt>");
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
// Expect: <index> <rest>
|
|
861
|
+
const indexMatch = editArgs.match(/^(\d+)\s+(.+)$/);
|
|
862
|
+
if (!indexMatch) {
|
|
863
|
+
await this.bot.sendMessage(chatId, "Usage: /cron edit <#> every <N><unit> <new prompt>");
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const index = parseInt(indexMatch[1], 10) - 1;
|
|
867
|
+
if (index < 0 || index >= jobs.length) {
|
|
868
|
+
await this.bot.sendMessage(chatId, `Invalid job number. Use /cron edit to see the list.`);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const job = jobs[index];
|
|
872
|
+
const editCmd = indexMatch[2];
|
|
873
|
+
// /cron edit <#> schedule every <N><unit>
|
|
874
|
+
if (editCmd.startsWith("schedule ")) {
|
|
875
|
+
const newSchedule = editCmd.slice("schedule ".length).trim();
|
|
876
|
+
const result = this.cron.update(chatId, job.id, { schedule: newSchedule });
|
|
877
|
+
if (result === null) {
|
|
878
|
+
await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
|
|
879
|
+
}
|
|
880
|
+
else if (result === false) {
|
|
881
|
+
await this.bot.sendMessage(chatId, "Job not found.");
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
await this.bot.sendMessage(chatId, `#${index + 1} schedule updated to ${newSchedule}.`);
|
|
885
|
+
}
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
// /cron edit <#> prompt <new-prompt>
|
|
889
|
+
if (editCmd.startsWith("prompt ")) {
|
|
890
|
+
const newPrompt = editCmd.slice("prompt ".length).trim();
|
|
891
|
+
const result = this.cron.update(chatId, job.id, { prompt: newPrompt });
|
|
892
|
+
if (result === false) {
|
|
893
|
+
await this.bot.sendMessage(chatId, "Job not found.");
|
|
894
|
+
}
|
|
895
|
+
else {
|
|
896
|
+
await this.bot.sendMessage(chatId, `#${index + 1} prompt updated to "${newPrompt}".`);
|
|
897
|
+
}
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
// /cron edit <#> every <N><unit> <new-prompt>
|
|
901
|
+
const fullMatch = editCmd.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
902
|
+
if (fullMatch) {
|
|
903
|
+
const newSchedule = fullMatch[1];
|
|
904
|
+
const newPrompt = fullMatch[2];
|
|
905
|
+
const result = this.cron.update(chatId, job.id, { schedule: newSchedule, prompt: newPrompt });
|
|
906
|
+
if (result === null) {
|
|
907
|
+
await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
|
|
908
|
+
}
|
|
909
|
+
else if (result === false) {
|
|
910
|
+
await this.bot.sendMessage(chatId, "Job not found.");
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
await this.bot.sendMessage(chatId, `#${index + 1} updated: ${newSchedule} ā "${newPrompt}"`);
|
|
914
|
+
}
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
await this.bot.sendMessage(chatId, "Edit options:\n" +
|
|
918
|
+
"/cron edit <#> every <N><unit> <new prompt>\n" +
|
|
919
|
+
"/cron edit <#> schedule every <N><unit>\n" +
|
|
920
|
+
"/cron edit <#> prompt <new prompt>");
|
|
921
|
+
}
|
|
852
922
|
/** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
|
|
853
923
|
findCcAgentPids() {
|
|
854
924
|
try {
|
|
@@ -874,33 +944,33 @@ export class CcTgBot {
|
|
|
874
944
|
}
|
|
875
945
|
return pids;
|
|
876
946
|
}
|
|
877
|
-
async handleReloadMcp(chatId
|
|
878
|
-
await this.
|
|
947
|
+
async handleReloadMcp(chatId) {
|
|
948
|
+
await this.bot.sendMessage(chatId, "Clearing npx cache and reloading MCP...");
|
|
879
949
|
try {
|
|
880
950
|
const home = process.env.HOME ?? "~";
|
|
881
951
|
execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
|
|
882
952
|
console.log("[mcp] cleared ~/.npm/_npx/");
|
|
883
953
|
}
|
|
884
954
|
catch (err) {
|
|
885
|
-
await this.
|
|
955
|
+
await this.bot.sendMessage(chatId, `Warning: failed to clear npx cache: ${err.message}`);
|
|
886
956
|
}
|
|
887
957
|
const pids = this.killCcAgent();
|
|
888
958
|
if (pids.length === 0) {
|
|
889
|
-
await this.
|
|
959
|
+
await this.bot.sendMessage(chatId, "NPX cache cleared. No cc-agent process found ā MCP will start fresh on the next agent call.");
|
|
890
960
|
return;
|
|
891
961
|
}
|
|
892
|
-
await this.
|
|
962
|
+
await this.bot.sendMessage(chatId, `NPX cache cleared. Sent SIGTERM to cc-agent (pid${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}).\nMCP restarted. New process will load on next agent call.`);
|
|
893
963
|
}
|
|
894
|
-
async handleMcpStatus(chatId
|
|
964
|
+
async handleMcpStatus(chatId) {
|
|
895
965
|
try {
|
|
896
966
|
const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
|
|
897
|
-
await this.
|
|
967
|
+
await this.bot.sendMessage(chatId, `MCP server status:\n\n${output || "(no output)"}`);
|
|
898
968
|
}
|
|
899
969
|
catch (err) {
|
|
900
|
-
await this.
|
|
970
|
+
await this.bot.sendMessage(chatId, `Failed to run claude mcp list: ${err.message}`);
|
|
901
971
|
}
|
|
902
972
|
}
|
|
903
|
-
async handleMcpVersion(chatId
|
|
973
|
+
async handleMcpVersion(chatId) {
|
|
904
974
|
let npmVersion = "unknown";
|
|
905
975
|
let cacheEntries = "(unavailable)";
|
|
906
976
|
try {
|
|
@@ -917,9 +987,9 @@ export class CcTgBot {
|
|
|
917
987
|
catch {
|
|
918
988
|
cacheEntries = "(empty or not found)";
|
|
919
989
|
}
|
|
920
|
-
await this.
|
|
990
|
+
await this.bot.sendMessage(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`);
|
|
921
991
|
}
|
|
922
|
-
async handleClearNpxCache(chatId
|
|
992
|
+
async handleClearNpxCache(chatId) {
|
|
923
993
|
const home = process.env.HOME ?? "/tmp";
|
|
924
994
|
const cleared = [];
|
|
925
995
|
const failed = [];
|
|
@@ -942,10 +1012,10 @@ export class CcTgBot {
|
|
|
942
1012
|
const clearNote = failed.length
|
|
943
1013
|
? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
|
|
944
1014
|
: `Cleared: ${cleared.join(", ")}.`;
|
|
945
|
-
await this.
|
|
1015
|
+
await this.bot.sendMessage(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`);
|
|
946
1016
|
}
|
|
947
|
-
async handleRestart(chatId
|
|
948
|
-
await this.
|
|
1017
|
+
async handleRestart(chatId) {
|
|
1018
|
+
await this.bot.sendMessage(chatId, "Clearing cache and restarting... brb.");
|
|
949
1019
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
950
1020
|
// Clear npm caches before restart so launchd brings up fresh version
|
|
951
1021
|
const home = process.env.HOME ?? "/tmp";
|
|
@@ -956,48 +1026,45 @@ export class CcTgBot {
|
|
|
956
1026
|
catch { }
|
|
957
1027
|
}
|
|
958
1028
|
// Kill all active Claude sessions cleanly
|
|
959
|
-
for (const
|
|
960
|
-
this.
|
|
961
|
-
session.claude.kill();
|
|
1029
|
+
for (const [cid] of this.sessions) {
|
|
1030
|
+
this.killSession(cid);
|
|
962
1031
|
}
|
|
963
|
-
this.sessions.clear();
|
|
964
1032
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
965
1033
|
process.exit(0);
|
|
966
1034
|
}
|
|
967
|
-
async handleGetFile(chatId, text
|
|
1035
|
+
async handleGetFile(chatId, text) {
|
|
968
1036
|
const arg = text.slice("/get_file".length).trim();
|
|
969
1037
|
if (!arg) {
|
|
970
|
-
await this.
|
|
1038
|
+
await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
|
|
971
1039
|
return;
|
|
972
1040
|
}
|
|
973
1041
|
const filePath = resolve(arg);
|
|
974
1042
|
const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
|
|
975
1043
|
const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
|
|
976
1044
|
if (!inSafeDir) {
|
|
977
|
-
await this.
|
|
1045
|
+
await this.bot.sendMessage(chatId, "Access denied: path not in allowed directories");
|
|
978
1046
|
return;
|
|
979
1047
|
}
|
|
980
1048
|
if (!existsSync(filePath)) {
|
|
981
|
-
await this.
|
|
1049
|
+
await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
|
|
982
1050
|
return;
|
|
983
1051
|
}
|
|
984
1052
|
if (!statSync(filePath).isFile()) {
|
|
985
|
-
await this.
|
|
1053
|
+
await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
|
|
986
1054
|
return;
|
|
987
1055
|
}
|
|
988
1056
|
if (this.isSensitiveFile(filePath)) {
|
|
989
|
-
await this.
|
|
1057
|
+
await this.bot.sendMessage(chatId, "Access denied: sensitive file");
|
|
990
1058
|
return;
|
|
991
1059
|
}
|
|
992
1060
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
993
1061
|
const fileSize = statSync(filePath).size;
|
|
994
1062
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
995
1063
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
996
|
-
await this.
|
|
1064
|
+
await this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`);
|
|
997
1065
|
return;
|
|
998
1066
|
}
|
|
999
|
-
|
|
1000
|
-
await this.bot.sendDocument(chatId, filePath, docOpts);
|
|
1067
|
+
await this.bot.sendDocument(chatId, filePath);
|
|
1001
1068
|
}
|
|
1002
1069
|
callCcAgentTool(toolName, args = {}) {
|
|
1003
1070
|
return new Promise((resolve) => {
|
|
@@ -1070,25 +1137,21 @@ export class CcTgBot {
|
|
|
1070
1137
|
proc.on("exit", () => { clearTimeout(timeout); done(null); });
|
|
1071
1138
|
});
|
|
1072
1139
|
}
|
|
1073
|
-
killSession(chatId,
|
|
1074
|
-
const
|
|
1075
|
-
const session = this.sessions.get(key);
|
|
1140
|
+
killSession(chatId, keepCrons = true) {
|
|
1141
|
+
const session = this.sessions.get(chatId);
|
|
1076
1142
|
if (session) {
|
|
1077
1143
|
this.stopTyping(session);
|
|
1078
1144
|
session.claude.kill();
|
|
1079
|
-
this.sessions.delete(
|
|
1145
|
+
this.sessions.delete(chatId);
|
|
1080
1146
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
return this.bot.getMe();
|
|
1147
|
+
if (!keepCrons)
|
|
1148
|
+
this.cron.clearAll(chatId);
|
|
1084
1149
|
}
|
|
1085
1150
|
stop() {
|
|
1086
1151
|
this.bot.stopPolling();
|
|
1087
|
-
for (const
|
|
1088
|
-
this.
|
|
1089
|
-
session.claude.kill();
|
|
1152
|
+
for (const [chatId] of this.sessions) {
|
|
1153
|
+
this.killSession(chatId);
|
|
1090
1154
|
}
|
|
1091
|
-
this.sessions.clear();
|
|
1092
1155
|
}
|
|
1093
1156
|
}
|
|
1094
1157
|
function buildPromptWithReplyContext(text, msg) {
|
|
@@ -1127,85 +1190,6 @@ function downloadToFile(url, destPath) {
|
|
|
1127
1190
|
}).on("error", reject);
|
|
1128
1191
|
});
|
|
1129
1192
|
}
|
|
1130
|
-
/** Fetch URL via Jina Reader and return first maxChars characters */
|
|
1131
|
-
function fetchUrlViaJina(url, maxChars = 2000) {
|
|
1132
|
-
const jinaUrl = `https://r.jina.ai/${url}`;
|
|
1133
|
-
return new Promise((resolve, reject) => {
|
|
1134
|
-
https.get(jinaUrl, (res) => {
|
|
1135
|
-
const chunks = [];
|
|
1136
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
1137
|
-
res.on("end", () => {
|
|
1138
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
1139
|
-
resolve(text.slice(0, maxChars));
|
|
1140
|
-
});
|
|
1141
|
-
res.on("error", reject);
|
|
1142
|
-
}).on("error", reject);
|
|
1143
|
-
});
|
|
1144
|
-
}
|
|
1145
|
-
/** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
|
|
1146
|
-
export async function enrichPromptWithUrls(text) {
|
|
1147
|
-
const urlRegex = /https?:\/\/[^\s]+/g;
|
|
1148
|
-
const urls = text.match(urlRegex);
|
|
1149
|
-
if (!urls || urls.length === 0)
|
|
1150
|
-
return text;
|
|
1151
|
-
const prefixes = [];
|
|
1152
|
-
for (const url of urls) {
|
|
1153
|
-
// Skip jina.ai URLs to avoid recursion
|
|
1154
|
-
if (url.includes("r.jina.ai"))
|
|
1155
|
-
continue;
|
|
1156
|
-
try {
|
|
1157
|
-
const content = await fetchUrlViaJina(url);
|
|
1158
|
-
if (content.trim()) {
|
|
1159
|
-
prefixes.push(`[Web content from ${url}]:\n${content}`);
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
catch (err) {
|
|
1163
|
-
console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
if (prefixes.length === 0)
|
|
1167
|
-
return text;
|
|
1168
|
-
return prefixes.join("\n\n") + "\n\n" + text;
|
|
1169
|
-
}
|
|
1170
|
-
/** Parse frontmatter description from a skill markdown file */
|
|
1171
|
-
function parseSkillDescription(content) {
|
|
1172
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
1173
|
-
if (!match)
|
|
1174
|
-
return null;
|
|
1175
|
-
const frontmatter = match[1];
|
|
1176
|
-
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
1177
|
-
return descMatch ? descMatch[1].trim() : null;
|
|
1178
|
-
}
|
|
1179
|
-
/** List available skills from ~/.claude/skills/ */
|
|
1180
|
-
export function listSkills() {
|
|
1181
|
-
const skillsDir = join(os.homedir(), ".claude", "skills");
|
|
1182
|
-
if (!existsSync(skillsDir)) {
|
|
1183
|
-
return "No skills directory found at ~/.claude/skills/";
|
|
1184
|
-
}
|
|
1185
|
-
let files;
|
|
1186
|
-
try {
|
|
1187
|
-
files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
|
|
1188
|
-
}
|
|
1189
|
-
catch {
|
|
1190
|
-
return "Could not read skills directory.";
|
|
1191
|
-
}
|
|
1192
|
-
if (files.length === 0) {
|
|
1193
|
-
return "No skills found in ~/.claude/skills/";
|
|
1194
|
-
}
|
|
1195
|
-
const lines = ["Available skills:"];
|
|
1196
|
-
for (const file of files.sort()) {
|
|
1197
|
-
const name = "/" + file.replace(/\.md$/, "");
|
|
1198
|
-
try {
|
|
1199
|
-
const content = readFileSync(join(skillsDir, file), "utf8");
|
|
1200
|
-
const description = parseSkillDescription(content);
|
|
1201
|
-
lines.push(description ? `${name} ā ${description}` : name);
|
|
1202
|
-
}
|
|
1203
|
-
catch {
|
|
1204
|
-
lines.push(name);
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
return lines.join("\n");
|
|
1208
|
-
}
|
|
1209
1193
|
export function splitMessage(text, maxLen = 4096) {
|
|
1210
1194
|
if (text.length <= maxLen)
|
|
1211
1195
|
return [text];
|