@gonzih/cc-discord 0.2.15 → 0.2.17
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 +19 -0
- package/dist/bot.js +116 -2
- package/dist/index.js +18 -0
- package/dist/meta-agent-manager.d.ts +9 -1
- package/dist/meta-agent-manager.js +49 -3
- package/dist/notifier.js +68 -0
- package/package.json +1 -1
package/dist/bot.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface DiscordBotOptions {
|
|
|
18
18
|
redis?: Redis;
|
|
19
19
|
namespace?: string;
|
|
20
20
|
guildIds?: string[];
|
|
21
|
+
/** Instance ID for singleton overlap detection */
|
|
22
|
+
instanceId?: string;
|
|
21
23
|
/** Called when a message is routed to a non-default namespace so the notifier
|
|
22
24
|
* can forward the response back to the originating Discord channel. */
|
|
23
25
|
registerRoutedChannelId?: (namespace: string, channelId: string) => void;
|
|
@@ -138,5 +140,22 @@ export declare class CcDiscordBot {
|
|
|
138
140
|
* Passes a live getter for the set of registered namespaces.
|
|
139
141
|
*/
|
|
140
142
|
startMetaAgentPolling(): void;
|
|
143
|
+
/**
|
|
144
|
+
* Resolve the namespace for a given channelId.
|
|
145
|
+
* Routed channels use their registered namespace; everything else uses the bot's primary namespace.
|
|
146
|
+
*/
|
|
147
|
+
private resolveNamespaceForChannel;
|
|
148
|
+
/**
|
|
149
|
+
* Delete all Claude session JSONL files for the given namespace's workspace.
|
|
150
|
+
* Claude stores sessions in ~/.claude/projects/{encoded-workspace-path}/*.jsonl
|
|
151
|
+
* The path encoding replaces / with - and prepends a leading -.
|
|
152
|
+
* Returns the number of files deleted.
|
|
153
|
+
*/
|
|
154
|
+
private clearClaudeSession;
|
|
155
|
+
/**
|
|
156
|
+
* Send /compact as a prompt to a one-shot claude --continue session in the namespace workspace.
|
|
157
|
+
* This triggers Claude's built-in context compaction.
|
|
158
|
+
*/
|
|
159
|
+
private compactClaudeSession;
|
|
141
160
|
stop(): void;
|
|
142
161
|
}
|
package/dist/bot.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* One ClaudeProcess per channel (or channel:thread) — sessions are isolated per channel.
|
|
4
4
|
*/
|
|
5
5
|
import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder, EmbedBuilder, AttachmentBuilder, Events, ChannelType, } from "discord.js";
|
|
6
|
-
import { existsSync, createWriteStream, mkdirSync, statSync } from "fs";
|
|
6
|
+
import { existsSync, createWriteStream, mkdirSync, statSync, readdirSync, rmSync } from "fs";
|
|
7
7
|
import { resolve, basename, join } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
8
9
|
import https from "https";
|
|
9
10
|
import http from "http";
|
|
10
11
|
import { createCcWire } from "@gonzih/cc-wire";
|
|
@@ -816,6 +817,15 @@ export class CcDiscordBot {
|
|
|
816
817
|
.setName("channel")
|
|
817
818
|
.setDescription("Create a Discord channel for a GitHub repo meta-agent")
|
|
818
819
|
.addStringOption((opt) => opt.setName("repo").setDescription("GitHub repo URL (e.g. https://github.com/org/repo)").setRequired(true)),
|
|
820
|
+
new SlashCommandBuilder()
|
|
821
|
+
.setName("restart")
|
|
822
|
+
.setDescription("Graceful restart — exits this process (launchd will respawn clean)"),
|
|
823
|
+
new SlashCommandBuilder()
|
|
824
|
+
.setName("clear")
|
|
825
|
+
.setDescription("Clear the Claude session context for this channel's namespace"),
|
|
826
|
+
new SlashCommandBuilder()
|
|
827
|
+
.setName("compact")
|
|
828
|
+
.setDescription("Compact the Claude session context for this channel's namespace"),
|
|
819
829
|
].map((cmd) => cmd.toJSON());
|
|
820
830
|
const rest = new REST().setToken(this.opts.discordToken);
|
|
821
831
|
if (this.opts.guildIds?.length) {
|
|
@@ -941,6 +951,27 @@ export class CcDiscordBot {
|
|
|
941
951
|
}
|
|
942
952
|
break;
|
|
943
953
|
}
|
|
954
|
+
case "restart": {
|
|
955
|
+
await interaction.reply("Restarting...");
|
|
956
|
+
setTimeout(() => { process.exit(0); }, 500);
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
case "clear": {
|
|
960
|
+
const ns = this.resolveNamespaceForChannel(channelId);
|
|
961
|
+
const deleted = this.clearClaudeSession(ns);
|
|
962
|
+
await interaction.reply(deleted > 0
|
|
963
|
+
? `Context cleared for ${ns} (${deleted} session file${deleted === 1 ? "" : "s"} removed). Next message starts fresh.`
|
|
964
|
+
: `No active session files found for ${ns}.`);
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
case "compact": {
|
|
968
|
+
const ns = this.resolveNamespaceForChannel(channelId);
|
|
969
|
+
await interaction.reply(`Compacting context for ${ns}...`);
|
|
970
|
+
this.compactClaudeSession(ns).catch((err) => {
|
|
971
|
+
console.warn(`[bot] /compact failed (ns=${ns}):`, err.message);
|
|
972
|
+
});
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
944
975
|
}
|
|
945
976
|
}
|
|
946
977
|
async handleCronsCommand(interaction, channelId) {
|
|
@@ -1293,7 +1324,90 @@ export class CcDiscordBot {
|
|
|
1293
1324
|
startMetaAgentPolling() {
|
|
1294
1325
|
if (!this.wire)
|
|
1295
1326
|
return;
|
|
1296
|
-
this.metaAgentManager.startPolling(this.wire, () => Array.from(this.channelNamespaceMap.values()));
|
|
1327
|
+
this.metaAgentManager.startPolling(this.wire, () => Array.from(this.channelNamespaceMap.values()), this.opts.instanceId);
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Resolve the namespace for a given channelId.
|
|
1331
|
+
* Routed channels use their registered namespace; everything else uses the bot's primary namespace.
|
|
1332
|
+
*/
|
|
1333
|
+
resolveNamespaceForChannel(channelId) {
|
|
1334
|
+
return this.channelNamespaceMap.get(channelId)?.namespace ?? this.namespace;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Delete all Claude session JSONL files for the given namespace's workspace.
|
|
1338
|
+
* Claude stores sessions in ~/.claude/projects/{encoded-workspace-path}/*.jsonl
|
|
1339
|
+
* The path encoding replaces / with - and prepends a leading -.
|
|
1340
|
+
* Returns the number of files deleted.
|
|
1341
|
+
*/
|
|
1342
|
+
clearClaudeSession(ns) {
|
|
1343
|
+
const wsPath = join(homedir(), "cc-discord-workspace", ns);
|
|
1344
|
+
// Claude encodes workspace path: strip leading /, replace / with -, then prepend -
|
|
1345
|
+
const encoded = "-" + wsPath.slice(1).replace(/\//g, "-");
|
|
1346
|
+
const projectsDir = join(homedir(), ".claude", "projects", encoded);
|
|
1347
|
+
if (!existsSync(projectsDir))
|
|
1348
|
+
return 0;
|
|
1349
|
+
let count = 0;
|
|
1350
|
+
try {
|
|
1351
|
+
const entries = readdirSync(projectsDir);
|
|
1352
|
+
for (const entry of entries) {
|
|
1353
|
+
if (entry.endsWith(".jsonl")) {
|
|
1354
|
+
rmSync(join(projectsDir, entry), { force: true });
|
|
1355
|
+
count++;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
catch (err) {
|
|
1360
|
+
console.warn(`[bot] clearClaudeSession(${ns}) failed:`, err.message);
|
|
1361
|
+
}
|
|
1362
|
+
console.log(`[bot] clearClaudeSession(${ns}): deleted ${count} files from ${projectsDir}`);
|
|
1363
|
+
return count;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Send /compact as a prompt to a one-shot claude --continue session in the namespace workspace.
|
|
1367
|
+
* This triggers Claude's built-in context compaction.
|
|
1368
|
+
*/
|
|
1369
|
+
async compactClaudeSession(ns) {
|
|
1370
|
+
const wsPath = join(homedir(), "cc-discord-workspace", ns);
|
|
1371
|
+
if (!existsSync(wsPath)) {
|
|
1372
|
+
console.warn(`[bot] compactClaudeSession(${ns}): workspace not found at ${wsPath}`);
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
const token = await this.resolveToken();
|
|
1376
|
+
const { spawn } = await import("child_process");
|
|
1377
|
+
const { existsSync: fsExists } = await import("fs");
|
|
1378
|
+
const resolveBin = () => {
|
|
1379
|
+
const dirs = (process.env.PATH ?? "").split(":");
|
|
1380
|
+
for (const dir of dirs) {
|
|
1381
|
+
const c = `${dir}/claude`;
|
|
1382
|
+
if (fsExists(c))
|
|
1383
|
+
return c;
|
|
1384
|
+
}
|
|
1385
|
+
for (const p of [`${homedir()}/.npm-global/bin/claude`, "/opt/homebrew/bin/claude", "/usr/local/bin/claude"]) {
|
|
1386
|
+
if (fsExists(p))
|
|
1387
|
+
return p;
|
|
1388
|
+
}
|
|
1389
|
+
return "claude";
|
|
1390
|
+
};
|
|
1391
|
+
const env = { ...process.env };
|
|
1392
|
+
if (token.startsWith("sk-ant-api")) {
|
|
1393
|
+
env.ANTHROPIC_API_KEY = token;
|
|
1394
|
+
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
1395
|
+
}
|
|
1396
|
+
else {
|
|
1397
|
+
env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
1398
|
+
delete env.ANTHROPIC_API_KEY;
|
|
1399
|
+
}
|
|
1400
|
+
return new Promise((resolve) => {
|
|
1401
|
+
const proc = spawn(resolveBin(), ["--continue", "-p", "/compact", "--output-format", "text", "--dangerously-skip-permissions"], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
1402
|
+
proc.on("exit", (code) => {
|
|
1403
|
+
console.log(`[bot] compactClaudeSession(${ns}) exited code=${code}`);
|
|
1404
|
+
resolve();
|
|
1405
|
+
});
|
|
1406
|
+
proc.on("error", (err) => {
|
|
1407
|
+
console.warn(`[bot] compactClaudeSession(${ns}) spawn error:`, err.message);
|
|
1408
|
+
resolve();
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1297
1411
|
}
|
|
1298
1412
|
stop() {
|
|
1299
1413
|
for (const [key, session] of this.sessions) {
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* CC_DISCORD_MCP_JSON — JSON template for .mcp.json injection into workspaces
|
|
21
21
|
*/
|
|
22
22
|
import { createRequire } from "node:module";
|
|
23
|
+
import { randomUUID } from "crypto";
|
|
23
24
|
import { Redis } from "ioredis";
|
|
24
25
|
import { createCcWire } from "@gonzih/cc-wire";
|
|
25
26
|
import { CcDiscordBot } from "./bot.js";
|
|
@@ -77,12 +78,28 @@ sharedRedis.on("error", (err) => {
|
|
|
77
78
|
});
|
|
78
79
|
// cc-wire factory
|
|
79
80
|
const wire = createCcWire(sharedRedis);
|
|
81
|
+
// Singleton instance ID — used to detect stale processes after a restart
|
|
82
|
+
const instanceId = randomUUID();
|
|
83
|
+
const INSTANCE_KEY = "cca:discord:instance";
|
|
84
|
+
const INSTANCE_TTL_MS = 30_000;
|
|
85
|
+
const INSTANCE_REFRESH_MS = 10_000;
|
|
80
86
|
sharedRedis.once("ready", () => {
|
|
81
87
|
// Announce version
|
|
82
88
|
sharedRedis.set(`cca:meta:cc-discord:version`, version).catch((err) => {
|
|
83
89
|
console.warn("[redis] failed to write version:", err.message);
|
|
84
90
|
});
|
|
85
91
|
console.log(`[cc-discord] version:reported ${version}`);
|
|
92
|
+
// Write singleton instance ID with 30s TTL
|
|
93
|
+
sharedRedis.set(INSTANCE_KEY, instanceId, "PX", INSTANCE_TTL_MS).catch((err) => {
|
|
94
|
+
console.warn("[cc-discord] failed to write instance ID:", err.message);
|
|
95
|
+
});
|
|
96
|
+
console.log(`[cc-discord] instance:${instanceId}`);
|
|
97
|
+
// Refresh TTL every 10s so launchd-respawned processes displace old ones
|
|
98
|
+
setInterval(() => {
|
|
99
|
+
sharedRedis.set(INSTANCE_KEY, instanceId, "PX", INSTANCE_TTL_MS).catch((err) => {
|
|
100
|
+
console.warn("[cc-discord] instance refresh failed:", err.message);
|
|
101
|
+
});
|
|
102
|
+
}, INSTANCE_REFRESH_MS);
|
|
86
103
|
// Store master token so MetaAgentManager can retrieve it
|
|
87
104
|
wire.token.setMaster(claudeToken).catch((err) => {
|
|
88
105
|
console.warn("[cc-discord] failed to set master token:", err.message);
|
|
@@ -140,6 +157,7 @@ const bot = new CcDiscordBot({
|
|
|
140
157
|
guildIds,
|
|
141
158
|
redis: sharedRedis,
|
|
142
159
|
namespace,
|
|
160
|
+
instanceId,
|
|
143
161
|
registerRoutedChannelId: (ns, channelId) => notifier.registerRoutedChannelId(ns, channelId),
|
|
144
162
|
});
|
|
145
163
|
const notifier = startNotifier(bot, notifyChannelId, namespace, sharedRedis, (channelId, text) => handleUserMessageFn?.(channelId, text), (channelId, text) => forwardNotificationFn?.(channelId, text), () => getLastActiveChannelIdFn(), (n) => bot.reverseSnowflakeLookup(n), (ns) => bot.getChannelIdForNamespace(ns));
|
|
@@ -30,9 +30,17 @@ export declare function ensureWorkspace(ns: string, repoUrl: string): Promise<vo
|
|
|
30
30
|
* {npmCache}, {trustedOwners}, {path}.
|
|
31
31
|
*/
|
|
32
32
|
export declare function injectMcp(ns: string, wsPath: string, token: string): void;
|
|
33
|
+
/**
|
|
34
|
+
* Redis keys for meta-agent stdout streaming.
|
|
35
|
+
* cca:meta:{ns}:stream — pub/sub channel (live streaming)
|
|
36
|
+
* cca:meta:{ns}:log — list (history, capped at 2000)
|
|
37
|
+
*/
|
|
38
|
+
export declare function metaStreamChannel(ns: string): string;
|
|
39
|
+
export declare function metaLogKey(ns: string): string;
|
|
33
40
|
/**
|
|
34
41
|
* Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
|
|
35
42
|
* namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
|
|
43
|
+
* Also streams each chunk to Redis: PUBLISH cca:meta:{ns}:stream and LPUSH cca:meta:{ns}:log.
|
|
36
44
|
* Returns a Promise that resolves when the process exits.
|
|
37
45
|
*/
|
|
38
46
|
export declare function spawnSession(ns: string, message: string, token: string, wire: Wire): Promise<void>;
|
|
@@ -42,7 +50,7 @@ export interface MetaAgentManager {
|
|
|
42
50
|
startPolling: (wire: Wire, getNamespaces: () => Array<{
|
|
43
51
|
namespace: string;
|
|
44
52
|
repoUrl: string;
|
|
45
|
-
}
|
|
53
|
+
}>, instanceId?: string) => void;
|
|
46
54
|
stop: () => void;
|
|
47
55
|
}
|
|
48
56
|
/**
|
|
@@ -64,7 +64,7 @@ export function injectMcp(ns, wsPath, token) {
|
|
|
64
64
|
return;
|
|
65
65
|
}
|
|
66
66
|
const npmCache = process.env.npm_config_cache ?? `${homedir()}/.npm`;
|
|
67
|
-
const trustedOwners = process.env.CC_AGENT_TRUSTED_OWNERS ?? "";
|
|
67
|
+
const trustedOwners = process.env.CC_AGENT_TRUSTED_OWNERS ?? "gonzih,ecoclaw,simorgh-app";
|
|
68
68
|
const systemPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
69
69
|
const config = {
|
|
70
70
|
mcpServers: {
|
|
@@ -112,9 +112,21 @@ function resolveClaude() {
|
|
|
112
112
|
}
|
|
113
113
|
return "claude";
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Redis keys for meta-agent stdout streaming.
|
|
117
|
+
* cca:meta:{ns}:stream — pub/sub channel (live streaming)
|
|
118
|
+
* cca:meta:{ns}:log — list (history, capped at 2000)
|
|
119
|
+
*/
|
|
120
|
+
export function metaStreamChannel(ns) {
|
|
121
|
+
return `cca:meta:${ns}:stream`;
|
|
122
|
+
}
|
|
123
|
+
export function metaLogKey(ns) {
|
|
124
|
+
return `cca:meta:${ns}:log`;
|
|
125
|
+
}
|
|
115
126
|
/**
|
|
116
127
|
* Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
|
|
117
128
|
* namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
|
|
129
|
+
* Also streams each chunk to Redis: PUBLISH cca:meta:{ns}:stream and LPUSH cca:meta:{ns}:log.
|
|
118
130
|
* Returns a Promise that resolves when the process exits.
|
|
119
131
|
*/
|
|
120
132
|
export function spawnSession(ns, message, token, wire) {
|
|
@@ -134,6 +146,7 @@ export function spawnSession(ns, message, token, wire) {
|
|
|
134
146
|
"--continue",
|
|
135
147
|
"-p", message,
|
|
136
148
|
"--output-format", "text",
|
|
149
|
+
"--verbose",
|
|
137
150
|
"--dangerously-skip-permissions",
|
|
138
151
|
], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
139
152
|
let lineBuffer = "";
|
|
@@ -153,8 +166,27 @@ export function spawnSession(ns, message, token, wire) {
|
|
|
153
166
|
console.warn(`[meta-agent-manager] publishOutgoing failed (ns=${ns}):`, err.message);
|
|
154
167
|
});
|
|
155
168
|
};
|
|
169
|
+
const rawRedis = wire._redis;
|
|
170
|
+
const streamToRedis = (chunk) => {
|
|
171
|
+
const entry = JSON.stringify({ ts: Date.now(), ns, text: chunk });
|
|
172
|
+
const streamCh = metaStreamChannel(ns);
|
|
173
|
+
const logKey = metaLogKey(ns);
|
|
174
|
+
// Publish for live consumers (fire-and-forget)
|
|
175
|
+
rawRedis.publish(streamCh, entry).catch((err) => {
|
|
176
|
+
console.warn(`[meta-agent-manager] stream publish failed (ns=${ns}):`, err.message);
|
|
177
|
+
});
|
|
178
|
+
// Persist to log list, cap at 2000
|
|
179
|
+
rawRedis.lpush(logKey, entry).then(() => {
|
|
180
|
+
rawRedis.ltrim(logKey, 0, 1999).catch(() => { });
|
|
181
|
+
}).catch((err) => {
|
|
182
|
+
console.warn(`[meta-agent-manager] log lpush failed (ns=${ns}):`, err.message);
|
|
183
|
+
});
|
|
184
|
+
};
|
|
156
185
|
proc.stdout.on("data", (chunk) => {
|
|
157
|
-
|
|
186
|
+
const chunkStr = chunk.toString();
|
|
187
|
+
// Stream raw chunk to Redis (before line-splitting)
|
|
188
|
+
streamToRedis(chunkStr);
|
|
189
|
+
lineBuffer += chunkStr;
|
|
158
190
|
const lines = lineBuffer.split("\n");
|
|
159
191
|
lineBuffer = lines.pop() ?? "";
|
|
160
192
|
for (const line of lines) {
|
|
@@ -194,9 +226,10 @@ export function createMetaAgentManager() {
|
|
|
194
226
|
const wsPath = workspacePath(ns);
|
|
195
227
|
injectMcp(ns, wsPath, token);
|
|
196
228
|
},
|
|
197
|
-
startPolling(wire, getNamespaces) {
|
|
229
|
+
startPolling(wire, getNamespaces, instanceId) {
|
|
198
230
|
if (pollInterval)
|
|
199
231
|
return; // already running
|
|
232
|
+
const INSTANCE_KEY = "cca:discord:instance";
|
|
200
233
|
pollInterval = setInterval(() => {
|
|
201
234
|
const namespaces = getNamespaces();
|
|
202
235
|
if (namespaces.length === 0)
|
|
@@ -208,6 +241,19 @@ export function createMetaAgentManager() {
|
|
|
208
241
|
.then(async (msg) => {
|
|
209
242
|
if (!msg)
|
|
210
243
|
return;
|
|
244
|
+
// Staleness check: if a newer instance has registered, this process is stale — exit.
|
|
245
|
+
if (instanceId) {
|
|
246
|
+
try {
|
|
247
|
+
const current = await wire._redis.get(INSTANCE_KEY);
|
|
248
|
+
if (current && current !== instanceId) {
|
|
249
|
+
console.log(`[meta-agent-manager] stale instance detected (current=${current}, ours=${instanceId}) — exiting`);
|
|
250
|
+
process.exit(0);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Redis error — proceed anyway
|
|
255
|
+
}
|
|
256
|
+
}
|
|
211
257
|
const content = typeof msg === "string" ? msg : msg.content ?? String(msg);
|
|
212
258
|
activeNamespaces.add(ns);
|
|
213
259
|
await wire.discord.setStatus(ns, {
|
package/dist/notifier.js
CHANGED
|
@@ -10,9 +10,53 @@
|
|
|
10
10
|
* cca:discord:chat:log:{ns} — LPUSH + LTRIM 0 499 (last 500 messages)
|
|
11
11
|
* cca:discord:chat:outgoing:{ns} — PUBLISH for web UI to consume
|
|
12
12
|
*/
|
|
13
|
+
import { createHash } from "crypto";
|
|
13
14
|
import { discordChatLog, discordChatOutgoing, discordNotify, notifyChannel, chatIncomingChannel, createCcWire, TIMING, } from "@gonzih/cc-wire";
|
|
14
15
|
import { splitLongMessage, stripAnsi } from "./formatter.js";
|
|
15
16
|
import { parseEvalReport } from "./loop-manager.js";
|
|
17
|
+
/** Redis key for per-namespace notification dedup set */
|
|
18
|
+
function dedupKey(ns) {
|
|
19
|
+
return `cca:discord:sent:${ns}`;
|
|
20
|
+
}
|
|
21
|
+
/** Compute a short stable dedup fingerprint for a raw notification string */
|
|
22
|
+
function notifFingerprint(raw) {
|
|
23
|
+
return createHash("sha256").update(raw.slice(0, 500)).digest("hex").slice(0, 16);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check whether this notification has already been forwarded for `ns` (Redis-backed dedup).
|
|
27
|
+
* If not, records it (SADD + EXPIRE 120s) and returns false (not a dup).
|
|
28
|
+
* Returns true if it's a duplicate and should be skipped.
|
|
29
|
+
*/
|
|
30
|
+
async function checkAndMarkSent(redis, ns, raw) {
|
|
31
|
+
const key = dedupKey(ns);
|
|
32
|
+
const fp = notifFingerprint(raw);
|
|
33
|
+
// SADD returns 1 if the element was added (new), 0 if already existed (dup)
|
|
34
|
+
const added = await redis.sadd(key, fp);
|
|
35
|
+
if (added === 0)
|
|
36
|
+
return true; // duplicate
|
|
37
|
+
// Set/refresh the TTL on the set key
|
|
38
|
+
await redis.expire(key, 120);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* In-memory dedup cache for pub/sub notifications (avoids async overhead on hot path).
|
|
43
|
+
* Maps fingerprint → expiry timestamp. Entries expire after 120 seconds.
|
|
44
|
+
*/
|
|
45
|
+
const inMemoryDedupCache = new Map();
|
|
46
|
+
const IN_MEMORY_DEDUP_TTL_MS = 120_000;
|
|
47
|
+
function checkAndMarkSentSync(ns, raw) {
|
|
48
|
+
const fp = `${ns}:${notifFingerprint(raw)}`;
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
// Evict expired entries (keep cache small)
|
|
51
|
+
for (const [k, exp] of inMemoryDedupCache) {
|
|
52
|
+
if (now > exp)
|
|
53
|
+
inMemoryDedupCache.delete(k);
|
|
54
|
+
}
|
|
55
|
+
if (inMemoryDedupCache.has(fp))
|
|
56
|
+
return true; // duplicate
|
|
57
|
+
inMemoryDedupCache.set(fp, now + IN_MEMORY_DEDUP_TTL_MS);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
16
60
|
function log(level, ...args) {
|
|
17
61
|
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
18
62
|
fn("[notifier]", ...args);
|
|
@@ -292,6 +336,25 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
292
336
|
const notification = parseNotification(raw);
|
|
293
337
|
if (notification === null)
|
|
294
338
|
continue; // routing excludes discord
|
|
339
|
+
// Dedup: skip if this notification was already forwarded recently
|
|
340
|
+
// Uses Redis when available; falls back to in-memory dedup on Redis errors.
|
|
341
|
+
let isDup = false;
|
|
342
|
+
try {
|
|
343
|
+
if (typeof redis.sadd === "function") {
|
|
344
|
+
isDup = await checkAndMarkSent(redis, ns, raw);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
isDup = checkAndMarkSentSync(ns, raw);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
log("warn", `dedup check failed (ns=${ns}), falling back to in-memory:`, err.message);
|
|
352
|
+
isDup = checkAndMarkSentSync(ns, raw);
|
|
353
|
+
}
|
|
354
|
+
if (isDup) {
|
|
355
|
+
log("info", `dedup: skipping already-sent notification (ns=${ns})`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
295
358
|
// Primary namespace: honour chatId-based per-channel routing via reverseSnowflakeLookup,
|
|
296
359
|
// then namespace → channelId lookup, then notifyChannelId / active channel.
|
|
297
360
|
// Routed namespaces: always deliver to the registered Discord channelId — no leakage.
|
|
@@ -348,6 +411,11 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
348
411
|
const notification = parseNotification(message);
|
|
349
412
|
if (notification === null)
|
|
350
413
|
return; // routing excludes discord
|
|
414
|
+
// Synchronous in-memory dedup — keeps pub/sub handler synchronous
|
|
415
|
+
if (checkAndMarkSentSync(ns, message)) {
|
|
416
|
+
log("info", `dedup: skipping already-sent pub/sub notification (ns=${ns})`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
351
419
|
let mainChannelId;
|
|
352
420
|
if (isPrimary) {
|
|
353
421
|
mainChannelId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup, ns, getChannelIdForNamespace);
|