@hoverlover/cc-discord 0.2.5 → 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 +8 -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/README.md
CHANGED
|
@@ -119,6 +119,7 @@ Project-local env files take precedence over `~/.config/cc-discord/`, so cloned-
|
|
|
119
119
|
| `MAX_ATTACHMENT_INLINE_BYTES` | `100000` | Max bytes for inline attachment content |
|
|
120
120
|
| `MAX_ATTACHMENT_DOWNLOAD_BYTES` | `10000000` | Max bytes for downloaded attachments |
|
|
121
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) |
|
|
122
123
|
|
|
123
124
|
### `.env.worker` — required
|
|
124
125
|
|
|
@@ -267,14 +268,14 @@ To keep cc-discord running across reboots and terminal closures, install it as a
|
|
|
267
268
|
|
|
268
269
|
### macOS (launchd)
|
|
269
270
|
|
|
270
|
-
1. Find the full
|
|
271
|
+
1. Find the full paths to `bunx` and `claude`:
|
|
271
272
|
|
|
272
273
|
```bash
|
|
273
|
-
which bunx
|
|
274
|
-
# e.g. /Users/you/.
|
|
274
|
+
which bunx # e.g. /Users/you/.bun/bin/bunx
|
|
275
|
+
which claude # e.g. /Users/you/.local/bin/claude
|
|
275
276
|
```
|
|
276
277
|
|
|
277
|
-
2. Create the plist file:
|
|
278
|
+
2. Create the plist file (replace all `/Users/you/...` paths with your actual paths from step 1):
|
|
278
279
|
|
|
279
280
|
```bash
|
|
280
281
|
cat > ~/Library/LaunchAgents/com.cc-discord.plist << 'EOF'
|
|
@@ -295,8 +296,9 @@ cat > ~/Library/LaunchAgents/com.cc-discord.plist << 'EOF'
|
|
|
295
296
|
|
|
296
297
|
<key>EnvironmentVariables</key>
|
|
297
298
|
<dict>
|
|
299
|
+
<!-- Must include directories for both bunx and claude -->
|
|
298
300
|
<key>PATH</key>
|
|
299
|
-
<string>/Users/you/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
301
|
+
<string>/Users/you/.bun/bin:/Users/you/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
300
302
|
</dict>
|
|
301
303
|
|
|
302
304
|
<key>RunAtLoad</key>
|
|
@@ -315,7 +317,7 @@ cat > ~/Library/LaunchAgents/com.cc-discord.plist << 'EOF'
|
|
|
315
317
|
EOF
|
|
316
318
|
```
|
|
317
319
|
|
|
318
|
-
3.
|
|
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.
|
|
319
321
|
|
|
320
322
|
4. Load the service:
|
|
321
323
|
|
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
|
|