@gonzih/cc-discord 0.1.8 → 0.1.10
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 +9 -1
- package/dist/bot.js +54 -4
- package/dist/index.js +4 -0
- package/dist/notifier.d.ts +11 -2
- package/dist/notifier.js +114 -51
- package/package.json +2 -2
package/dist/bot.d.ts
CHANGED
|
@@ -37,6 +37,13 @@ export declare class CcDiscordBot {
|
|
|
37
37
|
private channelNamespaceMap;
|
|
38
38
|
private storeSnowflake;
|
|
39
39
|
reverseSnowflakeLookup(n: number): string | undefined;
|
|
40
|
+
/** Persist a channelId → {namespace, repoUrl} mapping to Redis. */
|
|
41
|
+
private persistChannelMapping;
|
|
42
|
+
/**
|
|
43
|
+
* Load persisted channel→namespace mappings from Redis and repopulate
|
|
44
|
+
* channelNamespaceMap + routedChannelIds. Call once on startup after the notifier is ready.
|
|
45
|
+
*/
|
|
46
|
+
loadChannelMappings(): Promise<void>;
|
|
40
47
|
/** Session key: "channelId" or "channelId:threadId" for threads */
|
|
41
48
|
private sessionKey;
|
|
42
49
|
/** Get the channel/thread for sending messages */
|
|
@@ -71,7 +78,8 @@ export declare class CcDiscordBot {
|
|
|
71
78
|
* and start the meta-agent for `repoUrl`. Fire-and-forget after sending the confirmation message.
|
|
72
79
|
*/
|
|
73
80
|
private createChannelForRepo;
|
|
74
|
-
/** Write a message to the Redis chat log. Fire-and-forget.
|
|
81
|
+
/** Write a message to the Redis chat log. Fire-and-forget.
|
|
82
|
+
* Pass `ns` to write under a specific namespace; defaults to the bot's primary namespace. */
|
|
75
83
|
private writeChatMessage;
|
|
76
84
|
/** Returns the last channelId that sent a message. */
|
|
77
85
|
getLastActiveChannelId(): string | undefined;
|
package/dist/bot.js
CHANGED
|
@@ -14,6 +14,10 @@ import { getCurrentToken } from "./tokens.js";
|
|
|
14
14
|
import { writeChatLog } from "./notifier.js";
|
|
15
15
|
import { CronManager } from "./cron.js";
|
|
16
16
|
import { parseChannelCreateIntent, ensureMetaAgent, routeToMetaAgent } from "./router.js";
|
|
17
|
+
/** Redis key for persisting a Discord channelId → namespace mapping across restarts. */
|
|
18
|
+
function discordChannelKey(channelId) {
|
|
19
|
+
return `cca:discord:channel:${channelId}`;
|
|
20
|
+
}
|
|
17
21
|
/** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
|
|
18
22
|
function snowflakeToInt(id) {
|
|
19
23
|
// Discord snowflakes are up to 2^63, beyond Number.MAX_SAFE_INTEGER.
|
|
@@ -165,6 +169,49 @@ export class CcDiscordBot {
|
|
|
165
169
|
reverseSnowflakeLookup(n) {
|
|
166
170
|
return this.snowflakeMap.get(n);
|
|
167
171
|
}
|
|
172
|
+
/** Persist a channelId → {namespace, repoUrl} mapping to Redis. */
|
|
173
|
+
persistChannelMapping(channelId, namespace, repoUrl) {
|
|
174
|
+
if (!this.redis)
|
|
175
|
+
return;
|
|
176
|
+
const key = discordChannelKey(channelId);
|
|
177
|
+
const value = JSON.stringify({ namespace, repoUrl });
|
|
178
|
+
this.redis.set(key, value).catch((err) => {
|
|
179
|
+
console.warn(`[bot] persistChannelMapping failed for ${channelId}:`, err.message);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Load persisted channel→namespace mappings from Redis and repopulate
|
|
184
|
+
* channelNamespaceMap + routedChannelIds. Call once on startup after the notifier is ready.
|
|
185
|
+
*/
|
|
186
|
+
async loadChannelMappings() {
|
|
187
|
+
if (!this.redis)
|
|
188
|
+
return;
|
|
189
|
+
let keys;
|
|
190
|
+
try {
|
|
191
|
+
keys = await this.redis.keys("cca:discord:channel:*");
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
console.warn("[bot] loadChannelMappings keys scan failed:", err.message);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
for (const key of keys) {
|
|
198
|
+
try {
|
|
199
|
+
const raw = await this.redis.get(key);
|
|
200
|
+
if (!raw)
|
|
201
|
+
continue;
|
|
202
|
+
const { namespace, repoUrl } = JSON.parse(raw);
|
|
203
|
+
const channelId = key.slice("cca:discord:channel:".length);
|
|
204
|
+
if (!this.channelNamespaceMap.has(channelId)) {
|
|
205
|
+
this.channelNamespaceMap.set(channelId, { namespace, repoUrl });
|
|
206
|
+
this.opts.registerRoutedChannelId?.(namespace, channelId);
|
|
207
|
+
console.log(`[bot] restored channel mapping: ${channelId} → ${namespace}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
console.warn(`[bot] loadChannelMappings: failed to parse ${key}:`, err.message);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
168
215
|
/** Session key: "channelId" or "channelId:threadId" for threads */
|
|
169
216
|
sessionKey(channelId, threadId) {
|
|
170
217
|
return threadId ? `${channelId}:${threadId}` : channelId;
|
|
@@ -284,7 +331,7 @@ export class CcDiscordBot {
|
|
|
284
331
|
// Channel registered via createChannelForRepo or /channel — route directly to its meta-agent
|
|
285
332
|
const mappedNs = this.channelNamespaceMap.get(effectiveChannelId);
|
|
286
333
|
if (mappedNs && this.redis) {
|
|
287
|
-
this.writeChatMessage("user", "discord", text, effectiveChannelId);
|
|
334
|
+
this.writeChatMessage("user", "discord", text, effectiveChannelId, mappedNs.namespace);
|
|
288
335
|
this.opts.registerRoutedChannelId?.(mappedNs.namespace, effectiveChannelId);
|
|
289
336
|
const username = msg.member?.displayName ?? msg.author.username;
|
|
290
337
|
try {
|
|
@@ -642,6 +689,7 @@ export class CcDiscordBot {
|
|
|
642
689
|
const newChannel = await guild.channels.create({ name: namespace, type: ChannelType.GuildText, parent: resolveCategoryId(guild) });
|
|
643
690
|
this.channelNamespaceMap.set(newChannel.id, { namespace, repoUrl });
|
|
644
691
|
this.opts.registerRoutedChannelId?.(namespace, newChannel.id);
|
|
692
|
+
this.persistChannelMapping(newChannel.id, namespace, repoUrl);
|
|
645
693
|
await interaction.editReply(`Created <#${newChannel.id}> — messages there route to the ${repoUrl} meta-agent`);
|
|
646
694
|
// Start meta-agent in the background
|
|
647
695
|
if (this.redis) {
|
|
@@ -785,6 +833,7 @@ export class CcDiscordBot {
|
|
|
785
833
|
}
|
|
786
834
|
this.channelNamespaceMap.set(newChannel.id, { namespace, repoUrl });
|
|
787
835
|
this.opts.registerRoutedChannelId?.(namespace, newChannel.id);
|
|
836
|
+
this.persistChannelMapping(newChannel.id, namespace, repoUrl);
|
|
788
837
|
await channel.send(`Created <#${newChannel.id}> — messages there route to the ${repoUrl} meta-agent`).catch(() => { });
|
|
789
838
|
// Start meta-agent in the background after acknowledging the user
|
|
790
839
|
if (this.redis) {
|
|
@@ -795,8 +844,9 @@ export class CcDiscordBot {
|
|
|
795
844
|
});
|
|
796
845
|
}
|
|
797
846
|
}
|
|
798
|
-
/** Write a message to the Redis chat log. Fire-and-forget.
|
|
799
|
-
|
|
847
|
+
/** Write a message to the Redis chat log. Fire-and-forget.
|
|
848
|
+
* Pass `ns` to write under a specific namespace; defaults to the bot's primary namespace. */
|
|
849
|
+
writeChatMessage(role, source, content, channelId, ns) {
|
|
800
850
|
if (!this.redis)
|
|
801
851
|
return;
|
|
802
852
|
const msg = {
|
|
@@ -807,7 +857,7 @@ export class CcDiscordBot {
|
|
|
807
857
|
timestamp: new Date().toISOString(),
|
|
808
858
|
chatId: snowflakeToInt(channelId),
|
|
809
859
|
};
|
|
810
|
-
writeChatLog(this.redis, this.namespace, msg);
|
|
860
|
+
writeChatLog(this.redis, ns ?? this.namespace, msg);
|
|
811
861
|
}
|
|
812
862
|
/** Returns the last channelId that sent a message. */
|
|
813
863
|
getLastActiveChannelId() {
|
package/dist/index.js
CHANGED
|
@@ -95,6 +95,10 @@ const bot = new CcDiscordBot({
|
|
|
95
95
|
});
|
|
96
96
|
const notifier = startNotifier(bot, notifyChannelId, namespace, sharedRedis, (channelId, text) => handleUserMessageFn?.(channelId, text), (channelId, text) => forwardNotificationFn?.(channelId, text), () => getLastActiveChannelIdFn(), (n) => bot.reverseSnowflakeLookup(n));
|
|
97
97
|
console.log(`[notifier] started for namespace=${namespace} notifyChannelId=${notifyChannelId ?? "dynamic"}`);
|
|
98
|
+
// Restore persisted channel→namespace mappings so routing survives restarts
|
|
99
|
+
bot.loadChannelMappings().catch((err) => {
|
|
100
|
+
console.warn("[cc-discord] loadChannelMappings failed:", err.message);
|
|
101
|
+
});
|
|
98
102
|
// Wire closures now that bot is constructed
|
|
99
103
|
getLastActiveChannelIdFn = () => bot.getLastActiveChannelId();
|
|
100
104
|
handleUserMessageFn = (channelId, text) => { void bot.handleUserMessage(channelId, text); };
|
package/dist/notifier.d.ts
CHANGED
|
@@ -26,21 +26,30 @@ export interface ParsedNotification {
|
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* Parse a notification payload.
|
|
29
|
-
* Returns the display text plus an optional chatId for per-channel routing
|
|
29
|
+
* Returns the display text plus an optional chatId for per-channel routing,
|
|
30
|
+
* or null when the routing array excludes "discord".
|
|
30
31
|
* Appends a [driver] or [driver:model] badge when present.
|
|
31
32
|
* Appends " cost: $X.XXX" if a numeric cost field is present.
|
|
32
33
|
*/
|
|
33
|
-
export declare function parseNotification(raw: string): ParsedNotification;
|
|
34
|
+
export declare function parseNotification(raw: string): ParsedNotification | null;
|
|
34
35
|
/**
|
|
35
36
|
* Write a message to the chat log in Redis.
|
|
36
37
|
* Fire-and-forget — errors are logged but not thrown.
|
|
37
38
|
*/
|
|
38
39
|
export declare function writeChatLog(redis: Redis, namespace: string, msg: ChatMessage): void;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the target Discord channelId for a notification.
|
|
42
|
+
* When chatId is set and a reverse-lookup function is available, prefer the originating channel.
|
|
43
|
+
* Falls back to notifyChannelId, then getActiveChannelId.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveNotifyChannel(chatId: number | undefined, notifyChannelId: string | null, getActiveChannelId?: () => string | undefined, reverseSnowflakeLookup?: (n: number) => string | undefined): string | undefined;
|
|
39
46
|
export interface NotifierHandle {
|
|
40
47
|
/**
|
|
41
48
|
* Register the originating Discord channel ID for a routed namespace.
|
|
42
49
|
* When the meta-agent for `namespace` publishes a response, it will be
|
|
43
50
|
* forwarded to `channelId`.
|
|
51
|
+
* Also subscribes to notifyChannel(namespace) and chatIncomingChannel(namespace)
|
|
52
|
+
* so notifications and UI messages for that namespace are received.
|
|
44
53
|
*/
|
|
45
54
|
registerRoutedChannelId: (namespace: string, channelId: string) => void;
|
|
46
55
|
}
|
package/dist/notifier.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* cca:chat:log:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
|
|
11
11
|
* cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
|
|
12
12
|
*/
|
|
13
|
-
import { chatLogKey, chatOutgoingChannel, chatIncomingChannel, notifyChannel, metaAgentStatusKey, metaInputKey, } from "@gonzih/cc-wire";
|
|
13
|
+
import { chatLogKey, chatOutgoingChannel, chatIncomingChannel, notifyChannel, notifyListKey, metaAgentStatusKey, metaInputKey, } from "@gonzih/cc-wire";
|
|
14
14
|
import { splitLongMessage, stripAnsi } from "./formatter.js";
|
|
15
15
|
function log(level, ...args) {
|
|
16
16
|
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
@@ -32,7 +32,8 @@ function shortenModelName(model, driver) {
|
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
34
|
* Parse a notification payload.
|
|
35
|
-
* Returns the display text plus an optional chatId for per-channel routing
|
|
35
|
+
* Returns the display text plus an optional chatId for per-channel routing,
|
|
36
|
+
* or null when the routing array excludes "discord".
|
|
36
37
|
* Appends a [driver] or [driver:model] badge when present.
|
|
37
38
|
* Appends " cost: $X.XXX" if a numeric cost field is present.
|
|
38
39
|
*/
|
|
@@ -44,6 +45,10 @@ export function parseNotification(raw) {
|
|
|
44
45
|
let chatId;
|
|
45
46
|
try {
|
|
46
47
|
const parsed = JSON.parse(raw);
|
|
48
|
+
// routing: absent/empty → all transports; non-empty → only listed transports
|
|
49
|
+
if (parsed.routing && parsed.routing.length > 0 && !parsed.routing.includes("discord")) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
47
52
|
if (parsed.text)
|
|
48
53
|
text = parsed.text;
|
|
49
54
|
driver = parsed.driver;
|
|
@@ -86,7 +91,7 @@ export function writeChatLog(redis, namespace, msg) {
|
|
|
86
91
|
* When chatId is set and a reverse-lookup function is available, prefer the originating channel.
|
|
87
92
|
* Falls back to notifyChannelId, then getActiveChannelId.
|
|
88
93
|
*/
|
|
89
|
-
function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) {
|
|
94
|
+
export function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) {
|
|
90
95
|
if (chatId != null && reverseSnowflakeLookup) {
|
|
91
96
|
const resolved = reverseSnowflakeLookup(chatId);
|
|
92
97
|
if (resolved)
|
|
@@ -107,8 +112,10 @@ function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, rever
|
|
|
107
112
|
* @param reverseSnowflakeLookup - Optional callback to resolve a chatId integer to a Discord channelId
|
|
108
113
|
*/
|
|
109
114
|
export function startNotifier(bot, notifyChannelId, namespace, redis, handleUserMessage, forwardNotification, getActiveChannelId, reverseSnowflakeLookup) {
|
|
110
|
-
// Per-namespace channelId registry
|
|
115
|
+
// Per-namespace channelId registry — maps routed namespace → Discord channelId
|
|
111
116
|
const routedChannelIds = new Map();
|
|
117
|
+
// Track which namespaces we've already subscribed to (to avoid duplicate subscribe calls)
|
|
118
|
+
const subscribedNamespaces = new Set();
|
|
112
119
|
const sub = redis.duplicate({
|
|
113
120
|
retryStrategy: (times) => {
|
|
114
121
|
const delay = Math.min(1000 * Math.pow(2, times - 1), 30_000);
|
|
@@ -122,25 +129,39 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
122
129
|
sub.on("close", () => {
|
|
123
130
|
log("info", "subscriber disconnected, will reconnect with backoff");
|
|
124
131
|
});
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
132
|
+
// Reverse map: Redis channel string → namespace (for O(1) lookup in message handler)
|
|
133
|
+
const channelToNamespace = new Map();
|
|
134
|
+
function subscribeNamespace(ns) {
|
|
135
|
+
if (subscribedNamespaces.has(ns))
|
|
136
|
+
return;
|
|
137
|
+
subscribedNamespaces.add(ns);
|
|
138
|
+
const notifyCh = notifyChannel(ns);
|
|
139
|
+
const incomingCh = chatIncomingChannel(ns);
|
|
140
|
+
channelToNamespace.set(notifyCh, ns);
|
|
141
|
+
channelToNamespace.set(incomingCh, ns);
|
|
142
|
+
sub.subscribe(notifyCh, (err) => {
|
|
143
|
+
if (err) {
|
|
144
|
+
log("error", `subscribe ${notifyCh} failed:`, err.message);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
log("info", `subscribed to ${notifyCh}`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
sub.subscribe(incomingCh, (err) => {
|
|
151
|
+
if (err) {
|
|
152
|
+
log("error", `subscribe ${incomingCh} failed:`, err.message);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
log("info", `subscribed to ${incomingCh}`);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function resolveSubscribedNamespace(channel) {
|
|
160
|
+
return channelToNamespace.get(channel);
|
|
161
|
+
}
|
|
162
|
+
// Subscribe to the primary namespace immediately
|
|
163
|
+
subscribeNamespace(namespace);
|
|
164
|
+
// chatOutgoingChannel("*") — meta-agent stdout lines for ALL namespaces
|
|
144
165
|
sub.psubscribe(chatOutgoingChannel("*"), (err) => {
|
|
145
166
|
if (err) {
|
|
146
167
|
log("error", `psubscribe ${chatOutgoingChannel("*")} failed:`, err.message);
|
|
@@ -181,7 +202,12 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
181
202
|
const content = parsed.content;
|
|
182
203
|
if (!content)
|
|
183
204
|
return;
|
|
184
|
-
|
|
205
|
+
// For the primary namespace, fall back to notifyChannelId / getActiveChannelId.
|
|
206
|
+
// For any other namespace, ONLY use the registered channelId — never fall back to
|
|
207
|
+
// the primary channel, as that would cause cross-namespace leakage.
|
|
208
|
+
const targetChannelId = ns === namespace
|
|
209
|
+
? (routedChannelIds.get(ns) ?? notifyChannelId ?? getActiveChannelId?.())
|
|
210
|
+
: routedChannelIds.get(ns);
|
|
185
211
|
if (targetChannelId == null) {
|
|
186
212
|
log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping line`);
|
|
187
213
|
return;
|
|
@@ -196,24 +222,21 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
196
222
|
clearTimeout(buf.timer);
|
|
197
223
|
buf.timer = setTimeout(() => flushMetaAgentBuffer(ns, targetChannelId), META_AGENT_FLUSH_DELAY_MS);
|
|
198
224
|
});
|
|
199
|
-
// Poll
|
|
200
|
-
const notifyListKey = notifyChannel(namespace);
|
|
225
|
+
// Poll notifyListKey(ns) LIST every 5 seconds — covers primary + all routed namespaces.
|
|
201
226
|
const MAX_PER_CYCLE = 20;
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
if (targetId == null)
|
|
205
|
-
return;
|
|
227
|
+
const pollOneNamespace = async (ns, targetChannelId) => {
|
|
228
|
+
const listKey = notifyListKey(ns);
|
|
206
229
|
const items = [];
|
|
207
230
|
try {
|
|
208
231
|
for (let i = 0; i < MAX_PER_CYCLE; i++) {
|
|
209
|
-
const item = await redis.rpop(
|
|
232
|
+
const item = await redis.rpop(listKey);
|
|
210
233
|
if (item === null)
|
|
211
234
|
break;
|
|
212
235
|
items.push(item);
|
|
213
236
|
}
|
|
214
237
|
}
|
|
215
238
|
catch (err) {
|
|
216
|
-
log("warn",
|
|
239
|
+
log("warn", `notify list rpop failed (ns=${ns}):`, err.message);
|
|
217
240
|
return;
|
|
218
241
|
}
|
|
219
242
|
if (items.length === 0)
|
|
@@ -221,47 +244,82 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
221
244
|
let remaining = 0;
|
|
222
245
|
if (items.length === MAX_PER_CYCLE) {
|
|
223
246
|
try {
|
|
224
|
-
remaining = await redis.llen(
|
|
247
|
+
remaining = await redis.llen(listKey);
|
|
225
248
|
}
|
|
226
249
|
catch (err) {
|
|
227
|
-
log("warn",
|
|
250
|
+
log("warn", `notify list llen failed (ns=${ns}):`, err.message);
|
|
228
251
|
}
|
|
229
252
|
}
|
|
230
253
|
for (const raw of items) {
|
|
231
254
|
const notification = parseNotification(raw);
|
|
232
|
-
|
|
255
|
+
if (notification === null)
|
|
256
|
+
continue; // routing excludes discord
|
|
257
|
+
// Primary namespace: honour chatId-based per-channel routing via reverseSnowflakeLookup.
|
|
258
|
+
// Routed namespaces: always deliver to the registered Discord channelId — no leakage.
|
|
259
|
+
const destChannelId = ns === namespace
|
|
260
|
+
? (resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) ?? targetChannelId)
|
|
261
|
+
: targetChannelId;
|
|
233
262
|
bot.sendToChannelById(destChannelId, notification.text).catch((err) => {
|
|
234
|
-
log("warn",
|
|
263
|
+
log("warn", `notify list send failed (ns=${ns}):`, err.message);
|
|
235
264
|
});
|
|
236
265
|
if (forwardNotification) {
|
|
237
266
|
forwardNotification(destChannelId, notification.text);
|
|
238
267
|
}
|
|
239
268
|
}
|
|
240
269
|
if (remaining > 0) {
|
|
241
|
-
bot.sendToChannelById(
|
|
242
|
-
log("warn",
|
|
270
|
+
bot.sendToChannelById(targetChannelId, `...and ${remaining} more notifications`).catch((err) => {
|
|
271
|
+
log("warn", `notify list summary send failed (ns=${ns}):`, err.message);
|
|
243
272
|
});
|
|
244
273
|
}
|
|
245
274
|
};
|
|
275
|
+
const pollNotifyList = async () => {
|
|
276
|
+
// Primary namespace
|
|
277
|
+
const primaryTargetId = notifyChannelId ?? getActiveChannelId?.();
|
|
278
|
+
if (primaryTargetId != null) {
|
|
279
|
+
await pollOneNamespace(namespace, primaryTargetId);
|
|
280
|
+
}
|
|
281
|
+
// All registered routed namespaces
|
|
282
|
+
for (const [ns, channelId] of routedChannelIds) {
|
|
283
|
+
if (ns !== namespace) {
|
|
284
|
+
await pollOneNamespace(ns, channelId);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
246
288
|
setInterval(() => {
|
|
247
289
|
void pollNotifyList();
|
|
248
290
|
}, 5_000);
|
|
249
291
|
sub.on("message", (channel, message) => {
|
|
250
|
-
|
|
251
|
-
const
|
|
292
|
+
// Determine which namespace this channel belongs to
|
|
293
|
+
const ns = resolveSubscribedNamespace(channel);
|
|
294
|
+
if (!ns)
|
|
295
|
+
return;
|
|
296
|
+
const isPrimary = ns === namespace;
|
|
297
|
+
const notifyCh = notifyChannel(ns);
|
|
298
|
+
const incomingCh = chatIncomingChannel(ns);
|
|
252
299
|
if (channel === notifyCh) {
|
|
253
300
|
const notification = parseNotification(message);
|
|
254
|
-
|
|
301
|
+
if (notification === null)
|
|
302
|
+
return; // routing excludes discord
|
|
303
|
+
let targetId;
|
|
304
|
+
if (isPrimary) {
|
|
305
|
+
targetId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
// For routed namespaces, only use the registered channelId — no fallback to primary
|
|
309
|
+
targetId = notification.chatId != null && reverseSnowflakeLookup
|
|
310
|
+
? (reverseSnowflakeLookup(notification.chatId) ?? routedChannelIds.get(ns))
|
|
311
|
+
: routedChannelIds.get(ns);
|
|
312
|
+
}
|
|
255
313
|
if (targetId != null) {
|
|
256
314
|
bot.sendToChannelById(targetId, notification.text).catch((err) => {
|
|
257
|
-
log("warn",
|
|
315
|
+
log("warn", `notify send failed (ns=${ns}):`, err.message);
|
|
258
316
|
});
|
|
259
317
|
if (forwardNotification) {
|
|
260
318
|
forwardNotification(targetId, notification.text);
|
|
261
319
|
}
|
|
262
320
|
}
|
|
263
321
|
else {
|
|
264
|
-
log("warn",
|
|
322
|
+
log("warn", `notify: no channelId available for ns=${ns}, dropping notification`);
|
|
265
323
|
}
|
|
266
324
|
return;
|
|
267
325
|
}
|
|
@@ -278,11 +336,13 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
278
336
|
catch {
|
|
279
337
|
// raw string message — use as-is
|
|
280
338
|
}
|
|
281
|
-
const targetChannelId =
|
|
339
|
+
const targetChannelId = isPrimary
|
|
340
|
+
? (notifyChannelId ?? getActiveChannelId?.())
|
|
341
|
+
: routedChannelIds.get(ns);
|
|
282
342
|
if (targetChannelId !== undefined) {
|
|
283
343
|
// Echo to Discord so the user sees UI messages
|
|
284
344
|
bot.sendToChannelById(targetChannelId, `[from UI]: ${content}`).catch((err) => {
|
|
285
|
-
log("warn",
|
|
345
|
+
log("warn", `sendToChannelById (UI echo) failed (ns=${ns}):`, err.message);
|
|
286
346
|
});
|
|
287
347
|
// Log the incoming message
|
|
288
348
|
const inMsg = {
|
|
@@ -293,12 +353,12 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
293
353
|
timestamp: originalTimestamp ?? new Date().toISOString(),
|
|
294
354
|
chatId: 0, // no numeric chatId for Discord — stored by channelId string
|
|
295
355
|
};
|
|
296
|
-
writeChatLog(redis,
|
|
356
|
+
writeChatLog(redis, ns, inMsg);
|
|
297
357
|
// Check if a meta-agent is running; if so, route there instead
|
|
298
358
|
void (async () => {
|
|
299
359
|
let routedToMetaAgent = false;
|
|
300
360
|
try {
|
|
301
|
-
const statusRaw = await redis.get(metaAgentStatusKey(
|
|
361
|
+
const statusRaw = await redis.get(metaAgentStatusKey(ns));
|
|
302
362
|
if (statusRaw) {
|
|
303
363
|
const status = JSON.parse(statusRaw);
|
|
304
364
|
if (status.status === "running") {
|
|
@@ -307,14 +367,14 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
307
367
|
content,
|
|
308
368
|
timestamp: new Date().toISOString(),
|
|
309
369
|
});
|
|
310
|
-
await redis.rpush(metaInputKey(
|
|
311
|
-
log("info", `cca:chat:incoming: routed to meta-agent for namespace ${
|
|
370
|
+
await redis.rpush(metaInputKey(ns), entry);
|
|
371
|
+
log("info", `cca:chat:incoming: routed to meta-agent for namespace ${ns}`);
|
|
312
372
|
routedToMetaAgent = true;
|
|
313
373
|
}
|
|
314
374
|
}
|
|
315
375
|
}
|
|
316
376
|
catch (err) {
|
|
317
|
-
log("warn",
|
|
377
|
+
log("warn", `meta-agent status check failed (ns=${ns}):`, err.message);
|
|
318
378
|
}
|
|
319
379
|
if (!routedToMetaAgent && handleUserMessage) {
|
|
320
380
|
handleUserMessage(targetChannelId, content);
|
|
@@ -322,13 +382,16 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
322
382
|
})();
|
|
323
383
|
}
|
|
324
384
|
else {
|
|
325
|
-
log("warn",
|
|
385
|
+
log("warn", `cca:chat:incoming: no active channelId for ns=${ns}, dropping message`);
|
|
326
386
|
}
|
|
327
387
|
}
|
|
328
388
|
});
|
|
329
389
|
return {
|
|
330
390
|
registerRoutedChannelId: (ns, channelId) => {
|
|
331
391
|
routedChannelIds.set(ns, channelId);
|
|
392
|
+
// Subscribe to this namespace's Redis channels so we receive its notifications
|
|
393
|
+
// and incoming UI messages. No-op if already subscribed.
|
|
394
|
+
subscribeNamespace(ns);
|
|
332
395
|
},
|
|
333
396
|
};
|
|
334
397
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonzih/cc-discord",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Claude Code Discord bot — chat with Claude Code via Discord",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"dist/"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@gonzih/cc-wire": "^0.1.
|
|
21
|
+
"@gonzih/cc-wire": "^0.1.6",
|
|
22
22
|
"discord.js": "^14.0.0",
|
|
23
23
|
"ioredis": "^5.0.0"
|
|
24
24
|
},
|