@gonzih/cc-tg 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bot.js DELETED
@@ -1,1100 +0,0 @@
1
- /**
2
- * Telegram bot that routes messages to/from a Claude Code subprocess.
3
- * One ClaudeProcess per chat_id — sessions are isolated per user.
4
- */
5
- import TelegramBot from "node-telegram-bot-api";
6
- import { existsSync, createWriteStream, mkdirSync, statSync, readdirSync, readFileSync, writeFileSync } from "fs";
7
- import { resolve, basename, join } from "path";
8
- import os from "os";
9
- import { execSync, spawn } from "child_process";
10
- import https from "https";
11
- import http from "http";
12
- import { ClaudeProcess, extractText } from "./claude.js";
13
- import { transcribeVoice, isVoiceAvailable } from "./voice.js";
14
- import { CronManager } from "./cron.js";
15
- const BOT_COMMANDS = [
16
- { command: "start", description: "Reset session and start fresh" },
17
- { command: "reset", description: "Reset Claude session" },
18
- { command: "stop", description: "Stop the current Claude task" },
19
- { command: "status", description: "Check if a session is active" },
20
- { command: "help", description: "Show all available commands" },
21
- { command: "cron", description: "Manage cron jobs — add/list/edit/remove/clear" },
22
- { command: "reload_mcp", description: "Restart the cc-agent MCP server process" },
23
- { command: "mcp_status", description: "Check MCP server connection status" },
24
- { command: "mcp_version", description: "Show cc-agent npm version and npx cache info" },
25
- { command: "clear_npx_cache", description: "Clear npx cache and restart MCP to pick up latest version" },
26
- { command: "restart", description: "Restart the bot process in-place" },
27
- { command: "get_file", description: "Send a file from the server to this chat" },
28
- { command: "cost", description: "Show session token usage and cost" },
29
- ];
30
- const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
31
- const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
32
- // Claude Sonnet 4.6 pricing (per 1M tokens)
33
- const PRICING = {
34
- inputPerM: 3.00,
35
- outputPerM: 15.00,
36
- cacheReadPerM: 0.30,
37
- cacheWritePerM: 3.75,
38
- };
39
- function computeCostUsd(usage) {
40
- return (usage.inputTokens * PRICING.inputPerM / 1_000_000 +
41
- usage.outputTokens * PRICING.outputPerM / 1_000_000 +
42
- usage.cacheReadTokens * PRICING.cacheReadPerM / 1_000_000 +
43
- usage.cacheWriteTokens * PRICING.cacheWritePerM / 1_000_000);
44
- }
45
- function formatTokens(n) {
46
- if (n >= 1000)
47
- return `${(n / 1000).toFixed(1)}k`;
48
- return String(n);
49
- }
50
- function formatCostReport(cost) {
51
- const inputCost = cost.totalInputTokens * PRICING.inputPerM / 1_000_000;
52
- const outputCost = cost.totalOutputTokens * PRICING.outputPerM / 1_000_000;
53
- const cacheReadCost = cost.totalCacheReadTokens * PRICING.cacheReadPerM / 1_000_000;
54
- const cacheWriteCost = cost.totalCacheWriteTokens * PRICING.cacheWritePerM / 1_000_000;
55
- return [
56
- "📊 Session cost",
57
- `Messages: ${cost.messageCount}`,
58
- `Total: $${cost.totalCostUsd.toFixed(3)}`,
59
- ` Input: ${formatTokens(cost.totalInputTokens)} tokens ($${inputCost.toFixed(3)})`,
60
- ` Output: ${formatTokens(cost.totalOutputTokens)} tokens ($${outputCost.toFixed(3)})`,
61
- ` Cache read: ${formatTokens(cost.totalCacheReadTokens)} tokens ($${cacheReadCost.toFixed(3)})`,
62
- ` Cache write: ${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`,
63
- ].join("\n");
64
- }
65
- function formatCronCostFooter(usage) {
66
- const cost = computeCostUsd(usage);
67
- return `\n💰 Cron cost: $${cost.toFixed(4)} (${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out tokens)`;
68
- }
69
- function formatAgentCostSummary(text) {
70
- try {
71
- const data = JSON.parse(text);
72
- const totalCost = (data.total_cost_usd ?? data.total_cost ?? 0);
73
- const totalJobs = (data.total_jobs ?? data.job_count ?? 0);
74
- const byRepo = (data.by_repo ?? []);
75
- const lines = [
76
- "🤖 Agent jobs (all time)",
77
- `Total: $${totalCost.toFixed(2)} across ${totalJobs} jobs`,
78
- ];
79
- for (const entry of byRepo) {
80
- const repo = (entry.repo ?? entry.repository ?? "unknown");
81
- const cost = (entry.cost_usd ?? entry.cost ?? 0);
82
- const jobs = (entry.job_count ?? entry.jobs ?? 0);
83
- lines.push(` ${repo}: $${cost.toFixed(2)} (${jobs} jobs)`);
84
- }
85
- return lines.join("\n");
86
- }
87
- catch {
88
- return `🤖 Agent jobs (all time)\n${text}`;
89
- }
90
- }
91
- class CostStore {
92
- costs = new Map();
93
- storePath;
94
- constructor(cwd) {
95
- this.storePath = join(cwd, ".cc-tg", "costs.json");
96
- this.load();
97
- }
98
- get(chatId) {
99
- let cost = this.costs.get(chatId);
100
- if (!cost) {
101
- cost = { totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, totalCostUsd: 0, messageCount: 0 };
102
- this.costs.set(chatId, cost);
103
- }
104
- return cost;
105
- }
106
- addUsage(chatId, usage) {
107
- const cost = this.get(chatId);
108
- cost.totalInputTokens += usage.inputTokens;
109
- cost.totalOutputTokens += usage.outputTokens;
110
- cost.totalCacheReadTokens += usage.cacheReadTokens;
111
- cost.totalCacheWriteTokens += usage.cacheWriteTokens;
112
- cost.totalCostUsd += computeCostUsd(usage);
113
- this.persist();
114
- }
115
- incrementMessages(chatId) {
116
- const cost = this.get(chatId);
117
- cost.messageCount++;
118
- this.persist();
119
- }
120
- persist() {
121
- try {
122
- const dir = join(this.storePath, "..");
123
- if (!existsSync(dir))
124
- mkdirSync(dir, { recursive: true });
125
- const data = {};
126
- for (const [chatId, cost] of this.costs) {
127
- data[String(chatId)] = cost;
128
- }
129
- writeFileSync(this.storePath, JSON.stringify(data, null, 2));
130
- }
131
- catch (err) {
132
- console.error("[costs] persist error:", err.message);
133
- }
134
- }
135
- load() {
136
- if (!existsSync(this.storePath))
137
- return;
138
- try {
139
- const data = JSON.parse(readFileSync(this.storePath, "utf8"));
140
- for (const [key, cost] of Object.entries(data)) {
141
- this.costs.set(Number(key), cost);
142
- }
143
- console.log(`[costs] loaded ${this.costs.size} session costs from disk`);
144
- }
145
- catch (err) {
146
- console.error("[costs] load error:", err.message);
147
- }
148
- }
149
- }
150
- export class CcTgBot {
151
- bot;
152
- sessions = new Map();
153
- opts;
154
- cron;
155
- costStore;
156
- constructor(opts) {
157
- this.opts = opts;
158
- this.bot = new TelegramBot(opts.telegramToken, { polling: true });
159
- this.bot.on("message", (msg) => this.handleTelegram(msg));
160
- this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
161
- // Cron manager — fires each task into an isolated ClaudeProcess
162
- this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt) => {
163
- this.runCronTask(chatId, prompt);
164
- });
165
- this.costStore = new CostStore(opts.cwd ?? process.cwd());
166
- this.registerBotCommands();
167
- console.log("cc-tg bot started");
168
- console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
169
- }
170
- registerBotCommands() {
171
- this.bot.setMyCommands(BOT_COMMANDS)
172
- .then(() => console.log("[tg] bot commands registered"))
173
- .catch((err) => console.error("[tg] setMyCommands failed:", err.message));
174
- }
175
- isAllowed(userId) {
176
- if (!this.opts.allowedUserIds?.length)
177
- return true;
178
- return this.opts.allowedUserIds.includes(userId);
179
- }
180
- async handleTelegram(msg) {
181
- const chatId = msg.chat.id;
182
- const userId = msg.from?.id ?? chatId;
183
- if (!this.isAllowed(userId)) {
184
- await this.bot.sendMessage(chatId, "Not authorized.");
185
- return;
186
- }
187
- // Voice message — transcribe then feed as text
188
- if (msg.voice || msg.audio) {
189
- await this.handleVoice(chatId, msg);
190
- return;
191
- }
192
- // Photo — send as base64 image content block to Claude
193
- if (msg.photo?.length) {
194
- await this.handlePhoto(chatId, msg);
195
- return;
196
- }
197
- // Document — download to CWD/.cc-tg/uploads/, tell Claude the path
198
- if (msg.document) {
199
- await this.handleDocument(chatId, msg);
200
- return;
201
- }
202
- const text = msg.text?.trim();
203
- if (!text)
204
- return;
205
- // /start or /reset — kill existing session and ack
206
- if (text === "/start" || text === "/reset") {
207
- this.killSession(chatId);
208
- await this.bot.sendMessage(chatId, "Session reset. Send a message to start.");
209
- return;
210
- }
211
- // /stop — kill active session (interrupt running Claude task)
212
- if (text === "/stop") {
213
- const has = this.sessions.has(chatId);
214
- this.killSession(chatId);
215
- await this.bot.sendMessage(chatId, has ? "Stopped." : "No active session.");
216
- return;
217
- }
218
- // /help — list all commands
219
- if (text === "/help") {
220
- const lines = BOT_COMMANDS.map((c) => `/${c.command} — ${c.description}`);
221
- await this.bot.sendMessage(chatId, lines.join("\n"));
222
- return;
223
- }
224
- // /status
225
- if (text === "/status") {
226
- const has = this.sessions.has(chatId);
227
- await this.bot.sendMessage(chatId, has ? "Session active." : "No active session.");
228
- return;
229
- }
230
- // /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
231
- if (text.startsWith("/cron")) {
232
- await this.handleCron(chatId, text);
233
- return;
234
- }
235
- // /reload_mcp — kill cc-agent process so Claude Code auto-restarts it
236
- if (text === "/reload_mcp") {
237
- await this.handleReloadMcp(chatId);
238
- return;
239
- }
240
- // /mcp_status — run `claude mcp list` and show connection status
241
- if (text === "/mcp_status") {
242
- await this.handleMcpStatus(chatId);
243
- return;
244
- }
245
- // /mcp_version — show published npm version and cached npx entries
246
- if (text === "/mcp_version") {
247
- await this.handleMcpVersion(chatId);
248
- return;
249
- }
250
- // /clear_npx_cache — wipe ~/.npm/_npx/ then restart cc-agent
251
- if (text === "/clear_npx_cache") {
252
- await this.handleClearNpxCache(chatId);
253
- return;
254
- }
255
- // /restart — restart the bot process in-place
256
- if (text === "/restart") {
257
- await this.handleRestart(chatId);
258
- return;
259
- }
260
- // /get_file <path> — send a file from the server to the user
261
- if (text.startsWith("/get_file")) {
262
- await this.handleGetFile(chatId, text);
263
- return;
264
- }
265
- // /cost — show session token usage and cost
266
- if (text === "/cost") {
267
- const cost = this.costStore.get(chatId);
268
- let reply = formatCostReport(cost);
269
- try {
270
- const rawSummary = await this.callCcAgentTool("cost_summary");
271
- if (rawSummary) {
272
- reply += "\n\n" + formatAgentCostSummary(rawSummary);
273
- }
274
- }
275
- catch (err) {
276
- console.error("[cost] cc-agent cost_summary failed:", err.message);
277
- }
278
- await this.bot.sendMessage(chatId, reply);
279
- return;
280
- }
281
- const session = this.getOrCreateSession(chatId);
282
- try {
283
- session.claude.sendPrompt(buildPromptWithReplyContext(text, msg));
284
- this.startTyping(chatId, session);
285
- }
286
- catch (err) {
287
- await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
288
- this.killSession(chatId);
289
- }
290
- }
291
- async handleVoice(chatId, msg) {
292
- const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
293
- if (!fileId)
294
- return;
295
- console.log(`[voice:${chatId}] received voice message, transcribing...`);
296
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
297
- try {
298
- const fileLink = await this.bot.getFileLink(fileId);
299
- const transcript = await transcribeVoice(fileLink);
300
- console.log(`[voice:${chatId}] transcribed: ${transcript}`);
301
- if (!transcript || transcript === "[empty transcription]") {
302
- await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
303
- return;
304
- }
305
- // Feed transcript into Claude as if user typed it
306
- const session = this.getOrCreateSession(chatId);
307
- try {
308
- session.claude.sendPrompt(buildPromptWithReplyContext(transcript, msg));
309
- this.startTyping(chatId, session);
310
- }
311
- catch (err) {
312
- await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
313
- this.killSession(chatId);
314
- }
315
- }
316
- catch (err) {
317
- console.error(`[voice:${chatId}] error:`, err.message);
318
- await this.bot.sendMessage(chatId, `Voice transcription failed: ${err.message}`);
319
- }
320
- }
321
- async handlePhoto(chatId, msg) {
322
- // Pick highest resolution photo
323
- const photos = msg.photo;
324
- const best = photos[photos.length - 1];
325
- const caption = msg.caption?.trim();
326
- console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
327
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
328
- try {
329
- const fileLink = await this.bot.getFileLink(best.file_id);
330
- const imageData = await fetchAsBase64(fileLink);
331
- // Telegram photos are always JPEG
332
- const session = this.getOrCreateSession(chatId);
333
- session.claude.sendImage(imageData, "image/jpeg", caption);
334
- this.startTyping(chatId, session);
335
- }
336
- catch (err) {
337
- console.error(`[photo:${chatId}] error:`, err.message);
338
- await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
339
- }
340
- }
341
- async handleDocument(chatId, msg) {
342
- const doc = msg.document;
343
- const caption = msg.caption?.trim();
344
- const fileName = doc.file_name ?? `file_${doc.file_id}`;
345
- console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
346
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
347
- try {
348
- const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
349
- mkdirSync(uploadsDir, { recursive: true });
350
- const destPath = join(uploadsDir, fileName);
351
- const fileLink = await this.bot.getFileLink(doc.file_id);
352
- await downloadToFile(fileLink, destPath);
353
- console.log(`[doc:${chatId}] saved to ${destPath}`);
354
- const prompt = caption
355
- ? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
356
- : `ATTACHMENTS: [${fileName}](${destPath})`;
357
- const session = this.getOrCreateSession(chatId);
358
- session.claude.sendPrompt(prompt);
359
- this.startTyping(chatId, session);
360
- }
361
- catch (err) {
362
- console.error(`[doc:${chatId}] error:`, err.message);
363
- await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
364
- }
365
- }
366
- getOrCreateSession(chatId) {
367
- const existing = this.sessions.get(chatId);
368
- if (existing && !existing.claude.exited)
369
- return existing;
370
- const claude = new ClaudeProcess({
371
- cwd: this.opts.cwd,
372
- token: this.opts.claudeToken,
373
- });
374
- const session = {
375
- claude,
376
- pendingText: "",
377
- flushTimer: null,
378
- typingTimer: null,
379
- writtenFiles: new Set(),
380
- };
381
- claude.on("usage", (usage) => {
382
- this.costStore.addUsage(chatId, usage);
383
- });
384
- claude.on("message", (msg) => {
385
- // Verbose logging — log every message type and subtype
386
- const subtype = msg.payload.subtype ?? "";
387
- const toolName = this.extractToolName(msg);
388
- const logParts = [`[claude:${chatId}] msg=${msg.type}`];
389
- if (subtype)
390
- logParts.push(`subtype=${subtype}`);
391
- if (toolName)
392
- logParts.push(`tool=${toolName}`);
393
- console.log(logParts.join(" "));
394
- // Track files written by Write/Edit tool calls
395
- this.trackWrittenFiles(msg, session, this.opts.cwd);
396
- this.handleClaudeMessage(chatId, session, msg);
397
- });
398
- claude.on("stderr", (data) => {
399
- const line = data.trim();
400
- if (line)
401
- console.error(`[claude:${chatId}:stderr]`, line);
402
- });
403
- claude.on("exit", (code) => {
404
- console.log(`[claude:${chatId}] exited code=${code}`);
405
- this.stopTyping(session);
406
- this.sessions.delete(chatId);
407
- });
408
- claude.on("error", (err) => {
409
- console.error(`[claude:${chatId}] process error: ${err.message}`);
410
- this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
411
- this.stopTyping(session);
412
- this.sessions.delete(chatId);
413
- });
414
- this.sessions.set(chatId, session);
415
- return session;
416
- }
417
- handleClaudeMessage(chatId, session, msg) {
418
- // Use only the final `result` message — it contains the complete response text.
419
- // Ignore `assistant` streaming chunks to avoid duplicates.
420
- if (msg.type !== "result")
421
- return;
422
- this.stopTyping(session);
423
- this.costStore.incrementMessages(chatId);
424
- const text = extractText(msg);
425
- if (!text)
426
- return;
427
- // Accumulate text and debounce — Claude streams chunks rapidly
428
- session.pendingText += text;
429
- if (session.flushTimer)
430
- clearTimeout(session.flushTimer);
431
- session.flushTimer = setTimeout(() => this.flushPending(chatId, session), FLUSH_DELAY_MS);
432
- }
433
- startTyping(chatId, session) {
434
- this.stopTyping(session);
435
- // Send immediately, then keep alive every 4s
436
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
437
- session.typingTimer = setInterval(() => {
438
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
439
- }, TYPING_INTERVAL_MS);
440
- }
441
- stopTyping(session) {
442
- if (session.typingTimer) {
443
- clearInterval(session.typingTimer);
444
- session.typingTimer = null;
445
- }
446
- }
447
- flushPending(chatId, session) {
448
- const raw = session.pendingText.trim();
449
- session.pendingText = "";
450
- session.flushTimer = null;
451
- if (!raw)
452
- return;
453
- const text = raw;
454
- // Telegram max message length is 4096 chars — split if needed
455
- const chunks = splitMessage(text);
456
- for (const chunk of chunks) {
457
- this.bot.sendMessage(chatId, chunk, { parse_mode: "Markdown" }).catch(() => {
458
- // Markdown parse failed — retry as plain text
459
- this.bot.sendMessage(chatId, chunk).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
460
- });
461
- }
462
- // Hybrid file upload: find files mentioned in result text that Claude actually wrote
463
- try {
464
- this.uploadMentionedFiles(chatId, text, session);
465
- }
466
- catch (err) {
467
- console.error(`[tg:${chatId}] uploadMentionedFiles error:`, err.message);
468
- }
469
- }
470
- trackWrittenFiles(msg, session, cwd) {
471
- // Only look at assistant messages with tool_use blocks
472
- if (msg.type !== "assistant")
473
- return;
474
- const message = msg.payload.message;
475
- if (!message)
476
- return;
477
- const content = message.content;
478
- if (!Array.isArray(content))
479
- return;
480
- for (const block of content) {
481
- if (block.type !== "tool_use")
482
- continue;
483
- const name = block.name;
484
- const input = block.input;
485
- if (!input)
486
- continue;
487
- if (["Write", "Edit", "NotebookEdit"].includes(name)) {
488
- // Write tool uses file_path, Edit uses file_path
489
- const filePath = input.file_path ?? input.path;
490
- if (!filePath)
491
- continue;
492
- // Resolve relative paths against cwd
493
- const resolved = filePath.startsWith("/")
494
- ? filePath
495
- : resolve(cwd ?? process.cwd(), filePath);
496
- console.log(`[claude:files] tracked written file: ${resolved}`);
497
- session.writtenFiles.add(resolved);
498
- }
499
- else if (name === "Bash") {
500
- const cmd = input.command ?? "";
501
- if (/\byt-dlp\b|\bffmpeg\b/.test(cmd)) {
502
- // Scan output dir for recently modified media files (template paths like /tmp/%(title)s.%(ext)s
503
- // make the actual filename unknowable at tracking time)
504
- const oFlagMatch = cmd.match(/-o\s+["']?([^\s"']+)/);
505
- let scanDir = "/tmp/";
506
- if (oFlagMatch) {
507
- const oPath = oFlagMatch[1].replace(/["'].*$/, "");
508
- const dirEnd = oPath.lastIndexOf("/");
509
- if (dirEnd > 0)
510
- scanDir = oPath.slice(0, dirEnd + 1);
511
- }
512
- const MEDIA_EXTS = new Set([".mp3", ".mp4", ".wav", ".ogg", ".flac", ".webm", ".m4a", ".aac"]);
513
- const nowMs = Date.now();
514
- try {
515
- for (const entry of readdirSync(scanDir)) {
516
- const dotIdx = entry.lastIndexOf(".");
517
- if (dotIdx < 0)
518
- continue;
519
- const ext = entry.slice(dotIdx).toLowerCase();
520
- if (!MEDIA_EXTS.has(ext))
521
- continue;
522
- const full = join(scanDir, entry);
523
- try {
524
- if (nowMs - statSync(full).mtimeMs <= 90_000) {
525
- console.log(`[claude:files] tracked yt-dlp/ffmpeg output: ${full}`);
526
- session.writtenFiles.add(full);
527
- }
528
- }
529
- catch { /* skip unreadable entries */ }
530
- }
531
- }
532
- catch { /* scanDir doesn't exist or unreadable */ }
533
- }
534
- else {
535
- // Other bash commands: try to extract output path from -o flag
536
- const oFlag = cmd.match(/-o\s+["']?([^\s"']+\.[\w]{1,10})["']?/);
537
- if (oFlag)
538
- session.writtenFiles.add(resolve(cwd ?? process.cwd(), oFlag[1]));
539
- }
540
- // mv source dest — track dest
541
- const mvMatch = cmd.match(/\bmv\s+\S+\s+["']?([^\s"']+)["']?$/);
542
- if (mvMatch)
543
- session.writtenFiles.add(resolve(cwd ?? process.cwd(), mvMatch[1]));
544
- // cp source dest — track dest
545
- const cpMatch = cmd.match(/\bcp\s+\S+\s+["']?([^\s"']+)["']?$/);
546
- if (cpMatch)
547
- session.writtenFiles.add(resolve(cwd ?? process.cwd(), cpMatch[1]));
548
- // curl -o path or wget -O path
549
- const curlMatch = cmd.match(/curl\s+.*?-o\s+["']?([^\s"']+)["']?/);
550
- if (curlMatch)
551
- session.writtenFiles.add(resolve(cwd ?? process.cwd(), curlMatch[1]));
552
- // wget -O path
553
- const wgetMatch = cmd.match(/wget\s+.*?-O\s+["']?([^\s"']+)["']?/);
554
- if (wgetMatch)
555
- session.writtenFiles.add(resolve(cwd ?? process.cwd(), wgetMatch[1]));
556
- }
557
- }
558
- }
559
- isSensitiveFile(filePath) {
560
- const name = basename(filePath).toLowerCase();
561
- const sensitivePatterns = [
562
- /credential/i, /secret/i, /password/i, /passwd/i, /\.env/i,
563
- /api[_-]?key/i, /token/i, /private[_-]?key/i, /id_rsa/i,
564
- /\.pem$/i, /\.key$/i, /\.pfx$/i, /\.p12$/i,
565
- /gmail/i, /oauth/i, /\bauth\b/i,
566
- ];
567
- return sensitivePatterns.some((p) => p.test(name));
568
- }
569
- uploadMentionedFiles(chatId, resultText, session) {
570
- // Extract file path candidates from result text
571
- // Match: /absolute/path/file.ext or relative like ./foo/bar.csv or just foo.pdf
572
- const pathPattern = /(?:^|[\s`'"(])(\/?[\w.\-/]+\.[\w]{1,10})(?:[\s`'")\n]|$)/gm;
573
- const quotedPattern = /"([^"]+\.[a-zA-Z0-9]{1,10})"|'([^']+\.[a-zA-Z0-9]{1,10})'/g;
574
- const candidates = new Set();
575
- let match;
576
- while ((match = pathPattern.exec(resultText)) !== null) {
577
- candidates.add(match[1]);
578
- }
579
- while ((match = quotedPattern.exec(resultText)) !== null) {
580
- candidates.add(match[1] ?? match[2]);
581
- }
582
- const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/"];
583
- const isSafeDir = (p) => safeDirs.some(d => p.startsWith(d)) || p.startsWith(this.opts.cwd ?? process.cwd());
584
- const toUpload = [];
585
- if (session.writtenFiles.size > 0) {
586
- for (const candidate of candidates) {
587
- // Try as-is (absolute), or resolve against cwd
588
- const resolved = candidate.startsWith("/")
589
- ? candidate
590
- : resolve(this.opts.cwd ?? process.cwd(), candidate);
591
- if (session.writtenFiles.has(resolved) && existsSync(resolved)) {
592
- toUpload.push(resolved);
593
- }
594
- else {
595
- // Also check by basename — result might mention just the filename
596
- for (const written of session.writtenFiles) {
597
- if (basename(written) === basename(candidate) && existsSync(written)) {
598
- toUpload.push(written);
599
- break;
600
- }
601
- }
602
- }
603
- }
604
- }
605
- // Also upload files mentioned in result text that exist in safe dirs
606
- // even if not tracked via Write tool
607
- for (const candidate of candidates) {
608
- const resolved = candidate.startsWith("/")
609
- ? candidate
610
- : resolve(this.opts.cwd ?? process.cwd(), candidate);
611
- if (existsSync(resolved) && isSafeDir(resolved) && !toUpload.includes(resolved)) {
612
- toUpload.push(resolved);
613
- }
614
- }
615
- // Deduplicate and filter sensitive files
616
- const unique = [...new Set(toUpload)];
617
- for (const filePath of unique) {
618
- if (this.isSensitiveFile(filePath)) {
619
- console.log(`[claude:files] skipping sensitive file: ${filePath}`);
620
- continue;
621
- }
622
- let fileSize;
623
- try {
624
- fileSize = statSync(filePath).size;
625
- }
626
- catch {
627
- continue; // file disappeared between existsSync and statSync
628
- }
629
- const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
630
- if (fileSize > MAX_TG_FILE_BYTES) {
631
- const mb = (fileSize / (1024 * 1024)).toFixed(1);
632
- this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`).catch(() => { });
633
- continue;
634
- }
635
- console.log(`[claude:files] uploading to telegram: ${filePath}`);
636
- this.bot.sendDocument(chatId, filePath).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
637
- }
638
- // Clear written files for next turn
639
- session.writtenFiles.clear();
640
- }
641
- extractToolName(msg) {
642
- const message = msg.payload.message;
643
- if (!message)
644
- return "";
645
- const content = message.content;
646
- if (!Array.isArray(content))
647
- return "";
648
- const toolUse = content.find((b) => b.type === "tool_use");
649
- return toolUse?.name ?? "";
650
- }
651
- runCronTask(chatId, prompt) {
652
- // Fresh isolated Claude session — never touches main conversation
653
- const cronProcess = new ClaudeProcess({
654
- cwd: this.opts.cwd,
655
- token: this.opts.claudeToken,
656
- });
657
- const taskPrompt = [
658
- "You are handling a scheduled background task.",
659
- "This is NOT part of the user's ongoing conversation.",
660
- "Be concise. Report results only. No greetings or pleasantries.",
661
- "If there is nothing to report, say so in one sentence.",
662
- "",
663
- `SCHEDULED TASK: ${prompt}`,
664
- ].join("\n");
665
- let output = "";
666
- const cronUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
667
- cronProcess.on("usage", (usage) => {
668
- cronUsage.inputTokens += usage.inputTokens;
669
- cronUsage.outputTokens += usage.outputTokens;
670
- cronUsage.cacheReadTokens += usage.cacheReadTokens;
671
- cronUsage.cacheWriteTokens += usage.cacheWriteTokens;
672
- });
673
- cronProcess.on("message", (msg) => {
674
- if (msg.type === "result") {
675
- const text = extractText(msg);
676
- if (text)
677
- output += text;
678
- const result = output.trim();
679
- if (result) {
680
- let footer = "";
681
- try {
682
- footer = formatCronCostFooter(cronUsage);
683
- }
684
- catch (err) {
685
- console.error(`[cron] cost footer error:`, err.message);
686
- }
687
- const chunks = splitMessage(`🕐 ${result}${footer}`);
688
- (async () => {
689
- for (const chunk of chunks) {
690
- try {
691
- await this.bot.sendMessage(chatId, chunk);
692
- }
693
- catch (err) {
694
- console.error(`[cron] failed to send result to chat=${chatId}:`, err.message);
695
- }
696
- }
697
- })();
698
- }
699
- cronProcess.kill();
700
- }
701
- });
702
- cronProcess.on("error", (err) => {
703
- console.error(`[cron] task error for chat=${chatId}:`, err.message);
704
- cronProcess.kill();
705
- });
706
- cronProcess.on("exit", () => {
707
- console.log(`[cron] task complete for chat=${chatId}`);
708
- });
709
- cronProcess.sendPrompt(taskPrompt);
710
- }
711
- async handleCron(chatId, text) {
712
- const args = text.slice("/cron".length).trim();
713
- // /cron list
714
- if (args === "list" || args === "") {
715
- const jobs = this.cron.list(chatId);
716
- if (!jobs.length) {
717
- await this.bot.sendMessage(chatId, "No cron jobs.");
718
- return;
719
- }
720
- const lines = jobs.map((j, i) => {
721
- const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
722
- return `#${i + 1} ${j.schedule} — "${short}"`;
723
- });
724
- await this.bot.sendMessage(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`);
725
- return;
726
- }
727
- // /cron clear
728
- if (args === "clear") {
729
- const n = this.cron.clearAll(chatId);
730
- await this.bot.sendMessage(chatId, `Cleared ${n} cron job(s).`);
731
- return;
732
- }
733
- // /cron remove <id>
734
- if (args.startsWith("remove ")) {
735
- const id = args.slice("remove ".length).trim();
736
- const ok = this.cron.remove(chatId, id);
737
- await this.bot.sendMessage(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`);
738
- return;
739
- }
740
- // /cron edit [<#> ...]
741
- if (args === "edit" || args.startsWith("edit ")) {
742
- await this.handleCronEdit(chatId, args.slice("edit".length).trim());
743
- return;
744
- }
745
- // /cron every 1h <prompt>
746
- const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
747
- if (!scheduleMatch) {
748
- await this.bot.sendMessage(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron edit\n/cron remove <id>\n/cron clear");
749
- return;
750
- }
751
- const schedule = scheduleMatch[1];
752
- const prompt = scheduleMatch[2];
753
- const job = this.cron.add(chatId, schedule, prompt);
754
- if (!job) {
755
- await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
756
- return;
757
- }
758
- await this.bot.sendMessage(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`);
759
- }
760
- async handleCronEdit(chatId, editArgs) {
761
- const jobs = this.cron.list(chatId);
762
- // No args — show numbered list with edit instructions
763
- if (!editArgs) {
764
- if (!jobs.length) {
765
- await this.bot.sendMessage(chatId, "No cron jobs to edit.");
766
- return;
767
- }
768
- const lines = jobs.map((j, i) => {
769
- const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
770
- return `#${i + 1} ${j.schedule} — "${short}"`;
771
- });
772
- await this.bot.sendMessage(chatId, `Cron jobs:\n${lines.join("\n")}\n\n` +
773
- "Edit options:\n" +
774
- "/cron edit <#> every <N><unit> <new prompt>\n" +
775
- "/cron edit <#> schedule every <N><unit>\n" +
776
- "/cron edit <#> prompt <new prompt>");
777
- return;
778
- }
779
- // Expect: <index> <rest>
780
- const indexMatch = editArgs.match(/^(\d+)\s+(.+)$/);
781
- if (!indexMatch) {
782
- await this.bot.sendMessage(chatId, "Usage: /cron edit <#> every <N><unit> <new prompt>");
783
- return;
784
- }
785
- const index = parseInt(indexMatch[1], 10) - 1;
786
- if (index < 0 || index >= jobs.length) {
787
- await this.bot.sendMessage(chatId, `Invalid job number. Use /cron edit to see the list.`);
788
- return;
789
- }
790
- const job = jobs[index];
791
- const editCmd = indexMatch[2];
792
- // /cron edit <#> schedule every <N><unit>
793
- if (editCmd.startsWith("schedule ")) {
794
- const newSchedule = editCmd.slice("schedule ".length).trim();
795
- const result = this.cron.update(chatId, job.id, { schedule: newSchedule });
796
- if (result === null) {
797
- await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
798
- }
799
- else if (result === false) {
800
- await this.bot.sendMessage(chatId, "Job not found.");
801
- }
802
- else {
803
- await this.bot.sendMessage(chatId, `#${index + 1} schedule updated to ${newSchedule}.`);
804
- }
805
- return;
806
- }
807
- // /cron edit <#> prompt <new-prompt>
808
- if (editCmd.startsWith("prompt ")) {
809
- const newPrompt = editCmd.slice("prompt ".length).trim();
810
- const result = this.cron.update(chatId, job.id, { prompt: newPrompt });
811
- if (result === false) {
812
- await this.bot.sendMessage(chatId, "Job not found.");
813
- }
814
- else {
815
- await this.bot.sendMessage(chatId, `#${index + 1} prompt updated to "${newPrompt}".`);
816
- }
817
- return;
818
- }
819
- // /cron edit <#> every <N><unit> <new-prompt>
820
- const fullMatch = editCmd.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
821
- if (fullMatch) {
822
- const newSchedule = fullMatch[1];
823
- const newPrompt = fullMatch[2];
824
- const result = this.cron.update(chatId, job.id, { schedule: newSchedule, prompt: newPrompt });
825
- if (result === null) {
826
- await this.bot.sendMessage(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d");
827
- }
828
- else if (result === false) {
829
- await this.bot.sendMessage(chatId, "Job not found.");
830
- }
831
- else {
832
- await this.bot.sendMessage(chatId, `#${index + 1} updated: ${newSchedule} — "${newPrompt}"`);
833
- }
834
- return;
835
- }
836
- await this.bot.sendMessage(chatId, "Edit options:\n" +
837
- "/cron edit <#> every <N><unit> <new prompt>\n" +
838
- "/cron edit <#> schedule every <N><unit>\n" +
839
- "/cron edit <#> prompt <new prompt>");
840
- }
841
- /** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
842
- findCcAgentPids() {
843
- try {
844
- const out = execSync("pgrep -f cc-agent", { encoding: "utf8" }).trim();
845
- return out.split("\n").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n) && n > 0);
846
- }
847
- catch {
848
- // pgrep exits with code 1 when no match — that's fine
849
- return [];
850
- }
851
- }
852
- /** Kill cc-agent PIDs with SIGTERM. Returns the list of killed PIDs. */
853
- killCcAgent() {
854
- const pids = this.findCcAgentPids();
855
- for (const pid of pids) {
856
- try {
857
- process.kill(pid, "SIGTERM");
858
- console.log(`[mcp] sent SIGTERM to cc-agent pid=${pid}`);
859
- }
860
- catch (err) {
861
- console.warn(`[mcp] failed to kill pid=${pid}:`, err.message);
862
- }
863
- }
864
- return pids;
865
- }
866
- async handleReloadMcp(chatId) {
867
- await this.bot.sendMessage(chatId, "Clearing npx cache and reloading MCP...");
868
- try {
869
- const home = process.env.HOME ?? "~";
870
- execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
871
- console.log("[mcp] cleared ~/.npm/_npx/");
872
- }
873
- catch (err) {
874
- await this.bot.sendMessage(chatId, `Warning: failed to clear npx cache: ${err.message}`);
875
- }
876
- const pids = this.killCcAgent();
877
- if (pids.length === 0) {
878
- await this.bot.sendMessage(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.");
879
- return;
880
- }
881
- 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.`);
882
- }
883
- async handleMcpStatus(chatId) {
884
- try {
885
- const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
886
- await this.bot.sendMessage(chatId, `MCP server status:\n\n${output || "(no output)"}`);
887
- }
888
- catch (err) {
889
- await this.bot.sendMessage(chatId, `Failed to run claude mcp list: ${err.message}`);
890
- }
891
- }
892
- async handleMcpVersion(chatId) {
893
- let npmVersion = "unknown";
894
- let cacheEntries = "(unavailable)";
895
- try {
896
- npmVersion = execSync("npm view @gonzih/cc-agent version", { encoding: "utf8" }).trim();
897
- }
898
- catch (err) {
899
- npmVersion = `error: ${err.message.split("\n")[0]}`;
900
- }
901
- try {
902
- const home = process.env.HOME ?? "~";
903
- const cacheOut = execSync(`ls "${home}/.npm/_npx/" 2>/dev/null | head -5`, { encoding: "utf8", shell: "/bin/sh" }).trim();
904
- cacheEntries = cacheOut || "(empty)";
905
- }
906
- catch {
907
- cacheEntries = "(empty or not found)";
908
- }
909
- await this.bot.sendMessage(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`);
910
- }
911
- async handleClearNpxCache(chatId) {
912
- try {
913
- const home = process.env.HOME ?? "~";
914
- execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
915
- console.log("[mcp] cleared ~/.npm/_npx/");
916
- }
917
- catch (err) {
918
- await this.bot.sendMessage(chatId, `Failed to clear npx cache: ${err.message}`);
919
- return;
920
- }
921
- const pids = this.killCcAgent();
922
- const pidNote = pids.length > 0
923
- ? ` Sent SIGTERM to pid${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}.`
924
- : " No cc-agent process found (will start fresh on next call).";
925
- await this.bot.sendMessage(chatId, `NPX cache cleared and MCP restarted.${pidNote} Will pick up latest npm version on next call.`);
926
- }
927
- async handleRestart(chatId) {
928
- await this.bot.sendMessage(chatId, "Restarting... brb.");
929
- await new Promise(resolve => setTimeout(resolve, 500));
930
- process.exit(0);
931
- }
932
- async handleGetFile(chatId, text) {
933
- const arg = text.slice("/get_file".length).trim();
934
- if (!arg) {
935
- await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
936
- return;
937
- }
938
- const filePath = resolve(arg);
939
- const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
940
- const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
941
- if (!inSafeDir) {
942
- await this.bot.sendMessage(chatId, "Access denied: path not in allowed directories");
943
- return;
944
- }
945
- if (!existsSync(filePath)) {
946
- await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
947
- return;
948
- }
949
- if (!statSync(filePath).isFile()) {
950
- await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
951
- return;
952
- }
953
- if (this.isSensitiveFile(filePath)) {
954
- await this.bot.sendMessage(chatId, "Access denied: sensitive file");
955
- return;
956
- }
957
- const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
958
- const fileSize = statSync(filePath).size;
959
- if (fileSize > MAX_TG_FILE_BYTES) {
960
- const mb = (fileSize / (1024 * 1024)).toFixed(1);
961
- await this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`);
962
- return;
963
- }
964
- await this.bot.sendDocument(chatId, filePath);
965
- }
966
- callCcAgentTool(toolName, args = {}) {
967
- return new Promise((resolve) => {
968
- let settled = false;
969
- const done = (val) => {
970
- if (!settled) {
971
- settled = true;
972
- resolve(val);
973
- }
974
- };
975
- let proc;
976
- try {
977
- proc = spawn("npx", ["-y", "@gonzih/cc-agent@latest"], {
978
- env: { ...process.env },
979
- stdio: ["pipe", "pipe", "pipe"],
980
- });
981
- }
982
- catch (err) {
983
- console.error("[mcp] failed to spawn cc-agent:", err.message);
984
- done(null);
985
- return;
986
- }
987
- const timeout = setTimeout(() => {
988
- console.warn("[mcp] cc-agent tool call timed out");
989
- proc.kill();
990
- done(null);
991
- }, 30_000);
992
- let buffer = "";
993
- const sendMsg = (msg) => { proc.stdin.write(JSON.stringify(msg) + "\n"); };
994
- sendMsg({
995
- jsonrpc: "2.0", id: 1, method: "initialize",
996
- params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "cc-tg", version: "1.0.0" } },
997
- });
998
- proc.stdout.on("data", (chunk) => {
999
- buffer += chunk.toString();
1000
- const lines = buffer.split("\n");
1001
- buffer = lines.pop() ?? "";
1002
- for (const line of lines) {
1003
- if (!line.trim())
1004
- continue;
1005
- try {
1006
- const msg = JSON.parse(line);
1007
- if (msg.id === 1 && "result" in msg) {
1008
- sendMsg({ jsonrpc: "2.0", method: "notifications/initialized" });
1009
- sendMsg({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: toolName, arguments: args } });
1010
- }
1011
- else if (msg.id === 2) {
1012
- clearTimeout(timeout);
1013
- if (msg.error) {
1014
- console.error("[mcp] cost_summary error:", JSON.stringify(msg.error));
1015
- proc.kill();
1016
- done(null);
1017
- return;
1018
- }
1019
- const result = msg.result;
1020
- const content = result?.content;
1021
- const text = (content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("");
1022
- proc.kill();
1023
- done(text || null);
1024
- }
1025
- }
1026
- catch { /* ignore non-JSON lines */ }
1027
- }
1028
- });
1029
- proc.on("error", (err) => {
1030
- console.error("[mcp] cc-agent spawn error:", err.message);
1031
- clearTimeout(timeout);
1032
- done(null);
1033
- });
1034
- proc.on("exit", () => { clearTimeout(timeout); done(null); });
1035
- });
1036
- }
1037
- killSession(chatId, keepCrons = true) {
1038
- const session = this.sessions.get(chatId);
1039
- if (session) {
1040
- this.stopTyping(session);
1041
- session.claude.kill();
1042
- this.sessions.delete(chatId);
1043
- }
1044
- if (!keepCrons)
1045
- this.cron.clearAll(chatId);
1046
- }
1047
- stop() {
1048
- this.bot.stopPolling();
1049
- for (const [chatId] of this.sessions) {
1050
- this.killSession(chatId);
1051
- }
1052
- }
1053
- }
1054
- function buildPromptWithReplyContext(text, msg) {
1055
- const reply = msg.reply_to_message;
1056
- if (!reply)
1057
- return text;
1058
- const quotedText = reply.text || reply.caption || null;
1059
- if (!quotedText)
1060
- return text;
1061
- const truncated = quotedText.length > 500
1062
- ? quotedText.slice(0, 500) + "... [truncated]"
1063
- : quotedText;
1064
- return `[Replying to: "${truncated}"]\n\n${text}`;
1065
- }
1066
- /** Download a URL and return its contents as a base64 string */
1067
- function fetchAsBase64(url) {
1068
- return new Promise((resolve, reject) => {
1069
- const client = url.startsWith("https") ? https : http;
1070
- client.get(url, (res) => {
1071
- const chunks = [];
1072
- res.on("data", (chunk) => chunks.push(chunk));
1073
- res.on("end", () => resolve(Buffer.concat(chunks).toString("base64")));
1074
- res.on("error", reject);
1075
- }).on("error", reject);
1076
- });
1077
- }
1078
- /** Download a URL to a local file path */
1079
- function downloadToFile(url, destPath) {
1080
- return new Promise((resolve, reject) => {
1081
- const client = url.startsWith("https") ? https : http;
1082
- const file = createWriteStream(destPath);
1083
- client.get(url, (res) => {
1084
- res.pipe(file);
1085
- file.on("finish", () => file.close(() => resolve()));
1086
- file.on("error", reject);
1087
- }).on("error", reject);
1088
- });
1089
- }
1090
- export function splitMessage(text, maxLen = 4096) {
1091
- if (text.length <= maxLen)
1092
- return [text];
1093
- const chunks = [];
1094
- let i = 0;
1095
- while (i < text.length) {
1096
- chunks.push(text.slice(i, i + maxLen));
1097
- i += maxLen;
1098
- }
1099
- return chunks;
1100
- }