@gonzih/cc-tg 0.9.15 → 0.9.17
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 +36 -0
- package/dist/bot.d.ts +35 -4
- package/dist/bot.js +536 -337
- package/dist/cron.d.ts +7 -1
- package/dist/cron.js +24 -3
- package/dist/formatter.d.ts +14 -12
- package/dist/formatter.js +72 -36
- package/dist/index.js +77 -21
- package/dist/notifier.d.ts +37 -0
- package/dist/notifier.js +154 -0
- package/dist/tokens.d.ts +22 -0
- package/dist/tokens.js +56 -0
- package/dist/usage-limit.js +2 -3
- package/dist/voice.js +28 -26
- package/package.json +4 -3
package/dist/cron.d.ts
CHANGED
|
@@ -11,9 +11,15 @@ export interface CronJob {
|
|
|
11
11
|
createdAt: string;
|
|
12
12
|
schedule: string;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
/** Called when a job fires. `done` must be called when the task completes so
|
|
15
|
+
* the next scheduled tick is allowed to run. Until `done` is called, concurrent
|
|
16
|
+
* ticks for the same job are silently skipped (prevents the resume-loop explosion
|
|
17
|
+
* where each tick spawns more agents than the last). */
|
|
18
|
+
type FireCallback = (chatId: number, prompt: string, jobId: string, done: () => void) => void;
|
|
15
19
|
export declare class CronManager {
|
|
16
20
|
private jobs;
|
|
21
|
+
/** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
|
|
22
|
+
private activeJobs;
|
|
17
23
|
private storePath;
|
|
18
24
|
private fire;
|
|
19
25
|
constructor(cwd: string, fire: FireCallback);
|
package/dist/cron.js
CHANGED
|
@@ -7,6 +7,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
export class CronManager {
|
|
9
9
|
jobs = new Map();
|
|
10
|
+
/** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
|
|
11
|
+
activeJobs = new Set();
|
|
10
12
|
storePath;
|
|
11
13
|
fire;
|
|
12
14
|
constructor(cwd, fire) {
|
|
@@ -36,8 +38,13 @@ export class CronManager {
|
|
|
36
38
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
37
39
|
const job = { id, chatId, intervalMs, prompt, schedule, createdAt: new Date().toISOString() };
|
|
38
40
|
const timer = setInterval(() => {
|
|
41
|
+
if (this.activeJobs.has(id)) {
|
|
42
|
+
console.log(`[cron:${id}] skipping tick — previous task still running`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.activeJobs.add(id);
|
|
39
46
|
console.log(`[cron:${id}] firing for chat=${chatId} prompt="${prompt}"`);
|
|
40
|
-
this.fire(chatId, prompt);
|
|
47
|
+
this.fire(chatId, prompt, id, () => { this.activeJobs.delete(id); });
|
|
41
48
|
}, intervalMs);
|
|
42
49
|
this.jobs.set(id, { ...job, timer });
|
|
43
50
|
this.persist();
|
|
@@ -48,6 +55,7 @@ export class CronManager {
|
|
|
48
55
|
if (!job || job.chatId !== chatId)
|
|
49
56
|
return false;
|
|
50
57
|
clearInterval(job.timer);
|
|
58
|
+
this.activeJobs.delete(id);
|
|
51
59
|
this.jobs.delete(id);
|
|
52
60
|
this.persist();
|
|
53
61
|
return true;
|
|
@@ -57,6 +65,7 @@ export class CronManager {
|
|
|
57
65
|
for (const [id, job] of this.jobs) {
|
|
58
66
|
if (job.chatId === chatId) {
|
|
59
67
|
clearInterval(job.timer);
|
|
68
|
+
this.activeJobs.delete(id);
|
|
60
69
|
this.jobs.delete(id);
|
|
61
70
|
count++;
|
|
62
71
|
}
|
|
@@ -86,9 +95,16 @@ export class CronManager {
|
|
|
86
95
|
}
|
|
87
96
|
// Recreate timer so it uses updated intervalMs and always reads latest job.prompt
|
|
88
97
|
clearInterval(job.timer);
|
|
98
|
+
// Also clear any active-job lock so the updated timer can fire immediately next tick
|
|
99
|
+
this.activeJobs.delete(job.id);
|
|
89
100
|
job.timer = setInterval(() => {
|
|
101
|
+
if (this.activeJobs.has(job.id)) {
|
|
102
|
+
console.log(`[cron:${job.id}] skipping tick — previous task still running`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.activeJobs.add(job.id);
|
|
90
106
|
console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
|
|
91
|
-
this.fire(job.chatId, job.prompt);
|
|
107
|
+
this.fire(job.chatId, job.prompt, job.id, () => { this.activeJobs.delete(job.id); });
|
|
92
108
|
}, job.intervalMs);
|
|
93
109
|
this.persist();
|
|
94
110
|
const { timer: _t, ...cronJob } = job;
|
|
@@ -113,8 +129,13 @@ export class CronManager {
|
|
|
113
129
|
const data = JSON.parse(readFileSync(this.storePath, "utf8"));
|
|
114
130
|
for (const job of data) {
|
|
115
131
|
const timer = setInterval(() => {
|
|
132
|
+
if (this.activeJobs.has(job.id)) {
|
|
133
|
+
console.log(`[cron:${job.id}] skipping tick — previous task still running`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.activeJobs.add(job.id);
|
|
116
137
|
console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
|
|
117
|
-
this.fire(job.chatId, job.prompt);
|
|
138
|
+
this.fire(job.chatId, job.prompt, job.id, () => { this.activeJobs.delete(job.id); });
|
|
118
139
|
}, job.intervalMs);
|
|
119
140
|
this.jobs.set(job.id, { ...job, timer });
|
|
120
141
|
}
|
package/dist/formatter.d.ts
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram
|
|
3
|
-
* Converts standard markdown to Telegram's
|
|
2
|
+
* Telegram HTML post-processor.
|
|
3
|
+
* Converts standard markdown to Telegram's HTML parse mode format.
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
|
-
* Convert standard markdown text to Telegram
|
|
6
|
+
* Convert standard markdown text to Telegram HTML format.
|
|
7
7
|
*
|
|
8
8
|
* Processing order:
|
|
9
|
-
* 1. Extract code blocks (
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
12
|
-
* 4. Convert
|
|
13
|
-
* 5. Convert
|
|
14
|
-
* 6. Convert
|
|
15
|
-
* 7.
|
|
16
|
-
* 8.
|
|
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: & → & < → < > → >
|
|
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
|
|
17
19
|
*/
|
|
18
20
|
export declare function formatForTelegram(text: string): string;
|
|
19
21
|
/**
|
|
20
22
|
* Split a long message at natural boundaries (paragraph > line > word).
|
|
21
|
-
* Never splits mid-word. Chunks are at most maxLen characters.
|
|
23
|
+
* Never splits mid-word or inside <pre> blocks. Chunks are at most maxLen characters.
|
|
22
24
|
*/
|
|
23
25
|
export declare function splitLongMessage(text: string, maxLen?: number): string[];
|
package/dist/formatter.js
CHANGED
|
@@ -1,54 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram
|
|
3
|
-
* Converts standard markdown to Telegram's
|
|
2
|
+
* Telegram HTML post-processor.
|
|
3
|
+
* Converts standard markdown to Telegram's HTML parse mode format.
|
|
4
4
|
*/
|
|
5
|
+
function htmlEscape(text) {
|
|
6
|
+
return text
|
|
7
|
+
.replace(/&/g, "&")
|
|
8
|
+
.replace(/</g, "<")
|
|
9
|
+
.replace(/>/g, ">");
|
|
10
|
+
}
|
|
5
11
|
/**
|
|
6
|
-
* Convert standard markdown text to Telegram
|
|
12
|
+
* Convert standard markdown text to Telegram HTML format.
|
|
7
13
|
*
|
|
8
14
|
* Processing order:
|
|
9
|
-
* 1. Extract code blocks (
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
12
|
-
* 4. Convert
|
|
13
|
-
* 5. Convert
|
|
14
|
-
* 6. Convert
|
|
15
|
-
* 7.
|
|
16
|
-
* 8.
|
|
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: & → & < → < > → >
|
|
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
|
|
17
25
|
*/
|
|
18
26
|
export function formatForTelegram(text) {
|
|
19
|
-
// Step 1: Extract code blocks and inline code to protect them
|
|
20
27
|
const placeholders = [];
|
|
21
|
-
//
|
|
22
|
-
let out = text.replace(/```[\s\S]
|
|
23
|
-
placeholders.push(
|
|
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>`);
|
|
24
31
|
return `\x00P${placeholders.length - 1}\x00`;
|
|
25
32
|
});
|
|
26
|
-
//
|
|
27
|
-
out = out.replace(/`[^`\n]
|
|
28
|
-
placeholders.push(
|
|
33
|
+
// Step 2: Extract inline code (`...`) → <code>
|
|
34
|
+
out = out.replace(/`([^`\n]+)`/g, (_, content) => {
|
|
35
|
+
placeholders.push(`<code>${htmlEscape(content)}</code>`);
|
|
29
36
|
return `\x00P${placeholders.length - 1}\x00`;
|
|
30
37
|
});
|
|
31
|
-
// Step
|
|
32
|
-
out = out
|
|
33
|
-
// Step
|
|
38
|
+
// Step 3: HTML-escape remaining text
|
|
39
|
+
out = htmlEscape(out);
|
|
40
|
+
// Step 4: Convert --- → blank line
|
|
34
41
|
out = out.replace(/^-{3,}$/gm, "");
|
|
35
|
-
// Step
|
|
36
|
-
out = out.replace(/^#{1,6}\s+(.+)$/gm, "
|
|
37
|
-
// Step
|
|
38
|
-
out = out.replace(/\*\*(.+?)\*\*/gs, "
|
|
39
|
-
// Step
|
|
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
|
|
40
47
|
out = out.replace(/^[ \t]*[-*]\s+(.+)$/gm, "• $1");
|
|
41
|
-
// Step
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
46
54
|
out = out.replace(/\x00P(\d+)\x00/g, (_, i) => placeholders[parseInt(i, 10)]);
|
|
47
55
|
return out;
|
|
48
56
|
}
|
|
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
|
+
}
|
|
49
77
|
/**
|
|
50
78
|
* Split a long message at natural boundaries (paragraph > line > word).
|
|
51
|
-
* Never splits mid-word. Chunks are at most maxLen characters.
|
|
79
|
+
* Never splits mid-word or inside <pre> blocks. Chunks are at most maxLen characters.
|
|
52
80
|
*/
|
|
53
81
|
export function splitLongMessage(text, maxLen = 4096) {
|
|
54
82
|
if (text.length <= maxLen)
|
|
@@ -57,6 +85,7 @@ export function splitLongMessage(text, maxLen = 4096) {
|
|
|
57
85
|
let remaining = text;
|
|
58
86
|
while (remaining.length > maxLen) {
|
|
59
87
|
const slice = remaining.slice(0, maxLen);
|
|
88
|
+
const preRanges = findPreRanges(remaining);
|
|
60
89
|
// Prefer paragraph boundary (\n\n)
|
|
61
90
|
const lastPara = slice.lastIndexOf("\n\n");
|
|
62
91
|
// Then line boundary (\n)
|
|
@@ -64,17 +93,24 @@ export function splitLongMessage(text, maxLen = 4096) {
|
|
|
64
93
|
// Then word boundary (space)
|
|
65
94
|
const lastSpace = slice.lastIndexOf(" ");
|
|
66
95
|
let splitAt;
|
|
67
|
-
if (lastPara > 0) {
|
|
96
|
+
if (lastPara > 0 && !isInsidePre(lastPara, preRanges)) {
|
|
68
97
|
splitAt = lastPara + 2;
|
|
69
98
|
}
|
|
70
|
-
else if (lastLine > 0) {
|
|
99
|
+
else if (lastLine > 0 && !isInsidePre(lastLine, preRanges)) {
|
|
71
100
|
splitAt = lastLine + 1;
|
|
72
101
|
}
|
|
73
|
-
else if (lastSpace > 0) {
|
|
102
|
+
else if (lastSpace > 0 && !isInsidePre(lastSpace, preRanges)) {
|
|
74
103
|
splitAt = lastSpace + 1;
|
|
75
104
|
}
|
|
76
105
|
else {
|
|
77
|
-
|
|
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
|
+
}
|
|
78
114
|
}
|
|
79
115
|
chunks.push(remaining.slice(0, splitAt).trimEnd());
|
|
80
116
|
remaining = remaining.slice(splitAt).trimStart();
|
package/dist/index.js
CHANGED
|
@@ -15,21 +15,27 @@
|
|
|
15
15
|
* CWD — working directory for Claude Code (default: process.cwd())
|
|
16
16
|
*/
|
|
17
17
|
import { createServer, createConnection } from "net";
|
|
18
|
-
import { unlinkSync } from "fs";
|
|
18
|
+
import { unlinkSync, readFileSync } from "fs";
|
|
19
19
|
import { tmpdir } from "os";
|
|
20
|
-
import
|
|
21
|
-
import {
|
|
20
|
+
import os from "os";
|
|
21
|
+
import { join, dirname } from "path";
|
|
22
|
+
import { fileURLToPath } from "url";
|
|
23
|
+
import TelegramBot from "node-telegram-bot-api";
|
|
22
24
|
import { CcTgBot } from "./bot.js";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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`);
|
|
35
|
+
function acquireLock() {
|
|
30
36
|
return new Promise((resolve) => {
|
|
31
37
|
const server = createServer();
|
|
32
|
-
server.listen(
|
|
38
|
+
server.listen(LOCK_SOCKET, () => {
|
|
33
39
|
// Bound successfully — we own the lock. Socket auto-released on any exit incl. SIGKILL.
|
|
34
40
|
resolve(true);
|
|
35
41
|
});
|
|
@@ -39,7 +45,7 @@ function acquireLock(socketPath) {
|
|
|
39
45
|
return;
|
|
40
46
|
}
|
|
41
47
|
// Socket path exists — probe if anything is actually listening
|
|
42
|
-
const probe = createConnection(
|
|
48
|
+
const probe = createConnection(LOCK_SOCKET);
|
|
43
49
|
probe.on("connect", () => {
|
|
44
50
|
probe.destroy();
|
|
45
51
|
console.error("[cc-tg] Another instance is already running. Exiting.");
|
|
@@ -48,16 +54,20 @@ function acquireLock(socketPath) {
|
|
|
48
54
|
probe.on("error", () => {
|
|
49
55
|
// Nothing listening — stale socket, remove and retry
|
|
50
56
|
try {
|
|
51
|
-
unlinkSync(
|
|
57
|
+
unlinkSync(LOCK_SOCKET);
|
|
52
58
|
}
|
|
53
59
|
catch { }
|
|
54
60
|
const retry = createServer();
|
|
55
|
-
retry.listen(
|
|
61
|
+
retry.listen(LOCK_SOCKET, () => resolve(true));
|
|
56
62
|
retry.on("error", () => resolve(true)); // give up on lock, just start
|
|
57
63
|
});
|
|
58
64
|
});
|
|
59
65
|
});
|
|
60
66
|
}
|
|
67
|
+
const lockAcquired = await acquireLock();
|
|
68
|
+
if (!lockAcquired) {
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
61
71
|
function required(name) {
|
|
62
72
|
const val = process.env[name];
|
|
63
73
|
if (!val) {
|
|
@@ -78,13 +88,6 @@ Or add to your shell profile / .env file.
|
|
|
78
88
|
return val;
|
|
79
89
|
}
|
|
80
90
|
const telegramToken = required("TELEGRAM_BOT_TOKEN");
|
|
81
|
-
// Acquire lock before doing anything else. Socket derived from token hash so
|
|
82
|
-
// multiple instances (different bots / users) never share the same socket.
|
|
83
|
-
const LOCK_SOCKET = lockSocketPath(telegramToken);
|
|
84
|
-
const lockAcquired = await acquireLock(LOCK_SOCKET);
|
|
85
|
-
if (!lockAcquired) {
|
|
86
|
-
process.exit(1);
|
|
87
|
-
}
|
|
88
91
|
// Accept CLAUDE_CODE_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, or ANTHROPIC_API_KEY
|
|
89
92
|
const claudeToken = process.env.CLAUDE_CODE_TOKEN ??
|
|
90
93
|
process.env.CLAUDE_CODE_OAUTH_TOKEN ??
|
|
@@ -98,6 +101,11 @@ Set one and run again:
|
|
|
98
101
|
`);
|
|
99
102
|
process.exit(1);
|
|
100
103
|
}
|
|
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
|
+
}
|
|
101
109
|
const allowedUserIds = process.env.ALLOWED_USER_IDS
|
|
102
110
|
? process.env.ALLOWED_USER_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
|
|
103
111
|
: [];
|
|
@@ -105,13 +113,61 @@ const groupChatIds = process.env.GROUP_CHAT_IDS
|
|
|
105
113
|
? process.env.GROUP_CHAT_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
|
|
106
114
|
: [];
|
|
107
115
|
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
|
+
sharedRedis.once("ready", () => {
|
|
126
|
+
sharedRedis.set("cca:meta:cc-tg:version", pkg.version).catch((err) => {
|
|
127
|
+
console.warn("[redis] failed to write version:", err.message);
|
|
128
|
+
});
|
|
129
|
+
console.log(`[cc-tg] version:reported ${pkg.version}`);
|
|
130
|
+
});
|
|
108
131
|
const bot = new CcTgBot({
|
|
109
132
|
telegramToken,
|
|
110
133
|
claudeToken,
|
|
111
134
|
cwd,
|
|
112
135
|
allowedUserIds,
|
|
113
136
|
groupChatIds,
|
|
137
|
+
redis: sharedRedis,
|
|
138
|
+
namespace,
|
|
114
139
|
});
|
|
140
|
+
if (process.env.CC_AGENT_OPS_PORT) {
|
|
141
|
+
const botInfo = await bot.getMe();
|
|
142
|
+
const registry = new Registry(sharedRedis);
|
|
143
|
+
await registry.register({
|
|
144
|
+
namespace,
|
|
145
|
+
hostname: os.hostname(),
|
|
146
|
+
user: os.userInfo().username,
|
|
147
|
+
pid: String(process.pid),
|
|
148
|
+
version: pkg.version,
|
|
149
|
+
cwd: process.env.CWD || process.cwd(),
|
|
150
|
+
control_port: process.env.CC_AGENT_OPS_PORT,
|
|
151
|
+
bot_username: botInfo.username ?? "",
|
|
152
|
+
started_at: new Date().toISOString(),
|
|
153
|
+
});
|
|
154
|
+
setInterval(() => registry.heartbeat(namespace), 60_000);
|
|
155
|
+
startControlServer(Number(process.env.CC_AGENT_OPS_PORT), {
|
|
156
|
+
namespace,
|
|
157
|
+
version: pkg.version,
|
|
158
|
+
logFile: process.env.CC_AGENT_LOG_FILE || process.env.LOG_FILE,
|
|
159
|
+
});
|
|
160
|
+
console.log(`[ops] control server on port ${process.env.CC_AGENT_OPS_PORT}`);
|
|
161
|
+
}
|
|
162
|
+
// Notifier — always subscribe to cca:notify and cca:chat:incoming channels.
|
|
163
|
+
// CC_AGENT_NOTIFY_CHAT_ID pins a fixed Telegram chatId; without it the last
|
|
164
|
+
// active chatId is used dynamically for the chat bridge.
|
|
165
|
+
const notifyChatId = process.env.CC_AGENT_NOTIFY_CHAT_ID
|
|
166
|
+
? Number(process.env.CC_AGENT_NOTIFY_CHAT_ID)
|
|
167
|
+
: null;
|
|
168
|
+
const notifierBot = new TelegramBot(telegramToken, { polling: false });
|
|
169
|
+
startNotifier(notifierBot, notifyChatId, namespace, sharedRedis, (cid, text) => bot.handleUserMessage(cid, text), () => bot.getLastActiveChatId());
|
|
170
|
+
console.log(`[notifier] started for namespace=${namespace} chatId=${notifyChatId ?? "dynamic"}`);
|
|
115
171
|
process.on("SIGINT", () => {
|
|
116
172
|
console.log("\nShutting down...");
|
|
117
173
|
bot.stop();
|
|
@@ -0,0 +1,37 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
const targetId = chatId ?? getActiveChatId?.();
|
|
81
|
+
if (targetId != null) {
|
|
82
|
+
bot.sendMessage(targetId, message).catch((err) => {
|
|
83
|
+
log("warn", "sendMessage failed:", err.message);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
log("warn", "notify: no chatId available, dropping notification");
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (channel === incomingChannel) {
|
|
92
|
+
let content = message;
|
|
93
|
+
let originalTimestamp;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(message);
|
|
96
|
+
if (parsed.content)
|
|
97
|
+
content = parsed.content;
|
|
98
|
+
if (parsed.timestamp)
|
|
99
|
+
originalTimestamp = parsed.timestamp;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// raw string message — use as-is
|
|
103
|
+
}
|
|
104
|
+
// Resolve the target chatId: prefer the fixed chatId, fall back to last active
|
|
105
|
+
const targetChatId = chatId ?? getActiveChatId?.();
|
|
106
|
+
if (targetChatId !== undefined) {
|
|
107
|
+
// Echo to Telegram so the user sees UI messages in the chat
|
|
108
|
+
bot.sendMessage(targetChatId, `📱 [from UI]: ${content}`).catch((err) => {
|
|
109
|
+
log("warn", "sendMessage (UI echo) failed:", err.message);
|
|
110
|
+
});
|
|
111
|
+
// Log the incoming message — preserve original timestamp from UI if present
|
|
112
|
+
const inMsg = {
|
|
113
|
+
id: `ui-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
114
|
+
source: "ui", // 'ui' distinguishes this from telegram/claude messages
|
|
115
|
+
role: "user",
|
|
116
|
+
content,
|
|
117
|
+
// ISO 8601 — matches cc-agent-ui /chat/send format; preserve original if present
|
|
118
|
+
timestamp: originalTimestamp ?? new Date().toISOString(),
|
|
119
|
+
chatId: targetChatId,
|
|
120
|
+
};
|
|
121
|
+
writeChatLog(redis, namespace, inMsg);
|
|
122
|
+
// Check if a meta-agent is running for this namespace; if so, route there instead
|
|
123
|
+
void (async () => {
|
|
124
|
+
let routedToMetaAgent = false;
|
|
125
|
+
try {
|
|
126
|
+
const statusRaw = await redis.get(`cca:meta-agent:status:${namespace}`);
|
|
127
|
+
if (statusRaw) {
|
|
128
|
+
const status = JSON.parse(statusRaw);
|
|
129
|
+
if (status.status === "running") {
|
|
130
|
+
const entry = JSON.stringify({
|
|
131
|
+
id: crypto.randomUUID(),
|
|
132
|
+
content,
|
|
133
|
+
timestamp: new Date().toISOString(),
|
|
134
|
+
});
|
|
135
|
+
await redis.lpush(`cca:meta:${namespace}:input`, entry);
|
|
136
|
+
log("info", `cca:chat:incoming: routed to meta-agent for namespace ${namespace}`);
|
|
137
|
+
routedToMetaAgent = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
log("warn", "meta-agent status check failed, falling back to coordinator:", err.message);
|
|
143
|
+
}
|
|
144
|
+
if (!routedToMetaAgent && handleUserMessage) {
|
|
145
|
+
handleUserMessage(targetChatId, content);
|
|
146
|
+
}
|
|
147
|
+
})();
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
log("warn", "cca:chat:incoming: no active chatId to route message to");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth token pool management.
|
|
3
|
+
*
|
|
4
|
+
* Supports CLAUDE_CODE_OAUTH_TOKENS (comma-separated list of tokens).
|
|
5
|
+
* Falls back to CLAUDE_CODE_OAUTH_TOKEN for single-token / backwards compat.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Load tokens from env vars. Called on startup; also re-callable in tests.
|
|
9
|
+
* Priority: CLAUDE_CODE_OAUTH_TOKENS > CLAUDE_CODE_OAUTH_TOKEN > (empty)
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadTokens(): string[];
|
|
12
|
+
/** Returns the current active token, or empty string if none configured. */
|
|
13
|
+
export declare function getCurrentToken(): string;
|
|
14
|
+
/**
|
|
15
|
+
* Advance to the next token (wraps around).
|
|
16
|
+
* Returns the new current token.
|
|
17
|
+
*/
|
|
18
|
+
export declare function rotateToken(): string;
|
|
19
|
+
/** Zero-based index of the current token. */
|
|
20
|
+
export declare function getTokenIndex(): number;
|
|
21
|
+
/** Total number of tokens in the pool. */
|
|
22
|
+
export declare function getTokenCount(): number;
|