@gonzih/cc-discord 0.1.7 → 0.1.9
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 +11 -3
- package/dist/bot.js +68 -13
- package/dist/index.js +4 -0
- package/dist/notifier.d.ts +8 -0
- package/dist/notifier.js +75 -35
- package/package.json +1 -1
package/dist/bot.d.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* One ClaudeProcess per channel (or channel:thread) — sessions are isolated per channel.
|
|
4
4
|
*/
|
|
5
5
|
import { Redis } from "ioredis";
|
|
6
|
-
/** Prepend [
|
|
7
|
-
export declare function stampPrompt(text: string, now?: Date): string;
|
|
6
|
+
/** Prepend [DayOfWeek HH:MM] username: so Claude knows when the message was received and from whom. */
|
|
7
|
+
export declare function stampPrompt(text: string, username?: string, now?: Date): string;
|
|
8
8
|
export interface DiscordBotOptions {
|
|
9
9
|
discordToken: string;
|
|
10
10
|
claudeToken?: string;
|
|
@@ -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.
|
|
@@ -37,13 +41,14 @@ function computeCostUsd(usage) {
|
|
|
37
41
|
const FLUSH_DELAY_MS = 800;
|
|
38
42
|
// Discord typing indicator: re-send every 9s (indicator expires after ~10s)
|
|
39
43
|
const TYPING_INTERVAL_MS = 9000;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
+
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
45
|
+
/** Prepend [DayOfWeek HH:MM] username: so Claude knows when the message was received and from whom. */
|
|
46
|
+
export function stampPrompt(text, username, now = new Date()) {
|
|
47
|
+
const day = DAYS[now.getDay()];
|
|
44
48
|
const hh = String(now.getHours()).padStart(2, "0");
|
|
45
49
|
const min = String(now.getMinutes()).padStart(2, "0");
|
|
46
|
-
|
|
50
|
+
const header = username ? `[${day} ${hh}:${min}] ${username}: ` : `[${day} ${hh}:${min}] `;
|
|
51
|
+
return header + text;
|
|
47
52
|
}
|
|
48
53
|
function formatTokens(n) {
|
|
49
54
|
if (n >= 1000)
|
|
@@ -164,6 +169,49 @@ export class CcDiscordBot {
|
|
|
164
169
|
reverseSnowflakeLookup(n) {
|
|
165
170
|
return this.snowflakeMap.get(n);
|
|
166
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
|
+
}
|
|
167
215
|
/** Session key: "channelId" or "channelId:threadId" for threads */
|
|
168
216
|
sessionKey(channelId, threadId) {
|
|
169
217
|
return threadId ? `${channelId}:${threadId}` : channelId;
|
|
@@ -283,10 +331,11 @@ export class CcDiscordBot {
|
|
|
283
331
|
// Channel registered via createChannelForRepo or /channel — route directly to its meta-agent
|
|
284
332
|
const mappedNs = this.channelNamespaceMap.get(effectiveChannelId);
|
|
285
333
|
if (mappedNs && this.redis) {
|
|
286
|
-
this.writeChatMessage("user", "discord", text, effectiveChannelId);
|
|
334
|
+
this.writeChatMessage("user", "discord", text, effectiveChannelId, mappedNs.namespace);
|
|
287
335
|
this.opts.registerRoutedChannelId?.(mappedNs.namespace, effectiveChannelId);
|
|
336
|
+
const username = msg.member?.displayName ?? msg.author.username;
|
|
288
337
|
try {
|
|
289
|
-
await routeToMetaAgent(mappedNs.namespace, text, this.redis);
|
|
338
|
+
await routeToMetaAgent(mappedNs.namespace, stampPrompt(text, username, msg.createdAt), this.redis);
|
|
290
339
|
}
|
|
291
340
|
catch (err) {
|
|
292
341
|
await msg.channel.send(`Failed to route to ${mappedNs.namespace}: ${err.message}`).catch(() => { });
|
|
@@ -295,9 +344,10 @@ export class CcDiscordBot {
|
|
|
295
344
|
}
|
|
296
345
|
// Local Claude session
|
|
297
346
|
const session = this.getOrCreateSession(effectiveChannelId, msg.channel);
|
|
347
|
+
const username = msg.member?.displayName ?? msg.author.username;
|
|
298
348
|
try {
|
|
299
349
|
session.currentPrompt = text;
|
|
300
|
-
session.claude.sendPrompt(stampPrompt(text));
|
|
350
|
+
session.claude.sendPrompt(stampPrompt(text, username, msg.createdAt));
|
|
301
351
|
this.startTyping(effectiveChannelId, msg.channel, session);
|
|
302
352
|
this.writeChatMessage("user", "discord", text, effectiveChannelId);
|
|
303
353
|
}
|
|
@@ -317,7 +367,8 @@ export class CcDiscordBot {
|
|
|
317
367
|
}
|
|
318
368
|
const session = this.getOrCreateSession(channelId, channel);
|
|
319
369
|
session.currentPrompt = transcript;
|
|
320
|
-
|
|
370
|
+
const voiceUsername = msg.member?.displayName ?? msg.author.username;
|
|
371
|
+
session.claude.sendPrompt(stampPrompt(transcript, voiceUsername, msg.createdAt));
|
|
321
372
|
this.startTyping(channelId, channel, session);
|
|
322
373
|
this.writeChatMessage("user", "discord", transcript, channelId);
|
|
323
374
|
}
|
|
@@ -342,8 +393,9 @@ export class CcDiscordBot {
|
|
|
342
393
|
try {
|
|
343
394
|
const base64Data = await fetchAsBase64(imageUrl);
|
|
344
395
|
const caption = msg.content.trim() || "";
|
|
396
|
+
const imgUsername = msg.member?.displayName ?? msg.author.username;
|
|
345
397
|
const session = this.getOrCreateSession(channelId, channel);
|
|
346
|
-
session.claude.sendImage(base64Data, contentType, stampPrompt(caption));
|
|
398
|
+
session.claude.sendImage(base64Data, contentType, stampPrompt(caption, imgUsername, msg.createdAt));
|
|
347
399
|
this.startTyping(channelId, channel, session);
|
|
348
400
|
}
|
|
349
401
|
catch (err) {
|
|
@@ -637,6 +689,7 @@ export class CcDiscordBot {
|
|
|
637
689
|
const newChannel = await guild.channels.create({ name: namespace, type: ChannelType.GuildText, parent: resolveCategoryId(guild) });
|
|
638
690
|
this.channelNamespaceMap.set(newChannel.id, { namespace, repoUrl });
|
|
639
691
|
this.opts.registerRoutedChannelId?.(namespace, newChannel.id);
|
|
692
|
+
this.persistChannelMapping(newChannel.id, namespace, repoUrl);
|
|
640
693
|
await interaction.editReply(`Created <#${newChannel.id}> — messages there route to the ${repoUrl} meta-agent`);
|
|
641
694
|
// Start meta-agent in the background
|
|
642
695
|
if (this.redis) {
|
|
@@ -780,6 +833,7 @@ export class CcDiscordBot {
|
|
|
780
833
|
}
|
|
781
834
|
this.channelNamespaceMap.set(newChannel.id, { namespace, repoUrl });
|
|
782
835
|
this.opts.registerRoutedChannelId?.(namespace, newChannel.id);
|
|
836
|
+
this.persistChannelMapping(newChannel.id, namespace, repoUrl);
|
|
783
837
|
await channel.send(`Created <#${newChannel.id}> — messages there route to the ${repoUrl} meta-agent`).catch(() => { });
|
|
784
838
|
// Start meta-agent in the background after acknowledging the user
|
|
785
839
|
if (this.redis) {
|
|
@@ -790,8 +844,9 @@ export class CcDiscordBot {
|
|
|
790
844
|
});
|
|
791
845
|
}
|
|
792
846
|
}
|
|
793
|
-
/** Write a message to the Redis chat log. Fire-and-forget.
|
|
794
|
-
|
|
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) {
|
|
795
850
|
if (!this.redis)
|
|
796
851
|
return;
|
|
797
852
|
const msg = {
|
|
@@ -802,7 +857,7 @@ export class CcDiscordBot {
|
|
|
802
857
|
timestamp: new Date().toISOString(),
|
|
803
858
|
chatId: snowflakeToInt(channelId),
|
|
804
859
|
};
|
|
805
|
-
writeChatLog(this.redis, this.namespace, msg);
|
|
860
|
+
writeChatLog(this.redis, ns ?? this.namespace, msg);
|
|
806
861
|
}
|
|
807
862
|
/** Returns the last channelId that sent a message. */
|
|
808
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
|
@@ -37,11 +37,19 @@ export declare function parseNotification(raw: string): ParsedNotification | nul
|
|
|
37
37
|
* Fire-and-forget — errors are logged but not thrown.
|
|
38
38
|
*/
|
|
39
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;
|
|
40
46
|
export interface NotifierHandle {
|
|
41
47
|
/**
|
|
42
48
|
* Register the originating Discord channel ID for a routed namespace.
|
|
43
49
|
* When the meta-agent for `namespace` publishes a response, it will be
|
|
44
50
|
* forwarded to `channelId`.
|
|
51
|
+
* Also subscribes to notifyChannel(namespace) and chatIncomingChannel(namespace)
|
|
52
|
+
* so notifications and UI messages for that namespace are received.
|
|
45
53
|
*/
|
|
46
54
|
registerRoutedChannelId: (namespace: string, channelId: string) => void;
|
|
47
55
|
}
|
package/dist/notifier.js
CHANGED
|
@@ -91,7 +91,7 @@ export function writeChatLog(redis, namespace, msg) {
|
|
|
91
91
|
* When chatId is set and a reverse-lookup function is available, prefer the originating channel.
|
|
92
92
|
* Falls back to notifyChannelId, then getActiveChannelId.
|
|
93
93
|
*/
|
|
94
|
-
function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) {
|
|
94
|
+
export function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) {
|
|
95
95
|
if (chatId != null && reverseSnowflakeLookup) {
|
|
96
96
|
const resolved = reverseSnowflakeLookup(chatId);
|
|
97
97
|
if (resolved)
|
|
@@ -112,8 +112,10 @@ function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, rever
|
|
|
112
112
|
* @param reverseSnowflakeLookup - Optional callback to resolve a chatId integer to a Discord channelId
|
|
113
113
|
*/
|
|
114
114
|
export function startNotifier(bot, notifyChannelId, namespace, redis, handleUserMessage, forwardNotification, getActiveChannelId, reverseSnowflakeLookup) {
|
|
115
|
-
// Per-namespace channelId registry
|
|
115
|
+
// Per-namespace channelId registry — maps routed namespace → Discord channelId
|
|
116
116
|
const routedChannelIds = new Map();
|
|
117
|
+
// Track which namespaces we've already subscribed to (to avoid duplicate subscribe calls)
|
|
118
|
+
const subscribedNamespaces = new Set();
|
|
117
119
|
const sub = redis.duplicate({
|
|
118
120
|
retryStrategy: (times) => {
|
|
119
121
|
const delay = Math.min(1000 * Math.pow(2, times - 1), 30_000);
|
|
@@ -127,25 +129,39 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
127
129
|
sub.on("close", () => {
|
|
128
130
|
log("info", "subscriber disconnected, will reconnect with backoff");
|
|
129
131
|
});
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
149
165
|
sub.psubscribe(chatOutgoingChannel("*"), (err) => {
|
|
150
166
|
if (err) {
|
|
151
167
|
log("error", `psubscribe ${chatOutgoingChannel("*")} failed:`, err.message);
|
|
@@ -186,7 +202,12 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
186
202
|
const content = parsed.content;
|
|
187
203
|
if (!content)
|
|
188
204
|
return;
|
|
189
|
-
|
|
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);
|
|
190
211
|
if (targetChannelId == null) {
|
|
191
212
|
log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping line`);
|
|
192
213
|
return;
|
|
@@ -254,23 +275,37 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
254
275
|
void pollNotifyList();
|
|
255
276
|
}, 5_000);
|
|
256
277
|
sub.on("message", (channel, message) => {
|
|
257
|
-
|
|
258
|
-
const
|
|
278
|
+
// Determine which namespace this channel belongs to
|
|
279
|
+
const ns = resolveSubscribedNamespace(channel);
|
|
280
|
+
if (!ns)
|
|
281
|
+
return;
|
|
282
|
+
const isPrimary = ns === namespace;
|
|
283
|
+
const notifyCh = notifyChannel(ns);
|
|
284
|
+
const incomingCh = chatIncomingChannel(ns);
|
|
259
285
|
if (channel === notifyCh) {
|
|
260
286
|
const notification = parseNotification(message);
|
|
261
287
|
if (notification === null)
|
|
262
288
|
return; // routing excludes discord
|
|
263
|
-
|
|
289
|
+
let targetId;
|
|
290
|
+
if (isPrimary) {
|
|
291
|
+
targetId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
// For routed namespaces, only use the registered channelId — no fallback to primary
|
|
295
|
+
targetId = notification.chatId != null && reverseSnowflakeLookup
|
|
296
|
+
? (reverseSnowflakeLookup(notification.chatId) ?? routedChannelIds.get(ns))
|
|
297
|
+
: routedChannelIds.get(ns);
|
|
298
|
+
}
|
|
264
299
|
if (targetId != null) {
|
|
265
300
|
bot.sendToChannelById(targetId, notification.text).catch((err) => {
|
|
266
|
-
log("warn",
|
|
301
|
+
log("warn", `notify send failed (ns=${ns}):`, err.message);
|
|
267
302
|
});
|
|
268
303
|
if (forwardNotification) {
|
|
269
304
|
forwardNotification(targetId, notification.text);
|
|
270
305
|
}
|
|
271
306
|
}
|
|
272
307
|
else {
|
|
273
|
-
log("warn",
|
|
308
|
+
log("warn", `notify: no channelId available for ns=${ns}, dropping notification`);
|
|
274
309
|
}
|
|
275
310
|
return;
|
|
276
311
|
}
|
|
@@ -287,11 +322,13 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
287
322
|
catch {
|
|
288
323
|
// raw string message — use as-is
|
|
289
324
|
}
|
|
290
|
-
const targetChannelId =
|
|
325
|
+
const targetChannelId = isPrimary
|
|
326
|
+
? (notifyChannelId ?? getActiveChannelId?.())
|
|
327
|
+
: routedChannelIds.get(ns);
|
|
291
328
|
if (targetChannelId !== undefined) {
|
|
292
329
|
// Echo to Discord so the user sees UI messages
|
|
293
330
|
bot.sendToChannelById(targetChannelId, `[from UI]: ${content}`).catch((err) => {
|
|
294
|
-
log("warn",
|
|
331
|
+
log("warn", `sendToChannelById (UI echo) failed (ns=${ns}):`, err.message);
|
|
295
332
|
});
|
|
296
333
|
// Log the incoming message
|
|
297
334
|
const inMsg = {
|
|
@@ -302,12 +339,12 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
302
339
|
timestamp: originalTimestamp ?? new Date().toISOString(),
|
|
303
340
|
chatId: 0, // no numeric chatId for Discord — stored by channelId string
|
|
304
341
|
};
|
|
305
|
-
writeChatLog(redis,
|
|
342
|
+
writeChatLog(redis, ns, inMsg);
|
|
306
343
|
// Check if a meta-agent is running; if so, route there instead
|
|
307
344
|
void (async () => {
|
|
308
345
|
let routedToMetaAgent = false;
|
|
309
346
|
try {
|
|
310
|
-
const statusRaw = await redis.get(metaAgentStatusKey(
|
|
347
|
+
const statusRaw = await redis.get(metaAgentStatusKey(ns));
|
|
311
348
|
if (statusRaw) {
|
|
312
349
|
const status = JSON.parse(statusRaw);
|
|
313
350
|
if (status.status === "running") {
|
|
@@ -316,14 +353,14 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
316
353
|
content,
|
|
317
354
|
timestamp: new Date().toISOString(),
|
|
318
355
|
});
|
|
319
|
-
await redis.rpush(metaInputKey(
|
|
320
|
-
log("info", `cca:chat:incoming: routed to meta-agent for namespace ${
|
|
356
|
+
await redis.rpush(metaInputKey(ns), entry);
|
|
357
|
+
log("info", `cca:chat:incoming: routed to meta-agent for namespace ${ns}`);
|
|
321
358
|
routedToMetaAgent = true;
|
|
322
359
|
}
|
|
323
360
|
}
|
|
324
361
|
}
|
|
325
362
|
catch (err) {
|
|
326
|
-
log("warn",
|
|
363
|
+
log("warn", `meta-agent status check failed (ns=${ns}):`, err.message);
|
|
327
364
|
}
|
|
328
365
|
if (!routedToMetaAgent && handleUserMessage) {
|
|
329
366
|
handleUserMessage(targetChannelId, content);
|
|
@@ -331,13 +368,16 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
331
368
|
})();
|
|
332
369
|
}
|
|
333
370
|
else {
|
|
334
|
-
log("warn",
|
|
371
|
+
log("warn", `cca:chat:incoming: no active channelId for ns=${ns}, dropping message`);
|
|
335
372
|
}
|
|
336
373
|
}
|
|
337
374
|
});
|
|
338
375
|
return {
|
|
339
376
|
registerRoutedChannelId: (ns, channelId) => {
|
|
340
377
|
routedChannelIds.set(ns, channelId);
|
|
378
|
+
// Subscribe to this namespace's Redis channels so we receive its notifications
|
|
379
|
+
// and incoming UI messages. No-op if already subscribed.
|
|
380
|
+
subscribeNamespace(ns);
|
|
341
381
|
},
|
|
342
382
|
};
|
|
343
383
|
}
|