@gonzih/cc-tg 0.9.19 → 0.9.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -36
- package/dist/bot.d.ts +4 -35
- package/dist/bot.js +347 -550
- package/dist/cron.d.ts +1 -7
- package/dist/cron.js +3 -24
- package/dist/formatter.d.ts +12 -14
- package/dist/formatter.js +36 -72
- package/dist/index.js +21 -77
- package/dist/usage-limit.js +3 -2
- package/dist/voice.js +34 -29
- package/package.json +3 -4
- package/dist/notifier.d.ts +0 -37
- package/dist/notifier.js +0 -209
- package/dist/tokens.d.ts +0 -22
- package/dist/tokens.js +0 -56
package/dist/cron.d.ts
CHANGED
|
@@ -11,15 +11,9 @@ export interface CronJob {
|
|
|
11
11
|
createdAt: string;
|
|
12
12
|
schedule: string;
|
|
13
13
|
}
|
|
14
|
-
|
|
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;
|
|
14
|
+
type FireCallback = (chatId: number, prompt: string) => void;
|
|
19
15
|
export declare class CronManager {
|
|
20
16
|
private jobs;
|
|
21
|
-
/** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
|
|
22
|
-
private activeJobs;
|
|
23
17
|
private storePath;
|
|
24
18
|
private fire;
|
|
25
19
|
constructor(cwd: string, fire: FireCallback);
|
package/dist/cron.js
CHANGED
|
@@ -7,8 +7,6 @@ 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();
|
|
12
10
|
storePath;
|
|
13
11
|
fire;
|
|
14
12
|
constructor(cwd, fire) {
|
|
@@ -38,13 +36,8 @@ export class CronManager {
|
|
|
38
36
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
39
37
|
const job = { id, chatId, intervalMs, prompt, schedule, createdAt: new Date().toISOString() };
|
|
40
38
|
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);
|
|
46
39
|
console.log(`[cron:${id}] firing for chat=${chatId} prompt="${prompt}"`);
|
|
47
|
-
this.fire(chatId, prompt
|
|
40
|
+
this.fire(chatId, prompt);
|
|
48
41
|
}, intervalMs);
|
|
49
42
|
this.jobs.set(id, { ...job, timer });
|
|
50
43
|
this.persist();
|
|
@@ -55,7 +48,6 @@ export class CronManager {
|
|
|
55
48
|
if (!job || job.chatId !== chatId)
|
|
56
49
|
return false;
|
|
57
50
|
clearInterval(job.timer);
|
|
58
|
-
this.activeJobs.delete(id);
|
|
59
51
|
this.jobs.delete(id);
|
|
60
52
|
this.persist();
|
|
61
53
|
return true;
|
|
@@ -65,7 +57,6 @@ export class CronManager {
|
|
|
65
57
|
for (const [id, job] of this.jobs) {
|
|
66
58
|
if (job.chatId === chatId) {
|
|
67
59
|
clearInterval(job.timer);
|
|
68
|
-
this.activeJobs.delete(id);
|
|
69
60
|
this.jobs.delete(id);
|
|
70
61
|
count++;
|
|
71
62
|
}
|
|
@@ -95,16 +86,9 @@ export class CronManager {
|
|
|
95
86
|
}
|
|
96
87
|
// Recreate timer so it uses updated intervalMs and always reads latest job.prompt
|
|
97
88
|
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);
|
|
100
89
|
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);
|
|
106
90
|
console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
|
|
107
|
-
this.fire(job.chatId, job.prompt
|
|
91
|
+
this.fire(job.chatId, job.prompt);
|
|
108
92
|
}, job.intervalMs);
|
|
109
93
|
this.persist();
|
|
110
94
|
const { timer: _t, ...cronJob } = job;
|
|
@@ -129,13 +113,8 @@ export class CronManager {
|
|
|
129
113
|
const data = JSON.parse(readFileSync(this.storePath, "utf8"));
|
|
130
114
|
for (const job of data) {
|
|
131
115
|
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);
|
|
137
116
|
console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
|
|
138
|
-
this.fire(job.chatId, job.prompt
|
|
117
|
+
this.fire(job.chatId, job.prompt);
|
|
139
118
|
}, job.intervalMs);
|
|
140
119
|
this.jobs.set(job.id, { ...job, timer });
|
|
141
120
|
}
|
package/dist/formatter.d.ts
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram
|
|
3
|
-
* Converts standard markdown to Telegram's
|
|
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
|
|
6
|
+
* Convert standard markdown text to Telegram MarkdownV2 format.
|
|
7
7
|
*
|
|
8
8
|
* Processing order:
|
|
9
|
-
* 1. Extract
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
12
|
-
* 4. Convert
|
|
13
|
-
* 5. Convert
|
|
14
|
-
* 6. Convert
|
|
15
|
-
* 7.
|
|
16
|
-
* 8.
|
|
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
|
|
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
|
|
3
|
-
* Converts standard markdown to Telegram's
|
|
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, "&")
|
|
8
|
-
.replace(/</g, "<")
|
|
9
|
-
.replace(/>/g, ">");
|
|
10
|
-
}
|
|
11
5
|
/**
|
|
12
|
-
* Convert standard markdown text to Telegram
|
|
6
|
+
* Convert standard markdown text to Telegram MarkdownV2 format.
|
|
13
7
|
*
|
|
14
8
|
* Processing order:
|
|
15
|
-
* 1. Extract
|
|
16
|
-
* 2.
|
|
17
|
-
* 3.
|
|
18
|
-
* 4. Convert
|
|
19
|
-
* 5. Convert
|
|
20
|
-
* 6. Convert
|
|
21
|
-
* 7.
|
|
22
|
-
* 8.
|
|
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
|
-
//
|
|
29
|
-
let out = text.replace(/```
|
|
30
|
-
placeholders.push(
|
|
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
|
-
//
|
|
34
|
-
out = out.replace(/`
|
|
35
|
-
placeholders.push(
|
|
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
|
|
39
|
-
out =
|
|
40
|
-
// Step
|
|
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
|
|
43
|
-
out = out.replace(/^#{1,6}\s+(.+)$/gm, "
|
|
44
|
-
// Step
|
|
45
|
-
out = out.replace(/\*\*(.+?)\*\*/gs, "
|
|
46
|
-
// Step
|
|
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
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
67
|
+
if (lastPara > 0) {
|
|
97
68
|
splitAt = lastPara + 2;
|
|
98
69
|
}
|
|
99
|
-
else if (lastLine > 0
|
|
70
|
+
else if (lastLine > 0) {
|
|
100
71
|
splitAt = lastLine + 1;
|
|
101
72
|
}
|
|
102
|
-
else if (lastSpace > 0
|
|
73
|
+
else if (lastSpace > 0) {
|
|
103
74
|
splitAt = lastSpace + 1;
|
|
104
75
|
}
|
|
105
76
|
else {
|
|
106
|
-
|
|
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,27 +15,21 @@
|
|
|
15
15
|
* CWD — working directory for Claude Code (default: process.cwd())
|
|
16
16
|
*/
|
|
17
17
|
import { createServer, createConnection } from "net";
|
|
18
|
-
import { unlinkSync
|
|
18
|
+
import { unlinkSync } from "fs";
|
|
19
19
|
import { tmpdir } from "os";
|
|
20
|
-
import
|
|
21
|
-
import {
|
|
22
|
-
import { fileURLToPath } from "url";
|
|
23
|
-
import TelegramBot from "node-telegram-bot-api";
|
|
20
|
+
import { join } from "path";
|
|
21
|
+
import { createHash } from "crypto";
|
|
24
22
|
import { CcTgBot } from "./bot.js";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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() {
|
|
23
|
+
// Derive socket path from token hash so multiple instances (feral/law/simorgh)
|
|
24
|
+
// never collide, and the path is stable across restarts on the same machine.
|
|
25
|
+
function lockSocketPath(token) {
|
|
26
|
+
const hash = createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
27
|
+
return join(tmpdir(), `cc-tg-${hash}.sock`);
|
|
28
|
+
}
|
|
29
|
+
function acquireLock(socketPath) {
|
|
36
30
|
return new Promise((resolve) => {
|
|
37
31
|
const server = createServer();
|
|
38
|
-
server.listen(
|
|
32
|
+
server.listen(socketPath, () => {
|
|
39
33
|
// Bound successfully — we own the lock. Socket auto-released on any exit incl. SIGKILL.
|
|
40
34
|
resolve(true);
|
|
41
35
|
});
|
|
@@ -45,7 +39,7 @@ function acquireLock() {
|
|
|
45
39
|
return;
|
|
46
40
|
}
|
|
47
41
|
// Socket path exists — probe if anything is actually listening
|
|
48
|
-
const probe = createConnection(
|
|
42
|
+
const probe = createConnection(socketPath);
|
|
49
43
|
probe.on("connect", () => {
|
|
50
44
|
probe.destroy();
|
|
51
45
|
console.error("[cc-tg] Another instance is already running. Exiting.");
|
|
@@ -54,20 +48,16 @@ function acquireLock() {
|
|
|
54
48
|
probe.on("error", () => {
|
|
55
49
|
// Nothing listening — stale socket, remove and retry
|
|
56
50
|
try {
|
|
57
|
-
unlinkSync(
|
|
51
|
+
unlinkSync(socketPath);
|
|
58
52
|
}
|
|
59
53
|
catch { }
|
|
60
54
|
const retry = createServer();
|
|
61
|
-
retry.listen(
|
|
55
|
+
retry.listen(socketPath, () => resolve(true));
|
|
62
56
|
retry.on("error", () => resolve(true)); // give up on lock, just start
|
|
63
57
|
});
|
|
64
58
|
});
|
|
65
59
|
});
|
|
66
60
|
}
|
|
67
|
-
const lockAcquired = await acquireLock();
|
|
68
|
-
if (!lockAcquired) {
|
|
69
|
-
process.exit(1);
|
|
70
|
-
}
|
|
71
61
|
function required(name) {
|
|
72
62
|
const val = process.env[name];
|
|
73
63
|
if (!val) {
|
|
@@ -88,6 +78,13 @@ Or add to your shell profile / .env file.
|
|
|
88
78
|
return val;
|
|
89
79
|
}
|
|
90
80
|
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
|
+
}
|
|
91
88
|
// Accept CLAUDE_CODE_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, or ANTHROPIC_API_KEY
|
|
92
89
|
const claudeToken = process.env.CLAUDE_CODE_TOKEN ??
|
|
93
90
|
process.env.CLAUDE_CODE_OAUTH_TOKEN ??
|
|
@@ -101,11 +98,6 @@ Set one and run again:
|
|
|
101
98
|
`);
|
|
102
99
|
process.exit(1);
|
|
103
100
|
}
|
|
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
101
|
const allowedUserIds = process.env.ALLOWED_USER_IDS
|
|
110
102
|
? process.env.ALLOWED_USER_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
|
|
111
103
|
: [];
|
|
@@ -113,61 +105,13 @@ const groupChatIds = process.env.GROUP_CHAT_IDS
|
|
|
113
105
|
? process.env.GROUP_CHAT_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
|
|
114
106
|
: [];
|
|
115
107
|
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
|
-
});
|
|
131
108
|
const bot = new CcTgBot({
|
|
132
109
|
telegramToken,
|
|
133
110
|
claudeToken,
|
|
134
111
|
cwd,
|
|
135
112
|
allowedUserIds,
|
|
136
113
|
groupChatIds,
|
|
137
|
-
redis: sharedRedis,
|
|
138
|
-
namespace,
|
|
139
114
|
});
|
|
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"}`);
|
|
171
115
|
process.on("SIGINT", () => {
|
|
172
116
|
console.log("\nShutting down...");
|
|
173
117
|
bot.stop();
|
package/dist/usage-limit.js
CHANGED
|
@@ -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('
|
|
16
|
+
if (lower.includes('currently overloaded') || lower.includes('overloaded with requests')) {
|
|
16
17
|
return {
|
|
17
18
|
detected: true,
|
|
18
19
|
reason: 'rate_limit',
|
package/dist/voice.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { execFile } from "child_process";
|
|
6
6
|
import { promisify } from "util";
|
|
7
7
|
import { existsSync } from "fs";
|
|
8
|
-
import { unlink
|
|
8
|
+
import { unlink } from "fs/promises";
|
|
9
9
|
import { tmpdir } from "os";
|
|
10
10
|
import { join } from "path";
|
|
11
11
|
import https from "https";
|
|
@@ -92,34 +92,40 @@ export async function transcribeVoice(fileUrl) {
|
|
|
92
92
|
"-c:a", "pcm_s16le",
|
|
93
93
|
wavPath,
|
|
94
94
|
]);
|
|
95
|
-
// 3. Run whisper-cpp
|
|
96
|
-
// --output-txt
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
95
|
+
// 3. Run whisper-cpp (with one retry on signal-kill)
|
|
96
|
+
// Note: omit --output-txt — we read from stdout directly, no file write needed
|
|
97
|
+
const whisperArgs = [
|
|
98
|
+
"-m", model,
|
|
99
|
+
"-f", wavPath,
|
|
100
|
+
"--no-timestamps",
|
|
101
|
+
"-l", "auto",
|
|
102
|
+
"--no-prints", // suppress progress/timing logs from stdout so we get clean text
|
|
103
|
+
];
|
|
104
|
+
let stdout = "";
|
|
105
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
106
|
+
try {
|
|
107
|
+
const result = await execFileAsync(whisperBin, whisperArgs, {
|
|
108
|
+
timeout: 120000, // 2 min — large audio files can take a while
|
|
109
|
+
maxBuffer: 10 * 1024 * 1024, // 10 MB — generous for long transcriptions
|
|
110
|
+
});
|
|
111
|
+
stdout = result.stdout;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
// On attempt 1: retry if killed by signal or empty message (OOM/SIGKILL)
|
|
116
|
+
if (attempt < 2 && (e.signal || !e.message)) {
|
|
117
|
+
console.warn(`[voice] whisper attempt ${attempt} killed (${e.signal || 'no message'}), retrying...`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Re-throw with stderr attached so caller can show useful diagnostics
|
|
121
|
+
const detail = (e.stderr || "").toString().trim().split("\n").slice(-5).join(" | ");
|
|
122
|
+
if (detail)
|
|
123
|
+
e.message = `${e.message || "whisper failed"} — ${detail}`;
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
121
126
|
}
|
|
122
|
-
|
|
127
|
+
// whisper outputs to stdout — strip leading/trailing whitespace and [BLANK_AUDIO] artifacts
|
|
128
|
+
const text = stdout
|
|
123
129
|
.replace(/\[BLANK_AUDIO\]/gi, "")
|
|
124
130
|
.replace(/\[.*?\]/g, "") // remove timestamp artifacts
|
|
125
131
|
.trim();
|
|
@@ -129,7 +135,6 @@ export async function transcribeVoice(fileUrl) {
|
|
|
129
135
|
// Cleanup temp files
|
|
130
136
|
await unlink(oggPath).catch(() => { });
|
|
131
137
|
await unlink(wavPath).catch(() => { });
|
|
132
|
-
await unlink(`${wavPath}.txt`).catch(() => { });
|
|
133
138
|
}
|
|
134
139
|
}
|
|
135
140
|
/**
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonzih/cc-tg",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.21",
|
|
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
|
|
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.
|
|
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",
|
package/dist/notifier.d.ts
DELETED
|
@@ -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;
|