@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/dist/cron.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Cron job manager for cc-tg.
3
+ * Persists jobs to <cwd>/.cc-tg/crons.json.
4
+ * Fires prompts into Claude sessions on schedule.
5
+ */
6
+ export interface CronJob {
7
+ id: string;
8
+ chatId: number;
9
+ intervalMs: number;
10
+ prompt: string;
11
+ createdAt: string;
12
+ schedule: string;
13
+ }
14
+ type FireCallback = (chatId: number, prompt: string) => void;
15
+ export declare class CronManager {
16
+ private jobs;
17
+ private storePath;
18
+ private fire;
19
+ constructor(cwd: string, fire: FireCallback);
20
+ /** Parse "every 30m", "every 2h", "every 1d" → ms */
21
+ static parseSchedule(schedule: string): number | null;
22
+ add(chatId: number, schedule: string, prompt: string): CronJob | null;
23
+ remove(chatId: number, id: string): boolean;
24
+ clearAll(chatId: number): number;
25
+ list(chatId: number): CronJob[];
26
+ update(chatId: number, id: string, updates: {
27
+ schedule?: string;
28
+ prompt?: string;
29
+ }): CronJob | null | false;
30
+ private persist;
31
+ private load;
32
+ }
33
+ export {};
package/dist/cron.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Cron job manager for cc-tg.
3
+ * Persists jobs to <cwd>/.cc-tg/crons.json.
4
+ * Fires prompts into Claude sessions on schedule.
5
+ */
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
7
+ import { join } from "path";
8
+ export class CronManager {
9
+ jobs = new Map();
10
+ storePath;
11
+ fire;
12
+ constructor(cwd, fire) {
13
+ this.storePath = join(cwd, ".cc-tg", "crons.json");
14
+ this.fire = fire;
15
+ this.load();
16
+ }
17
+ /** Parse "every 30m", "every 2h", "every 1d" → ms */
18
+ static parseSchedule(schedule) {
19
+ const m = schedule.trim().match(/^every\s+(\d+)(m|h|d)$/i);
20
+ if (!m)
21
+ return null;
22
+ const n = parseInt(m[1]);
23
+ const unit = m[2].toLowerCase();
24
+ if (unit === "m")
25
+ return n * 60_000;
26
+ if (unit === "h")
27
+ return n * 3_600_000;
28
+ if (unit === "d")
29
+ return n * 86_400_000;
30
+ return null;
31
+ }
32
+ add(chatId, schedule, prompt) {
33
+ const intervalMs = CronManager.parseSchedule(schedule);
34
+ if (!intervalMs)
35
+ return null;
36
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
37
+ const job = { id, chatId, intervalMs, prompt, schedule, createdAt: new Date().toISOString() };
38
+ const timer = setInterval(() => {
39
+ console.log(`[cron:${id}] firing for chat=${chatId} prompt="${prompt}"`);
40
+ this.fire(chatId, prompt);
41
+ }, intervalMs);
42
+ this.jobs.set(id, { ...job, timer });
43
+ this.persist();
44
+ return job;
45
+ }
46
+ remove(chatId, id) {
47
+ const job = this.jobs.get(id);
48
+ if (!job || job.chatId !== chatId)
49
+ return false;
50
+ clearInterval(job.timer);
51
+ this.jobs.delete(id);
52
+ this.persist();
53
+ return true;
54
+ }
55
+ clearAll(chatId) {
56
+ let count = 0;
57
+ for (const [id, job] of this.jobs) {
58
+ if (job.chatId === chatId) {
59
+ clearInterval(job.timer);
60
+ this.jobs.delete(id);
61
+ count++;
62
+ }
63
+ }
64
+ if (count)
65
+ this.persist();
66
+ return count;
67
+ }
68
+ list(chatId) {
69
+ return [...this.jobs.values()]
70
+ .filter((j) => j.chatId === chatId)
71
+ .map(({ timer: _t, ...j }) => j);
72
+ }
73
+ update(chatId, id, updates) {
74
+ const job = this.jobs.get(id);
75
+ if (!job || job.chatId !== chatId)
76
+ return false;
77
+ if (updates.schedule !== undefined) {
78
+ const intervalMs = CronManager.parseSchedule(updates.schedule);
79
+ if (!intervalMs)
80
+ return null;
81
+ job.intervalMs = intervalMs;
82
+ job.schedule = updates.schedule;
83
+ }
84
+ if (updates.prompt !== undefined) {
85
+ job.prompt = updates.prompt;
86
+ }
87
+ // Recreate timer so it uses updated intervalMs and always reads latest job.prompt
88
+ clearInterval(job.timer);
89
+ job.timer = setInterval(() => {
90
+ console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
91
+ this.fire(job.chatId, job.prompt);
92
+ }, job.intervalMs);
93
+ this.persist();
94
+ const { timer: _t, ...cronJob } = job;
95
+ return cronJob;
96
+ }
97
+ persist() {
98
+ try {
99
+ const dir = join(this.storePath, "..");
100
+ if (!existsSync(dir))
101
+ mkdirSync(dir, { recursive: true });
102
+ const data = [...this.jobs.values()].map(({ timer: _t, ...j }) => j);
103
+ writeFileSync(this.storePath, JSON.stringify(data, null, 2));
104
+ }
105
+ catch (err) {
106
+ console.error("[cron] persist error:", err.message);
107
+ }
108
+ }
109
+ load() {
110
+ if (!existsSync(this.storePath))
111
+ return;
112
+ try {
113
+ const data = JSON.parse(readFileSync(this.storePath, "utf8"));
114
+ for (const job of data) {
115
+ const timer = setInterval(() => {
116
+ console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
117
+ this.fire(job.chatId, job.prompt);
118
+ }, job.intervalMs);
119
+ this.jobs.set(job.id, { ...job, timer });
120
+ }
121
+ console.log(`[cron] loaded ${data.length} jobs from disk`);
122
+ }
123
+ catch (err) {
124
+ console.error("[cron] load error:", err.message);
125
+ }
126
+ }
127
+ }
@@ -1,25 +1,23 @@
1
1
  /**
2
- * Telegram HTML post-processor.
3
- * Converts standard markdown to Telegram's HTML parse mode format.
2
+ * Telegram MarkdownV2 post-processor.
3
+ * Converts standard markdown to Telegram's MarkdownV2 format.
4
4
  */
