@gonzih/cc-tg 0.9.1 ā 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 -31
- package/dist/bot.js +312 -335
- 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 -62
- package/dist/usage-limit.js +3 -2
- package/package.json +3 -4
- package/dist/notifier.d.ts +0 -37
- package/dist/notifier.js +0 -124
- 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,16 +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
|
-
lastActiveChatId;
|
|
160
161
|
constructor(opts) {
|
|
161
162
|
this.opts = opts;
|
|
162
|
-
this.redis = opts.redis;
|
|
163
|
-
this.namespace = opts.namespace ?? "default";
|
|
164
163
|
this.bot = new TelegramBot(opts.telegramToken, { polling: true });
|
|
165
164
|
this.bot.on("message", (msg) => this.handleTelegram(msg));
|
|
166
165
|
this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
|
|
@@ -169,6 +168,10 @@ export class CcTgBot {
|
|
|
169
168
|
this.botId = me.id;
|
|
170
169
|
console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
|
|
171
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
|
+
});
|
|
172
175
|
this.costStore = new CostStore(opts.cwd ?? process.cwd());
|
|
173
176
|
this.registerBotCommands();
|
|
174
177
|
console.log("cc-tg bot started");
|
|
@@ -179,55 +182,6 @@ export class CcTgBot {
|
|
|
179
182
|
.then(() => console.log("[tg] bot commands registered"))
|
|
180
183
|
.catch((err) => console.error("[tg] setMyCommands failed:", err.message));
|
|
181
184
|
}
|
|
182
|
-
/** Write a message to the Redis chat log. Fire-and-forget ā no-op if Redis is not configured. */
|
|
183
|
-
writeChatMessage(role, source, content, chatId) {
|
|
184
|
-
if (!this.redis)
|
|
185
|
-
return;
|
|
186
|
-
const msg = {
|
|
187
|
-
id: `${source}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
188
|
-
source,
|
|
189
|
-
role,
|
|
190
|
-
content,
|
|
191
|
-
timestamp: new Date().toISOString(),
|
|
192
|
-
chatId,
|
|
193
|
-
};
|
|
194
|
-
writeChatLog(this.redis, this.namespace, msg);
|
|
195
|
-
}
|
|
196
|
-
/** Returns the last chatId that sent a message ā used by the chat bridge when no fixed chatId is configured. */
|
|
197
|
-
getLastActiveChatId() {
|
|
198
|
-
return this.lastActiveChatId;
|
|
199
|
-
}
|
|
200
|
-
/** Session key: "chatId:threadId" for topics, "chatId:main" for DMs/non-topic groups */
|
|
201
|
-
sessionKey(chatId, threadId) {
|
|
202
|
-
return `${chatId}:${threadId ?? 'main'}`;
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Send a message back to the correct thread (or plain chat if no thread).
|
|
206
|
-
* When threadId is undefined, calls sendMessage with exactly 2 args to preserve
|
|
207
|
-
* backward-compatible call signatures (no extra options object).
|
|
208
|
-
*/
|
|
209
|
-
replyToChat(chatId, text, threadId, opts) {
|
|
210
|
-
if (threadId !== undefined) {
|
|
211
|
-
return this.bot.sendMessage(chatId, text, { ...opts, message_thread_id: threadId });
|
|
212
|
-
}
|
|
213
|
-
if (opts) {
|
|
214
|
-
return this.bot.sendMessage(chatId, text, opts);
|
|
215
|
-
}
|
|
216
|
-
return this.bot.sendMessage(chatId, text);
|
|
217
|
-
}
|
|
218
|
-
/** Parse THREAD_CWD_MAP env var ā maps thread name or thread_id to a CWD path */
|
|
219
|
-
getThreadCwdMap() {
|
|
220
|
-
const raw = process.env.THREAD_CWD_MAP;
|
|
221
|
-
if (!raw)
|
|
222
|
-
return {};
|
|
223
|
-
try {
|
|
224
|
-
return JSON.parse(raw);
|
|
225
|
-
}
|
|
226
|
-
catch {
|
|
227
|
-
console.warn('[cc-tg] THREAD_CWD_MAP is not valid JSON, ignoring');
|
|
228
|
-
return {};
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
185
|
isAllowed(userId) {
|
|
232
186
|
if (!this.opts.allowedUserIds?.length)
|
|
233
187
|
return true;
|
|
@@ -236,20 +190,10 @@ export class CcTgBot {
|
|
|
236
190
|
async handleTelegram(msg) {
|
|
237
191
|
const chatId = msg.chat.id;
|
|
238
192
|
const userId = msg.from?.id ?? chatId;
|
|
239
|
-
// Forum topic thread_id ā undefined for DMs and non-topic group messages
|
|
240
|
-
const threadId = msg.message_thread_id;
|
|
241
|
-
// Thread name is available on the service message that creates a new topic.
|
|
242
|
-
// forum_topic_created is not in older @types/node-telegram-bot-api versions, so cast via unknown.
|
|
243
|
-
const rawMsg = msg;
|
|
244
|
-
const threadName = rawMsg.forum_topic_created
|
|
245
|
-
? rawMsg.forum_topic_created.name
|
|
246
|
-
: undefined;
|
|
247
193
|
if (!this.isAllowed(userId)) {
|
|
248
|
-
await this.
|
|
194
|
+
await this.bot.sendMessage(chatId, "Not authorized.");
|
|
249
195
|
return;
|
|
250
196
|
}
|
|
251
|
-
// Track the last chat that sent us a message for the chat bridge
|
|
252
|
-
this.lastActiveChatId = chatId;
|
|
253
197
|
// Group chat handling
|
|
254
198
|
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
|
255
199
|
if (isGroup) {
|
|
@@ -268,17 +212,17 @@ export class CcTgBot {
|
|
|
268
212
|
}
|
|
269
213
|
// Voice message ā transcribe then feed as text
|
|
270
214
|
if (msg.voice || msg.audio) {
|
|
271
|
-
await this.handleVoice(chatId, msg
|
|
215
|
+
await this.handleVoice(chatId, msg);
|
|
272
216
|
return;
|
|
273
217
|
}
|
|
274
218
|
// Photo ā send as base64 image content block to Claude
|
|
275
219
|
if (msg.photo?.length) {
|
|
276
|
-
await this.handlePhoto(chatId, msg
|
|
220
|
+
await this.handlePhoto(chatId, msg);
|
|
277
221
|
return;
|
|
278
222
|
}
|
|
279
223
|
// Document ā download to CWD/.cc-tg/uploads/, tell Claude the path
|
|
280
224
|
if (msg.document) {
|
|
281
|
-
await this.handleDocument(chatId, msg
|
|
225
|
+
await this.handleDocument(chatId, msg);
|
|
282
226
|
return;
|
|
283
227
|
}
|
|
284
228
|
let text = msg.text?.trim();
|
|
@@ -288,64 +232,68 @@ export class CcTgBot {
|
|
|
288
232
|
if (this.botUsername) {
|
|
289
233
|
text = text.replace(new RegExp(`@${this.botUsername}\\s*`, "g"), "").trim();
|
|
290
234
|
}
|
|
291
|
-
const sessionKey = this.sessionKey(chatId, threadId);
|
|
292
235
|
// /start or /reset ā kill existing session and ack
|
|
293
236
|
if (text === "/start" || text === "/reset") {
|
|
294
|
-
this.killSession(chatId
|
|
295
|
-
await this.
|
|
237
|
+
this.killSession(chatId);
|
|
238
|
+
await this.bot.sendMessage(chatId, "Session reset. Send a message to start.");
|
|
296
239
|
return;
|
|
297
240
|
}
|
|
298
241
|
// /stop ā kill active session (interrupt running Claude task)
|
|
299
242
|
if (text === "/stop") {
|
|
300
|
-
const has = this.sessions.has(
|
|
301
|
-
this.killSession(chatId
|
|
302
|
-
await this.
|
|
243
|
+
const has = this.sessions.has(chatId);
|
|
244
|
+
this.killSession(chatId);
|
|
245
|
+
await this.bot.sendMessage(chatId, has ? "Stopped." : "No active session.");
|
|
303
246
|
return;
|
|
304
247
|
}
|
|
305
248
|
// /help ā list all commands
|
|
306
249
|
if (text === "/help") {
|
|
307
250
|
const lines = BOT_COMMANDS.map((c) => `/${c.command} ā ${c.description}`);
|
|
308
|
-
await this.
|
|
251
|
+
await this.bot.sendMessage(chatId, lines.join("\n"));
|
|
309
252
|
return;
|
|
310
253
|
}
|
|
311
254
|
// /status
|
|
312
255
|
if (text === "/status") {
|
|
313
|
-
const has = this.sessions.has(
|
|
256
|
+
const has = this.sessions.has(chatId);
|
|
314
257
|
let status = has ? "Session active." : "No active session.";
|
|
315
258
|
const sleeping = this.pendingRetries.size;
|
|
316
259
|
if (sleeping > 0)
|
|
317
260
|
status += `\nāø ${sleeping} request(s) sleeping (usage limit).`;
|
|
318
|
-
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);
|
|
319
267
|
return;
|
|
320
268
|
}
|
|
321
269
|
// /reload_mcp ā kill cc-agent process so Claude Code auto-restarts it
|
|
322
270
|
if (text === "/reload_mcp") {
|
|
323
|
-
await this.handleReloadMcp(chatId
|
|
271
|
+
await this.handleReloadMcp(chatId);
|
|
324
272
|
return;
|
|
325
273
|
}
|
|
326
274
|
// /mcp_status ā run `claude mcp list` and show connection status
|
|
327
275
|
if (text === "/mcp_status") {
|
|
328
|
-
await this.handleMcpStatus(chatId
|
|
276
|
+
await this.handleMcpStatus(chatId);
|
|
329
277
|
return;
|
|
330
278
|
}
|
|
331
279
|
// /mcp_version ā show published npm version and cached npx entries
|
|
332
280
|
if (text === "/mcp_version") {
|
|
333
|
-
await this.handleMcpVersion(chatId
|
|
281
|
+
await this.handleMcpVersion(chatId);
|
|
334
282
|
return;
|
|
335
283
|
}
|
|
336
284
|
// /clear_npx_cache ā wipe ~/.npm/_npx/ then restart cc-agent
|
|
337
285
|
if (text === "/clear_npx_cache") {
|
|
338
|
-
await this.handleClearNpxCache(chatId
|
|
286
|
+
await this.handleClearNpxCache(chatId);
|
|
339
287
|
return;
|
|
340
288
|
}
|
|
341
289
|
// /restart ā restart the bot process in-place
|
|
342
290
|
if (text === "/restart") {
|
|
343
|
-
await this.handleRestart(chatId
|
|
291
|
+
await this.handleRestart(chatId);
|
|
344
292
|
return;
|
|
345
293
|
}
|
|
346
294
|
// /get_file <path> ā send a file from the server to the user
|
|
347
295
|
if (text.startsWith("/get_file")) {
|
|
348
|
-
await this.handleGetFile(chatId, text
|
|
296
|
+
await this.handleGetFile(chatId, text);
|
|
349
297
|
return;
|
|
350
298
|
}
|
|
351
299
|
// /cost ā show session token usage and cost
|
|
@@ -361,62 +309,37 @@ export class CcTgBot {
|
|
|
361
309
|
catch (err) {
|
|
362
310
|
console.error("[cost] cc-agent cost_summary failed:", err.message);
|
|
363
311
|
}
|
|
364
|
-
await this.
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
// /skills ā list available Claude skills from ~/.claude/skills/
|
|
368
|
-
if (text === "/skills") {
|
|
369
|
-
await this.replyToChat(chatId, listSkills(), threadId);
|
|
312
|
+
await this.bot.sendMessage(chatId, reply);
|
|
370
313
|
return;
|
|
371
314
|
}
|
|
372
|
-
const session = this.getOrCreateSession(chatId
|
|
315
|
+
const session = this.getOrCreateSession(chatId);
|
|
373
316
|
try {
|
|
374
|
-
const
|
|
375
|
-
const prompt = buildPromptWithReplyContext(enriched, msg);
|
|
317
|
+
const prompt = buildPromptWithReplyContext(text, msg);
|
|
376
318
|
session.currentPrompt = prompt;
|
|
377
319
|
session.claude.sendPrompt(prompt);
|
|
378
320
|
this.startTyping(chatId, session);
|
|
379
|
-
this.writeChatMessage("user", "telegram", text, chatId);
|
|
380
|
-
}
|
|
381
|
-
catch (err) {
|
|
382
|
-
await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
|
|
383
|
-
this.killSession(chatId, true, threadId);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
/**
|
|
387
|
-
* Feed a text message into the active Claude session for the given chat.
|
|
388
|
-
* Called by the notifier when a UI message arrives via Redis pub/sub.
|
|
389
|
-
*/
|
|
390
|
-
async handleUserMessage(chatId, text) {
|
|
391
|
-
const session = this.getOrCreateSession(chatId);
|
|
392
|
-
try {
|
|
393
|
-
const enriched = await enrichPromptWithUrls(text);
|
|
394
|
-
session.currentPrompt = enriched;
|
|
395
|
-
session.claude.sendPrompt(enriched);
|
|
396
|
-
this.startTyping(chatId, session);
|
|
397
|
-
this.writeChatMessage("user", "ui", text, chatId);
|
|
398
321
|
}
|
|
399
322
|
catch (err) {
|
|
400
|
-
await this.
|
|
401
|
-
this.killSession(chatId
|
|
323
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
324
|
+
this.killSession(chatId);
|
|
402
325
|
}
|
|
403
326
|
}
|
|
404
|
-
async handleVoice(chatId, msg
|
|
327
|
+
async handleVoice(chatId, msg) {
|
|
405
328
|
const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
|
|
406
329
|
if (!fileId)
|
|
407
330
|
return;
|
|
408
331
|
console.log(`[voice:${chatId}] received voice message, transcribing...`);
|
|
409
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
332
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
410
333
|
try {
|
|
411
334
|
const fileLink = await this.bot.getFileLink(fileId);
|
|
412
335
|
const transcript = await transcribeVoice(fileLink);
|
|
413
336
|
console.log(`[voice:${chatId}] transcribed: ${transcript}`);
|
|
414
337
|
if (!transcript || transcript === "[empty transcription]") {
|
|
415
|
-
await this.
|
|
338
|
+
await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
|
|
416
339
|
return;
|
|
417
340
|
}
|
|
418
341
|
// Feed transcript into Claude as if user typed it
|
|
419
|
-
const session = this.getOrCreateSession(chatId
|
|
342
|
+
const session = this.getOrCreateSession(chatId);
|
|
420
343
|
try {
|
|
421
344
|
const prompt = buildPromptWithReplyContext(transcript, msg);
|
|
422
345
|
session.currentPrompt = prompt;
|
|
@@ -424,41 +347,41 @@ export class CcTgBot {
|
|
|
424
347
|
this.startTyping(chatId, session);
|
|
425
348
|
}
|
|
426
349
|
catch (err) {
|
|
427
|
-
await this.
|
|
428
|
-
this.killSession(chatId
|
|
350
|
+
await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
|
|
351
|
+
this.killSession(chatId);
|
|
429
352
|
}
|
|
430
353
|
}
|
|
431
354
|
catch (err) {
|
|
432
355
|
console.error(`[voice:${chatId}] error:`, err.message);
|
|
433
|
-
await this.
|
|
356
|
+
await this.bot.sendMessage(chatId, `Voice transcription failed: ${err.message}`);
|
|
434
357
|
}
|
|
435
358
|
}
|
|
436
|
-
async handlePhoto(chatId, msg
|
|
359
|
+
async handlePhoto(chatId, msg) {
|
|
437
360
|
// Pick highest resolution photo
|
|
438
361
|
const photos = msg.photo;
|
|
439
362
|
const best = photos[photos.length - 1];
|
|
440
363
|
const caption = msg.caption?.trim();
|
|
441
364
|
console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
|
|
442
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
365
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
443
366
|
try {
|
|
444
367
|
const fileLink = await this.bot.getFileLink(best.file_id);
|
|
445
368
|
const imageData = await fetchAsBase64(fileLink);
|
|
446
369
|
// Telegram photos are always JPEG
|
|
447
|
-
const session = this.getOrCreateSession(chatId
|
|
370
|
+
const session = this.getOrCreateSession(chatId);
|
|
448
371
|
session.claude.sendImage(imageData, "image/jpeg", caption);
|
|
449
372
|
this.startTyping(chatId, session);
|
|
450
373
|
}
|
|
451
374
|
catch (err) {
|
|
452
375
|
console.error(`[photo:${chatId}] error:`, err.message);
|
|
453
|
-
await this.
|
|
376
|
+
await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
|
|
454
377
|
}
|
|
455
378
|
}
|
|
456
|
-
async handleDocument(chatId, msg
|
|
379
|
+
async handleDocument(chatId, msg) {
|
|
457
380
|
const doc = msg.document;
|
|
458
381
|
const caption = msg.caption?.trim();
|
|
459
382
|
const fileName = doc.file_name ?? `file_${doc.file_id}`;
|
|
460
383
|
console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
|
|
461
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
384
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
462
385
|
try {
|
|
463
386
|
const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
|
|
464
387
|
mkdirSync(uploadsDir, { recursive: true });
|
|
@@ -469,34 +392,22 @@ export class CcTgBot {
|
|
|
469
392
|
const prompt = caption
|
|
470
393
|
? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
|
|
471
394
|
: `ATTACHMENTS: [${fileName}](${destPath})`;
|
|
472
|
-
const session = this.getOrCreateSession(chatId
|
|
395
|
+
const session = this.getOrCreateSession(chatId);
|
|
473
396
|
session.claude.sendPrompt(prompt);
|
|
474
397
|
this.startTyping(chatId, session);
|
|
475
398
|
}
|
|
476
399
|
catch (err) {
|
|
477
400
|
console.error(`[doc:${chatId}] error:`, err.message);
|
|
478
|
-
await this.
|
|
401
|
+
await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
|
|
479
402
|
}
|
|
480
403
|
}
|
|
481
|
-
getOrCreateSession(chatId
|
|
482
|
-
const
|
|
483
|
-
const existing = this.sessions.get(key);
|
|
404
|
+
getOrCreateSession(chatId) {
|
|
405
|
+
const existing = this.sessions.get(chatId);
|
|
484
406
|
if (existing && !existing.claude.exited)
|
|
485
407
|
return existing;
|
|
486
|
-
// Determine CWD for this thread ā check THREAD_CWD_MAP by name then by ID
|
|
487
|
-
let sessionCwd = this.opts.cwd;
|
|
488
|
-
const threadCwdMap = this.getThreadCwdMap();
|
|
489
|
-
if (threadName && threadCwdMap[threadName]) {
|
|
490
|
-
sessionCwd = threadCwdMap[threadName];
|
|
491
|
-
console.log(`[cc-tg] thread "${threadName}" ā cwd: ${sessionCwd}`);
|
|
492
|
-
}
|
|
493
|
-
else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
|
|
494
|
-
sessionCwd = threadCwdMap[String(threadId)];
|
|
495
|
-
console.log(`[cc-tg] thread ${threadId} ā cwd: ${sessionCwd}`);
|
|
496
|
-
}
|
|
497
408
|
const claude = new ClaudeProcess({
|
|
498
|
-
cwd:
|
|
499
|
-
token:
|
|
409
|
+
cwd: this.opts.cwd,
|
|
410
|
+
token: this.opts.claudeToken,
|
|
500
411
|
});
|
|
501
412
|
const session = {
|
|
502
413
|
claude,
|
|
@@ -506,7 +417,6 @@ export class CcTgBot {
|
|
|
506
417
|
writtenFiles: new Set(),
|
|
507
418
|
currentPrompt: "",
|
|
508
419
|
isRetry: false,
|
|
509
|
-
threadId,
|
|
510
420
|
};
|
|
511
421
|
claude.on("usage", (usage) => {
|
|
512
422
|
this.costStore.addUsage(chatId, usage);
|
|
@@ -515,47 +425,33 @@ export class CcTgBot {
|
|
|
515
425
|
// Verbose logging ā log every message type and subtype
|
|
516
426
|
const subtype = msg.payload.subtype ?? "";
|
|
517
427
|
const toolName = this.extractToolName(msg);
|
|
518
|
-
const logParts = [`[claude:${
|
|
428
|
+
const logParts = [`[claude:${chatId}] msg=${msg.type}`];
|
|
519
429
|
if (subtype)
|
|
520
430
|
logParts.push(`subtype=${subtype}`);
|
|
521
431
|
if (toolName)
|
|
522
432
|
logParts.push(`tool=${toolName}`);
|
|
523
433
|
console.log(logParts.join(" "));
|
|
524
434
|
// Track files written by Write/Edit tool calls
|
|
525
|
-
this.trackWrittenFiles(msg, session,
|
|
526
|
-
// Publish tool call events to the chat log
|
|
527
|
-
if (msg.type === "assistant") {
|
|
528
|
-
const message = msg.payload.message;
|
|
529
|
-
const content = message?.content;
|
|
530
|
-
if (Array.isArray(content)) {
|
|
531
|
-
for (const block of content) {
|
|
532
|
-
if (block.type !== "tool_use")
|
|
533
|
-
continue;
|
|
534
|
-
const name = block.name;
|
|
535
|
-
const input = block.input;
|
|
536
|
-
this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {}).slice(0, 120)}`, chatId);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
435
|
+
this.trackWrittenFiles(msg, session, this.opts.cwd);
|
|
540
436
|
this.handleClaudeMessage(chatId, session, msg);
|
|
541
437
|
});
|
|
542
438
|
claude.on("stderr", (data) => {
|
|
543
439
|
const line = data.trim();
|
|
544
440
|
if (line)
|
|
545
|
-
console.error(`[claude:${
|
|
441
|
+
console.error(`[claude:${chatId}:stderr]`, line);
|
|
546
442
|
});
|
|
547
443
|
claude.on("exit", (code) => {
|
|
548
|
-
console.log(`[claude:${
|
|
444
|
+
console.log(`[claude:${chatId}] exited code=${code}`);
|
|
549
445
|
this.stopTyping(session);
|
|
550
|
-
this.sessions.delete(
|
|
446
|
+
this.sessions.delete(chatId);
|
|
551
447
|
});
|
|
552
448
|
claude.on("error", (err) => {
|
|
553
|
-
console.error(`[claude:${
|
|
449
|
+
console.error(`[claude:${chatId}] process error: ${err.message}`);
|
|
554
450
|
this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
|
|
555
451
|
this.stopTyping(session);
|
|
556
|
-
this.sessions.delete(
|
|
452
|
+
this.sessions.delete(chatId);
|
|
557
453
|
});
|
|
558
|
-
this.sessions.set(
|
|
454
|
+
this.sessions.set(chatId, session);
|
|
559
455
|
return session;
|
|
560
456
|
}
|
|
561
457
|
handleClaudeMessage(chatId, session, msg) {
|
|
@@ -571,58 +467,33 @@ export class CcTgBot {
|
|
|
571
467
|
// Check for usage/rate limit signals before forwarding to Telegram
|
|
572
468
|
const sig = detectUsageLimit(text);
|
|
573
469
|
if (sig.detected) {
|
|
574
|
-
const threadId = session.threadId;
|
|
575
|
-
const retryKey = this.sessionKey(chatId, threadId);
|
|
576
470
|
const lastPrompt = session.currentPrompt;
|
|
577
|
-
const prevRetry = this.pendingRetries.get(
|
|
471
|
+
const prevRetry = this.pendingRetries.get(chatId);
|
|
578
472
|
const attempt = (prevRetry?.attempt ?? 0) + 1;
|
|
579
473
|
if (prevRetry)
|
|
580
474
|
clearTimeout(prevRetry.timer);
|
|
581
|
-
this.
|
|
582
|
-
this.killSession(chatId
|
|
583
|
-
// Token rotation: if this is a usage_exhausted signal and we have multiple
|
|
584
|
-
// tokens, rotate to the next one and retry immediately instead of sleeping.
|
|
585
|
-
// Only rotate if we haven't yet cycled through all tokens (attempt <= count-1).
|
|
586
|
-
if (sig.reason === "usage_exhausted" && getTokenCount() > 1 && attempt <= getTokenCount() - 1) {
|
|
587
|
-
const prevIdx = getTokenIndex();
|
|
588
|
-
rotateToken();
|
|
589
|
-
const newIdx = getTokenIndex();
|
|
590
|
-
const total = getTokenCount();
|
|
591
|
-
console.log(`[cc-tg] Token ${prevIdx + 1}/${total} exhausted, rotating to token ${newIdx + 1}/${total}`);
|
|
592
|
-
this.replyToChat(chatId, `š Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`, threadId).catch(() => { });
|
|
593
|
-
this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
|
|
594
|
-
try {
|
|
595
|
-
const retrySession = this.getOrCreateSession(chatId, threadId);
|
|
596
|
-
retrySession.currentPrompt = lastPrompt;
|
|
597
|
-
retrySession.isRetry = true;
|
|
598
|
-
retrySession.claude.sendPrompt(lastPrompt);
|
|
599
|
-
this.startTyping(chatId, retrySession);
|
|
600
|
-
}
|
|
601
|
-
catch (err) {
|
|
602
|
-
this.replyToChat(chatId, `ā Failed to retry with rotated token: ${err.message}`, threadId).catch(() => { });
|
|
603
|
-
}
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
475
|
+
this.bot.sendMessage(chatId, sig.humanMessage).catch(() => { });
|
|
476
|
+
this.killSession(chatId);
|
|
606
477
|
if (attempt > 3) {
|
|
607
|
-
this.
|
|
608
|
-
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);
|
|
609
480
|
return;
|
|
610
481
|
}
|
|
611
|
-
console.log(`[usage-limit:${
|
|
482
|
+
console.log(`[usage-limit:${chatId}] ${sig.reason} ā scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
|
|
612
483
|
const timer = setTimeout(() => {
|
|
613
|
-
this.pendingRetries.delete(
|
|
484
|
+
this.pendingRetries.delete(chatId);
|
|
614
485
|
try {
|
|
615
|
-
const retrySession = this.getOrCreateSession(chatId
|
|
486
|
+
const retrySession = this.getOrCreateSession(chatId);
|
|
616
487
|
retrySession.currentPrompt = lastPrompt;
|
|
617
488
|
retrySession.isRetry = true;
|
|
618
489
|
retrySession.claude.sendPrompt(lastPrompt);
|
|
619
490
|
this.startTyping(chatId, retrySession);
|
|
620
491
|
}
|
|
621
492
|
catch (err) {
|
|
622
|
-
this.
|
|
493
|
+
this.bot.sendMessage(chatId, `ā Failed to retry: ${err.message}`).catch(() => { });
|
|
623
494
|
}
|
|
624
495
|
}, sig.retryAfterMs);
|
|
625
|
-
this.pendingRetries.set(
|
|
496
|
+
this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer });
|
|
626
497
|
return;
|
|
627
498
|
}
|
|
628
499
|
// Accumulate text and debounce ā Claude streams chunks rapidly
|
|
@@ -634,11 +505,9 @@ export class CcTgBot {
|
|
|
634
505
|
startTyping(chatId, session) {
|
|
635
506
|
this.stopTyping(session);
|
|
636
507
|
// Send immediately, then keep alive every 4s
|
|
637
|
-
|
|
638
|
-
const threadOpts = session.threadId !== undefined ? { message_thread_id: session.threadId } : undefined;
|
|
639
|
-
this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
|
|
508
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
640
509
|
session.typingTimer = setInterval(() => {
|
|
641
|
-
this.bot.sendChatAction(chatId, "typing"
|
|
510
|
+
this.bot.sendChatAction(chatId, "typing").catch(() => { });
|
|
642
511
|
}, TYPING_INTERVAL_MS);
|
|
643
512
|
}
|
|
644
513
|
stopTyping(session) {
|
|
@@ -653,17 +522,15 @@ export class CcTgBot {
|
|
|
653
522
|
session.flushTimer = null;
|
|
654
523
|
if (!raw)
|
|
655
524
|
return;
|
|
656
|
-
this.writeChatMessage("assistant", "cc-tg", raw, chatId);
|
|
657
525
|
const text = session.isRetry ? `ā
Claude is back!\n\n${raw}` : raw;
|
|
658
526
|
session.isRetry = false;
|
|
659
|
-
// Format for Telegram
|
|
527
|
+
// Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
|
|
660
528
|
const formatted = formatForTelegram(text);
|
|
661
529
|
const chunks = splitLongMessage(formatted);
|
|
662
|
-
const threadId = session.threadId;
|
|
663
530
|
for (const chunk of chunks) {
|
|
664
|
-
this.
|
|
665
|
-
//
|
|
666
|
-
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));
|
|
667
534
|
});
|
|
668
535
|
}
|
|
669
536
|
// Hybrid file upload: find files mentioned in result text that Claude actually wrote
|
|
@@ -836,12 +703,11 @@ export class CcTgBot {
|
|
|
836
703
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
837
704
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
838
705
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
839
|
-
this.
|
|
706
|
+
this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`).catch(() => { });
|
|
840
707
|
continue;
|
|
841
708
|
}
|
|
842
709
|
console.log(`[claude:files] uploading to telegram: ${filePath}`);
|
|
843
|
-
|
|
844
|
-
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));
|
|
845
711
|
}
|
|
846
712
|
// Clear written files for next turn
|
|
847
713
|
session.writtenFiles.clear();
|
|
@@ -856,6 +722,203 @@ export class CcTgBot {
|
|
|
856
722
|
const toolUse = content.find((b) => b.type === "tool_use");
|
|
857
723
|
return toolUse?.name ?? "";
|
|
858
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
|
+
}
|
|
859
922
|
/** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
|
|
860
923
|
findCcAgentPids() {
|
|
861
924
|
try {
|
|
@@ -881,33 +944,33 @@ export class CcTgBot {
|
|
|
881
944
|
}
|
|
882
945
|
return pids;
|
|
883
946
|
}
|
|
884
|
-
async handleReloadMcp(chatId
|
|
885
|
-
await this.
|
|
947
|
+
async handleReloadMcp(chatId) {
|
|
948
|
+
await this.bot.sendMessage(chatId, "Clearing npx cache and reloading MCP...");
|
|
886
949
|
try {
|
|
887
950
|
const home = process.env.HOME ?? "~";
|
|
888
951
|
execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
|
|
889
952
|
console.log("[mcp] cleared ~/.npm/_npx/");
|
|
890
953
|
}
|
|
891
954
|
catch (err) {
|
|
892
|
-
await this.
|
|
955
|
+
await this.bot.sendMessage(chatId, `Warning: failed to clear npx cache: ${err.message}`);
|
|
893
956
|
}
|
|
894
957
|
const pids = this.killCcAgent();
|
|
895
958
|
if (pids.length === 0) {
|
|
896
|
-
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.");
|
|
897
960
|
return;
|
|
898
961
|
}
|
|
899
|
-
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.`);
|
|
900
963
|
}
|
|
901
|
-
async handleMcpStatus(chatId
|
|
964
|
+
async handleMcpStatus(chatId) {
|
|
902
965
|
try {
|
|
903
966
|
const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
|
|
904
|
-
await this.
|
|
967
|
+
await this.bot.sendMessage(chatId, `MCP server status:\n\n${output || "(no output)"}`);
|
|
905
968
|
}
|
|
906
969
|
catch (err) {
|
|
907
|
-
await this.
|
|
970
|
+
await this.bot.sendMessage(chatId, `Failed to run claude mcp list: ${err.message}`);
|
|
908
971
|
}
|
|
909
972
|
}
|
|
910
|
-
async handleMcpVersion(chatId
|
|
973
|
+
async handleMcpVersion(chatId) {
|
|
911
974
|
let npmVersion = "unknown";
|
|
912
975
|
let cacheEntries = "(unavailable)";
|
|
913
976
|
try {
|
|
@@ -924,9 +987,9 @@ export class CcTgBot {
|
|
|
924
987
|
catch {
|
|
925
988
|
cacheEntries = "(empty or not found)";
|
|
926
989
|
}
|
|
927
|
-
await this.
|
|
990
|
+
await this.bot.sendMessage(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`);
|
|
928
991
|
}
|
|
929
|
-
async handleClearNpxCache(chatId
|
|
992
|
+
async handleClearNpxCache(chatId) {
|
|
930
993
|
const home = process.env.HOME ?? "/tmp";
|
|
931
994
|
const cleared = [];
|
|
932
995
|
const failed = [];
|
|
@@ -949,10 +1012,10 @@ export class CcTgBot {
|
|
|
949
1012
|
const clearNote = failed.length
|
|
950
1013
|
? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
|
|
951
1014
|
: `Cleared: ${cleared.join(", ")}.`;
|
|
952
|
-
await this.
|
|
1015
|
+
await this.bot.sendMessage(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`);
|
|
953
1016
|
}
|
|
954
|
-
async handleRestart(chatId
|
|
955
|
-
await this.
|
|
1017
|
+
async handleRestart(chatId) {
|
|
1018
|
+
await this.bot.sendMessage(chatId, "Clearing cache and restarting... brb.");
|
|
956
1019
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
957
1020
|
// Clear npm caches before restart so launchd brings up fresh version
|
|
958
1021
|
const home = process.env.HOME ?? "/tmp";
|
|
@@ -963,48 +1026,45 @@ export class CcTgBot {
|
|
|
963
1026
|
catch { }
|
|
964
1027
|
}
|
|
965
1028
|
// Kill all active Claude sessions cleanly
|
|
966
|
-
for (const
|
|
967
|
-
this.
|
|
968
|
-
session.claude.kill();
|
|
1029
|
+
for (const [cid] of this.sessions) {
|
|
1030
|
+
this.killSession(cid);
|
|
969
1031
|
}
|
|
970
|
-
this.sessions.clear();
|
|
971
1032
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
972
1033
|
process.exit(0);
|
|
973
1034
|
}
|
|
974
|
-
async handleGetFile(chatId, text
|
|
1035
|
+
async handleGetFile(chatId, text) {
|
|
975
1036
|
const arg = text.slice("/get_file".length).trim();
|
|
976
1037
|
if (!arg) {
|
|
977
|
-
await this.
|
|
1038
|
+
await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
|
|
978
1039
|
return;
|
|
979
1040
|
}
|
|
980
1041
|
const filePath = resolve(arg);
|
|
981
1042
|
const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
|
|
982
1043
|
const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
|
|
983
1044
|
if (!inSafeDir) {
|
|
984
|
-
await this.
|
|
1045
|
+
await this.bot.sendMessage(chatId, "Access denied: path not in allowed directories");
|
|
985
1046
|
return;
|
|
986
1047
|
}
|
|
987
1048
|
if (!existsSync(filePath)) {
|
|
988
|
-
await this.
|
|
1049
|
+
await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
|
|
989
1050
|
return;
|
|
990
1051
|
}
|
|
991
1052
|
if (!statSync(filePath).isFile()) {
|
|
992
|
-
await this.
|
|
1053
|
+
await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
|
|
993
1054
|
return;
|
|
994
1055
|
}
|
|
995
1056
|
if (this.isSensitiveFile(filePath)) {
|
|
996
|
-
await this.
|
|
1057
|
+
await this.bot.sendMessage(chatId, "Access denied: sensitive file");
|
|
997
1058
|
return;
|
|
998
1059
|
}
|
|
999
1060
|
const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
|
|
1000
1061
|
const fileSize = statSync(filePath).size;
|
|
1001
1062
|
if (fileSize > MAX_TG_FILE_BYTES) {
|
|
1002
1063
|
const mb = (fileSize / (1024 * 1024)).toFixed(1);
|
|
1003
|
-
await this.
|
|
1064
|
+
await this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`);
|
|
1004
1065
|
return;
|
|
1005
1066
|
}
|
|
1006
|
-
|
|
1007
|
-
await this.bot.sendDocument(chatId, filePath, docOpts);
|
|
1067
|
+
await this.bot.sendDocument(chatId, filePath);
|
|
1008
1068
|
}
|
|
1009
1069
|
callCcAgentTool(toolName, args = {}) {
|
|
1010
1070
|
return new Promise((resolve) => {
|
|
@@ -1077,25 +1137,21 @@ export class CcTgBot {
|
|
|
1077
1137
|
proc.on("exit", () => { clearTimeout(timeout); done(null); });
|
|
1078
1138
|
});
|
|
1079
1139
|
}
|
|
1080
|
-
killSession(chatId,
|
|
1081
|
-
const
|
|
1082
|
-
const session = this.sessions.get(key);
|
|
1140
|
+
killSession(chatId, keepCrons = true) {
|
|
1141
|
+
const session = this.sessions.get(chatId);
|
|
1083
1142
|
if (session) {
|
|
1084
1143
|
this.stopTyping(session);
|
|
1085
1144
|
session.claude.kill();
|
|
1086
|
-
this.sessions.delete(
|
|
1145
|
+
this.sessions.delete(chatId);
|
|
1087
1146
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
return this.bot.getMe();
|
|
1147
|
+
if (!keepCrons)
|
|
1148
|
+
this.cron.clearAll(chatId);
|
|
1091
1149
|
}
|
|
1092
1150
|
stop() {
|
|
1093
1151
|
this.bot.stopPolling();
|
|
1094
|
-
for (const
|
|
1095
|
-
this.
|
|
1096
|
-
session.claude.kill();
|
|
1152
|
+
for (const [chatId] of this.sessions) {
|
|
1153
|
+
this.killSession(chatId);
|
|
1097
1154
|
}
|
|
1098
|
-
this.sessions.clear();
|
|
1099
1155
|
}
|
|
1100
1156
|
}
|
|
1101
1157
|
function buildPromptWithReplyContext(text, msg) {
|
|
@@ -1134,85 +1190,6 @@ function downloadToFile(url, destPath) {
|
|
|
1134
1190
|
}).on("error", reject);
|
|
1135
1191
|
});
|
|
1136
1192
|
}
|
|
1137
|
-
/** Fetch URL via Jina Reader and return first maxChars characters */
|
|
1138
|
-
function fetchUrlViaJina(url, maxChars = 2000) {
|
|
1139
|
-
const jinaUrl = `https://r.jina.ai/${url}`;
|
|
1140
|
-
return new Promise((resolve, reject) => {
|
|
1141
|
-
https.get(jinaUrl, (res) => {
|
|
1142
|
-
const chunks = [];
|
|
1143
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
1144
|
-
res.on("end", () => {
|
|
1145
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
1146
|
-
resolve(text.slice(0, maxChars));
|
|
1147
|
-
});
|
|
1148
|
-
res.on("error", reject);
|
|
1149
|
-
}).on("error", reject);
|
|
1150
|
-
});
|
|
1151
|
-
}
|
|
1152
|
-
/** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
|
|
1153
|
-
export async function enrichPromptWithUrls(text) {
|
|
1154
|
-
const urlRegex = /https?:\/\/[^\s]+/g;
|
|
1155
|
-
const urls = text.match(urlRegex);
|
|
1156
|
-
if (!urls || urls.length === 0)
|
|
1157
|
-
return text;
|
|
1158
|
-
const prefixes = [];
|
|
1159
|
-
for (const url of urls) {
|
|
1160
|
-
// Skip jina.ai URLs to avoid recursion
|
|
1161
|
-
if (url.includes("r.jina.ai"))
|
|
1162
|
-
continue;
|
|
1163
|
-
try {
|
|
1164
|
-
const content = await fetchUrlViaJina(url);
|
|
1165
|
-
if (content.trim()) {
|
|
1166
|
-
prefixes.push(`[Web content from ${url}]:\n${content}`);
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
catch (err) {
|
|
1170
|
-
console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
if (prefixes.length === 0)
|
|
1174
|
-
return text;
|
|
1175
|
-
return prefixes.join("\n\n") + "\n\n" + text;
|
|
1176
|
-
}
|
|
1177
|
-
/** Parse frontmatter description from a skill markdown file */
|
|
1178
|
-
function parseSkillDescription(content) {
|
|
1179
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
1180
|
-
if (!match)
|
|
1181
|
-
return null;
|
|
1182
|
-
const frontmatter = match[1];
|
|
1183
|
-
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
1184
|
-
return descMatch ? descMatch[1].trim() : null;
|
|
1185
|
-
}
|
|
1186
|
-
/** List available skills from ~/.claude/skills/ */
|
|
1187
|
-
export function listSkills() {
|
|
1188
|
-
const skillsDir = join(os.homedir(), ".claude", "skills");
|
|
1189
|
-
if (!existsSync(skillsDir)) {
|
|
1190
|
-
return "No skills directory found at ~/.claude/skills/";
|
|
1191
|
-
}
|
|
1192
|
-
let files;
|
|
1193
|
-
try {
|
|
1194
|
-
files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
|
|
1195
|
-
}
|
|
1196
|
-
catch {
|
|
1197
|
-
return "Could not read skills directory.";
|
|
1198
|
-
}
|
|
1199
|
-
if (files.length === 0) {
|
|
1200
|
-
return "No skills found in ~/.claude/skills/";
|
|
1201
|
-
}
|
|
1202
|
-
const lines = ["Available skills:"];
|
|
1203
|
-
for (const file of files.sort()) {
|
|
1204
|
-
const name = "/" + file.replace(/\.md$/, "");
|
|
1205
|
-
try {
|
|
1206
|
-
const content = readFileSync(join(skillsDir, file), "utf8");
|
|
1207
|
-
const description = parseSkillDescription(content);
|
|
1208
|
-
lines.push(description ? `${name} ā ${description}` : name);
|
|
1209
|
-
}
|
|
1210
|
-
catch {
|
|
1211
|
-
lines.push(name);
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
return lines.join("\n");
|
|
1215
|
-
}
|
|
1216
1193
|
export function splitMessage(text, maxLen = 4096) {
|
|
1217
1194
|
if (text.length <= maxLen)
|
|
1218
1195
|
return [text];
|