@hoverlover/cc-discord 0.2.4 → 0.3.0
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/README.md +156 -4
- package/package.json +1 -3
- package/scripts/channel-agent.sh +1 -1
- package/scripts/generate-settings.sh +1 -1
- package/scripts/start-orchestrator.sh +1 -1
- package/scripts/start.sh +9 -5
- package/server/catchup.ts +54 -0
- package/server/config.ts +15 -0
- package/server/index.ts +8 -13
- package/server/messages.ts +4 -2
- package/scripts/migrate-memory-to-channel-keys.ts +0 -149
package/README.md
CHANGED
|
@@ -37,7 +37,6 @@ Edit those files with your credentials, then run again. Override the config loca
|
|
|
37
37
|
git clone https://github.com/hoverlover/cc-discord.git
|
|
38
38
|
cd cc-discord
|
|
39
39
|
bun install
|
|
40
|
-
bun run generate-settings
|
|
41
40
|
bun start
|
|
42
41
|
```
|
|
43
42
|
|
|
@@ -120,6 +119,7 @@ Project-local env files take precedence over `~/.config/cc-discord/`, so cloned-
|
|
|
120
119
|
| `MAX_ATTACHMENT_INLINE_BYTES` | `100000` | Max bytes for inline attachment content |
|
|
121
120
|
| `MAX_ATTACHMENT_DOWNLOAD_BYTES` | `10000000` | Max bytes for downloaded attachments |
|
|
122
121
|
| `ATTACHMENT_TTL_MS` | `3600000` | TTL for downloaded attachment files (ms) |
|
|
122
|
+
| `CATCHUP_MESSAGE_LIMIT` | `100` | Messages to fetch per channel on startup for catch-up (`0` to disable) |
|
|
123
123
|
|
|
124
124
|
### `.env.worker` — required
|
|
125
125
|
|
|
@@ -262,6 +262,160 @@ In interactive mode, the orchestrator runs as a Claude Code session with a visib
|
|
|
262
262
|
|
|
263
263
|
---
|
|
264
264
|
|
|
265
|
+
## Running as a daemon
|
|
266
|
+
|
|
267
|
+
To keep cc-discord running across reboots and terminal closures, install it as a system service.
|
|
268
|
+
|
|
269
|
+
### macOS (launchd)
|
|
270
|
+
|
|
271
|
+
1. Find the full paths to `bunx` and `claude`:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
which bunx # e.g. /Users/you/.bun/bin/bunx
|
|
275
|
+
which claude # e.g. /Users/you/.local/bin/claude
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
2. Create the plist file (replace all `/Users/you/...` paths with your actual paths from step 1):
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
cat > ~/Library/LaunchAgents/com.cc-discord.plist << 'EOF'
|
|
282
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
283
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
284
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
285
|
+
<plist version="1.0">
|
|
286
|
+
<dict>
|
|
287
|
+
<key>Label</key>
|
|
288
|
+
<string>com.cc-discord</string>
|
|
289
|
+
|
|
290
|
+
<key>ProgramArguments</key>
|
|
291
|
+
<array>
|
|
292
|
+
<!-- Replace with the output of: which bunx -->
|
|
293
|
+
<string>/Users/you/.bun/bin/bunx</string>
|
|
294
|
+
<string>@hoverlover/cc-discord</string>
|
|
295
|
+
</array>
|
|
296
|
+
|
|
297
|
+
<key>EnvironmentVariables</key>
|
|
298
|
+
<dict>
|
|
299
|
+
<!-- Must include directories for both bunx and claude -->
|
|
300
|
+
<key>PATH</key>
|
|
301
|
+
<string>/Users/you/.bun/bin:/Users/you/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
302
|
+
</dict>
|
|
303
|
+
|
|
304
|
+
<key>RunAtLoad</key>
|
|
305
|
+
<true/>
|
|
306
|
+
|
|
307
|
+
<key>KeepAlive</key>
|
|
308
|
+
<true/>
|
|
309
|
+
|
|
310
|
+
<key>StandardOutPath</key>
|
|
311
|
+
<string>/tmp/cc-discord/launchd-stdout.log</string>
|
|
312
|
+
|
|
313
|
+
<key>StandardErrorPath</key>
|
|
314
|
+
<string>/tmp/cc-discord/launchd-stderr.log</string>
|
|
315
|
+
</dict>
|
|
316
|
+
</plist>
|
|
317
|
+
EOF
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
3. **Important:** launchd does not source your shell profile, so the `PATH` must explicitly include the directories for both `bunx` and `claude`. Verify the paths match your system.
|
|
321
|
+
|
|
322
|
+
4. Load the service:
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
mkdir -p /tmp/cc-discord
|
|
326
|
+
launchctl load ~/Library/LaunchAgents/com.cc-discord.plist
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
5. Manage the service:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
# Check status
|
|
333
|
+
launchctl list | grep cc-discord
|
|
334
|
+
|
|
335
|
+
# Stop
|
|
336
|
+
launchctl stop com.cc-discord
|
|
337
|
+
|
|
338
|
+
# Start
|
|
339
|
+
launchctl start com.cc-discord
|
|
340
|
+
|
|
341
|
+
# Uninstall
|
|
342
|
+
launchctl unload ~/Library/LaunchAgents/com.cc-discord.plist
|
|
343
|
+
rm ~/Library/LaunchAgents/com.cc-discord.plist
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Linux (systemd)
|
|
347
|
+
|
|
348
|
+
1. Find the full paths to `bunx` and `claude`:
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
which bunx # e.g. /home/you/.bun/bin/bunx
|
|
352
|
+
which claude # e.g. /home/you/.local/bin/claude
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
2. Create the service file:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
sudo cat > /etc/systemd/system/cc-discord.service << 'EOF'
|
|
359
|
+
[Unit]
|
|
360
|
+
Description=cc-discord — Discord <-> Claude Code relay
|
|
361
|
+
After=network-online.target
|
|
362
|
+
Wants=network-online.target
|
|
363
|
+
|
|
364
|
+
[Service]
|
|
365
|
+
Type=simple
|
|
366
|
+
# Replace "you" with your username
|
|
367
|
+
User=you
|
|
368
|
+
# Replace with the output of: which bunx
|
|
369
|
+
ExecStart=/home/you/.bun/bin/bunx @hoverlover/cc-discord
|
|
370
|
+
# Ensure bun and claude are on PATH
|
|
371
|
+
Environment=PATH=/home/you/.bun/bin:/home/you/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
372
|
+
Environment=HOME=/home/you
|
|
373
|
+
Restart=on-failure
|
|
374
|
+
RestartSec=10
|
|
375
|
+
|
|
376
|
+
[Install]
|
|
377
|
+
WantedBy=multi-user.target
|
|
378
|
+
EOF
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
3. Replace `you` with your actual username and update the paths to match your system.
|
|
382
|
+
|
|
383
|
+
4. Enable and start the service:
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
sudo systemctl daemon-reload
|
|
387
|
+
sudo systemctl enable cc-discord
|
|
388
|
+
sudo systemctl start cc-discord
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
5. Manage the service:
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
# Check status
|
|
395
|
+
systemctl status cc-discord
|
|
396
|
+
|
|
397
|
+
# View logs
|
|
398
|
+
journalctl -u cc-discord -f
|
|
399
|
+
|
|
400
|
+
# Stop
|
|
401
|
+
sudo systemctl stop cc-discord
|
|
402
|
+
|
|
403
|
+
# Restart
|
|
404
|
+
sudo systemctl restart cc-discord
|
|
405
|
+
|
|
406
|
+
# Disable (won't start on boot)
|
|
407
|
+
sudo systemctl disable cc-discord
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Notes
|
|
411
|
+
|
|
412
|
+
- Both approaches run `bunx @hoverlover/cc-discord`, which is the same as `bun start` in the cloned repo.
|
|
413
|
+
- Environment files are read from `~/.config/cc-discord/` — make sure those are configured before starting the service.
|
|
414
|
+
- Application logs still go to `CC_DISCORD_LOG_DIR` (default `/tmp/cc-discord/logs`). The launchd/systemd logs are separate and capture startup errors.
|
|
415
|
+
- Claude CLI must be authenticated (`claude auth login`) as the user the service runs under before starting.
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
265
419
|
## Development
|
|
266
420
|
|
|
267
421
|
### Scripts
|
|
@@ -273,10 +427,8 @@ In interactive mode, the orchestrator runs as a Claude Code session with a visib
|
|
|
273
427
|
| `bun run start:orchestrator` | Start headless orchestrator only |
|
|
274
428
|
| `bun run start:orchestrator-interactive` | Start interactive orchestrator (terminal UI) |
|
|
275
429
|
| `bun run dev` | Alias for `start:relay` |
|
|
276
|
-
| `bun run generate-settings` | Generate `.claude/settings.json` with absolute hook paths |
|
|
277
430
|
| `bun run memory:smoke` | Run memory system smoke test |
|
|
278
431
|
| `bun run memory:inspect` | Inspect memory database contents |
|
|
279
|
-
| `bun run memory:migrate` | Migrate memory to channel-scoped keys |
|
|
280
432
|
| `bun run lint` | Run Biome linter |
|
|
281
433
|
| `bun run lint:fix` | Run Biome linter with auto-fix |
|
|
282
434
|
| `bun run format` | Format code with Biome |
|
|
@@ -284,7 +436,7 @@ In interactive mode, the orchestrator runs as a Claude Code session with a visib
|
|
|
284
436
|
|
|
285
437
|
### Hook system
|
|
286
438
|
|
|
287
|
-
Claude Code hooks are configured in `.claude/settings.json
|
|
439
|
+
Claude Code hooks are configured in `.claude/settings.local.json`, generated automatically from `.claude/settings.template.json` when the relay starts. This file is gitignored and only exists while the relay is running — `start.sh` creates it on startup and removes it on shutdown so hooks don't interfere with normal development. The template uses `__ORCHESTRATOR_DIR__` placeholders that are replaced with absolute paths at generation time.
|
|
288
440
|
|
|
289
441
|
| Hook | Event | Description |
|
|
290
442
|
|---|---|---|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoverlover/cc-discord",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Discord <-> Claude Code relay: use your Claude subscription to power per-channel AI bots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,10 +25,8 @@
|
|
|
25
25
|
"start:orchestrator": "bash scripts/orchestrator.sh",
|
|
26
26
|
"start:orchestrator-interactive": "bash scripts/start-orchestrator.sh",
|
|
27
27
|
"dev": "bash scripts/start-relay.sh",
|
|
28
|
-
"generate-settings": "bash scripts/generate-settings.sh",
|
|
29
28
|
"memory:smoke": "bun tools/memory-smoke.ts",
|
|
30
29
|
"memory:inspect": "bun tools/memory-inspect.ts",
|
|
31
|
-
"memory:migrate": "bun scripts/migrate-memory-to-channel-keys.ts",
|
|
32
30
|
"lint": "bunx biome check .",
|
|
33
31
|
"lint:fix": "bunx biome check --write .",
|
|
34
32
|
"format": "bunx biome format --write .",
|
package/scripts/channel-agent.sh
CHANGED
|
@@ -47,7 +47,7 @@ load_env_keys "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env" "${WORKE
|
|
|
47
47
|
# Ensure bun is on PATH for hooks/tools
|
|
48
48
|
export PATH="$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$ROOT_DIR/tools:$PATH"
|
|
49
49
|
|
|
50
|
-
SETTINGS_PATH="$ROOT_DIR/.claude/settings.json"
|
|
50
|
+
SETTINGS_PATH="$ROOT_DIR/.claude/settings.local.json"
|
|
51
51
|
PROMPT_TEMPLATE="$ROOT_DIR/prompts/channel-system.md"
|
|
52
52
|
|
|
53
53
|
if ! command -v claude >/dev/null 2>&1; then
|
|
@@ -11,7 +11,7 @@ SCRIPT_DIR="$(cd "$(dirname "$_SCRIPT")" && pwd)"
|
|
|
11
11
|
ORCHESTRATOR_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
12
12
|
|
|
13
13
|
TEMPLATE="$ORCHESTRATOR_DIR/.claude/settings.template.json"
|
|
14
|
-
OUTPUT="$ORCHESTRATOR_DIR/.claude/settings.json"
|
|
14
|
+
OUTPUT="$ORCHESTRATOR_DIR/.claude/settings.local.json"
|
|
15
15
|
|
|
16
16
|
if [ ! -f "$TEMPLATE" ]; then
|
|
17
17
|
echo "Template not found: $TEMPLATE"
|
|
@@ -34,7 +34,7 @@ load_env_keys "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env.worker" "
|
|
|
34
34
|
load_env_keys "$ROOT_DIR/.env" "${WORKER_KEYS[@]}"
|
|
35
35
|
load_env_keys "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env" "${WORKER_KEYS[@]}"
|
|
36
36
|
|
|
37
|
-
SETTINGS_PATH="$ROOT_DIR/.claude/settings.json"
|
|
37
|
+
SETTINGS_PATH="$ROOT_DIR/.claude/settings.local.json"
|
|
38
38
|
SYSTEM_PROMPT_PATH="$ROOT_DIR/prompts/orchestrator-system.md"
|
|
39
39
|
|
|
40
40
|
if ! command -v claude >/dev/null 2>&1; then
|
package/scripts/start.sh
CHANGED
|
@@ -85,11 +85,11 @@ if [ -d "$ROOT_DIR/.claude/skills" ] && [ "$ROOT_DIR" != "$CC_DISCORD_HOME" ]; t
|
|
|
85
85
|
done
|
|
86
86
|
fi
|
|
87
87
|
|
|
88
|
-
#
|
|
89
|
-
if [
|
|
88
|
+
# Generate settings.local.json (hooks + relay permissions) so Claude agents see project config.
|
|
89
|
+
if [ -f "$ROOT_DIR/.claude/settings.template.json" ]; then
|
|
90
90
|
bash "$ROOT_DIR/scripts/generate-settings.sh"
|
|
91
|
-
if [ -f "$ROOT_DIR/.claude/settings.json" ]; then
|
|
92
|
-
cp "$ROOT_DIR/.claude/settings.json" "$CC_DISCORD_HOME/.claude/settings.json"
|
|
91
|
+
if [ "$ROOT_DIR" != "$CC_DISCORD_HOME" ] && [ -f "$ROOT_DIR/.claude/settings.local.json" ]; then
|
|
92
|
+
cp "$ROOT_DIR/.claude/settings.local.json" "$CC_DISCORD_HOME/.claude/settings.local.json"
|
|
93
93
|
fi
|
|
94
94
|
fi
|
|
95
95
|
|
|
@@ -120,6 +120,10 @@ cleanup() {
|
|
|
120
120
|
wait "$RELAY_PID" 2>/dev/null || true
|
|
121
121
|
fi
|
|
122
122
|
|
|
123
|
+
# Remove generated settings.local.json so hooks don't fire during normal development
|
|
124
|
+
rm -f "$ROOT_DIR/.claude/settings.local.json"
|
|
125
|
+
rm -f "$CC_DISCORD_HOME/.claude/settings.local.json"
|
|
126
|
+
|
|
123
127
|
log "All processes stopped."
|
|
124
128
|
exit 0
|
|
125
129
|
}
|
|
@@ -145,7 +149,7 @@ RELAY_PORT="${RELAY_PORT:-3199}"
|
|
|
145
149
|
RELAY_API_TOKEN="${RELAY_API_TOKEN:-}"
|
|
146
150
|
|
|
147
151
|
# Ensure bun/curl/claude are findable
|
|
148
|
-
export PATH="$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
152
|
+
export PATH="$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
149
153
|
|
|
150
154
|
# ---- Pre-flight: Claude auth check ----
|
|
151
155
|
if ! claude auth status >/dev/null 2>&1; then
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catch-up: fetch recent messages from allowed channels on startup
|
|
3
|
+
* to fill in any messages missed while the relay was offline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Client } from "discord.js";
|
|
7
|
+
import { CATCHUP_MESSAGE_LIMIT, IGNORED_CHANNEL_IDS, ALLOWED_CHANNEL_IDS, isAllowedUser } from "./config.ts";
|
|
8
|
+
import { persistInboundDiscordMessage, persistOutboundDiscordMessage } from "./messages.ts";
|
|
9
|
+
import { startTypingIndicator } from "./typing.ts";
|
|
10
|
+
|
|
11
|
+
export async function catchUpMissedMessages(client: Client) {
|
|
12
|
+
if (CATCHUP_MESSAGE_LIMIT <= 0) {
|
|
13
|
+
console.log("[Catchup] Disabled (CATCHUP_MESSAGE_LIMIT=0)");
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let channelsScanned = 0;
|
|
18
|
+
let messagesCaughtUp = 0;
|
|
19
|
+
|
|
20
|
+
for (const [, guild] of client.guilds.cache) {
|
|
21
|
+
const guildChannels = await guild.channels.fetch();
|
|
22
|
+
for (const [, channel] of guildChannels) {
|
|
23
|
+
if (!channel || !channel.isTextBased() || channel.isThread() || channel.isVoiceBased()) continue;
|
|
24
|
+
if (IGNORED_CHANNEL_IDS.has(channel.id)) continue;
|
|
25
|
+
if (ALLOWED_CHANNEL_IDS.length > 0 && !ALLOWED_CHANNEL_IDS.includes(channel.id)) continue;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const messages = await channel.messages.fetch({ limit: CATCHUP_MESSAGE_LIMIT });
|
|
29
|
+
channelsScanned++;
|
|
30
|
+
|
|
31
|
+
// Sort oldest-first so they're persisted in chronological order
|
|
32
|
+
const sorted = [...messages.values()]
|
|
33
|
+
.filter((m) => !m.author.bot && isAllowedUser(m.author.id))
|
|
34
|
+
.sort((a, b) => a.createdTimestamp - b.createdTimestamp);
|
|
35
|
+
|
|
36
|
+
let channelHasNew = false;
|
|
37
|
+
for (const msg of sorted) {
|
|
38
|
+
const isNew = await persistInboundDiscordMessage(msg);
|
|
39
|
+
if (isNew) {
|
|
40
|
+
messagesCaughtUp++;
|
|
41
|
+
channelHasNew = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (channelHasNew) {
|
|
45
|
+
startTypingIndicator(client, channel.id, persistOutboundDiscordMessage);
|
|
46
|
+
}
|
|
47
|
+
} catch (err: unknown) {
|
|
48
|
+
console.error(`[Catchup] Failed to fetch messages for #${channel.name} (${channel.id}):`, (err as Error).message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(`[Catchup] Done — scanned ${channelsScanned} channel(s), caught up ${messagesCaughtUp} message(s)`);
|
|
54
|
+
}
|
package/server/config.ts
CHANGED
|
@@ -83,8 +83,23 @@ export const MAX_ATTACHMENT_INLINE_BYTES = Number(process.env.MAX_ATTACHMENT_INL
|
|
|
83
83
|
export const MAX_ATTACHMENT_DOWNLOAD_BYTES = Number(process.env.MAX_ATTACHMENT_DOWNLOAD_BYTES || 10_000_000);
|
|
84
84
|
export const ATTACHMENT_TTL_MS = Number(process.env.ATTACHMENT_TTL_MS || 3_600_000);
|
|
85
85
|
|
|
86
|
+
export const CATCHUP_MESSAGE_LIMIT = Number(process.env.CATCHUP_MESSAGE_LIMIT ?? 100);
|
|
87
|
+
|
|
86
88
|
export const ATTACHMENT_DIR = join("/tmp", "cc-discord", "attachments");
|
|
87
89
|
|
|
90
|
+
export function isAllowedChannel(channelId: string): boolean {
|
|
91
|
+
if (!channelId) return false;
|
|
92
|
+
if (IGNORED_CHANNEL_IDS.has(channelId)) return false;
|
|
93
|
+
if (ALLOWED_CHANNEL_IDS.length > 0) return ALLOWED_CHANNEL_IDS.includes(channelId);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isAllowedUser(userId: string | undefined): boolean {
|
|
98
|
+
if (!userId) return false;
|
|
99
|
+
if (ALLOWED_DISCORD_USER_IDS.length === 0) return true;
|
|
100
|
+
return ALLOWED_DISCORD_USER_IDS.includes(userId);
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
function loadDotEnv(path: string) {
|
|
89
104
|
if (!existsSync(path)) return;
|
|
90
105
|
const raw = readFileSync(path, "utf8");
|
package/server/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from "di
|
|
|
4
4
|
import express, { type NextFunction, type Request, type Response } from "express";
|
|
5
5
|
import { cleanupOldAttachments } from "./attachment.ts";
|
|
6
6
|
import { maybeNotifyBusyQueued } from "./busy-notify.ts";
|
|
7
|
+
import { catchUpMissedMessages } from "./catchup.ts";
|
|
7
8
|
import {
|
|
8
9
|
ALLOWED_CHANNEL_IDS,
|
|
9
10
|
ALLOWED_DISCORD_USER_IDS,
|
|
@@ -13,6 +14,8 @@ import {
|
|
|
13
14
|
DISCORD_BOT_TOKEN,
|
|
14
15
|
DISCORD_SESSION_ID,
|
|
15
16
|
IGNORED_CHANNEL_IDS,
|
|
17
|
+
isAllowedChannel,
|
|
18
|
+
isAllowedUser,
|
|
16
19
|
MESSAGE_ROUTING_MODE,
|
|
17
20
|
RELAY_ALLOW_NO_AUTH,
|
|
18
21
|
RELAY_API_TOKEN,
|
|
@@ -39,19 +42,6 @@ const client = new Client({
|
|
|
39
42
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
|
|
40
43
|
});
|
|
41
44
|
|
|
42
|
-
function isAllowedChannel(channelId: string): boolean {
|
|
43
|
-
if (!channelId) return false;
|
|
44
|
-
if (IGNORED_CHANNEL_IDS.has(channelId)) return false;
|
|
45
|
-
if (ALLOWED_CHANNEL_IDS.length > 0) return ALLOWED_CHANNEL_IDS.includes(channelId);
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function isAllowedUser(userId: string | undefined): boolean {
|
|
50
|
-
if (!userId) return false;
|
|
51
|
-
if (ALLOWED_DISCORD_USER_IDS.length === 0) return true;
|
|
52
|
-
return ALLOWED_DISCORD_USER_IDS.includes(userId);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
45
|
function requireAuth(req: Request, res: Response): boolean {
|
|
56
46
|
if (RELAY_ALLOW_NO_AUTH) return true;
|
|
57
47
|
const token = req.header("x-api-token") || req.header("authorization")?.replace(/^Bearer\s+/i, "");
|
|
@@ -104,6 +94,11 @@ client.once("clientReady", async () => {
|
|
|
104
94
|
|
|
105
95
|
// Start live trace thread flush loop
|
|
106
96
|
startTraceFlushLoop(client);
|
|
97
|
+
|
|
98
|
+
// Catch up messages missed while offline
|
|
99
|
+
catchUpMissedMessages(client).catch((err) => {
|
|
100
|
+
console.error("[Relay] Catch-up failed:", (err as Error).message);
|
|
101
|
+
});
|
|
107
102
|
});
|
|
108
103
|
|
|
109
104
|
client.on("messageCreate", async (message) => {
|
package/server/messages.ts
CHANGED
|
@@ -26,7 +26,7 @@ export async function formatInboundMessage(message: any) {
|
|
|
26
26
|
return `${author}: ${fullText}`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export async function persistInboundDiscordMessage(message: any) {
|
|
29
|
+
export async function persistInboundDiscordMessage(message: any): Promise<boolean> {
|
|
30
30
|
const normalizedContent = await formatInboundMessage(message);
|
|
31
31
|
// In channel mode, route to channelId so per-channel subagents consume independently.
|
|
32
32
|
// In agent mode (legacy), route to CLAUDE_AGENT_ID for single-agent consumption.
|
|
@@ -57,13 +57,15 @@ export async function persistInboundDiscordMessage(message: any) {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
console.log(`[Relay] queued Discord message ${message.id} -> ${targetAgent}`);
|
|
60
|
+
return true;
|
|
60
61
|
} catch (err: unknown) {
|
|
61
62
|
const msg = String((err as any)?.message || "");
|
|
62
63
|
if (msg.includes("UNIQUE constraint failed")) {
|
|
63
64
|
// Discord can re-deliver in edge cases; idempotent ignore
|
|
64
|
-
return;
|
|
65
|
+
return false;
|
|
65
66
|
}
|
|
66
67
|
console.error("[Relay] failed to persist inbound message:", (err as Error).message);
|
|
68
|
+
return false;
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* One-time migration: re-key memory_turns from the legacy shared session key
|
|
4
|
-
* (discord:default:claude-discord) into per-channel session keys
|
|
5
|
-
* (discord:default:{channelId}).
|
|
6
|
-
*
|
|
7
|
-
* Each turn's channelId is read from its metadata_json. Turns without a
|
|
8
|
-
* channelId are left in the old key.
|
|
9
|
-
*
|
|
10
|
-
* Turn indices are renumbered per-channel to be sequential, starting after
|
|
11
|
-
* any existing turns already in that channel key.
|
|
12
|
-
*
|
|
13
|
-
* Usage:
|
|
14
|
-
* bun scripts/migrate-memory-to-channel-keys.ts [path/to/memory.db]
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { Database } from "bun:sqlite";
|
|
18
|
-
import { dirname, join } from "node:path";
|
|
19
|
-
import { fileURLToPath } from "node:url";
|
|
20
|
-
|
|
21
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
-
const defaultDataDir = process.env.CC_DISCORD_DATA_DIR || join(process.env.HOME || "", ".cc-discord", "data");
|
|
23
|
-
const dbPath = process.argv[2] || join(defaultDataDir, "memory.db");
|
|
24
|
-
|
|
25
|
-
console.log(`[migrate] Opening ${dbPath}`);
|
|
26
|
-
const db = new Database(dbPath);
|
|
27
|
-
|
|
28
|
-
const OLD_SESSION_KEY = "discord:default:claude-discord";
|
|
29
|
-
|
|
30
|
-
// Read all turns under the old key
|
|
31
|
-
const turns = db
|
|
32
|
-
.prepare(`
|
|
33
|
-
SELECT id, session_key, turn_index, role, content, metadata_json, created_at
|
|
34
|
-
FROM memory_turns
|
|
35
|
-
WHERE session_key = ?
|
|
36
|
-
ORDER BY turn_index ASC
|
|
37
|
-
`)
|
|
38
|
-
.all(OLD_SESSION_KEY) as any[];
|
|
39
|
-
|
|
40
|
-
console.log(`[migrate] Found ${turns.length} turns under ${OLD_SESSION_KEY}`);
|
|
41
|
-
|
|
42
|
-
if (turns.length === 0) {
|
|
43
|
-
console.log("[migrate] Nothing to migrate.");
|
|
44
|
-
db.close();
|
|
45
|
-
process.exit(0);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Group by channelId from metadata
|
|
49
|
-
const byChannel = new Map<string, typeof turns>();
|
|
50
|
-
const noChannel: typeof turns = [];
|
|
51
|
-
|
|
52
|
-
for (const turn of turns) {
|
|
53
|
-
let channelId: string | null = null;
|
|
54
|
-
try {
|
|
55
|
-
const meta = JSON.parse(String((turn as any).metadata_json || "{}"));
|
|
56
|
-
channelId = meta.channelId || null;
|
|
57
|
-
} catch {}
|
|
58
|
-
|
|
59
|
-
if (!channelId) {
|
|
60
|
-
noChannel.push(turn);
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (!byChannel.has(channelId)) byChannel.set(channelId, []);
|
|
65
|
-
byChannel.get(channelId)!.push(turn);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
console.log(`[migrate] Splitting into ${byChannel.size} channels:`);
|
|
69
|
-
for (const [ch, chTurns] of byChannel) {
|
|
70
|
-
console.log(` discord:default:${ch} -> ${chTurns.length} turns`);
|
|
71
|
-
}
|
|
72
|
-
if (noChannel.length > 0) {
|
|
73
|
-
console.log(` (${noChannel.length} turns have no channelId -- left in old key)`);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Find the current max turn_index for each target channel key
|
|
77
|
-
function getMaxTurnIndex(sessionKey: string): number {
|
|
78
|
-
const row = db
|
|
79
|
-
.prepare("SELECT MAX(turn_index) as max_idx FROM memory_turns WHERE session_key = ?")
|
|
80
|
-
.get(sessionKey) as any;
|
|
81
|
-
return row?.max_idx ?? 0;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Ensure per-channel session keys exist in memory_sessions
|
|
85
|
-
const upsertSession = db.prepare(`
|
|
86
|
-
INSERT OR IGNORE INTO memory_sessions (session_key, created_at)
|
|
87
|
-
VALUES (?, datetime('now'))
|
|
88
|
-
`);
|
|
89
|
-
|
|
90
|
-
// Update each turn's session_key and re-index
|
|
91
|
-
const updateTurn = db.prepare(`
|
|
92
|
-
UPDATE memory_turns SET session_key = ?, turn_index = ? WHERE id = ?
|
|
93
|
-
`);
|
|
94
|
-
|
|
95
|
-
// Copy runtime state for new keys
|
|
96
|
-
const readRuntimeState = db.prepare(`
|
|
97
|
-
SELECT * FROM memory_runtime_state WHERE session_key = ?
|
|
98
|
-
`);
|
|
99
|
-
const upsertRuntimeState = db.prepare(`
|
|
100
|
-
INSERT OR IGNORE INTO memory_runtime_state (session_key, runtime_context_id, runtime_epoch, updated_at)
|
|
101
|
-
VALUES (?, ?, ?, datetime('now'))
|
|
102
|
-
`);
|
|
103
|
-
|
|
104
|
-
db.exec("BEGIN TRANSACTION");
|
|
105
|
-
try {
|
|
106
|
-
const oldRuntime = readRuntimeState.get(OLD_SESSION_KEY) as any;
|
|
107
|
-
|
|
108
|
-
for (const [channelId, chTurns] of byChannel) {
|
|
109
|
-
const newKey = `discord:default:${channelId}`;
|
|
110
|
-
upsertSession.run(newKey);
|
|
111
|
-
|
|
112
|
-
// Start numbering after any existing turns in the target key
|
|
113
|
-
const startIndex = getMaxTurnIndex(newKey) + 1;
|
|
114
|
-
|
|
115
|
-
for (let i = 0; i < chTurns.length; i++) {
|
|
116
|
-
updateTurn.run(newKey, startIndex + i, chTurns[i].id);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Bootstrap runtime state for the new key if it doesn't exist
|
|
120
|
-
if (oldRuntime) {
|
|
121
|
-
upsertRuntimeState.run(newKey, `migrated_from_${OLD_SESSION_KEY}`, 1);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
console.log(
|
|
125
|
-
`[migrate] Updated ${chTurns.length} turns -> ${newKey} (indices ${startIndex}..${startIndex + chTurns.length - 1})`,
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
db.exec("COMMIT");
|
|
130
|
-
console.log("[migrate] Migration complete.");
|
|
131
|
-
} catch (err) {
|
|
132
|
-
db.exec("ROLLBACK");
|
|
133
|
-
console.error("[migrate] Migration failed, rolled back:", (err as Error).message || err);
|
|
134
|
-
process.exit(1);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Summary
|
|
138
|
-
for (const [channelId] of byChannel) {
|
|
139
|
-
const newKey = `discord:default:${channelId}`;
|
|
140
|
-
const count = (db.prepare("SELECT COUNT(*) as cnt FROM memory_turns WHERE session_key = ?").get(newKey) as any).cnt;
|
|
141
|
-
console.log(` ${newKey}: ${count} turns total`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const remaining = (
|
|
145
|
-
db.prepare("SELECT COUNT(*) as cnt FROM memory_turns WHERE session_key = ?").get(OLD_SESSION_KEY) as any
|
|
146
|
-
).cnt;
|
|
147
|
-
console.log(` ${OLD_SESSION_KEY}: ${remaining} turns remaining`);
|
|
148
|
-
|
|
149
|
-
db.close();
|