5
5
  /**
6
- * Convert standard markdown text to Telegram HTML format.
6
+ * Convert standard markdown text to Telegram MarkdownV2 format.
7
7
  *
8
8
  * Processing order:
9
- * 1. Extract fenced code blocks (``` ... ```) <pre>, protect from further processing
10
- * 2. Extract inline code (`...`) → <code>, protect from further processing
11
- * 3. HTML-escape remaining text: & &amp; < → &lt; > → &gt;
12
- * 4. Convert ---blank line
13
- * 5. Convert ## headings <b>Heading</b>
14
- * 6. Convert **bold**<b>bold</b>
15
- * 7. Convert - item / * item → • item
16
- * 8. Convert *bold* <b>bold</b>
17
- * 9. Convert _italic_ → <i>italic</i>
18
- * 10. Reinsert code blocks
9
+ * 1. Extract code blocks (fenced + inline) protect from further processing
10
+ * 2. Strip raw HTML tags
11
+ * 3. Convert ---blank line
12
+ * 4. Convert ## headings *bold*
13
+ * 5. Convert **bold***bold*
14
+ * 6. Convert - list items • item
15
+ * 7. Escape MarkdownV2 special chars (outside code blocks)
16
+ * 8. Reinsert code blocks unchanged
19
17
  */
20
18
  export declare function formatForTelegram(text: string): string;
21
19
  /**
22
20
  * Split a long message at natural boundaries (paragraph > line > word).
23
- * Never splits mid-word or inside <pre> blocks. Chunks are at most maxLen characters.
21
+ * Never splits mid-word. Chunks are at most maxLen characters.
24
22
  */
25
23
  export declare function splitLongMessage(text: string, maxLen?: number): string[];
package/dist/formatter.js CHANGED
@@ -1,82 +1,54 @@
1
1
  /**
2
- * Telegram HTML post-processor.
3
- * Converts standard markdown to Telegram's HTML parse mode format.
2
+ * Telegram MarkdownV2 post-processor.
3
+ * Converts standard markdown to Telegram's MarkdownV2 format.
4
4
  */
