@gonzih/cc-tg 0.6.0 → 0.6.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/dist/bot.d.ts CHANGED
@@ -63,4 +63,8 @@ export declare class CcTgBot {
63
63
  getMe(): Promise<TelegramBot.User>;
64
64
  stop(): void;
65
65
  }
66
+ /** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
67
+ export declare function enrichPromptWithUrls(text: string): Promise<string>;
68
+ /** List available skills from ~/.claude/skills/ */
69
+ export declare function listSkills(): string;
66
70
  export declare function splitMessage(text: string, maxLen?: number): string[];
package/dist/bot.js CHANGED
@@ -29,6 +29,7 @@ const BOT_COMMANDS = [
29
29
  { command: "restart", description: "Restart the bot process in-place" },
30
30
  { command: "get_file", description: "Send a file from the server to this chat" },
31
31
  { command: "cost", description: "Show session token usage and cost" },
32
+ { command: "skills", description: "List available Claude skills with descriptions" },
32
33
  ];
33
34
  const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
34
35
  const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
@@ -355,9 +356,15 @@ export class CcTgBot {
355
356
  await this.replyToChat(chatId, reply, threadId);
356
357
  return;
357
358
  }
359
+ // /skills — list available Claude skills from ~/.claude/skills/
360
+ if (text === "/skills") {
361
+ await this.replyToChat(chatId, listSkills(), threadId);
362
+ return;
363
+ }
358
364
  const session = this.getOrCreateSession(chatId, threadId, threadName);
359
365
  try {
360
- const prompt = buildPromptWithReplyContext(text, msg);
366
+ const enriched = await enrichPromptWithUrls(text);
367
+ const prompt = buildPromptWithReplyContext(enriched, msg);
361
368
  session.currentPrompt = prompt;
362
369
  session.claude.sendPrompt(prompt);
363
370
  this.startTyping(chatId, session);
@@ -1291,6 +1298,85 @@ function downloadToFile(url, destPath) {
1291
1298
  }).on("error", reject);
1292
1299
  });
1293
1300
  }
