@gonzih/cc-tg 0.9.28 → 0.9.29
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/bot.d.ts +86 -0
- package/dist/bot.js +1517 -0
- package/dist/claude.d.ts +54 -0
- package/dist/claude.js +208 -0
- package/dist/cron.d.ts +39 -0
- package/dist/cron.js +148 -0
- package/dist/formatter.d.ts +25 -0
- package/dist/formatter.js +122 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +181 -0
- package/dist/notifier.d.ts +47 -0
- package/dist/notifier.js +319 -0
- package/dist/seed.d.ts +1 -0
- package/dist/seed.js +251 -0
- package/dist/tokens.d.ts +22 -0
- package/dist/tokens.js +56 -0
- package/dist/usage-limit.d.ts +7 -0
- package/dist/usage-limit.js +29 -0
- package/dist/voice.d.ts +13 -0
- package/dist/voice.js +142 -0
- package/package.json +1 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cc-tg — Claude Code Telegram bot
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx @gonzih/cc-tg
|
|
7
|
+
*
|
|
8
|
+
* Required env:
|
|
9
|
+
* TELEGRAM_BOT_TOKEN — from @BotFather
|
|
10
|
+
* CLAUDE_CODE_TOKEN — your Claude Code OAuth token (or ANTHROPIC_API_KEY)
|
|
11
|
+
*
|
|
12
|
+
* Optional env:
|
|
13
|
+
* ALLOWED_USER_IDS — comma-separated Telegram user IDs (leave empty to allow all)
|
|
14
|
+
* GROUP_CHAT_IDS — comma-separated Telegram group/supergroup chat IDs (leave empty to allow all groups)
|
|
15
|
+
* CWD — working directory for Claude Code (default: process.cwd())
|
|
16
|
+
*/
|
|
17
|
+
import { createServer, createConnection } from "net";
|
|
18
|
+
import { unlinkSync, readFileSync } from "fs";
|
|
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";
|
|
24
|
+
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
|
+
import { seedClaudeMd } from "./seed.js";
|
|
30
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
31
|
+
const __dirname = dirname(__filename);
|
|
32
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
33
|
+
// Make lock socket unique per bot token so multiple users on the same machine don't collide
|
|
34
|
+
const _tokenHash = Buffer.from(process.env.TELEGRAM_BOT_TOKEN ?? "default").toString("base64").replace(/[^a-z0-9]/gi, "").slice(0, 16);
|
|
35
|
+
const LOCK_SOCKET = join(tmpdir(), `cc-tg-${_tokenHash}.sock`);
|
|
36
|
+
function acquireLock() {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const server = createServer();
|
|
39
|
+
server.listen(LOCK_SOCKET, () => {
|
|
40
|
+
// Bound successfully — we own the lock. Socket auto-released on any exit incl. SIGKILL.
|
|
41
|
+
resolve(true);
|
|
42
|
+
});
|
|
43
|
+
server.on("error", (err) => {
|
|
44
|
+
if (err.code !== "EADDRINUSE") {
|
|
45
|
+
resolve(true); // unrelated error, proceed
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Socket path exists — probe if anything is actually listening
|
|
49
|
+
const probe = createConnection(LOCK_SOCKET);
|
|
50
|
+
probe.on("connect", () => {
|
|
51
|
+
probe.destroy();
|
|
52
|
+
console.error("[cc-tg] Another instance is already running. Exiting.");
|
|
53
|
+
resolve(false);
|
|
54
|
+
});
|
|
55
|
+
probe.on("error", () => {
|
|
56
|
+
// Nothing listening — stale socket, remove and retry
|
|
57
|
+
try {
|
|
58
|
+
unlinkSync(LOCK_SOCKET);
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
const retry = createServer();
|
|
62
|
+
retry.listen(LOCK_SOCKET, () => resolve(true));
|
|
63
|
+
retry.on("error", () => resolve(true)); // give up on lock, just start
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const lockAcquired = await acquireLock();
|
|
69
|
+
if (!lockAcquired) {
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
function required(name) {
|
|
73
|
+
const val = process.env[name];
|
|
74
|
+
if (!val) {
|
|
75
|
+
console.error(`
|
|
76
|
+
ERROR: ${name} is not set.
|
|
77
|
+
|
|
78
|
+
cc-tg requires:
|
|
79
|
+
TELEGRAM_BOT_TOKEN — get one from @BotFather on Telegram
|
|
80
|
+
CLAUDE_CODE_TOKEN — your Claude Code OAuth token
|
|
81
|
+
|
|
82
|
+
Set them and run again:
|
|
83
|
+
TELEGRAM_BOT_TOKEN=xxx CLAUDE_CODE_TOKEN=yyy npx @gonzih/cc-tg
|
|
84
|
+
|
|
85
|
+
Or add to your shell profile / .env file.
|
|
86
|
+
`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
return val;
|
|
90
|
+
}
|
|
91
|
+
const telegramToken = required("TELEGRAM_BOT_TOKEN");
|
|
92
|
+
// Accept CLAUDE_CODE_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, or ANTHROPIC_API_KEY
|
|
93
|
+
const claudeToken = process.env.CLAUDE_CODE_TOKEN ??
|
|
94
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ??
|
|
95
|
+
process.env.ANTHROPIC_API_KEY;
|
|
96
|
+
if (!claudeToken) {
|
|
97
|
+
console.error(`
|
|
98
|
+
ERROR: No Claude token set. Set one of: CLAUDE_CODE_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, or ANTHROPIC_API_KEY.
|
|
99
|
+
|
|
100
|
+
Set one and run again:
|
|
101
|
+
TELEGRAM_BOT_TOKEN=xxx CLAUDE_CODE_TOKEN=yyy npx @gonzih/cc-tg
|
|
102
|
+
`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
// Load OAuth token pool (supports CLAUDE_CODE_OAUTH_TOKENS for multi-account rotation)
|
|
106
|
+
const tokenPool = loadTokens();
|
|
107
|
+
if (tokenPool.length > 1) {
|
|
108
|
+
console.log(`[cc-tg] Token pool loaded: ${tokenPool.length} tokens — will rotate on usage limit`);
|
|
109
|
+
}
|
|
110
|
+
const allowedUserIds = process.env.ALLOWED_USER_IDS
|
|
111
|
+
? process.env.ALLOWED_USER_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
|
|
112
|
+
: [];
|
|
113
|
+
const groupChatIds = process.env.GROUP_CHAT_IDS
|
|
114
|
+
? process.env.GROUP_CHAT_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
|
|
115
|
+
: [];
|
|
116
|
+
const cwd = process.env.CWD ?? process.cwd();
|
|
117
|
+
seedClaudeMd(cwd);
|
|
118
|
+
// agent-ops / chat bridge — Redis is always initialized so the chat bridge works
|
|
119
|
+
// regardless of whether CC_AGENT_OPS_PORT or CC_AGENT_NOTIFY_CHAT_ID are set.
|
|
120
|
+
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
|
121
|
+
const namespace = process.env.CC_AGENT_NAMESPACE || "default";
|
|
122
|
+
const sharedRedis = new Redis(redisUrl);
|
|
123
|
+
sharedRedis.on("error", (err) => {
|
|
124
|
+
// Non-fatal — Redis features (chat bridge, ops) degrade gracefully
|
|
125
|
+
console.warn("[redis] connection error:", err.message);
|
|
126
|
+
});
|
|
127
|
+
sharedRedis.once("ready", () => {
|
|
128
|
+
sharedRedis.set("cca:meta:cc-tg:version", pkg.version).catch((err) => {
|
|
129
|
+
console.warn("[redis] failed to write version:", err.message);
|
|
130
|
+
});
|
|
131
|
+
console.log(`[cc-tg] version:reported ${pkg.version}`);
|
|
132
|
+
});
|
|
133
|
+
const bot = new CcTgBot({
|
|
134
|
+
telegramToken,
|
|
135
|
+
claudeToken,
|
|
136
|
+
cwd,
|
|
137
|
+
allowedUserIds,
|
|
138
|
+
groupChatIds,
|
|
139
|
+
redis: sharedRedis,
|
|
140
|
+
namespace,
|
|
141
|
+
});
|
|
142
|
+
if (process.env.CC_AGENT_OPS_PORT) {
|
|
143
|
+
const botInfo = await bot.getMe();
|
|
144
|
+
const registry = new Registry(sharedRedis);
|
|
145
|
+
await registry.register({
|
|
146
|
+
namespace,
|
|
147
|
+
hostname: os.hostname(),
|
|
148
|
+
user: os.userInfo().username,
|
|
149
|
+
pid: String(process.pid),
|
|
150
|
+
version: pkg.version,
|
|
151
|
+
cwd: process.env.CWD || process.cwd(),
|
|
152
|
+
control_port: process.env.CC_AGENT_OPS_PORT,
|
|
153
|
+
bot_username: botInfo.username ?? "",
|
|
154
|
+
started_at: new Date().toISOString(),
|
|
155
|
+
});
|
|
156
|
+
setInterval(() => registry.heartbeat(namespace), 60_000);
|
|
157
|
+
startControlServer(Number(process.env.CC_AGENT_OPS_PORT), {
|
|
158
|
+
namespace,
|
|
159
|
+
version: pkg.version,
|
|
160
|
+
logFile: process.env.CC_AGENT_LOG_FILE || process.env.LOG_FILE,
|
|
161
|
+
});
|
|
162
|
+
console.log(`[ops] control server on port ${process.env.CC_AGENT_OPS_PORT}`);
|
|
163
|
+
}
|
|
164
|
+
// Notifier — always subscribe to cca:notify and cca:chat:incoming channels.
|
|
165
|
+
// CC_AGENT_NOTIFY_CHAT_ID pins a fixed Telegram chatId; without it the last
|
|
166
|
+
// active chatId is used dynamically for the chat bridge.
|
|
167
|
+
const notifyChatId = process.env.CC_AGENT_NOTIFY_CHAT_ID
|
|
168
|
+
? Number(process.env.CC_AGENT_NOTIFY_CHAT_ID)
|
|
169
|
+
: null;
|
|
170
|
+
const notifierBot = new TelegramBot(telegramToken, { polling: false });
|
|
171
|
+
startNotifier(notifierBot, notifyChatId, namespace, sharedRedis, (cid, text) => bot.handleUserMessage(cid, text), () => bot.getLastActiveChatId());
|
|
172
|
+
console.log(`[notifier] started for namespace=${namespace} chatId=${notifyChatId ?? "dynamic"}`);
|
|
173
|
+
process.on("SIGINT", () => {
|
|
174
|
+
console.log("\nShutting down...");
|
|
175
|
+
bot.stop();
|
|
176
|
+
process.exit(0);
|
|
177
|
+
});
|
|
178
|
+
process.on("SIGTERM", () => {
|
|
179
|
+
bot.stop();
|
|
180
|
+
process.exit(0);
|
|
181
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
* cca:chat:outgoing:* — meta-agent stdout lines (source=claude) → buffer+debounce → Telegram
|
|
8
|
+
*
|
|
9
|
+
* All messages (Telegram incoming, Claude responses) are also written to:
|
|
10
|
+
* cca:chat:log:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
|
|
11
|
+
* cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
|
|
12
|
+
*/
|
|
13
|
+
import { Redis } from "ioredis";
|
|
14
|
+
import TelegramBot from "node-telegram-bot-api";
|
|
15
|
+
export interface ChatMessage {
|
|
16
|
+
id: string;
|
|
17
|
+
source: "telegram" | "ui" | "claude" | "cc-tg";
|
|
18
|
+
role: "user" | "assistant" | "tool";
|
|
19
|
+
content: string;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
chatId: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse a notification payload and return the display text.
|
|
25
|
+
* Appends a [driver] or [driver:model] badge whenever the driver field is present.
|
|
26
|
+
* Appends " cost: $X.XXX" if a numeric cost field is present.
|
|
27
|
+
*
|
|
28
|
+
* Payload format (JSON): { text: string, driver?: string, model?: string, cost?: number }
|
|
29
|
+
* Falls back to raw string if not valid JSON.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseNotification(raw: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Write a message to the chat log in Redis.
|
|
34
|
+
* Fire-and-forget — errors are logged but not thrown.
|
|
35
|
+
*/
|
|
36
|
+
export declare function writeChatLog(redis: Redis, namespace: string, msg: ChatMessage): void;
|
|
37
|
+
/**
|
|
38
|
+
* Start the notifier.
|
|
39
|
+
*
|
|
40
|
+
* @param bot - Telegram bot instance (for sending messages)
|
|
41
|
+
* @param chatId - Telegram chat ID to forward notifications to. Pass null to use getActiveChatId.
|
|
42
|
+
* @param namespace - cc-agent namespace (used to build Redis channel names)
|
|
43
|
+
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
44
|
+
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
45
|
+
* @param getActiveChatId - Optional callback to resolve chatId dynamically (used when chatId is null)
|
|
46
|
+
*/
|
|
47
|
+
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,319 @@
|
|
|
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
|
+
* cca:chat:outgoing:* — meta-agent stdout lines (source=claude) → buffer+debounce → Telegram
|
|
8
|
+
*
|
|
9
|
+
* All messages (Telegram incoming, Claude responses) are also written to:
|
|
10
|
+
* cca:chat:log:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
|
|
11
|
+
* cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
|
|
12
|
+
*/
|
|
13
|
+
import { splitLongMessage } from "./formatter.js";
|
|
14
|
+
function log(level, ...args) {
|
|
15
|
+
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
16
|
+
fn("[notifier]", ...args);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Shorten a model name for display in a badge.
|
|
20
|
+
* - Strips driver prefix (e.g. "claude-sonnet-4-6" with driver "claude" → "sonnet-4-6")
|
|
21
|
+
* - Strips vendor/ prefix for openrouter-style names (e.g. "openai/gpt-4o" → "gpt-4o")
|
|
22
|
+
* - Returns empty string when model is absent.
|
|
23
|
+
*/
|
|
24
|
+
function shortenModelName(model, driver) {
|
|
25
|
+
if (!model.trim())
|
|
26
|
+
return "";
|
|
27
|
+
const pfx = driver.toLowerCase() + "-";
|
|
28
|
+
if (model.toLowerCase().startsWith(pfx))
|
|
29
|
+
return model.slice(pfx.length);
|
|
30
|
+
const slashIdx = model.indexOf("/");
|
|
31
|
+
if (slashIdx >= 0)
|
|
32
|
+
return model.slice(slashIdx + 1);
|
|
33
|
+
return model;
|
|
34
|
+
}
|
|
35
|
+
/** Strip ANSI escape sequences from a string before sending to Telegram. */
|
|
36
|
+
function stripAnsi(text) {
|
|
37
|
+
// eslint-disable-next-line no-control-regex
|
|
38
|
+
return text.replace(/\x1B\[[0-9;]*[mGKHF]/g, "");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse a notification payload and return the display text.
|
|
42
|
+
* Appends a [driver] or [driver:model] badge whenever the driver field is present.
|
|
43
|
+
* Appends " cost: $X.XXX" if a numeric cost field is present.
|
|
44
|
+
*
|
|
45
|
+
* Payload format (JSON): { text: string, driver?: string, model?: string, cost?: number }
|
|
46
|
+
* Falls back to raw string if not valid JSON.
|
|
47
|
+
*/
|
|
48
|
+
export function parseNotification(raw) {
|
|
49
|
+
let text = raw;
|
|
50
|
+
let driver;
|
|
51
|
+
let model;
|
|
52
|
+
let cost;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
if (parsed.text)
|
|
56
|
+
text = parsed.text;
|
|
57
|
+
driver = parsed.driver;
|
|
58
|
+
model = parsed.model;
|
|
59
|
+
if (typeof parsed.cost === "number")
|
|
60
|
+
cost = parsed.cost;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// not JSON — use raw string as-is, no badge
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
// Show badge whenever driver field is present
|
|
67
|
+
if (!driver)
|
|
68
|
+
return text;
|
|
69
|
+
const shortModel = shortenModelName(model ?? "", driver);
|
|
70
|
+
const badge = shortModel ? `${driver}:${shortModel}` : driver;
|
|
71
|
+
const costStr = cost != null ? ` cost: $${cost.toFixed(3)}` : "";
|
|
72
|
+
return `${text}\n[${badge}]${costStr}`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Write a message to the chat log in Redis.
|
|
76
|
+
* Fire-and-forget — errors are logged but not thrown.
|
|
77
|
+
*/
|
|
78
|
+
export function writeChatLog(redis, namespace, msg) {
|
|
79
|
+
const logKey = `cca:chat:log:${namespace}`;
|
|
80
|
+
const outKey = `cca:chat:outgoing:${namespace}`;
|
|
81
|
+
const payload = JSON.stringify(msg);
|
|
82
|
+
redis.lpush(logKey, payload).catch((err) => {
|
|
83
|
+
log("warn", "writeChatLog lpush failed:", err.message);
|
|
84
|
+
});
|
|
85
|
+
redis.ltrim(logKey, 0, 499).catch((err) => {
|
|
86
|
+
log("warn", "writeChatLog ltrim failed:", err.message);
|
|
87
|
+
});
|
|
88
|
+
redis.publish(outKey, payload).catch((err) => {
|
|
89
|
+
log("warn", "writeChatLog publish failed:", err.message);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Start the notifier.
|
|
94
|
+
*
|
|
95
|
+
* @param bot - Telegram bot instance (for sending messages)
|
|
96
|
+
* @param chatId - Telegram chat ID to forward notifications to. Pass null to use getActiveChatId.
|
|
97
|
+
* @param namespace - cc-agent namespace (used to build Redis channel names)
|
|
98
|
+
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
99
|
+
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
100
|
+
* @param getActiveChatId - Optional callback to resolve chatId dynamically (used when chatId is null)
|
|
101
|
+
*/
|
|
102
|
+
export function startNotifier(bot, chatId, namespace, redis, handleUserMessage, getActiveChatId) {
|
|
103
|
+
const sub = redis.duplicate({
|
|
104
|
+
retryStrategy: (times) => {
|
|
105
|
+
const delay = Math.min(1000 * Math.pow(2, times - 1), 30_000);
|
|
106
|
+
log("info", `subscriber reconnecting in ${delay}ms (attempt ${times})`);
|
|
107
|
+
return delay;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
sub.on("error", (err) => {
|
|
111
|
+
log("warn", "subscriber error:", err.message);
|
|
112
|
+
});
|
|
113
|
+
sub.on("close", () => {
|
|
114
|
+
log("info", "subscriber disconnected, will reconnect with backoff");
|
|
115
|
+
});
|
|
116
|
+
// cca:notify:{namespace} — forward job completion notifications to Telegram
|
|
117
|
+
sub.subscribe(`cca:notify:${namespace}`, (err) => {
|
|
118
|
+
if (err) {
|
|
119
|
+
log("error", `subscribe cca:notify:${namespace} failed:`, err.message);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
log("info", `subscribed to cca:notify:${namespace}`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
// cca:chat:incoming:{namespace} — messages from UI
|
|
126
|
+
sub.subscribe(`cca:chat:incoming:${namespace}`, (err) => {
|
|
127
|
+
if (err) {
|
|
128
|
+
log("error", `subscribe cca:chat:incoming:${namespace} failed:`, err.message);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
log("info", `subscribed to cca:chat:incoming:${namespace}`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
// cca:chat:outgoing:* — meta-agent stdout lines (source=claude) → buffer+debounce → Telegram
|
|
135
|
+
// Using psubscribe so we catch all namespaces (money-brain, isoc-nevada, etc.)
|
|
136
|
+
sub.psubscribe("cca:chat:outgoing:*", (err) => {
|
|
137
|
+
if (err) {
|
|
138
|
+
log("error", "psubscribe cca:chat:outgoing:* failed:", err.message);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
log("info", "psubscribed to cca:chat:outgoing:*");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
// Per-namespace debounce buffer: accumulate streaming lines, flush after 1.5s silence
|
|
145
|
+
const metaAgentBuffers = new Map();
|
|
146
|
+
function flushMetaAgentBuffer(ns, targetChatId) {
|
|
147
|
+
const buf = metaAgentBuffers.get(ns);
|
|
148
|
+
if (!buf || !buf.text.trim())
|
|
149
|
+
return;
|
|
150
|
+
const text = stripAnsi(buf.text.trim());
|
|
151
|
+
buf.text = "";
|
|
152
|
+
buf.timer = null;
|
|
153
|
+
const chunks = splitLongMessage(text);
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
bot.sendMessage(targetChatId, chunk).catch((err) => {
|
|
156
|
+
log("warn", `meta-agent flush sendMessage failed (ns=${ns}):`, err.message);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
sub.on("pmessage", (pattern, channel, message) => {
|
|
161
|
+
void pattern; // used only as a type guard
|
|
162
|
+
const ns = channel.replace("cca:chat:outgoing:", "");
|
|
163
|
+
let parsed = null;
|
|
164
|
+
try {
|
|
165
|
+
parsed = JSON.parse(message);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return; // non-JSON line — skip
|
|
169
|
+
}
|
|
170
|
+
// Only forward messages from the meta-agent (source=claude).
|
|
171
|
+
// cc-tg itself publishes to this channel with source "cc-tg"/"telegram"/"ui" — skip those.
|
|
172
|
+
if (parsed.source !== "claude")
|
|
173
|
+
return;
|
|
174
|
+
const content = parsed.content;
|
|
175
|
+
if (!content)
|
|
176
|
+
return;
|
|
177
|
+
const targetChatId = chatId ?? getActiveChatId?.();
|
|
178
|
+
if (targetChatId == null) {
|
|
179
|
+
log("warn", `meta-agent output: no chatId for namespace=${ns}, dropping line`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Accumulate into per-namespace buffer and (re-)arm debounce timer
|
|
183
|
+
let buf = metaAgentBuffers.get(ns);
|
|
184
|
+
if (!buf) {
|
|
185
|
+
buf = { text: "", timer: null };
|
|
186
|
+
metaAgentBuffers.set(ns, buf);
|
|
187
|
+
}
|
|
188
|
+
buf.text += (buf.text ? "\n" : "") + content;
|
|
189
|
+
if (buf.timer)
|
|
190
|
+
clearTimeout(buf.timer);
|
|
191
|
+
buf.timer = setTimeout(() => flushMetaAgentBuffer(ns, targetChatId), 1500);
|
|
192
|
+
});
|
|
193
|
+
// Poll the cca:notify:{namespace} LIST every 5 seconds.
|
|
194
|
+
// Jobs push to this list via RPUSH; pub/sub alone won't deliver those messages.
|
|
195
|
+
const notifyListKey = `cca:notify:${namespace}`;
|
|
196
|
+
const MAX_PER_CYCLE = 20;
|
|
197
|
+
const pollNotifyList = async () => {
|
|
198
|
+
const targetId = chatId ?? getActiveChatId?.();
|
|
199
|
+
if (targetId == null)
|
|
200
|
+
return;
|
|
201
|
+
const items = [];
|
|
202
|
+
try {
|
|
203
|
+
for (let i = 0; i < MAX_PER_CYCLE; i++) {
|
|
204
|
+
const item = await redis.rpop(notifyListKey);
|
|
205
|
+
if (item === null)
|
|
206
|
+
break;
|
|
207
|
+
items.push(item);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
log("warn", "notify list rpop failed:", err.message);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (items.length === 0)
|
|
215
|
+
return;
|
|
216
|
+
let remaining = 0;
|
|
217
|
+
if (items.length === MAX_PER_CYCLE) {
|
|
218
|
+
try {
|
|
219
|
+
remaining = await redis.llen(notifyListKey);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
log("warn", "notify list llen failed:", err.message);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const raw of items) {
|
|
226
|
+
const text = parseNotification(raw);
|
|
227
|
+
bot.sendMessage(targetId, text).catch((err) => {
|
|
228
|
+
log("warn", "notify list sendMessage failed:", err.message);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (remaining > 0) {
|
|
232
|
+
bot.sendMessage(targetId, `...and ${remaining} more notifications`).catch((err) => {
|
|
233
|
+
log("warn", "notify list summary sendMessage failed:", err.message);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
setInterval(() => {
|
|
238
|
+
void pollNotifyList();
|
|
239
|
+
}, 5_000);
|
|
240
|
+
sub.on("message", (channel, message) => {
|
|
241
|
+
const notifyChannel = `cca:notify:${namespace}`;
|
|
242
|
+
const incomingChannel = `cca:chat:incoming:${namespace}`;
|
|
243
|
+
if (channel === notifyChannel) {
|
|
244
|
+
const targetId = chatId ?? getActiveChatId?.();
|
|
245
|
+
if (targetId != null) {
|
|
246
|
+
const text = parseNotification(message);
|
|
247
|
+
bot.sendMessage(targetId, text).catch((err) => {
|
|
248
|
+
log("warn", "sendMessage failed:", err.message);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
log("warn", "notify: no chatId available, dropping notification");
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (channel === incomingChannel) {
|
|
257
|
+
let content = message;
|
|
258
|
+
let originalTimestamp;
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(message);
|
|
261
|
+
if (parsed.content)
|
|
262
|
+
content = parsed.content;
|
|
263
|
+
if (parsed.timestamp)
|
|
264
|
+
originalTimestamp = parsed.timestamp;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// raw string message — use as-is
|
|
268
|
+
}
|
|
269
|
+
// Resolve the target chatId: prefer the fixed chatId, fall back to last active
|
|
270
|
+
const targetChatId = chatId ?? getActiveChatId?.();
|
|
271
|
+
if (targetChatId !== undefined) {
|
|
272
|
+
// Echo to Telegram so the user sees UI messages in the chat
|
|
273
|
+
bot.sendMessage(targetChatId, `📱 [from UI]: ${content}`).catch((err) => {
|
|
274
|
+
log("warn", "sendMessage (UI echo) failed:", err.message);
|
|
275
|
+
});
|
|
276
|
+
// Log the incoming message — preserve original timestamp from UI if present
|
|
277
|
+
const inMsg = {
|
|
278
|
+
id: `ui-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
279
|
+
source: "ui", // 'ui' distinguishes this from telegram/claude messages
|
|
280
|
+
role: "user",
|
|
281
|
+
content,
|
|
282
|
+
// ISO 8601 — matches cc-agent-ui /chat/send format; preserve original if present
|
|
283
|
+
timestamp: originalTimestamp ?? new Date().toISOString(),
|
|
284
|
+
chatId: targetChatId,
|
|
285
|
+
};
|
|
286
|
+
writeChatLog(redis, namespace, inMsg);
|
|
287
|
+
// Check if a meta-agent is running for this namespace; if so, route there instead
|
|
288
|
+
void (async () => {
|
|
289
|
+
let routedToMetaAgent = false;
|
|
290
|
+
try {
|
|
291
|
+
const statusRaw = await redis.get(`cca:meta-agent:status:${namespace}`);
|
|
292
|
+
if (statusRaw) {
|
|
293
|
+
const status = JSON.parse(statusRaw);
|
|
294
|
+
if (status.status === "running") {
|
|
295
|
+
const entry = JSON.stringify({
|
|
296
|
+
id: crypto.randomUUID(),
|
|
297
|
+
content,
|
|
298
|
+
timestamp: new Date().toISOString(),
|
|
299
|
+
});
|
|
300
|
+
await redis.lpush(`cca:meta:${namespace}:input`, entry);
|
|
301
|
+
log("info", `cca:chat:incoming: routed to meta-agent for namespace ${namespace}`);
|
|
302
|
+
routedToMetaAgent = true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
log("warn", "meta-agent status check failed, falling back to coordinator:", err.message);
|
|
308
|
+
}
|
|
309
|
+
if (!routedToMetaAgent && handleUserMessage) {
|
|
310
|
+
handleUserMessage(targetChatId, content);
|
|
311
|
+
}
|
|
312
|
+
})();
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
log("warn", "cca:chat:incoming: no active chatId to route message to");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
package/dist/seed.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function seedClaudeMd(cwd: string): void;
|