5
- function htmlEscape(text) {
6
- return text
7
- .replace(/&/g, "&amp;")
8
- .replace(/</g, "&lt;")
9
- .replace(/>/g, "&gt;");
10
- }
11
5
  /**
12
- * Convert standard markdown text to Telegram HTML format.
6
+ * Convert standard markdown text to Telegram MarkdownV2 format.
13
7
  *
14
8
  * Processing order:
15
- * 1. Extract fenced code blocks (``` ... ```) <pre>, protect from further processing
16
- * 2. Extract inline code (`...`) → <code>, protect from further processing
17
- * 3. HTML-escape remaining text: & &amp; < → &lt; > → &gt;
18
- * 4. Convert ---blank line
19
- * 5. Convert ## headings <b>Heading</b>
20
- * 6. Convert **bold**<b>bold</b>
21
- * 7. Convert - item / * item → • item
22
- * 8. Convert *bold* <b>bold</b>
23
- * 9. Convert _italic_ → <i>italic</i>
24
- * 10. Reinsert code blocks
9
+ * 1. Extract code blocks (fenced + inline) protect from further processing
10
+ * 2. Strip raw HTML tags
11
+ * 3. Convert ---blank line
12
+ * 4. Convert ## headings *bold*
13
+ * 5. Convert **bold***bold*
14
+ * 6. Convert - list items • item
15
+ * 7. Escape MarkdownV2 special chars (outside code blocks)
16
+ * 8. Reinsert code blocks unchanged
25
17
  */
