@gonzih/cc-tg 0.9.18 → 0.9.19
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 +209 -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 +29 -34
- package/package.json +4 -3
package/dist/notifier.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
// Poll the cca:notify:{namespace} LIST every 5 seconds.
|
|
77
|
+
// Jobs push to this list via RPUSH; pub/sub alone won't deliver those messages.
|
|
78
|
+
const notifyListKey = `cca:notify:${namespace}`;
|
|
79
|
+
const MAX_PER_CYCLE = 20;
|
|
80
|
+
const pollNotifyList = async () => {
|
|
81
|
+
const targetId = chatId ?? getActiveChatId?.();
|
|
82
|
+
if (targetId == null)
|
|
83
|
+
return;
|
|
84
|
+
const items = [];
|
|
85
|
+
try {
|
|
86
|
+
for (let i = 0; i < MAX_PER_CYCLE; i++) {
|
|
87
|
+
const item = await redis.rpop(notifyListKey);
|
|
88
|
+
if (item === null)
|
|
89
|
+
break;
|
|
90
|
+
items.push(item);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
log("warn", "notify list rpop failed:", err.message);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (items.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
let remaining = 0;
|
|
100
|
+
if (items.length === MAX_PER_CYCLE) {
|
|
101
|
+
try {
|
|
102
|
+
remaining = await redis.llen(notifyListKey);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
log("warn", "notify list llen failed:", err.message);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const raw of items) {
|
|
109
|
+
let text = raw;
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(raw);
|
|
112
|
+
if (parsed.text)
|
|
113
|
+
text = parsed.text;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// not JSON — use raw string as-is
|
|
117
|
+
}
|
|
118
|
+
bot.sendMessage(targetId, text).catch((err) => {
|
|
119
|
+
log("warn", "notify list sendMessage failed:", err.message);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (remaining > 0) {
|
|
123
|
+
bot.sendMessage(targetId, `...and ${remaining} more notifications`).catch((err) => {
|
|
124
|
+
log("warn", "notify list summary sendMessage failed:", err.message);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
setInterval(() => {
|
|
129
|
+
void pollNotifyList();
|
|
130
|
+
}, 5_000);
|
|
131
|
+
sub.on("message", (channel, message) => {
|
|
132
|
+
const notifyChannel = `cca:notify:${namespace}`;
|
|
133
|
+
const incomingChannel = `cca:chat:incoming:${namespace}`;
|
|
134
|
+
if (channel === notifyChannel) {
|
|
135
|
+
const targetId = chatId ?? getActiveChatId?.();
|
|
136
|
+
if (targetId != null) {
|
|
137
|
+
bot.sendMessage(targetId, message).catch((err) => {
|
|
138
|
+
log("warn", "sendMessage failed:", err.message);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
log("warn", "notify: no chatId available, dropping notification");
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (channel === incomingChannel) {
|
|
147
|
+
let content = message;
|
|
148
|
+
let originalTimestamp;
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(message);
|
|
151
|
+
if (parsed.content)
|
|
152
|
+
content = parsed.content;
|
|
153
|
+
if (parsed.timestamp)
|
|
154
|
+
originalTimestamp = parsed.timestamp;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// raw string message — use as-is
|
|
158
|
+
}
|
|
159
|
+
// Resolve the target chatId: prefer the fixed chatId, fall back to last active
|
|
160
|
+
const targetChatId = chatId ?? getActiveChatId?.();
|
|
161
|
+
if (targetChatId !== undefined) {
|
|
162
|
+
// Echo to Telegram so the user sees UI messages in the chat
|
|
163
|
+
bot.sendMessage(targetChatId, `📱 [from UI]: ${content}`).catch((err) => {
|
|
164
|
+
log("warn", "sendMessage (UI echo) failed:", err.message);
|
|
165
|
+
});
|
|
166
|
+
// Log the incoming message — preserve original timestamp from UI if present
|
|
167
|
+
const inMsg = {
|
|
168
|
+
id: `ui-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
169
|
+
source: "ui", // 'ui' distinguishes this from telegram/claude messages
|
|
170
|
+
role: "user",
|
|
171
|
+
content,
|
|
172
|
+
// ISO 8601 — matches cc-agent-ui /chat/send format; preserve original if present
|
|
173
|
+
timestamp: originalTimestamp ?? new Date().toISOString(),
|
|
174
|
+
chatId: targetChatId,
|
|
175
|
+
};
|
|
176
|
+
writeChatLog(redis, namespace, inMsg);
|
|
177
|
+
// Check if a meta-agent is running for this namespace; if so, route there instead
|
|
178
|
+
void (async () => {
|
|
179
|
+
let routedToMetaAgent = false;
|
|
180
|
+
try {
|
|
181
|
+
const statusRaw = await redis.get(`cca:meta-agent:status:${namespace}`);
|
|
182
|
+
if (statusRaw) {
|
|
183
|
+
const status = JSON.parse(statusRaw);
|
|
184
|
+
if (status.status === "running") {
|
|
185
|
+
const entry = JSON.stringify({
|
|
186
|
+
id: crypto.randomUUID(),
|
|
187
|
+
content,
|
|
188
|
+
timestamp: new Date().toISOString(),
|
|
189
|
+
});
|
|
190
|
+
await redis.lpush(`cca:meta:${namespace}:input`, entry);
|
|
191
|
+
log("info", `cca:chat:incoming: routed to meta-agent for namespace ${namespace}`);
|
|
192
|
+
routedToMetaAgent = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
log("warn", "meta-agent status check failed, falling back to coordinator:", err.message);
|
|
198
|
+
}
|
|
199
|
+
if (!routedToMetaAgent && handleUserMessage) {
|
|
200
|
+
handleUserMessage(targetChatId, content);
|
|
201
|
+
}
|
|
202
|
+
})();
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
log("warn", "cca:chat:incoming: no active chatId to route message to");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
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;
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
let tokens = [];
|
|
8
|
+
let currentIndex = 0;
|
|
9
|
+
let initialized = false;
|
|
10
|
+
/**
|
|
11
|
+
* Load tokens from env vars. Called on startup; also re-callable in tests.
|
|
12
|
+
* Priority: CLAUDE_CODE_OAUTH_TOKENS > CLAUDE_CODE_OAUTH_TOKEN > (empty)
|
|
13
|
+
*/
|
|
14
|
+
export function loadTokens() {
|
|
15
|
+
const multi = process.env.CLAUDE_CODE_OAUTH_TOKENS;
|
|
16
|
+
if (multi) {
|
|
17
|
+
tokens = multi.split(",").map((t) => t.trim()).filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const single = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
21
|
+
tokens = single ? [single] : [];
|
|
22
|
+
}
|
|
23
|
+
currentIndex = 0;
|
|
24
|
+
initialized = true;
|
|
25
|
+
return tokens;
|
|
26
|
+
}
|
|
27
|
+
function ensureInitialized() {
|
|
28
|
+
if (!initialized)
|
|
29
|
+
loadTokens();
|
|
30
|
+
}
|
|
31
|
+
/** Returns the current active token, or empty string if none configured. */
|
|
32
|
+
export function getCurrentToken() {
|
|
33
|
+
ensureInitialized();
|
|
34
|
+
return tokens[currentIndex] ?? "";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Advance to the next token (wraps around).
|
|
38
|
+
* Returns the new current token.
|
|
39
|
+
*/
|
|
40
|
+
export function rotateToken() {
|
|
41
|
+
ensureInitialized();
|
|
42
|
+
if (tokens.length === 0)
|
|
43
|
+
return "";
|
|
44
|
+
currentIndex = (currentIndex + 1) % tokens.length;
|
|
45
|
+
return tokens[currentIndex];
|
|
46
|
+
}
|
|
47
|
+
/** Zero-based index of the current token. */
|
|
48
|
+
export function getTokenIndex() {
|
|
49
|
+
ensureInitialized();
|
|
50
|
+
return currentIndex;
|
|
51
|
+
}
|
|
52
|
+
/** Total number of tokens in the pool. */
|
|
53
|
+
export function getTokenCount() {
|
|
54
|
+
ensureInitialized();
|
|
55
|
+
return tokens.length;
|
|
56
|
+
}
|
package/dist/usage-limit.js
CHANGED
|
@@ -3,8 +3,7 @@ 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
|
|
7
|
-
lower.includes('your usage limit')) {
|
|
6
|
+
lower.includes('usage limit')) {
|
|
8
7
|
const wake = nextHourBoundary() + 5 * 60 * 1000;
|
|
9
8
|
return {
|
|
10
9
|
detected: true,
|
|
@@ -13,7 +12,7 @@ export function detectUsageLimit(text) {
|
|
|
13
12
|
humanMessage: `⏸ Claude usage limit reached. Will auto-resume at ${new Date(wake).toUTCString()}. I'll message you when it's back.`,
|
|
14
13
|
};
|
|
15
14
|
}
|
|
16
|
-
if (lower.includes('
|
|
15
|
+
if (lower.includes('rate limit') || lower.includes('overloaded')) {
|
|
17
16
|
return {
|
|
18
17
|
detected: true,
|
|
19
18
|
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 } from "fs/promises";
|
|
8
|
+
import { unlink, readFile } from "fs/promises";
|
|
9
9
|
import { tmpdir } from "os";
|
|
10
10
|
import { join } from "path";
|
|
11
11
|
import https from "https";
|
|
@@ -92,40 +92,34 @@ export async function transcribeVoice(fileUrl) {
|
|
|
92
92
|
"-c:a", "pcm_s16le",
|
|
93
93
|
wavPath,
|
|
94
94
|
]);
|
|
95
|
-
// 3. Run whisper-cpp
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
}
|
|
95
|
+
// 3. Run whisper-cpp
|
|
96
|
+
// --output-txt writes to ${wavPath}.txt (NOT stdout)
|
|
97
|
+
// -l auto fails with .en models — detect and use -l en instead
|
|
98
|
+
const isEnModel = model.includes(".en.");
|
|
99
|
+
const langArgs = isEnModel ? ["-l", "en"] : ["-l", "auto"];
|
|
100
|
+
try {
|
|
101
|
+
await execFileAsync(whisperBin, [
|
|
102
|
+
"-m", model,
|
|
103
|
+
"-f", wavPath,
|
|
104
|
+
"--no-timestamps",
|
|
105
|
+
...langArgs,
|
|
106
|
+
"--output-txt", // writes to wavPath + ".txt"
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
111
|
+
throw new Error(`whisper-cpp failed: ${msg}`);
|
|
112
|
+
}
|
|
113
|
+
// Read the output file whisper-cpp wrote
|
|
114
|
+
const txtPath = `${wavPath}.txt`;
|
|
115
|
+
let raw = "";
|
|
116
|
+
try {
|
|
117
|
+
raw = await readFile(txtPath, "utf-8");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw new Error("whisper-cpp ran but produced no output file");
|
|
126
121
|
}
|
|
127
|
-
|
|
128
|
-
const text = stdout
|
|
122
|
+
const text = raw
|
|
129
123
|
.replace(/\[BLANK_AUDIO\]/gi, "")
|
|
130
124
|
.replace(/\[.*?\]/g, "") // remove timestamp artifacts
|
|
131
125
|
.trim();
|
|
@@ -135,6 +129,7 @@ export async function transcribeVoice(fileUrl) {
|
|
|
135
129
|
// Cleanup temp files
|
|
136
130
|
await unlink(oggPath).catch(() => { });
|
|
137
131
|
await unlink(wavPath).catch(() => { });
|
|
132
|
+
await unlink(`${wavPath}.txt`).catch(() => { });
|
|
138
133
|
}
|
|
139
134
|
}
|
|
140
135
|
/**
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonzih/cc-tg",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.19",
|
|
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 && chmod +x dist/index.js",
|
|
11
11
|
"start": "node dist/index.js",
|
|
12
12
|
"dev": "node --loader ts-node/esm src/index.ts",
|
|
13
13
|
"test": "vitest run",
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
"dist/"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
+
"@gonzih/agent-ops": "^0.1.0",
|
|
21
22
|
"node-telegram-bot-api": "^0.66.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
|
-
"@types/node": "^22.
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
25
26
|
"@types/node-telegram-bot-api": "^0.64.0",
|
|
26
27
|
"@vitest/coverage-v8": "^4.1.0",
|
|
27
28
|
"typescript": "^5.5.0",
|