@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 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 path to `bunx`:
278
+ 1. Find the full paths to `bunx` and `claude`:
271
279
 
272
280
  ```bash
273
- which bunx
274
- # e.g. /Users/you/.bun/bin/bunx
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. Replace `/Users/you/.bun/bin/bunx` and the `PATH` value with your actual paths.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoverlover/cc-discord",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "Discord <-> Claude Code relay: use your Claude subscription to power per-channel AI bots",
5
5
  "type": "module",
6
6
  "bin": {
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) => {
@@ -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
 
@@ -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
- // Ensure thread is locked (idempotent handles pre-existing unlocked threads)
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
- // Ensure thread is locked
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);