26
18
  export function formatForTelegram(text) {
19
+ // Step 1: Extract code blocks and inline code to protect them
27
20
  const placeholders = [];
28
- // Step 1: Extract fenced code blocks (``` ... ```) → <pre>
29
- let out = text.replace(/```(?:\w*)\n?([\s\S]*?)```/g, (_, content) => {
30
- placeholders.push(`<pre>${htmlEscape(content)}</pre>`);
21
+ // Fenced code blocks first (``` ... ```)
22
+ let out = text.replace(/```[\s\S]*?```/g, (match) => {
23
+ placeholders.push(match);
31
24
  return `\x00P${placeholders.length - 1}\x00`;
32
25
  });
33
- // Step 2: Extract inline code (`...`) → <code>
34
- out = out.replace(/`([^`\n]+)`/g, (_, content) => {
35
- placeholders.push(`<code>${htmlEscape(content)}</code>`);
26
+ // Inline code (`...`)
27
+ out = out.replace(/`[^`\n]+`/g, (match) => {
28
+ placeholders.push(match);
36
29
  return `\x00P${placeholders.length - 1}\x00`;
37
30
  });
38
- // Step 3: HTML-escape remaining text
39
- out = htmlEscape(out);
40
- // Step 4: Convert --- → blank line
31
+ // Step 2: Strip raw HTML tags
32
+ out = out.replace(/<[^>]+>/g, "");
33
+ // Step 3: Convert --- → blank line
41
34
  out = out.replace(/^-{3,}$/gm, "");
42
- // Step 5: Convert ## headings → <b>Heading</b>
43
- out = out.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>");
44
- // Step 6: Convert **bold** → <b>bold</b>
45
- out = out.replace(/\*\*(.+?)\*\*/gs, "<b>$1</b>");
46
- // Step 7: Convert - item / * item item
35
+ // Step 4: Convert ## headings → *bold*
36
+ out = out.replace(/^#{1,6}\s+(.+)$/gm, "*$1*");
37
+ // Step 5: Convert **bold** → *bold*
38
+ out = out.replace(/\*\*(.+?)\*\*/gs, "*$1*");
39
+ // Step 6: Convert - list items item (leading - or * bullet)
47
40
  out = out.replace(/^[ \t]*[-*]\s+(.+)$/gm, "• $1");
48
- // Step 8: Convert *bold* <b>bold</b> (single asterisk, after bullets handled)
49
- out = out.replace(/\*([^*\n]+)\*/g, "<b>$1</b>");
50
- // Step 9: Convert _italic_ <i>italic</i>
51
- // Use word-boundary guards to avoid mangling snake_case identifiers
52
- out = out.replace(/(?<![a-zA-Z0-9])_([^_\n]+?)_(?![a-zA-Z0-9])/g, "<i>$1</i>");
53
- // Step 10: Reinsert code blocks
41
+ // Step 7: Escape MarkdownV2 special chars outside code blocks.
42
+ // Per Telegram spec, these must be escaped: _ [ ] ( ) ~ > # + - = | { } . ! \
43
+ // * is intentionally NOT escaped — it is used for bold formatting above.
44
+ out = out.replace(/([_\[\]()~>#+\-=|{}.!\\])/g, "\\$1");
45
+ // Step 8: Reinsert code blocks unchanged (no escaping inside them)
54
46
  out = out.replace(/\x00P(\d+)\x00/g, (_, i) => placeholders[parseInt(i, 10)]);
55
47
  return out;
56
48
  }
57
- function findPreRanges(text) {
58
- const ranges = [];
59
- const open = "<pre>";
60
- const close = "</pre>";
61
- let i = 0;
62
- while (i < text.length) {
63
- const start = text.indexOf(open, i);
64
- if (start === -1)
65
- break;
66
- const end = text.indexOf(close, start);
67
- if (end === -1)
68
- break;
69
- ranges.push([start, end + close.length]);
70
- i = end + close.length;
71
- }
72
- return ranges;
73
- }
74
- function isInsidePre(pos, ranges) {
75
- return ranges.some(([start, end]) => pos > start && pos < end);
76
- }
77
49
  /**
78
50
  * Split a long message at natural boundaries (paragraph > line > word).
79
- * Never splits mid-word or inside <pre> blocks. Chunks are at most maxLen characters.
51
+ * Never splits mid-word. Chunks are at most maxLen characters.
80
52
  */
81
53
  export function splitLongMessage(text, maxLen = 4096) {
82
54
  if (text.length <= maxLen)
@@ -85,7 +57,6 @@ export function splitLongMessage(text, maxLen = 4096) {
85
57
  let remaining = text;
86
58
  while (remaining.length > maxLen) {
87
59
  const slice = remaining.slice(0, maxLen);
88
- const preRanges = findPreRanges(remaining);
89
60
  // Prefer paragraph boundary (\n\n)
90
61
  const lastPara = slice.lastIndexOf("\n\n");
91
62
  // Then line boundary (\n)
@@ -93,24 +64,17 @@ export function splitLongMessage(text, maxLen = 4096) {
93
64
  // Then word boundary (space)
94
65
  const lastSpace = slice.lastIndexOf(" ");
95
66
  let splitAt;
96
- if (lastPara > 0 && !isInsidePre(lastPara, preRanges)) {
67
+ if (lastPara > 0) {
97
68
  splitAt = lastPara + 2;
98
69
  }
99
- else if (lastLine > 0 && !isInsidePre(lastLine, preRanges)) {
70
+ else if (lastLine > 0) {
100
71
  splitAt = lastLine + 1;
101
72
  }
102
- else if (lastSpace > 0 && !isInsidePre(lastSpace, preRanges)) {
73
+ else if (lastSpace > 0) {
103
74
  splitAt = lastSpace + 1;
104
75
  }
105
76
  else {
106
- // If all candidate split points are inside a <pre> block, split after it
107
- const coveringPre = preRanges.find(([start, end]) => start < maxLen && end > maxLen);
108
- if (coveringPre) {
109
- splitAt = coveringPre[1];
110
- }
111
- else {
112
- splitAt = maxLen;
113
- }
77
+ splitAt = maxLen;
114
78
  }
115
79
  chunks.push(remaining.slice(0, splitAt).trimEnd());
116
80
  remaining = remaining.slice(splitAt).trimStart();
package/dist/index.js CHANGED
@@ -15,23 +15,11 @@
15
15
  * CWD — working directory for Claude Code (default: process.cwd())
16
16
  */
17
17
  import { createServer, createConnection } from "net";
18
- import { unlinkSync, readFileSync } from "fs";
18
+ import { unlinkSync } from "fs";
19
19
  import { tmpdir } from "os";
20
- import os from "os";
21
- import { join, dirname } from "path";
22
- import { fileURLToPath } from "url";
23
- import TelegramBot from "node-telegram-bot-api";
20
+ import { join } from "path";
24
21
  import { CcTgBot } from "./bot.js";
25
- import { loadTokens } from "./tokens.js";
26
- import { Registry, startControlServer } from "@gonzih/agent-ops";
27
- import { Redis } from "ioredis";
28
- import { startNotifier } from "./notifier.js";
29
- const __filename = fileURLToPath(import.meta.url);
30
- const __dirname = dirname(__filename);
31
- const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
32
- // Make lock socket unique per bot token so multiple users on the same machine don't collide
33
- const _tokenHash = Buffer.from(process.env.TELEGRAM_BOT_TOKEN ?? "default").toString("base64").replace(/[^a-z0-9]/gi, "").slice(0, 16);
34
- const LOCK_SOCKET = join(tmpdir(), `cc-tg-${_tokenHash}.sock`);
22
+ const LOCK_SOCKET = join(tmpdir(), "cc-tg.sock");
35
23
  function acquireLock() {
36
24
  return new Promise((resolve) => {
37
25
  const server = createServer();
@@ -101,11 +89,6 @@ Set one and run again:
101
89
  `);
102
90
  process.exit(1);
103
91
  }
104
- // Load OAuth token pool (supports CLAUDE_CODE_OAUTH_TOKENS for multi-account rotation)
105
- const tokenPool = loadTokens();
106
- if (tokenPool.length > 1) {
107
- console.log(`[cc-tg] Token pool loaded: ${tokenPool.length} tokens — will rotate on usage limit`);
108
- }
109
92
  const allowedUserIds = process.env.ALLOWED_USER_IDS
110
93
  ? process.env.ALLOWED_USER_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
111
94
  : [];
@@ -113,55 +96,13 @@ const groupChatIds = process.env.GROUP_CHAT_IDS
113
96
  ? process.env.GROUP_CHAT_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
114
97
  : [];
115
98
  const cwd = process.env.CWD ?? process.cwd();
116
- // agent-ops / chat bridge — Redis is always initialized so the chat bridge works
117
- // regardless of whether CC_AGENT_OPS_PORT or CC_AGENT_NOTIFY_CHAT_ID are set.
118
- const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
119
- const namespace = process.env.CC_AGENT_NAMESPACE || "default";
120
- const sharedRedis = new Redis(redisUrl);
121
- sharedRedis.on("error", (err) => {
122
- // Non-fatal — Redis features (chat bridge, ops) degrade gracefully
123
- console.warn("[redis] connection error:", err.message);
124
- });
125
99
  const bot = new CcTgBot({
126
100
  telegramToken,
127
101
  claudeToken,
128
102
  cwd,
129
103
  allowedUserIds,
130
104
  groupChatIds,
131
- redis: sharedRedis,
132
- namespace,
133
105
  });
134
- if (process.env.CC_AGENT_OPS_PORT) {
135
- const botInfo = await bot.getMe();
136
- const registry = new Registry(sharedRedis);
137
- await registry.register({
138
- namespace,
139
- hostname: os.hostname(),
140
- user: os.userInfo().username,
141
- pid: String(process.pid),
142
- version: pkg.version,
143
- cwd: process.env.CWD || process.cwd(),
144
- control_port: process.env.CC_AGENT_OPS_PORT,
145
- bot_username: botInfo.username ?? "",
146
- started_at: new Date().toISOString(),
147
- });
148
- setInterval(() => registry.heartbeat(namespace), 60_000);
149
- startControlServer(Number(process.env.CC_AGENT_OPS_PORT), {
150
- namespace,
151
- version: pkg.version,
152
- logFile: process.env.CC_AGENT_LOG_FILE || process.env.LOG_FILE,
153
- });
154
- console.log(`[ops] control server on port ${process.env.CC_AGENT_OPS_PORT}`);
155
- }
156
- // Notifier — always subscribe to cca:notify and cca:chat:incoming channels.
157
- // CC_AGENT_NOTIFY_CHAT_ID pins a fixed Telegram chatId; without it the last
158
- // active chatId is used dynamically for the chat bridge.
159
- const notifyChatId = process.env.CC_AGENT_NOTIFY_CHAT_ID
160
- ? Number(process.env.CC_AGENT_NOTIFY_CHAT_ID)
161
- : null;
162
- const notifierBot = new TelegramBot(telegramToken, { polling: false });
163
- startNotifier(notifierBot, notifyChatId, namespace, sharedRedis, (cid, text) => bot.handleUserMessage(cid, text), () => bot.getLastActiveChatId());
164
- console.log(`[notifier] started for namespace=${namespace} chatId=${notifyChatId ?? "dynamic"}`);
165
106
  process.on("SIGINT", () => {
166
107
  console.log("\nShutting down...");
167
108
  bot.stop();
@@ -3,7 +3,8 @@ export function detectUsageLimit(text) {
3
3
  if (lower.includes('extra usage') ||
4
4
  lower.includes('usage has been disabled') ||
5
5
  lower.includes('billing_error') ||
6
- lower.includes('usage limit')) {
6
+ lower.includes('usage limit reached') ||
7
+ lower.includes('your usage limit')) {
7
8
  const wake = nextHourBoundary() + 5 * 60 * 1000;
8
9
  return {
9
10
  detected: true,
@@ -12,7 +13,7 @@ export function detectUsageLimit(text) {
12
13
  humanMessage: `⏸ Claude usage limit reached. Will auto-resume at ${new Date(wake).toUTCString()}. I'll message you when it's back.`,
13
14
  };
14
15
  }
15
- if (lower.includes('rate limit') || lower.includes('overloaded')) {
16
+ if (lower.includes('currently overloaded') || lower.includes('overloaded with requests')) {
16
17
  return {
17
18
  detected: true,
18
19
  reason: 'rate_limit',
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@gonzih/cc-tg",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cc-tg": "./dist/index.js"
8
8
  },
9
9
  "scripts": {
10
- "build": "tsc && chmod +x dist/index.js",
10
+ "build": "tsc",
11
11
  "start": "node dist/index.js",
12
12
  "dev": "node --loader ts-node/esm src/index.ts",
13
13
  "test": "vitest run",
@@ -18,11 +18,10 @@
18
18
  "dist/"
19
19
  ],
20
20
  "dependencies": {
21
- "@gonzih/agent-ops": "^0.1.0",
22
21
  "node-telegram-bot-api": "^0.66.0"
23
22
  },
24
23
  "devDependencies": {
25
- "@types/node": "^22.0.0",
24
+ "@types/node": "^22.19.15",
26
25
  "@types/node-telegram-bot-api": "^0.64.0",
27
26
  "@vitest/coverage-v8": "^4.1.0",
28
27
  "typescript": "^5.5.0",
@@ -1,37 +0,0 @@
1
- /**
2
- * Notifier — subscribes to Redis pub/sub channels and bridges messages to Telegram.
3
- *
4
- * Channels:
5
- * cca:notify:{namespace} — job completion notifications from cc-agent → forward to Telegram
6
- * cca:chat:incoming:{namespace} — messages from the web UI → echo to Telegram + feed into Claude session
7
- *
8
- * All messages (Telegram incoming, Claude responses) are also written to:
9
- * cca:chat:log:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
10
- * cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
11
- */
12
- import { Redis } from "ioredis";
13
- import TelegramBot from "node-telegram-bot-api";
14
- export interface ChatMessage {
15
- id: string;
16
- source: "telegram" | "ui" | "claude" | "cc-tg";
17
- role: "user" | "assistant" | "tool";
18
- content: string;
19
- timestamp: string;
20
- chatId: number;
21
- }
22
- /**
23
- * Write a message to the chat log in Redis.
24
- * Fire-and-forget — errors are logged but not thrown.
25
- */
26
- export declare function writeChatLog(redis: Redis, namespace: string, msg: ChatMessage): void;
27
- /**
28
- * Start the notifier.
29
- *
30
- * @param bot - Telegram bot instance (for sending messages)
31
- * @param chatId - Telegram chat ID to forward notifications to. Pass null to use getActiveChatId.
32
- * @param namespace - cc-agent namespace (used to build Redis channel names)
33
- * @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
34
- * @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
35
- * @param getActiveChatId - Optional callback to resolve chatId dynamically (used when chatId is null)
36
- */
37
- export declare function startNotifier(bot: TelegramBot, chatId: number | null, namespace: string, redis: Redis, handleUserMessage?: (chatId: number, text: string) => void, getActiveChatId?: () => number | undefined): void;
package/dist/notifier.js DELETED
@@ -1,124 +0,0 @@
1
- /**
2
- * Notifier — subscribes to Redis pub/sub channels and bridges messages to Telegram.
3
- *
4
- * Channels:
5
- * cca:notify:{namespace} — job completion notifications from cc-agent → forward to Telegram
6
- * cca:chat:incoming:{namespace} — messages from the web UI → echo to Telegram + feed into Claude session
7
- *
8
- * All messages (Telegram incoming, Claude responses) are also written to:
9
- * cca:chat:log:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
10
- * cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
11
- */
12
- function log(level, ...args) {
13
- const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
14
- fn("[notifier]", ...args);
15
- }
16
- /**
17
- * Write a message to the chat log in Redis.
18
- * Fire-and-forget — errors are logged but not thrown.
19
- */
20
- export function writeChatLog(redis, namespace, msg) {
21
- const logKey = `cca:chat:log:${namespace}`;
22
- const outKey = `cca:chat:outgoing:${namespace}`;
23
- const payload = JSON.stringify(msg);
24
- redis.lpush(logKey, payload).catch((err) => {
25
- log("warn", "writeChatLog lpush failed:", err.message);
26
- });
27
- redis.ltrim(logKey, 0, 499).catch((err) => {
28
- log("warn", "writeChatLog ltrim failed:", err.message);
29
- });
30
- redis.publish(outKey, payload).catch((err) => {
31
- log("warn", "writeChatLog publish failed:", err.message);
32
- });
33
- }
34
- /**
35
- * Start the notifier.
36
- *
37
- * @param bot - Telegram bot instance (for sending messages)
38
- * @param chatId - Telegram chat ID to forward notifications to. Pass null to use getActiveChatId.
39
- * @param namespace - cc-agent namespace (used to build Redis channel names)
40
- * @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
41
- * @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
42
- * @param getActiveChatId - Optional callback to resolve chatId dynamically (used when chatId is null)
43
- */
44
- export function startNotifier(bot, chatId, namespace, redis, handleUserMessage, getActiveChatId) {
45
- const sub = redis.duplicate({
46
- retryStrategy: (times) => {
47
- const delay = Math.min(1000 * Math.pow(2, times - 1), 30_000);
48
- log("info", `subscriber reconnecting in ${delay}ms (attempt ${times})`);
49
- return delay;
50
- },
51
- });
52
- sub.on("error", (err) => {
53
- log("warn", "subscriber error:", err.message);
54
- });
55
- sub.on("close", () => {
56
- log("info", "subscriber disconnected, will reconnect with backoff");
57
- });
58
- // cca:notify:{namespace} — forward job completion notifications to Telegram
59
- sub.subscribe(`cca:notify:${namespace}`, (err) => {
60
- if (err) {
61
- log("error", `subscribe cca:notify:${namespace} failed:`, err.message);
62
- }
63
- else {
64
- log("info", `subscribed to cca:notify:${namespace}`);
65
- }
66
- });
67
- // cca:chat:incoming:{namespace} — messages from UI
68
- sub.subscribe(`cca:chat:incoming:${namespace}`, (err) => {
69
- if (err) {
70
- log("error", `subscribe cca:chat:incoming:${namespace} failed:`, err.message);
71
- }
72
- else {
73
- log("info", `subscribed to cca:chat:incoming:${namespace}`);
74
- }
75
- });
76
- sub.on("message", (channel, message) => {
77
- const notifyChannel = `cca:notify:${namespace}`;
78
- const incomingChannel = `cca:chat:incoming:${namespace}`;
79
- if (channel === notifyChannel) {
80
- if (chatId !== null) {
81
- bot.sendMessage(chatId, message).catch((err) => {
82
- log("warn", "sendMessage failed:", err.message);
83
- });
84
- }
85
- return;
86
- }
87
- if (channel === incomingChannel) {
88
- let content = message;
89
- try {
90
- const parsed = JSON.parse(message);
91
- if (parsed.content)
92
- content = parsed.content;
93
- }
94
- catch {
95
- // raw string message — use as-is
96
- }
97
- // Resolve the target chatId: prefer the fixed chatId, fall back to last active
98
- const targetChatId = chatId ?? getActiveChatId?.();
99
- if (targetChatId !== undefined) {
100
- // Echo to Telegram so the user sees UI messages in the chat
101
- bot.sendMessage(targetChatId, `📱 [from UI]: ${content}`).catch((err) => {
102
- log("warn", "sendMessage (UI echo) failed:", err.message);
103
- });
104
- // Log the incoming message
105
- const inMsg = {
106
- id: `ui-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
107
- source: "ui",
108
- role: "user",
109
- content,
110
- timestamp: new Date().toISOString(),
111
- chatId: targetChatId,
112
- };
113
- writeChatLog(redis, namespace, inMsg);
114
- // Feed into active Claude session as if user typed it
115
- if (handleUserMessage) {
116
- handleUserMessage(targetChatId, content);
117
- }
118
- }
119
- else {
120
- log("warn", "cca:chat:incoming: no active chatId to route message to");
121
- }
122
- }
123
- });
124
- }