@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.
@@ -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
+ }
@@ -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
+ }
@@ -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 reached') ||
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('currently overloaded') || lower.includes('overloaded with requests')) {
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 (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
- }
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
- // whisper outputs to stdout — strip leading/trailing whitespace and [BLANK_AUDIO] artifacts
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.18",
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.19.15",
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",