@hoverlover/cc-discord 0.2.5 → 0.3.1
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/LICENSE +21 -0
- package/README.md +21 -6
- package/package.json +1 -1
- package/scripts/start.sh +1 -1
- 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/server/trace-thread.ts +44 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Chad Boyd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
- One autonomous Claude Code agent per Discord channel
|
|
6
6
|
- Messages stored in SQLite, delivered to agents via hooks
|
|
7
7
|
- Replies sent back to Discord via `send-discord` tool
|
|
8
|
+
- Automatic catch-up of missed messages on startup — no messages lost during restarts
|
|
8
9
|
- Typing indicators, busy notifications, live trace threads, memory context, attachment support, and more
|
|
9
10
|
|
|
10
11
|
## Quick start
|
|
@@ -119,6 +120,7 @@ Project-local env files take precedence over `~/.config/cc-discord/`, so cloned-
|
|
|
119
120
|
| `MAX_ATTACHMENT_INLINE_BYTES` | `100000` | Max bytes for inline attachment content |
|
|
120
121
|
| `MAX_ATTACHMENT_DOWNLOAD_BYTES` | `10000000` | Max bytes for downloaded attachments |
|
|
121
122
|
| `ATTACHMENT_TTL_MS` | `3600000` | TTL for downloaded attachment files (ms) |
|
|
123
|
+
| `CATCHUP_MESSAGE_LIMIT` | `100` | Messages to fetch per channel on startup for catch-up (`0` to disable) |
|
|
122
124
|
|
|
123
125
|
### `.env.worker` — required
|
|
124
126
|
|
|
@@ -197,6 +199,10 @@ Security note: the worker process intentionally does not receive `DISCORD_BOT_TO
|
|
|
197
199
|
|
|
198
200
|
## Features
|
|
199
201
|
|
|
202
|
+
### Message catch-up
|
|
203
|
+
|
|
204
|
+
When the relay starts, it fetches recent message history from each allowed channel and persists any messages that arrived while it was offline. Duplicates are silently ignored. This ensures no messages are lost during restarts or outages. Controlled by `CATCHUP_MESSAGE_LIMIT` (default 100, set to `0` to disable).
|
|
205
|
+
|
|
200
206
|
### Typing indicators
|
|
201
207
|
|
|
202
208
|
When a user sends a message, the relay starts a typing indicator that repeats every `TYPING_INTERVAL_MS` (default 8s). It stops automatically when Claude replies. After `TYPING_MAX_MS` (default 120s), a configurable fallback patience message is sent.
|
|
@@ -265,16 +271,18 @@ In interactive mode, the orchestrator runs as a Claude Code session with a visib
|
|
|
265
271
|
|
|
266
272
|
To keep cc-discord running across reboots and terminal closures, install it as a system service.
|
|
267
273
|
|
|
274
|
+
> **Dedicated Mac server?** If you're setting up a Mac mini (or similar) as a headless, always-on server, see the [macOS Headless Server Setup Guide](docs/mac-setup-guide.md). It covers disabling SIP, configuring TCC permissions, FileVault tradeoffs, auto-login, and LaunchDaemon setup for fully unattended operation.
|
|
275
|
+
|
|
268
276
|
### macOS (launchd)
|
|
269
277
|
|
|
270
|
-
1. Find the full
|
|
278
|
+
1. Find the full paths to `bunx` and `claude`:
|
|
271
279
|
|
|
272
280
|
```bash
|
|
273
|
-
which bunx
|
|
274
|
-
# e.g. /Users/you/.
|
|
281
|
+
which bunx # e.g. /Users/you/.bun/bin/bunx
|
|
282
|
+
which claude # e.g. /Users/you/.local/bin/claude
|
|
275
283
|
```
|
|
276
284
|
|
|
277
|
-
2. Create the plist file:
|
|
285
|
+
2. Create the plist file (replace all `/Users/you/...` paths with your actual paths from step 1):
|
|
278
286
|
|
|
279
287
|
```bash
|
|
280
288
|
cat > ~/Library/LaunchAgents/com.cc-discord.plist << 'EOF'
|
|
@@ -295,8 +303,9 @@ cat > ~/Library/LaunchAgents/com.cc-discord.plist << 'EOF'
|
|
|
295
303
|
|
|
296
304
|
<key>EnvironmentVariables</key>
|
|
297
305
|
<dict>
|
|
306
|
+
<!-- Must include directories for both bunx and claude -->
|
|
298
307
|
<key>PATH</key>
|
|
299
|
-
<string>/Users/you/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
308
|
+
<string>/Users/you/.bun/bin:/Users/you/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
300
309
|
</dict>
|
|
301
310
|
|
|
302
311
|
<key>RunAtLoad</key>
|
|
@@ -315,7 +324,7 @@ cat > ~/Library/LaunchAgents/com.cc-discord.plist << 'EOF'
|
|
|
315
324
|
EOF
|
|
316
325
|
```
|
|
317
326
|
|
|
318
|
-
3.
|
|
327
|
+
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.
|
|
319
328
|
|
|
320
329
|
4. Load the service:
|
|
321
330
|
|
|
@@ -461,3 +470,9 @@ Monitor all logs:
|
|
|
461
470
|
```bash
|
|
462
471
|
tail -f /tmp/cc-discord/logs/*.log
|
|
463
472
|
```
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
## License
|
|
477
|
+
|
|
478
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
package/scripts/start.sh
CHANGED
|
@@ -149,7 +149,7 @@ RELAY_PORT="${RELAY_PORT:-3199}"
|
|
|
149
149
|
RELAY_API_TOKEN="${RELAY_API_TOKEN:-}"
|
|
150
150
|
|
|
151
151
|
# Ensure bun/curl/claude are findable
|
|
152
|
-
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"
|
|
153
153
|
|
|
154
154
|
# ---- Pre-flight: Claude auth check ----
|
|
155
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
|
|
package/server/trace-thread.ts
CHANGED
|
@@ -36,6 +36,15 @@ let flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
36
36
|
|
|
37
37
|
// ── Thread lifecycle ────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
+
/** Unarchive a thread if it's been auto-archived by Discord. */
|
|
40
|
+
async function ensureUnarchived(thread: ThreadChannel): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
if (thread.archived) await thread.setArchived(false);
|
|
43
|
+
} catch {
|
|
44
|
+
// best-effort — bot may lack MANAGE_THREADS
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
/** Lock a thread if not already locked (idempotent). */
|
|
40
49
|
async function ensureLocked(thread: ThreadChannel): Promise<void> {
|
|
41
50
|
try {
|
|
@@ -60,7 +69,8 @@ async function ensureTraceThread(client: Client, channelId: string): Promise<Thr
|
|
|
60
69
|
try {
|
|
61
70
|
const thread = await client.channels.fetch(cachedThreadId);
|
|
62
71
|
if (thread && thread.isThread()) {
|
|
63
|
-
//
|
|
72
|
+
// Unarchive if Discord auto-archived it, then ensure locked
|
|
73
|
+
await ensureUnarchived(thread as ThreadChannel);
|
|
64
74
|
await ensureLocked(thread as ThreadChannel);
|
|
65
75
|
return thread as ThreadChannel;
|
|
66
76
|
}
|
|
@@ -76,7 +86,8 @@ async function ensureTraceThread(client: Client, channelId: string): Promise<Thr
|
|
|
76
86
|
try {
|
|
77
87
|
const thread = await client.channels.fetch(dbThreadId);
|
|
78
88
|
if (thread && thread.isThread()) {
|
|
79
|
-
//
|
|
89
|
+
// Unarchive if Discord auto-archived it, then ensure locked
|
|
90
|
+
await ensureUnarchived(thread as ThreadChannel);
|
|
80
91
|
await ensureLocked(thread as ThreadChannel);
|
|
81
92
|
threadCache.set(channelId, dbThreadId);
|
|
82
93
|
return thread as ThreadChannel;
|
|
@@ -86,6 +97,37 @@ async function ensureTraceThread(client: Client, channelId: string): Promise<Thr
|
|
|
86
97
|
}
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
// Search existing threads by name before creating a new one (fallback for
|
|
101
|
+
// cases where the DB record was lost but the thread still exists in Discord)
|
|
102
|
+
try {
|
|
103
|
+
const channel = await client.channels.fetch(channelId);
|
|
104
|
+
if (channel && channel.type === ChannelType.GuildText) {
|
|
105
|
+
const textChannel = channel as TextChannel;
|
|
106
|
+
// Check active threads
|
|
107
|
+
const activeThreads = await textChannel.threads.fetchActive();
|
|
108
|
+
const existing = activeThreads.threads.find((t) => t.name === TRACE_THREAD_NAME);
|
|
109
|
+
if (existing) {
|
|
110
|
+
await ensureUnarchived(existing);
|
|
111
|
+
await ensureLocked(existing);
|
|
112
|
+
setTraceThreadId(channelId, existing.id);
|
|
113
|
+
threadCache.set(channelId, existing.id);
|
|
114
|
+
return existing;
|
|
115
|
+
}
|
|
116
|
+
// Check archived threads
|
|
117
|
+
const archivedThreads = await textChannel.threads.fetchArchived({ limit: 10 });
|
|
118
|
+
const existingArchived = archivedThreads.threads.find((t) => t.name === TRACE_THREAD_NAME);
|
|
119
|
+
if (existingArchived) {
|
|
120
|
+
await ensureUnarchived(existingArchived);
|
|
121
|
+
await ensureLocked(existingArchived);
|
|
122
|
+
setTraceThreadId(channelId, existingArchived.id);
|
|
123
|
+
threadCache.set(channelId, existingArchived.id);
|
|
124
|
+
return existingArchived;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// best effort — fall through to create
|
|
129
|
+
}
|
|
130
|
+
|
|
89
131
|
// Create new thread
|
|
90
132
|
try {
|
|
91
133
|
const channel = await client.channels.fetch(channelId);
|