1301
+ /** Fetch URL via Jina Reader and return first maxChars characters */
1302
+ function fetchUrlViaJina(url, maxChars = 2000) {
1303
+ const jinaUrl = `https://r.jina.ai/${url}`;
1304
+ return new Promise((resolve, reject) => {
1305
+ https.get(jinaUrl, (res) => {
1306
+ const chunks = [];
1307
+ res.on("data", (chunk) => chunks.push(chunk));
1308
+ res.on("end", () => {
1309
+ const text = Buffer.concat(chunks).toString("utf8");
1310
+ resolve(text.slice(0, maxChars));
1311
+ });
1312
+ res.on("error", reject);
1313
+ }).on("error", reject);
1314
+ });
1315
+ }
1316
+ /** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
1317
+ export async function enrichPromptWithUrls(text) {
1318
+ const urlRegex = /https?:\/\/[^\s]+/g;
1319
+ const urls = text.match(urlRegex);
1320
+ if (!urls || urls.length === 0)
1321
+ return text;
1322
+ const prefixes = [];
1323
+ for (const url of urls) {
1324
+ // Skip jina.ai URLs to avoid recursion
1325
+ if (url.includes("r.jina.ai"))
1326
+ continue;
1327
+ try {
1328
+ const content = await fetchUrlViaJina(url);
1329
+ if (content.trim()) {
1330
+ prefixes.push(`[Web content from ${url}]:\n${content}`);
1331
+ }
1332
+ }
1333
+ catch (err) {
1334
+ console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
1335
+ }
1336
+ }
1337
+ if (prefixes.length === 0)
1338
+ return text;
1339
+ return prefixes.join("\n\n") + "\n\n" + text;
1340
+ }
1341
+ /** Parse frontmatter description from a skill markdown file */
1342
+ function parseSkillDescription(content) {
1343
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
1344
+ if (!match)
1345
+ return null;
1346
+ const frontmatter = match[1];
1347
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
1348
+ return descMatch ? descMatch[1].trim() : null;
1349
+ }
1350
+ /** List available skills from ~/.claude/skills/ */
1351
+ export function listSkills() {
1352
+ const skillsDir = join(os.homedir(), ".claude", "skills");
1353
+ if (!existsSync(skillsDir)) {
1354
+ return "No skills directory found at ~/.claude/skills/";
1355
+ }
1356
+ let files;
1357
+ try {
1358
+ files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
1359
+ }
1360
+ catch {
1361
+ return "Could not read skills directory.";
1362
+ }
1363
+ if (files.length === 0) {
1364
+ return "No skills found in ~/.claude/skills/";
1365
+ }
1366
+ const lines = ["Available skills:"];
1367
+ for (const file of files.sort()) {
1368
+ const name = "/" + file.replace(/\.md$/, "");
1369
+ try {
1370
+ const content = readFileSync(join(skillsDir, file), "utf8");
1371
+ const description = parseSkillDescription(content);
1372
+ lines.push(description ? `${name} — ${description}` : name);
1373
+ }
1374
+ catch {
1375
+ lines.push(name);
1376
+ }
1377
+ }
1378
+ return lines.join("\n");
1379
+ }
1294
1380
  export function splitMessage(text, maxLen = 4096) {
1295
1381
  if (text.length <= maxLen)
1296
1382
  return [text];
@@ -0,0 +1,55 @@
1
+ /**
2
+ * cc-agent Redis event subscriber.
3
+ *
4
+ * Listens to the `cca:events` pub/sub channel for job completion events,
5
+ * asks Claude to decide what to do, and acts accordingly:
6
+ * NOTIFY_ONLY — send a Telegram message to the configured chat
7
+ * SPAWN_FOLLOWUP — spawn a follow-up cc-agent job via MCP
8
+ * SILENT — log and do nothing
9
+ *
10
+ * Controlled via CC_AGENT_EVENTS_ENABLED env var (default: true).
11
+ * Requires CC_AGENT_NOTIFY_CHAT_ID to send Telegram notifications.
12
+ */
13
+ export interface JobEvent {
14
+ jobId: string;
15
+ status: "done" | "failed" | "interrupted" | "running" | "cancelled";
16
+ title: string;
17
+ repoUrl: string;
18
+ lastLines: string[];
19
+ score?: number;
20
+ timestamp: number;
21
+ }
22
+ export interface DecisionResult {
23
+ action: "NOTIFY_ONLY" | "SPAWN_FOLLOWUP" | "SILENT";
24
+ message?: string;
25
+ followup?: {
26
+ repo_url: string;
27
+ task: string;
28
+ };
29
+ }
30
+ /** Injectable dependencies for testability */
31
+ export interface HandlerDeps {
32
+ askClaude: (prompt: string) => Promise<string>;
33
+ sendTelegramMessage: (chatId: number, text: string) => Promise<void>;
34
+ spawnFollowupAgent: (repoUrl: string, task: string) => Promise<void>;
35
+ }
36
+ export declare function buildDecisionPrompt(event: JobEvent): string;
37
+ export declare function parseDecision(raw: string): DecisionResult;
38
+ /**
39
+ * Ask Claude to make a decision about a completed job.
40
+ * Returns the raw text response from Claude.
41
+ */
42
+ export declare function defaultAskClaude(prompt: string): Promise<string>;
43
+ export declare function defaultSendTelegramMessage(chatId: number, text: string): Promise<void>;
44
+ export declare function defaultSpawnFollowupAgent(repoUrl: string, task: string): Promise<void>;
45
+ /**
46
+ * Handle a single job event message from Redis pub/sub.
47
+ * Exported for testability — production code passes defaultDeps.
48
+ */
49
+ export declare function handleJobEvent(message: string, deps: HandlerDeps): Promise<void>;
50
+ /**
51
+ * Connect to Redis and subscribe to cca:events.
52
+ * Reconnects automatically on disconnect.
53
+ * Call once at startup.
54
+ */
55
+ export declare function connectEventSubscriber(): Promise<void>;
@@ -0,0 +1,288 @@
1
+ /**
2
+ * cc-agent Redis event subscriber.
3
+ *
4
+ * Listens to the `cca:events` pub/sub channel for job completion events,
5
+ * asks Claude to decide what to do, and acts accordingly:
6
+ * NOTIFY_ONLY — send a Telegram message to the configured chat
7
+ * SPAWN_FOLLOWUP — spawn a follow-up cc-agent job via MCP
8
+ * SILENT — log and do nothing
9
+ *
10
+ * Controlled via CC_AGENT_EVENTS_ENABLED env var (default: true).
11
+ * Requires CC_AGENT_NOTIFY_CHAT_ID to send Telegram notifications.
12
+ */
13
+ import { Redis } from "ioredis";
14
+ import TelegramBot from "node-telegram-bot-api";
15
+ import { ClaudeProcess, extractText } from "./claude.js";
16
+ function log(level, ...args) {
17
+ const fn = level === "error"
18
+ ? console.error
19
+ : level === "warn"
20
+ ? console.warn
21
+ : console.log;
22
+ fn("[cc-agent-events]", ...args);
23
+ }
24
+ export function buildDecisionPrompt(event) {
25
+ return `A cc-agent job just completed.
26
+
27
+ Job: ${event.title}
28
+ Repo: ${event.repoUrl}
29
+ Status: ${event.status}
30
+ Last output:
31
+ ${event.lastLines.join("\n")}
32
+
33
+ Decide what to do next. Options:
34
+ 1. NOTIFY_ONLY — send a brief Telegram message to Maksim summarizing what completed
35
+ 2. SPAWN_FOLLOWUP — spawn a follow-up cc-agent job (provide repo_url and task)
36
+ 3. SILENT — log it, no action needed (routine/expected completion)
37
+
38
+ Reply in this exact JSON format:
39
+ {
40
+ "action": "NOTIFY_ONLY" | "SPAWN_FOLLOWUP" | "SILENT",
41
+ "message": "...",
42
+ "followup": {
43
+ "repo_url": "...",
44
+ "task": "..."
45
+ }
46
+ }
47
+
48
+ Be conservative. Only SPAWN_FOLLOWUP if clearly needed. Only NOTIFY_ONLY for important completions. Use SILENT for routine jobs.`;
49
+ }
50
+ export function parseDecision(raw) {
51
+ const match = raw.match(/\{[\s\S]*\}/);
52
+ if (!match)
53
+ throw new Error(`No JSON found in Claude response: ${raw.slice(0, 200)}`);
54
+ const parsed = JSON.parse(match[0]);
55
+ if (!["NOTIFY_ONLY", "SPAWN_FOLLOWUP", "SILENT"].includes(parsed.action)) {
56
+ throw new Error(`Unknown action: ${parsed.action}`);
57
+ }
58
+ return parsed;
59
+ }
60
+ /**
61
+ * Ask Claude to make a decision about a completed job.
62
+ * Returns the raw text response from Claude.
63
+ */
64
+ export function defaultAskClaude(prompt) {
65
+ return new Promise((resolve, reject) => {
66
+ const token = process.env.CLAUDE_CODE_TOKEN ??
67
+ process.env.CLAUDE_CODE_OAUTH_TOKEN ??
68
+ process.env.ANTHROPIC_API_KEY;
69
+ if (!token) {
70
+ reject(new Error("No Claude token configured"));
71
+ return;
72
+ }
73
+ const claude = new ClaudeProcess({ token });
74
+ let output = "";
75
+ const timeout = setTimeout(() => {
76
+ claude.kill();
77
+ reject(new Error("Claude decision timed out after 60s"));
78
+ }, 60_000);
79
+ claude.on("message", (msg) => {
80
+ if (msg.type === "result") {
81
+ const text = extractText(msg);
82
+ if (text)
83
+ output += text;
84
+ clearTimeout(timeout);
85
+ claude.kill();
86
+ resolve(output.trim());
87
+ }
88
+ else if (msg.type === "assistant") {
89
+ const text = extractText(msg);
90
+ if (text)
91
+ output += text;
92
+ }
93
+ });
94
+ claude.on("error", (err) => {
95
+ clearTimeout(timeout);
96
+ reject(err);
97
+ });
98
+ claude.on("exit", (code) => {
99
+ clearTimeout(timeout);
100
+ if (!output) {
101
+ reject(new Error(`Claude exited with code ${code} and no output`));
102
+ }
103
+ else {
104
+ resolve(output.trim());
105
+ }
106
+ });
107
+ claude.sendPrompt(prompt);
108
+ });
109
+ }
110
+ export async function defaultSendTelegramMessage(chatId, text) {
111
+ const token = process.env.TELEGRAM_BOT_TOKEN;
112
+ if (!token)
113
+ throw new Error("TELEGRAM_BOT_TOKEN not set");
114
+ const tg = new TelegramBot(token, { polling: false });
115
+ await tg.sendMessage(chatId, text);
116
+ }
117
+ export async function defaultSpawnFollowupAgent(repoUrl, task) {
118
+ const token = process.env.CLAUDE_CODE_TOKEN ??
119
+ process.env.CLAUDE_CODE_OAUTH_TOKEN ??
120
+ process.env.ANTHROPIC_API_KEY;
121
+ const prompt = `Use the spawn_agent MCP tool to start a new cc-agent job with these parameters:
122
+ repo_url: ${repoUrl}
123
+ task: ${task}
124
+
125
+ Call the spawn_agent tool now with these exact parameters. Report the job ID when done.`;
126
+ return new Promise((resolve) => {
127
+ const claude = new ClaudeProcess({ token: token ?? undefined });
128
+ const timeout = setTimeout(() => {
129
+ log("warn", "spawnFollowupAgent: timed out");
130
+ claude.kill();
131
+ resolve();
132
+ }, 120_000);
133
+ claude.on("message", (msg) => {
134
+ if (msg.type === "result") {
135
+ clearTimeout(timeout);
136
+ claude.kill();
137
+ resolve();
138
+ }
139
+ });
140
+ claude.on("error", (err) => {
141
+ log("error", "spawnFollowupAgent error:", err.message);
142
+ clearTimeout(timeout);
143
+ resolve();
144
+ });
145
+ claude.on("exit", () => {
146
+ clearTimeout(timeout);
147
+ resolve();
148
+ });
149
+ claude.sendPrompt(prompt);
150
+ });
151
+ }
152
+ /**
153
+ * Handle a single job event message from Redis pub/sub.
154
+ * Exported for testability — production code passes defaultDeps.
155
+ */
156
+ export async function handleJobEvent(message, deps) {
157
+ let event;
158
+ try {
159
+ event = JSON.parse(message);
160
+ }
161
+ catch (err) {
162
+ log("error", "Failed to parse job event:", err.message);
163
+ return;
164
+ }
165
+ // Only act on terminal states
166
+ if (event.status !== "done" && event.status !== "failed") {
167
+ log("info", `Ignoring ${event.status} event for job ${event.jobId}`);
168
+ return;
169
+ }
170
+ log("info", `Processing ${event.status} event for job: ${event.title} (${event.jobId})`);
171
+ let decision;
172
+ try {
173
+ const rawResponse = await deps.askClaude(buildDecisionPrompt(event));
174
+ decision = parseDecision(rawResponse);
175
+ }
176
+ catch (err) {
177
+ log("error", "Claude decision failed:", err.message);
178
+ return;
179
+ }
180
+ log("info", `Decision: ${decision.action} for job ${event.jobId}`);
181
+ try {
182
+ if (decision.action === "NOTIFY_ONLY") {
183
+ const chatIdStr = process.env.CC_AGENT_NOTIFY_CHAT_ID;
184
+ if (!chatIdStr) {
185
+ log("warn", "NOTIFY_ONLY: CC_AGENT_NOTIFY_CHAT_ID not set, skipping notification");
186
+ return;
187
+ }
188
+ const chatId = Number(chatIdStr);
189
+ if (isNaN(chatId)) {
190
+ log("warn", `NOTIFY_ONLY: invalid CC_AGENT_NOTIFY_CHAT_ID: ${chatIdStr}`);
191
+ return;
192
+ }
193
+ await deps.sendTelegramMessage(chatId, decision.message ?? `Job completed: ${event.title}`);
194
+ }
195
+ else if (decision.action === "SPAWN_FOLLOWUP") {
196
+ if (!decision.followup) {
197
+ log("warn", "SPAWN_FOLLOWUP: no followup details in response");
198
+ return;
199
+ }
200
+ await deps.spawnFollowupAgent(decision.followup.repo_url, decision.followup.task);
201
+ }
202
+ else {
203
+ // SILENT — log only
204
+ log("info", `SILENT: no action taken for job ${event.jobId}`);
205
+ }
206
+ }
207
+ catch (err) {
208
+ log("error", `Action ${decision.action} failed:`, err.message);
209
+ }
210
+ }
211
+ function makeDefaultDeps() {
212
+ return {
213
+ askClaude: defaultAskClaude,
214
+ sendTelegramMessage: defaultSendTelegramMessage,
215
+ spawnFollowupAgent: defaultSpawnFollowupAgent,
216
+ };
217
+ }
218
+ let subscriberClient = null;
219
+ /**
220
+ * Connect to Redis and subscribe to cca:events.
221
+ * Reconnects automatically on disconnect.
222
+ * Call once at startup.
223
+ */
224
+ export async function connectEventSubscriber() {
225
+ if (process.env.CC_AGENT_EVENTS_ENABLED === "false") {
226
+ log("info", "CC_AGENT_EVENTS_ENABLED=false, skipping subscriber");
227
+ return;
228
+ }
229
+ await connectWithBackoff(0);
230
+ }
231
+ async function connectWithBackoff(attempt) {
232
+ const delay = Math.min(5_000 * Math.pow(2, attempt), 60_000);
233
+ const sub = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
234
+ lazyConnect: true,
235
+ enableOfflineQueue: false,
236
+ });
237
+ subscriberClient = sub;
238
+ sub.on("error", (err) => {
239
+ log("warn", "subscriber error, reconnecting...", err.message);
240
+ try {
241
+ sub.disconnect();
242
+ }
243
+ catch { }
244
+ setTimeout(() => connectWithBackoff(0), 5_000);
245
+ });
246
+ try {
247
+ await sub.connect();
248
+ }
249
+ catch (err) {
250
+ log("warn", `Redis connect failed (attempt ${attempt}), retrying in ${delay}ms:`, err.message);
251
+ try {
252
+ sub.disconnect();
253
+ }
254
+ catch { }
255
+ setTimeout(() => connectWithBackoff(attempt + 1), delay);
256
+ return;
257
+ }
258
+ const deps = makeDefaultDeps();
259
+ sub.on("message", (channel, message) => {
260
+ if (channel !== "cca:events")
261
+ return;
262
+ handleJobEvent(message, deps).catch((err) => {
263
+ log("error", "handleJobEvent uncaught:", err.message);
264
+ });
265
+ });
266
+ try {
267
+ await sub.subscribe("cca:events");
268
+ log("info", "Subscribed to cca:events");
269
+ }
270
+ catch (err) {
271
+ log("warn", "subscribe failed, retrying...", err.message);
272
+ try {
273
+ sub.disconnect();
274
+ }
275
+ catch { }
276
+ setTimeout(() => connectWithBackoff(attempt + 1), delay);
277
+ return;
278
+ }
279
+ const cleanup = async () => {
280
+ log("info", "SIGTERM received, shutting down event subscriber...");
281
+ try {
282
+ await sub.unsubscribe("cca:events");
283
+ sub.disconnect();
284
+ }
285
+ catch { }
286
+ };
287
+ process.once("SIGTERM", () => { cleanup().catch(() => { }); });
288
+ }
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import { CcTgBot } from "./bot.js";
24
24
  import { loadTokens } from "./tokens.js";
25
25
  import { Registry, startControlServer } from "@gonzih/agent-ops";
26
26
  import { Redis } from "ioredis";
27
+ import { connectEventSubscriber } from "./cc-agent-events.js";
27
28
  const __filename = fileURLToPath(import.meta.url);
28
29
  const __dirname = dirname(__filename);
29
30
  const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
@@ -143,6 +144,10 @@ if (process.env.CC_AGENT_OPS_PORT) {
143
144
  });
144
145
  console.log(`[ops] control server on port ${process.env.CC_AGENT_OPS_PORT}`);
145
146
  }
147
+ // cc-agent event subscriber — watches Redis cca:events for job completions
148
+ connectEventSubscriber().catch((err) => {
149
+ console.error("[cc-agent-events] startup error:", err.message);
150
+ });
146
151
  process.on("SIGINT", () => {
147
152
  console.log("\nShutting down...");
148
153
  bot.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-tg",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
5
5
  "type": "module",
6
6
  "bin": {