@gonzih/cc-discord 0.1.2 → 0.1.3
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 +89 -0
- package/dist/bot.js +841 -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 +100 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +107 -0
- package/dist/notifier.d.ts +53 -0
- package/dist/notifier.js +315 -0
- package/dist/router.d.ts +47 -0
- package/dist/router.js +165 -0
- package/dist/tokens.d.ts +24 -0
- package/dist/tokens.js +58 -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,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cc-discord — Claude Code Discord bot
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx @gonzih/cc-discord
|
|
7
|
+
*
|
|
8
|
+
* Required env:
|
|
9
|
+
* DISCORD_BOT_TOKEN — from Discord Developer Portal
|
|
10
|
+
* CLAUDE_CODE_OAUTH_TOKEN — your Claude Code OAuth token (or ANTHROPIC_API_KEY)
|
|
11
|
+
*
|
|
12
|
+
* Optional env:
|
|
13
|
+
* DISCORD_GUILD_IDS — comma-separated Discord guild/server IDs (for instant slash command registration)
|
|
14
|
+
* DISCORD_ALLOWED_USER_IDS — comma-separated Discord user IDs to whitelist (leave empty to allow all)
|
|
15
|
+
* DISCORD_NOTIFY_CHANNEL_ID — Discord channel ID for job notifications
|
|
16
|
+
* CC_AGENT_NAMESPACE — cc-agent namespace (default: money-brain)
|
|
17
|
+
* REDIS_URL — Redis connection URL (default: redis://localhost:6379)
|
|
18
|
+
* CWD — working directory for Claude Code (default: process.cwd())
|
|
19
|
+
* DEFAULT_GITHUB_ORG — default GitHub org for #repo routing (default: gonzih)
|
|
20
|
+
*/
|
|
21
|
+
import { Redis } from "ioredis";
|
|
22
|
+
import { CcDiscordBot } from "./bot.js";
|
|
23
|
+
import { startNotifier } from "./notifier.js";
|
|
24
|
+
import { loadTokens } from "./tokens.js";
|
|
25
|
+
function required(name) {
|
|
26
|
+
const val = process.env[name];
|
|
27
|
+
if (!val) {
|
|
28
|
+
console.error(`
|
|
29
|
+
ERROR: ${name} is not set.
|
|
30
|
+
|
|
31
|
+
cc-discord requires:
|
|
32
|
+
DISCORD_BOT_TOKEN — create a bot at https://discord.com/developers/applications
|
|
33
|
+
CLAUDE_CODE_OAUTH_TOKEN — your Claude Code OAuth token
|
|
34
|
+
|
|
35
|
+
Set them and run again:
|
|
36
|
+
DISCORD_BOT_TOKEN=xxx CLAUDE_CODE_OAUTH_TOKEN=yyy npx @gonzih/cc-discord
|
|
37
|
+
`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
return val;
|
|
41
|
+
}
|
|
42
|
+
const discordToken = required("DISCORD_BOT_TOKEN");
|
|
43
|
+
const claudeToken = process.env.CLAUDE_CODE_TOKEN ??
|
|
44
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ??
|
|
45
|
+
process.env.ANTHROPIC_API_KEY;
|
|
46
|
+
if (!claudeToken) {
|
|
47
|
+
console.error(`
|
|
48
|
+
ERROR: No Claude token set. Set one of: CLAUDE_CODE_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, or ANTHROPIC_API_KEY.
|
|
49
|
+
`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
// Load OAuth token pool
|
|
53
|
+
const tokenPool = loadTokens();
|
|
54
|
+
if (tokenPool.length > 1) {
|
|
55
|
+
console.log(`[cc-discord] Token pool loaded: ${tokenPool.length} tokens`);
|
|
56
|
+
}
|
|
57
|
+
const guildIds = process.env.DISCORD_GUILD_IDS
|
|
58
|
+
? process.env.DISCORD_GUILD_IDS.split(",").map((s) => s.trim()).filter(Boolean)
|
|
59
|
+
: [];
|
|
60
|
+
const allowedUserIds = process.env.DISCORD_ALLOWED_USER_IDS
|
|
61
|
+
? process.env.DISCORD_ALLOWED_USER_IDS.split(",").map((s) => s.trim()).filter(Boolean)
|
|
62
|
+
: [];
|
|
63
|
+
const cwd = process.env.CWD ?? process.cwd();
|
|
64
|
+
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
|
65
|
+
const namespace = process.env.CC_AGENT_NAMESPACE || "money-brain";
|
|
66
|
+
const notifyChannelId = process.env.DISCORD_NOTIFY_CHANNEL_ID ?? null;
|
|
67
|
+
// Redis
|
|
68
|
+
const sharedRedis = new Redis(redisUrl);
|
|
69
|
+
sharedRedis.on("error", (err) => {
|
|
70
|
+
console.warn("[redis] connection error:", err.message);
|
|
71
|
+
});
|
|
72
|
+
sharedRedis.once("ready", () => {
|
|
73
|
+
// Announce this version on Redis so other services can discover cc-discord
|
|
74
|
+
sharedRedis.set(`cca:meta:cc-discord:version`, "0.1.0").catch((err) => {
|
|
75
|
+
console.warn("[redis] failed to write version:", err.message);
|
|
76
|
+
});
|
|
77
|
+
console.log("[cc-discord] version:reported 0.1.0");
|
|
78
|
+
});
|
|
79
|
+
// Mutable placeholder closures — filled in once `bot` is created below
|
|
80
|
+
let getLastActiveChannelIdFn = () => undefined;
|
|
81
|
+
let handleUserMessageFn;
|
|
82
|
+
let forwardNotificationFn;
|
|
83
|
+
const bot = new CcDiscordBot({
|
|
84
|
+
discordToken,
|
|
85
|
+
claudeToken,
|
|
86
|
+
cwd,
|
|
87
|
+
allowedUserIds,
|
|
88
|
+
guildIds,
|
|
89
|
+
redis: sharedRedis,
|
|
90
|
+
namespace,
|
|
91
|
+
registerRoutedChannelId: (ns, channelId) => notifier.registerRoutedChannelId(ns, channelId),
|
|
92
|
+
});
|
|
93
|
+
const notifier = startNotifier(bot, notifyChannelId, namespace, sharedRedis, (channelId, text) => handleUserMessageFn?.(channelId, text), (channelId, text) => forwardNotificationFn?.(channelId, text), () => getLastActiveChannelIdFn());
|
|
94
|
+
console.log(`[notifier] started for namespace=${namespace} notifyChannelId=${notifyChannelId ?? "dynamic"}`);
|
|
95
|
+
// Wire closures now that bot is constructed
|
|
96
|
+
getLastActiveChannelIdFn = () => bot.getLastActiveChannelId();
|
|
97
|
+
handleUserMessageFn = (channelId, text) => { void bot.handleUserMessage(channelId, text); };
|
|
98
|
+
forwardNotificationFn = (channelId, text) => { bot.forwardNotification(channelId, text); };
|
|
99
|
+
process.on("SIGINT", () => {
|
|
100
|
+
console.log("\nShutting down...");
|
|
101
|
+
bot.stop();
|
|
102
|
+
process.exit(0);
|
|
103
|
+
});
|
|
104
|
+
process.on("SIGTERM", () => {
|
|
105
|
+
bot.stop();
|
|
106
|
+
process.exit(0);
|
|
107
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DiscordNotifier — subscribes to Redis pub/sub channels and bridges messages to Discord.
|
|
3
|
+
*
|
|
4
|
+
* Channels:
|
|
5
|
+
* cca:notify:{namespace} — job completion notifications from cc-agent → forward to DISCORD_NOTIFY_CHANNEL_ID
|
|
6
|
+
* cca:chat:incoming:{namespace} — messages from the web UI → echo to Discord + feed into Claude session
|
|
7
|
+
* cca:chat:outgoing:* — meta-agent stdout lines (source=claude) → buffer+debounce → Discord
|
|
8
|
+
*
|
|
9
|
+
* All messages (Discord 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 type { CcDiscordBot } from "./bot.js";
|
|
15
|
+
export interface ChatMessage {
|
|
16
|
+
id: string;
|
|
17
|
+
source: "discord" | "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
|
+
export declare function parseNotification(raw: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Write a message to the chat log in Redis.
|
|
31
|
+
* Fire-and-forget — errors are logged but not thrown.
|
|
32
|
+
*/
|
|
33
|
+
export declare function writeChatLog(redis: Redis, namespace: string, msg: ChatMessage): void;
|
|
34
|
+
export interface NotifierHandle {
|
|
35
|
+
/**
|
|
36
|
+
* Register the originating Discord channel ID for a routed namespace.
|
|
37
|
+
* When the meta-agent for `namespace` publishes a response, it will be
|
|
38
|
+
* forwarded to `channelId`.
|
|
39
|
+
*/
|
|
40
|
+
registerRoutedChannelId: (namespace: string, channelId: string) => void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Start the Discord notifier.
|
|
44
|
+
*
|
|
45
|
+
* @param bot - CcDiscordBot instance (for sending messages)
|
|
46
|
+
* @param notifyChannelId - Discord channel ID to forward notifications to. Pass null to use getActiveChannelId.
|
|
47
|
+
* @param namespace - cc-agent namespace (used to build Redis channel names)
|
|
48
|
+
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
49
|
+
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
50
|
+
* @param forwardNotification - Optional callback to forward job notifications
|
|
51
|
+
* @param getActiveChannelId - Optional callback to resolve channelId dynamically
|
|
52
|
+
*/
|
|
53
|
+
export declare function startNotifier(bot: CcDiscordBot, notifyChannelId: string | null, namespace: string, redis: Redis, handleUserMessage?: (channelId: string, text: string) => void, forwardNotification?: (channelId: string, text: string) => void, getActiveChannelId?: () => string | undefined): NotifierHandle;
|
package/dist/notifier.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DiscordNotifier — subscribes to Redis pub/sub channels and bridges messages to Discord.
|
|
3
|
+
*
|
|
4
|
+
* Channels:
|
|
5
|
+
* cca:notify:{namespace} — job completion notifications from cc-agent → forward to DISCORD_NOTIFY_CHANNEL_ID
|
|
6
|
+
* cca:chat:incoming:{namespace} — messages from the web UI → echo to Discord + feed into Claude session
|
|
7
|
+
* cca:chat:outgoing:* — meta-agent stdout lines (source=claude) → buffer+debounce → Discord
|
|
8
|
+
*
|
|
9
|
+
* All messages (Discord 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 { chatLogKey, chatOutgoingChannel, chatIncomingChannel, notifyChannel, metaAgentStatusKey, metaInputKey, } from "@gonzih/cc-wire";
|
|
14
|
+
import { splitLongMessage, stripAnsi } from "./formatter.js";
|
|
15
|
+
function log(level, ...args) {
|
|
16
|
+
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
17
|
+
fn("[notifier]", ...args);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Shorten a model name for display in a badge.
|
|
21
|
+
*/
|
|
22
|
+
function shortenModelName(model, driver) {
|
|
23
|
+
if (!model.trim())
|
|
24
|
+
return "";
|
|
25
|
+
const pfx = driver.toLowerCase() + "-";
|
|
26
|
+
if (model.toLowerCase().startsWith(pfx))
|
|
27
|
+
return model.slice(pfx.length);
|
|
28
|
+
const slashIdx = model.indexOf("/");
|
|
29
|
+
if (slashIdx >= 0)
|
|
30
|
+
return model.slice(slashIdx + 1);
|
|
31
|
+
return model;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse a notification payload and return the display text.
|
|
35
|
+
* Appends a [driver] or [driver:model] badge whenever the driver field is present.
|
|
36
|
+
* Appends " cost: $X.XXX" if a numeric cost field is present.
|
|
37
|
+
*/
|
|
38
|
+
export function parseNotification(raw) {
|
|
39
|
+
let text = raw;
|
|
40
|
+
let driver;
|
|
41
|
+
let model;
|
|
42
|
+
let cost;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
if (parsed.text)
|
|
46
|
+
text = parsed.text;
|
|
47
|
+
driver = parsed.driver;
|
|
48
|
+
model = parsed.model;
|
|
49
|
+
if (typeof parsed.cost === "number")
|
|
50
|
+
cost = parsed.cost;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
if (!driver)
|
|
56
|
+
return text;
|
|
57
|
+
const shortModel = shortenModelName(model ?? "", driver);
|
|
58
|
+
const badge = shortModel ? `${driver}:${shortModel}` : driver;
|
|
59
|
+
const costStr = cost != null ? ` cost: $${cost.toFixed(3)}` : "";
|
|
60
|
+
return `${text}\n[${badge}]${costStr}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Write a message to the chat log in Redis.
|
|
64
|
+
* Fire-and-forget — errors are logged but not thrown.
|
|
65
|
+
*/
|
|
66
|
+
export function writeChatLog(redis, namespace, msg) {
|
|
67
|
+
const logKey = chatLogKey(namespace);
|
|
68
|
+
const outKey = chatOutgoingChannel(namespace);
|
|
69
|
+
const payload = JSON.stringify(msg);
|
|
70
|
+
redis.lpush(logKey, payload).catch((err) => {
|
|
71
|
+
log("warn", "writeChatLog lpush failed:", err.message);
|
|
72
|
+
});
|
|
73
|
+
redis.ltrim(logKey, 0, 499).catch((err) => {
|
|
74
|
+
log("warn", "writeChatLog ltrim failed:", err.message);
|
|
75
|
+
});
|
|
76
|
+
redis.publish(outKey, payload).catch((err) => {
|
|
77
|
+
log("warn", "writeChatLog publish failed:", err.message);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Start the Discord notifier.
|
|
82
|
+
*
|
|
83
|
+
* @param bot - CcDiscordBot instance (for sending messages)
|
|
84
|
+
* @param notifyChannelId - Discord channel ID to forward notifications to. Pass null to use getActiveChannelId.
|
|
85
|
+
* @param namespace - cc-agent namespace (used to build Redis channel names)
|
|
86
|
+
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
87
|
+
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
88
|
+
* @param forwardNotification - Optional callback to forward job notifications
|
|
89
|
+
* @param getActiveChannelId - Optional callback to resolve channelId dynamically
|
|
90
|
+
*/
|
|
91
|
+
export function startNotifier(bot, notifyChannelId, namespace, redis, handleUserMessage, forwardNotification, getActiveChannelId) {
|
|
92
|
+
// Per-namespace channelId registry
|
|
93
|
+
const routedChannelIds = new Map();
|
|
94
|
+
const sub = redis.duplicate({
|
|
95
|
+
retryStrategy: (times) => {
|
|
96
|
+
const delay = Math.min(1000 * Math.pow(2, times - 1), 30_000);
|
|
97
|
+
log("info", `subscriber reconnecting in ${delay}ms (attempt ${times})`);
|
|
98
|
+
return delay;
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
sub.on("error", (err) => {
|
|
102
|
+
log("warn", "subscriber error:", err.message);
|
|
103
|
+
});
|
|
104
|
+
sub.on("close", () => {
|
|
105
|
+
log("info", "subscriber disconnected, will reconnect with backoff");
|
|
106
|
+
});
|
|
107
|
+
// notifyChannel(namespace) — forward job completion notifications to Discord
|
|
108
|
+
sub.subscribe(notifyChannel(namespace), (err) => {
|
|
109
|
+
if (err) {
|
|
110
|
+
log("error", `subscribe ${notifyChannel(namespace)} failed:`, err.message);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
log("info", `subscribed to ${notifyChannel(namespace)}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
// chatIncomingChannel(namespace) — messages from UI
|
|
117
|
+
sub.subscribe(chatIncomingChannel(namespace), (err) => {
|
|
118
|
+
if (err) {
|
|
119
|
+
log("error", `subscribe ${chatIncomingChannel(namespace)} failed:`, err.message);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
log("info", `subscribed to ${chatIncomingChannel(namespace)}`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
// chatOutgoingChannel("*") — meta-agent stdout lines
|
|
126
|
+
sub.psubscribe(chatOutgoingChannel("*"), (err) => {
|
|
127
|
+
if (err) {
|
|
128
|
+
log("error", `psubscribe ${chatOutgoingChannel("*")} failed:`, err.message);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
log("info", `psubscribed to ${chatOutgoingChannel("*")}`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
// 1.5s silence buffer for meta-agent streaming
|
|
135
|
+
const META_AGENT_FLUSH_DELAY_MS = 1500;
|
|
136
|
+
const metaAgentBuffers = new Map();
|
|
137
|
+
function flushMetaAgentBuffer(ns, targetChannelId) {
|
|
138
|
+
const buf = metaAgentBuffers.get(ns);
|
|
139
|
+
if (!buf || !buf.text.trim())
|
|
140
|
+
return;
|
|
141
|
+
const text = `← [${ns}] ` + stripAnsi(buf.text.trim());
|
|
142
|
+
buf.text = "";
|
|
143
|
+
buf.timer = null;
|
|
144
|
+
const chunks = splitLongMessage(text);
|
|
145
|
+
for (const chunk of chunks) {
|
|
146
|
+
bot.sendToChannelById(targetChannelId, chunk).catch((err) => {
|
|
147
|
+
log("warn", `meta-agent flush sendToChannelById failed (ns=${ns}):`, err.message);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
sub.on("pmessage", (pattern, channel, message) => {
|
|
152
|
+
void pattern;
|
|
153
|
+
const ns = channel.slice(chatOutgoingChannel("").length);
|
|
154
|
+
let parsed = null;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(message);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (parsed.source !== "claude")
|
|
162
|
+
return;
|
|
163
|
+
const content = parsed.content;
|
|
164
|
+
if (!content)
|
|
165
|
+
return;
|
|
166
|
+
const targetChannelId = routedChannelIds.get(ns) ?? notifyChannelId ?? getActiveChannelId?.();
|
|
167
|
+
if (targetChannelId == null) {
|
|
168
|
+
log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping line`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let buf = metaAgentBuffers.get(ns);
|
|
172
|
+
if (!buf) {
|
|
173
|
+
buf = { text: "", timer: null };
|
|
174
|
+
metaAgentBuffers.set(ns, buf);
|
|
175
|
+
}
|
|
176
|
+
buf.text += (buf.text ? "\n" : "") + content;
|
|
177
|
+
if (buf.timer)
|
|
178
|
+
clearTimeout(buf.timer);
|
|
179
|
+
buf.timer = setTimeout(() => flushMetaAgentBuffer(ns, targetChannelId), META_AGENT_FLUSH_DELAY_MS);
|
|
180
|
+
});
|
|
181
|
+
// Poll the notifyChannel(namespace) LIST every 5 seconds
|
|
182
|
+
const notifyListKey = notifyChannel(namespace);
|
|
183
|
+
const MAX_PER_CYCLE = 20;
|
|
184
|
+
const pollNotifyList = async () => {
|
|
185
|
+
const targetId = notifyChannelId ?? getActiveChannelId?.();
|
|
186
|
+
if (targetId == null)
|
|
187
|
+
return;
|
|
188
|
+
const items = [];
|
|
189
|
+
try {
|
|
190
|
+
for (let i = 0; i < MAX_PER_CYCLE; i++) {
|
|
191
|
+
const item = await redis.rpop(notifyListKey);
|
|
192
|
+
if (item === null)
|
|
193
|
+
break;
|
|
194
|
+
items.push(item);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
log("warn", "notify list rpop failed:", err.message);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (items.length === 0)
|
|
202
|
+
return;
|
|
203
|
+
let remaining = 0;
|
|
204
|
+
if (items.length === MAX_PER_CYCLE) {
|
|
205
|
+
try {
|
|
206
|
+
remaining = await redis.llen(notifyListKey);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
log("warn", "notify list llen failed:", err.message);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const raw of items) {
|
|
213
|
+
const text = parseNotification(raw);
|
|
214
|
+
bot.sendToChannelById(targetId, text).catch((err) => {
|
|
215
|
+
log("warn", "notify list send failed:", err.message);
|
|
216
|
+
});
|
|
217
|
+
if (forwardNotification) {
|
|
218
|
+
forwardNotification(targetId, text);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (remaining > 0) {
|
|
222
|
+
bot.sendToChannelById(targetId, `...and ${remaining} more notifications`).catch((err) => {
|
|
223
|
+
log("warn", "notify list summary send failed:", err.message);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
setInterval(() => {
|
|
228
|
+
void pollNotifyList();
|
|
229
|
+
}, 5_000);
|
|
230
|
+
sub.on("message", (channel, message) => {
|
|
231
|
+
const notifyCh = notifyChannel(namespace);
|
|
232
|
+
const incomingCh = chatIncomingChannel(namespace);
|
|
233
|
+
if (channel === notifyCh) {
|
|
234
|
+
const targetId = notifyChannelId ?? getActiveChannelId?.();
|
|
235
|
+
if (targetId != null) {
|
|
236
|
+
const text = parseNotification(message);
|
|
237
|
+
bot.sendToChannelById(targetId, text).catch((err) => {
|
|
238
|
+
log("warn", "notify send failed:", err.message);
|
|
239
|
+
});
|
|
240
|
+
if (forwardNotification) {
|
|
241
|
+
forwardNotification(targetId, text);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
log("warn", "notify: no channelId available, dropping notification");
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (channel === incomingCh) {
|
|
250
|
+
let content = message;
|
|
251
|
+
let originalTimestamp;
|
|
252
|
+
try {
|
|
253
|
+
const parsed = JSON.parse(message);
|
|
254
|
+
if (parsed.content)
|
|
255
|
+
content = parsed.content;
|
|
256
|
+
if (parsed.timestamp)
|
|
257
|
+
originalTimestamp = parsed.timestamp;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// raw string message — use as-is
|
|
261
|
+
}
|
|
262
|
+
const targetChannelId = notifyChannelId ?? getActiveChannelId?.();
|
|
263
|
+
if (targetChannelId !== undefined) {
|
|
264
|
+
// Echo to Discord so the user sees UI messages
|
|
265
|
+
bot.sendToChannelById(targetChannelId, `[from UI]: ${content}`).catch((err) => {
|
|
266
|
+
log("warn", "sendToChannelById (UI echo) failed:", err.message);
|
|
267
|
+
});
|
|
268
|
+
// Log the incoming message
|
|
269
|
+
const inMsg = {
|
|
270
|
+
id: crypto.randomUUID(),
|
|
271
|
+
source: "ui",
|
|
272
|
+
role: "user",
|
|
273
|
+
content,
|
|
274
|
+
timestamp: originalTimestamp ?? new Date().toISOString(),
|
|
275
|
+
chatId: 0, // no numeric chatId for Discord — stored by channelId string
|
|
276
|
+
};
|
|
277
|
+
writeChatLog(redis, namespace, inMsg);
|
|
278
|
+
// Check if a meta-agent is running; if so, route there instead
|
|
279
|
+
void (async () => {
|
|
280
|
+
let routedToMetaAgent = false;
|
|
281
|
+
try {
|
|
282
|
+
const statusRaw = await redis.get(metaAgentStatusKey(namespace));
|
|
283
|
+
if (statusRaw) {
|
|
284
|
+
const status = JSON.parse(statusRaw);
|
|
285
|
+
if (status.status === "running") {
|
|
286
|
+
const entry = JSON.stringify({
|
|
287
|
+
id: crypto.randomUUID(),
|
|
288
|
+
content,
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
});
|
|
291
|
+
await redis.rpush(metaInputKey(namespace), entry);
|
|
292
|
+
log("info", `cca:chat:incoming: routed to meta-agent for namespace ${namespace}`);
|
|
293
|
+
routedToMetaAgent = true;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
log("warn", "meta-agent status check failed:", err.message);
|
|
299
|
+
}
|
|
300
|
+
if (!routedToMetaAgent && handleUserMessage) {
|
|
301
|
+
handleUserMessage(targetChannelId, content);
|
|
302
|
+
}
|
|
303
|
+
})();
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
log("warn", "cca:chat:incoming: no active channelId to route message to");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
registerRoutedChannelId: (ns, channelId) => {
|
|
312
|
+
routedChannelIds.set(ns, channelId);
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing helpers: channel-creation intent detection and meta-agent routing.
|
|
3
|
+
*/
|
|
4
|
+
import { Redis } from "ioredis";
|
|
5
|
+
/** Callback type matching CcDiscordBot.callCcAgentTool */
|
|
6
|
+
export type CallToolFn = (toolName: string, args?: Record<string, unknown>) => Promise<string | null>;
|
|
7
|
+
/**
|
|
8
|
+
* Ensure a meta-agent for the given namespace is running.
|
|
9
|
+
*
|
|
10
|
+
* Steps:
|
|
11
|
+
* 1. Check Redis for readiness via two keys (see below) — return early if already ready.
|
|
12
|
+
* 2. Verify the GitHub repo exists; create it (public) if not.
|
|
13
|
+
* 3. Call the start_meta_agent MCP tool via callTool.
|
|
14
|
+
* 4. Poll both Redis keys every 1s until ready or META_AGENT_TIMEOUT_MS expires.
|
|
15
|
+
*
|
|
16
|
+
* Two Redis keys are checked:
|
|
17
|
+
* cca:meta-agent:status:{namespace} — live-status key written by writeLiveStatus()
|
|
18
|
+
* (only populated after the first message is processed by messageMetaAgent)
|
|
19
|
+
* cca:meta:{namespace} — state key written by startMetaAgent() directly via saveState()
|
|
20
|
+
* (populated as soon as the workspace is created, with status:"idle")
|
|
21
|
+
*
|
|
22
|
+
* Bug context: start_meta_agent writes cca:meta:{namespace} but NOT cca:meta-agent:status:{namespace}.
|
|
23
|
+
* Polling only the status key caused a 10s timeout on every cold start.
|
|
24
|
+
*
|
|
25
|
+
* Throws on failure (repo creation error, tool call failure, or timeout).
|
|
26
|
+
*/
|
|
27
|
+
export declare function ensureMetaAgent(namespace: string, repoUrl: string, callTool: CallToolFn, redis: Redis): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Detect a natural-language channel-creation request.
|
|
30
|
+
* Matches:
|
|
31
|
+
* "channel for https://github.com/org/repo"
|
|
32
|
+
* "create channel for https://github.com/org/repo"
|
|
33
|
+
* "add channel for https://github.com/org/repo"
|
|
34
|
+
*
|
|
35
|
+
* Returns { namespace, repoUrl } or null.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseChannelCreateIntent(text: string): {
|
|
38
|
+
namespace: string;
|
|
39
|
+
repoUrl: string;
|
|
40
|
+
} | null;
|
|
41
|
+
/**
|
|
42
|
+
* Route a message to a running meta-agent via Redis RPUSH.
|
|
43
|
+
* The cc-agent polls cca:meta:{namespace}:input every 3s (up to 3s delivery latency).
|
|
44
|
+
*
|
|
45
|
+
* No-op when strippedMessage is empty (user sent only the tag token).
|
|
46
|
+
*/
|
|
47
|
+
export declare function routeToMetaAgent(namespace: string, strippedMessage: string, redis: Redis): Promise<void>;
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing helpers: channel-creation intent detection and meta-agent routing.
|
|
3
|
+
*/
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { metaAgentStatusKey, metaKey, metaInputKey } from "@gonzih/cc-wire";
|
|
6
|
+
/**
|
|
7
|
+
* Ensure a meta-agent for the given namespace is running.
|
|
8
|
+
*
|
|
9
|
+
* Steps:
|
|
10
|
+
* 1. Check Redis for readiness via two keys (see below) — return early if already ready.
|
|
11
|
+
* 2. Verify the GitHub repo exists; create it (public) if not.
|
|
12
|
+
* 3. Call the start_meta_agent MCP tool via callTool.
|
|
13
|
+
* 4. Poll both Redis keys every 1s until ready or META_AGENT_TIMEOUT_MS expires.
|
|
14
|
+
*
|
|
15
|
+
* Two Redis keys are checked:
|
|
16
|
+
* cca:meta-agent:status:{namespace} — live-status key written by writeLiveStatus()
|
|
17
|
+
* (only populated after the first message is processed by messageMetaAgent)
|
|
18
|
+
* cca:meta:{namespace} — state key written by startMetaAgent() directly via saveState()
|
|
19
|
+
* (populated as soon as the workspace is created, with status:"idle")
|
|
20
|
+
*
|
|
21
|
+
* Bug context: start_meta_agent writes cca:meta:{namespace} but NOT cca:meta-agent:status:{namespace}.
|
|
22
|
+
* Polling only the status key caused a 10s timeout on every cold start.
|
|
23
|
+
*
|
|
24
|
+
* Throws on failure (repo creation error, tool call failure, or timeout).
|
|
25
|
+
*/
|
|
26
|
+
export async function ensureMetaAgent(namespace, repoUrl, callTool, redis) {
|
|
27
|
+
const timeoutMs = parseInt(process.env.META_AGENT_TIMEOUT_MS ?? "10000", 10);
|
|
28
|
+
const statusKey = metaAgentStatusKey(namespace);
|
|
29
|
+
// State key written by startMetaAgent() directly — the source of truth for workspace existence.
|
|
30
|
+
const stateKey = metaKey(namespace);
|
|
31
|
+
console.log(`[router] ensureMetaAgent namespace=${namespace}`);
|
|
32
|
+
// Fast path: check live-status key (written by messageMetaAgent after first message)
|
|
33
|
+
const statusRaw = await redis.get(statusKey);
|
|
34
|
+
if (statusRaw) {
|
|
35
|
+
try {
|
|
36
|
+
const status = JSON.parse(statusRaw);
|
|
37
|
+
if (status.status === "running" || status.status === "idle") {
|
|
38
|
+
console.log(`[router] meta-agent ${namespace} is already ready (status=${status.status})`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Corrupt status value — fall through
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Fast path: also check state key (written by startMetaAgent, persists 30 days).
|
|
47
|
+
// Presence of this key means the workspace was already created — no need to re-run start_meta_agent.
|
|
48
|
+
const stateRaw = await redis.get(stateKey);
|
|
49
|
+
if (stateRaw) {
|
|
50
|
+
try {
|
|
51
|
+
const state = JSON.parse(stateRaw);
|
|
52
|
+
if (state.status === "idle" || state.status === "running") {
|
|
53
|
+
console.log(`[router] meta-agent ${namespace} workspace exists (state.status=${state.status})`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Corrupt state — fall through and re-initialize
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Derive "org/repo" from the full URL for gh CLI calls
|
|
62
|
+
const orgRepo = repoUrl.replace(/^https:\/\/github\.com\//, "");
|
|
63
|
+
// Verify / create the GitHub repo
|
|
64
|
+
try {
|
|
65
|
+
execSync(`gh repo view ${orgRepo}`, { stdio: "ignore" });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Repo not found — create it
|
|
69
|
+
try {
|
|
70
|
+
execSync(`gh repo create ${orgRepo} --public --description "Meta-agent workspace for ${namespace}"`, { stdio: "pipe" });
|
|
71
|
+
console.log(`[router] created repo ${orgRepo} for namespace=${namespace}`);
|
|
72
|
+
}
|
|
73
|
+
catch (createErr) {
|
|
74
|
+
throw new Error(`Failed to create repo ${orgRepo}: ${createErr.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Start the meta-agent via MCP (clones workspace if needed, writes cca:meta:{namespace})
|
|
78
|
+
const result = await callTool("start_meta_agent", { namespace, repo_url: repoUrl });
|
|
79
|
+
if (result === null) {
|
|
80
|
+
throw new Error(`start_meta_agent returned null — tool may not be available in cc-agent`);
|
|
81
|
+
}
|
|
82
|
+
// Check for explicit failure payload (e.g. git clone error)
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(result);
|
|
85
|
+
if (parsed.ok === false) {
|
|
86
|
+
throw new Error(`start_meta_agent failed: ${parsed.error ?? "unknown error"}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (jsonErr) {
|
|
90
|
+
if (!(jsonErr instanceof SyntaxError))
|
|
91
|
+
throw jsonErr;
|
|
92
|
+
// Non-JSON result (e.g. plain "ok") — not an error, continue to poll
|
|
93
|
+
}
|
|
94
|
+
// Poll until ready. Check both keys:
|
|
95
|
+
// - statusKey: written by writeLiveStatus() during messageMetaAgent (may not exist yet on cold start)
|
|
96
|
+
// - stateKey: written by startMetaAgent() above — will appear within 1s of the tool call returning
|
|
97
|
+
const deadline = Date.now() + timeoutMs;
|
|
98
|
+
while (Date.now() < deadline) {
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
100
|
+
const raw = await redis.get(statusKey);
|
|
101
|
+
if (raw) {
|
|
102
|
+
try {
|
|
103
|
+
const s = JSON.parse(raw);
|
|
104
|
+
console.log(`[router] waiting for meta-agent ${namespace} — status key: ${s.status}`);
|
|
105
|
+
if (s.status === "running" || s.status === "idle")
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// ignore parse errors, keep polling
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Also check state key — startMetaAgent writes this synchronously before responding
|
|
113
|
+
const state = await redis.get(stateKey);
|
|
114
|
+
if (state) {
|
|
115
|
+
try {
|
|
116
|
+
const s = JSON.parse(state);
|
|
117
|
+
console.log(`[router] waiting for meta-agent ${namespace} — state key: ${s.status}`);
|
|
118
|
+
if (s.status === "idle" || s.status === "running")
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// ignore parse errors, keep polling
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
console.log(`[router] waiting for meta-agent ${namespace} — neither key present yet`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`Meta-agent for ${namespace} did not become ready within ${timeoutMs}ms`);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Detect a natural-language channel-creation request.
|
|
133
|
+
* Matches:
|
|
134
|
+
* "channel for https://github.com/org/repo"
|
|
135
|
+
* "create channel for https://github.com/org/repo"
|
|
136
|
+
* "add channel for https://github.com/org/repo"
|
|
137
|
+
*
|
|
138
|
+
* Returns { namespace, repoUrl } or null.
|
|
139
|
+
*/
|
|
140
|
+
export function parseChannelCreateIntent(text) {
|
|
141
|
+
const match = text.match(/(?:create\s+|add\s+)?channel\s+for\s+(https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+))/i);
|
|
142
|
+
if (!match)
|
|
143
|
+
return null;
|
|
144
|
+
const repoUrl = match[1];
|
|
145
|
+
const namespace = match[3];
|
|
146
|
+
return { namespace, repoUrl };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Route a message to a running meta-agent via Redis RPUSH.
|
|
150
|
+
* The cc-agent polls cca:meta:{namespace}:input every 3s (up to 3s delivery latency).
|
|
151
|
+
*
|
|
152
|
+
* No-op when strippedMessage is empty (user sent only the tag token).
|
|
153
|
+
*/
|
|
154
|
+
export async function routeToMetaAgent(namespace, strippedMessage, redis) {
|
|
155
|
+
if (!strippedMessage)
|
|
156
|
+
return;
|
|
157
|
+
const entry = JSON.stringify({
|
|
158
|
+
id: crypto.randomUUID(),
|
|
159
|
+
content: strippedMessage,
|
|
160
|
+
timestamp: new Date().toISOString(),
|
|
161
|
+
});
|
|
162
|
+
// FIFO — cc-agent reads via LPOP
|
|
163
|
+
await redis.rpush(metaInputKey(namespace), entry);
|
|
164
|
+
console.log(`[router] routed message to meta-agent namespace=${namespace}`);
|
|
165
|
+
}
|