cord-bot 1.0.2
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/.env.example +15 -0
- package/.vscode/settings.json +11 -0
- package/README.md +113 -0
- package/bin/cord.ts +224 -0
- package/bun.lock +131 -0
- package/index.ts +24 -0
- package/package.json +26 -0
- package/src/bot.ts +144 -0
- package/src/db.ts +41 -0
- package/src/discord.ts +80 -0
- package/src/queue.ts +42 -0
- package/src/spawner.ts +107 -0
- package/src/worker.ts +74 -0
- package/tsconfig.json +29 -0
package/.env.example
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Required
|
|
2
|
+
DISCORD_BOT_TOKEN=your-bot-token-here
|
|
3
|
+
|
|
4
|
+
# Optional - Redis connection
|
|
5
|
+
REDIS_HOST=localhost
|
|
6
|
+
REDIS_PORT=6379
|
|
7
|
+
|
|
8
|
+
# Optional - Claude configuration
|
|
9
|
+
CLAUDE_WORKING_DIR=/path/to/your/project
|
|
10
|
+
|
|
11
|
+
# Optional - Database
|
|
12
|
+
DB_PATH=./data/threads.db
|
|
13
|
+
|
|
14
|
+
# Optional - Timezone for datetime injection
|
|
15
|
+
TZ=America/New_York
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Cord
|
|
2
|
+
|
|
3
|
+
A simple bridge that connects Discord to Claude Code CLI.
|
|
4
|
+
|
|
5
|
+
> **cord** /kôrd/ — a connection between two things.
|
|
6
|
+
|
|
7
|
+
When someone @mentions your bot, it:
|
|
8
|
+
1. Creates a thread for the conversation
|
|
9
|
+
2. Queues the message for Claude processing
|
|
10
|
+
3. Posts Claude's response back to the thread
|
|
11
|
+
4. Remembers context for follow-up messages
|
|
12
|
+
|
|
13
|
+
## Architecture
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Discord Bot → BullMQ Queue → Claude Spawner
|
|
17
|
+
(Node.js) (Redis) (Bun)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- **Bot** (`src/bot.ts`): Catches @mentions, creates threads, sends to queue
|
|
21
|
+
- **Queue** (`src/queue.ts`): BullMQ job queue for reliable processing
|
|
22
|
+
- **Worker** (`src/worker.ts`): Pulls jobs, spawns Claude, posts responses
|
|
23
|
+
- **Spawner** (`src/spawner.ts`): The Claude CLI integration (the core)
|
|
24
|
+
- **DB** (`src/db.ts`): SQLite for thread→session mapping
|
|
25
|
+
|
|
26
|
+
## Prerequisites
|
|
27
|
+
|
|
28
|
+
- [Bun](https://bun.sh) runtime
|
|
29
|
+
- [Redis](https://redis.io) server
|
|
30
|
+
- [Claude Code CLI](https://claude.ai/code) installed and authenticated
|
|
31
|
+
- Discord bot token (see setup below)
|
|
32
|
+
|
|
33
|
+
## Discord Bot Setup
|
|
34
|
+
|
|
35
|
+
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
|
36
|
+
2. Click **New Application**, give it a name
|
|
37
|
+
3. Go to **Bot** tab → Click **Add Bot**
|
|
38
|
+
4. Under **Privileged Gateway Intents**, enable:
|
|
39
|
+
- **Message Content Intent** (required to read message text)
|
|
40
|
+
5. Click **Reset Token** → Copy the token (this is your `DISCORD_BOT_TOKEN`)
|
|
41
|
+
6. Go to **OAuth2** → **URL Generator**:
|
|
42
|
+
- Scopes: `bot`
|
|
43
|
+
- Bot Permissions: `Send Messages`, `Create Public Threads`, `Send Messages in Threads`, `Read Message History`
|
|
44
|
+
7. Copy the generated URL → Open in browser → Invite bot to your server
|
|
45
|
+
|
|
46
|
+
**Note:** This runs 100% locally. The bot connects *outbound* to Discord's gateway - no need to expose ports or use ngrok.
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Install dependencies
|
|
52
|
+
bun install
|
|
53
|
+
|
|
54
|
+
# Set environment variables
|
|
55
|
+
export DISCORD_BOT_TOKEN="your-bot-token"
|
|
56
|
+
|
|
57
|
+
# Start Redis (if not already running)
|
|
58
|
+
redis-server &
|
|
59
|
+
|
|
60
|
+
# Start the bot and worker
|
|
61
|
+
bun run src/bot.ts &
|
|
62
|
+
bun run src/worker.ts
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Environment Variables
|
|
66
|
+
|
|
67
|
+
| Variable | Required | Default | Description |
|
|
68
|
+
|----------|----------|---------|-------------|
|
|
69
|
+
| `DISCORD_BOT_TOKEN` | Yes | - | Your Discord bot token |
|
|
70
|
+
| `REDIS_HOST` | No | `localhost` | Redis server host |
|
|
71
|
+
| `REDIS_PORT` | No | `6379` | Redis server port |
|
|
72
|
+
| `CLAUDE_WORKING_DIR` | No | `cwd` | Working directory for Claude |
|
|
73
|
+
| `DB_PATH` | No | `./data/threads.db` | SQLite database path |
|
|
74
|
+
|
|
75
|
+
## How It Works
|
|
76
|
+
|
|
77
|
+
### New Mentions
|
|
78
|
+
|
|
79
|
+
1. User @mentions the bot with a question
|
|
80
|
+
2. Bot creates a thread from the message
|
|
81
|
+
3. Bot generates a UUID session ID
|
|
82
|
+
4. Bot stores thread_id → session_id in SQLite
|
|
83
|
+
5. Bot queues a job with the prompt and session ID
|
|
84
|
+
6. Worker picks up the job
|
|
85
|
+
7. Worker spawns Claude with `--session-id UUID`
|
|
86
|
+
8. Worker posts Claude's response to the thread
|
|
87
|
+
|
|
88
|
+
### Follow-up Messages
|
|
89
|
+
|
|
90
|
+
1. User sends another message in the thread
|
|
91
|
+
2. Bot looks up the session ID from SQLite
|
|
92
|
+
3. Bot queues a job with `resume: true`
|
|
93
|
+
4. Worker spawns Claude with `--resume UUID`
|
|
94
|
+
5. Claude has full context from previous messages
|
|
95
|
+
|
|
96
|
+
## Key CLI Flags
|
|
97
|
+
|
|
98
|
+
The magic is in `src/spawner.ts`:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
// For new sessions:
|
|
102
|
+
claude --print --session-id UUID -p "prompt"
|
|
103
|
+
|
|
104
|
+
// For follow-ups:
|
|
105
|
+
claude --print --resume UUID -p "prompt"
|
|
106
|
+
|
|
107
|
+
// Inject context that survives compaction:
|
|
108
|
+
claude --append-system-prompt "Current time: ..."
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
package/bin/cord.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Cord CLI - Manage your Discord-Claude bridge
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* cord start - Start bot and worker
|
|
7
|
+
* cord stop - Stop all processes
|
|
8
|
+
* cord status - Show running status
|
|
9
|
+
* cord logs - Show combined logs
|
|
10
|
+
* cord setup - Interactive setup wizard
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn, spawnSync } from 'bun';
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import * as readline from 'readline';
|
|
17
|
+
|
|
18
|
+
const PID_FILE = join(process.cwd(), '.cord.pid');
|
|
19
|
+
|
|
20
|
+
const command = process.argv[2];
|
|
21
|
+
|
|
22
|
+
async function prompt(question: string): Promise<string> {
|
|
23
|
+
const rl = readline.createInterface({
|
|
24
|
+
input: process.stdin,
|
|
25
|
+
output: process.stdout,
|
|
26
|
+
});
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
rl.question(question, (answer) => {
|
|
29
|
+
rl.close();
|
|
30
|
+
resolve(answer.trim());
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function setup() {
|
|
36
|
+
console.log('\n🔌 Cord Setup\n');
|
|
37
|
+
|
|
38
|
+
// Check for .env
|
|
39
|
+
const envPath = join(process.cwd(), '.env');
|
|
40
|
+
const envExamplePath = join(process.cwd(), '.env.example');
|
|
41
|
+
|
|
42
|
+
if (existsSync(envPath)) {
|
|
43
|
+
console.log('✓ .env file exists');
|
|
44
|
+
} else if (existsSync(envExamplePath)) {
|
|
45
|
+
console.log('Creating .env from .env.example...\n');
|
|
46
|
+
|
|
47
|
+
const token = await prompt('Discord Bot Token: ');
|
|
48
|
+
if (!token) {
|
|
49
|
+
console.log('Token required. Run setup again when ready.');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tz = await prompt('Timezone (default: America/New_York): ') || 'America/New_York';
|
|
54
|
+
|
|
55
|
+
let envContent = readFileSync(envExamplePath, 'utf-8');
|
|
56
|
+
envContent = envContent.replace('your-bot-token-here', token);
|
|
57
|
+
envContent = envContent.replace('TZ=America/New_York', `TZ=${tz}`);
|
|
58
|
+
|
|
59
|
+
writeFileSync(envPath, envContent);
|
|
60
|
+
console.log('\n✓ .env file created');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check Redis
|
|
64
|
+
const redis = spawnSync(['redis-cli', 'ping'], { stdout: 'pipe', stderr: 'pipe' });
|
|
65
|
+
if (redis.exitCode === 0) {
|
|
66
|
+
console.log('✓ Redis is running');
|
|
67
|
+
} else {
|
|
68
|
+
console.log('⚠ Redis not running. Start it with: redis-server');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check Claude CLI
|
|
72
|
+
const claude = spawnSync(['claude', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
|
73
|
+
if (claude.exitCode === 0) {
|
|
74
|
+
console.log('✓ Claude CLI installed');
|
|
75
|
+
} else {
|
|
76
|
+
console.log('⚠ Claude CLI not found. Install from: https://claude.ai/code');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log('\n✨ Setup complete! Run: cord start\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function start() {
|
|
83
|
+
if (existsSync(PID_FILE)) {
|
|
84
|
+
console.log('Cord is already running. Run: cord stop');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log('Starting Cord...\n');
|
|
89
|
+
|
|
90
|
+
// Start bot
|
|
91
|
+
const bot = spawn(['bun', 'run', 'src/bot.ts'], {
|
|
92
|
+
stdout: 'inherit',
|
|
93
|
+
stderr: 'inherit',
|
|
94
|
+
cwd: process.cwd(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Start worker
|
|
98
|
+
const worker = spawn(['bun', 'run', 'src/worker.ts'], {
|
|
99
|
+
stdout: 'inherit',
|
|
100
|
+
stderr: 'inherit',
|
|
101
|
+
cwd: process.cwd(),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Save PIDs
|
|
105
|
+
writeFileSync(PID_FILE, JSON.stringify({
|
|
106
|
+
bot: bot.pid,
|
|
107
|
+
worker: worker.pid,
|
|
108
|
+
startedAt: new Date().toISOString(),
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
console.log(`Bot PID: ${bot.pid}`);
|
|
112
|
+
console.log(`Worker PID: ${worker.pid}`);
|
|
113
|
+
console.log('\nCord is running. Press Ctrl+C to stop.\n');
|
|
114
|
+
|
|
115
|
+
// Handle exit
|
|
116
|
+
process.on('SIGINT', () => {
|
|
117
|
+
console.log('\nStopping Cord...');
|
|
118
|
+
bot.kill();
|
|
119
|
+
worker.kill();
|
|
120
|
+
if (existsSync(PID_FILE)) {
|
|
121
|
+
const fs = require('fs');
|
|
122
|
+
fs.unlinkSync(PID_FILE);
|
|
123
|
+
}
|
|
124
|
+
process.exit(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Wait for processes
|
|
128
|
+
await Promise.all([bot.exited, worker.exited]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stop() {
|
|
132
|
+
if (!existsSync(PID_FILE)) {
|
|
133
|
+
console.log('Cord is not running.');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const pids = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
process.kill(pids.bot);
|
|
141
|
+
console.log(`Stopped bot (PID ${pids.bot})`);
|
|
142
|
+
} catch {}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
process.kill(pids.worker);
|
|
146
|
+
console.log(`Stopped worker (PID ${pids.worker})`);
|
|
147
|
+
} catch {}
|
|
148
|
+
|
|
149
|
+
const fs = require('fs');
|
|
150
|
+
fs.unlinkSync(PID_FILE);
|
|
151
|
+
console.log('Cord stopped.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function status() {
|
|
155
|
+
if (!existsSync(PID_FILE)) {
|
|
156
|
+
console.log('Cord is not running.');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const pids = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
|
|
161
|
+
|
|
162
|
+
const botAlive = isProcessRunning(pids.bot);
|
|
163
|
+
const workerAlive = isProcessRunning(pids.worker);
|
|
164
|
+
|
|
165
|
+
console.log(`Bot: ${botAlive ? '✓ running' : '✗ stopped'} (PID ${pids.bot})`);
|
|
166
|
+
console.log(`Worker: ${workerAlive ? '✓ running' : '✗ stopped'} (PID ${pids.worker})`);
|
|
167
|
+
console.log(`Started: ${pids.startedAt}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isProcessRunning(pid: number): boolean {
|
|
171
|
+
try {
|
|
172
|
+
process.kill(pid, 0);
|
|
173
|
+
return true;
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function showHelp() {
|
|
180
|
+
console.log(`
|
|
181
|
+
Cord - Discord to Claude Code bridge
|
|
182
|
+
|
|
183
|
+
Usage: cord <command>
|
|
184
|
+
|
|
185
|
+
Commands:
|
|
186
|
+
start Start bot and worker
|
|
187
|
+
stop Stop all processes
|
|
188
|
+
status Show running status
|
|
189
|
+
setup Interactive setup wizard
|
|
190
|
+
help Show this help
|
|
191
|
+
|
|
192
|
+
Examples:
|
|
193
|
+
cord setup # First-time configuration
|
|
194
|
+
cord start # Start the bot
|
|
195
|
+
cord status # Check if running
|
|
196
|
+
cord stop # Stop everything
|
|
197
|
+
`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Main
|
|
201
|
+
switch (command) {
|
|
202
|
+
case 'start':
|
|
203
|
+
start();
|
|
204
|
+
break;
|
|
205
|
+
case 'stop':
|
|
206
|
+
stop();
|
|
207
|
+
break;
|
|
208
|
+
case 'status':
|
|
209
|
+
status();
|
|
210
|
+
break;
|
|
211
|
+
case 'setup':
|
|
212
|
+
setup();
|
|
213
|
+
break;
|
|
214
|
+
case 'help':
|
|
215
|
+
case '--help':
|
|
216
|
+
case '-h':
|
|
217
|
+
case undefined:
|
|
218
|
+
showHelp();
|
|
219
|
+
break;
|
|
220
|
+
default:
|
|
221
|
+
console.log(`Unknown command: ${command}`);
|
|
222
|
+
showHelp();
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
package/bun.lock
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "discord-claude-bridge",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"bullmq": "^5.67.1",
|
|
9
|
+
"discord.js": "^14.25.1",
|
|
10
|
+
"ioredis": "^5.9.2",
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/bun": "latest",
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"typescript": "^5",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
"packages": {
|
|
21
|
+
"@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="],
|
|
22
|
+
|
|
23
|
+
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
|
24
|
+
|
|
25
|
+
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
|
|
26
|
+
|
|
27
|
+
"@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="],
|
|
28
|
+
|
|
29
|
+
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
|
|
30
|
+
|
|
31
|
+
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
|
32
|
+
|
|
33
|
+
"@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="],
|
|
34
|
+
|
|
35
|
+
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
|
|
36
|
+
|
|
37
|
+
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
|
|
38
|
+
|
|
39
|
+
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="],
|
|
40
|
+
|
|
41
|
+
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="],
|
|
42
|
+
|
|
43
|
+
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
|
|
44
|
+
|
|
45
|
+
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
|
|
46
|
+
|
|
47
|
+
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
|
48
|
+
|
|
49
|
+
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
|
50
|
+
|
|
51
|
+
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
|
52
|
+
|
|
53
|
+
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
|
54
|
+
|
|
55
|
+
"@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
|
|
56
|
+
|
|
57
|
+
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
|
58
|
+
|
|
59
|
+
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
|
60
|
+
|
|
61
|
+
"bullmq": ["bullmq@5.67.1", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.9.2", "msgpackr": "1.11.5", "node-abort-controller": "3.1.1", "semver": "7.7.3", "tslib": "2.8.1", "uuid": "11.1.0" } }, "sha512-ELJEAzwzesgFxk29emvnAakqrwdBEhEyfZREPQ8pbG4ALVz/mk/AhfuChzxkFpJ7SfL2qclPHbiUGBZzaqcLvg=="],
|
|
62
|
+
|
|
63
|
+
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
|
64
|
+
|
|
65
|
+
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
|
66
|
+
|
|
67
|
+
"cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
|
|
68
|
+
|
|
69
|
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
70
|
+
|
|
71
|
+
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
|
72
|
+
|
|
73
|
+
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
|
74
|
+
|
|
75
|
+
"discord-api-types": ["discord-api-types@0.38.37", "", {}, "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w=="],
|
|
76
|
+
|
|
77
|
+
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
|
78
|
+
|
|
79
|
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
|
80
|
+
|
|
81
|
+
"ioredis": ["ioredis@5.9.2", "", { "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ=="],
|
|
82
|
+
|
|
83
|
+
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
|
84
|
+
|
|
85
|
+
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
|
86
|
+
|
|
87
|
+
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
|
88
|
+
|
|
89
|
+
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
|
90
|
+
|
|
91
|
+
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
|
|
92
|
+
|
|
93
|
+
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
|
|
94
|
+
|
|
95
|
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
|
96
|
+
|
|
97
|
+
"msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="],
|
|
98
|
+
|
|
99
|
+
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
|
|
100
|
+
|
|
101
|
+
"node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
|
|
102
|
+
|
|
103
|
+
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
|
|
104
|
+
|
|
105
|
+
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
|
106
|
+
|
|
107
|
+
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
|
108
|
+
|
|
109
|
+
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
|
110
|
+
|
|
111
|
+
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
|
112
|
+
|
|
113
|
+
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
|
114
|
+
|
|
115
|
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
116
|
+
|
|
117
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
118
|
+
|
|
119
|
+
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
|
|
120
|
+
|
|
121
|
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
122
|
+
|
|
123
|
+
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
|
124
|
+
|
|
125
|
+
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
|
126
|
+
|
|
127
|
+
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
|
128
|
+
|
|
129
|
+
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
|
130
|
+
}
|
|
131
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cord - Discord to Claude Code bridge
|
|
3
|
+
*
|
|
4
|
+
* A simple bridge that connects Discord to Claude Code CLI.
|
|
5
|
+
*
|
|
6
|
+
* Start the bot: bun run src/bot.ts
|
|
7
|
+
* Start the worker: bun run src/worker.ts
|
|
8
|
+
*
|
|
9
|
+
* Or run both: bun run start
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
console.log('Cord - Discord to Claude Code bridge');
|
|
13
|
+
console.log('');
|
|
14
|
+
console.log('To start the system:');
|
|
15
|
+
console.log(' 1. Start Redis: redis-server');
|
|
16
|
+
console.log(' 2. Start the bot: bun run src/bot.ts');
|
|
17
|
+
console.log(' 3. Start the worker: bun run src/worker.ts');
|
|
18
|
+
console.log('');
|
|
19
|
+
console.log('Environment variables:');
|
|
20
|
+
console.log(' DISCORD_BOT_TOKEN - Your Discord bot token (required)');
|
|
21
|
+
console.log(' REDIS_HOST - Redis host (default: localhost)');
|
|
22
|
+
console.log(' REDIS_PORT - Redis port (default: 6379)');
|
|
23
|
+
console.log(' CLAUDE_WORKING_DIR - Working directory for Claude (default: cwd)');
|
|
24
|
+
console.log(' DB_PATH - SQLite database path (default: ./data/threads.db)');
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cord-bot",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"module": "index.ts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cord": "bin/cord.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "bun run bin/cord.ts start",
|
|
11
|
+
"bot": "bun run src/bot.ts",
|
|
12
|
+
"worker": "bun run src/worker.ts",
|
|
13
|
+
"setup": "bun run bin/cord.ts setup"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "latest"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"typescript": "^5"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"bullmq": "^5.67.1",
|
|
23
|
+
"discord.js": "^14.25.1",
|
|
24
|
+
"ioredis": "^5.9.2"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord Bot - Catches @mentions, creates threads, forwards to queue
|
|
3
|
+
*
|
|
4
|
+
* This is the entry point for the Discord → Claude bridge.
|
|
5
|
+
* When someone @mentions the bot, it:
|
|
6
|
+
* 1. Creates a thread for the conversation
|
|
7
|
+
* 2. Queues the message for Claude processing
|
|
8
|
+
* 3. Posts responses back to the thread
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
Client,
|
|
13
|
+
GatewayIntentBits,
|
|
14
|
+
Events,
|
|
15
|
+
Message,
|
|
16
|
+
TextChannel,
|
|
17
|
+
ThreadAutoArchiveDuration
|
|
18
|
+
} from 'discord.js';
|
|
19
|
+
import { claudeQueue } from './queue.js';
|
|
20
|
+
import { db } from './db.js';
|
|
21
|
+
|
|
22
|
+
// Force unbuffered logging
|
|
23
|
+
const log = (msg: string) => process.stdout.write(`[bot] ${msg}\n`);
|
|
24
|
+
|
|
25
|
+
const client = new Client({
|
|
26
|
+
intents: [
|
|
27
|
+
GatewayIntentBits.Guilds,
|
|
28
|
+
GatewayIntentBits.GuildMessages,
|
|
29
|
+
GatewayIntentBits.MessageContent,
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
client.once(Events.ClientReady, (c) => {
|
|
34
|
+
log(`Logged in as ${c.user.tag}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
client.on(Events.MessageCreate, async (message: Message) => {
|
|
38
|
+
// Ignore bots
|
|
39
|
+
if (message.author.bot) return;
|
|
40
|
+
|
|
41
|
+
const isMentioned = client.user && message.mentions.has(client.user);
|
|
42
|
+
const isInThread = message.channel.isThread();
|
|
43
|
+
|
|
44
|
+
// =========================================================================
|
|
45
|
+
// THREAD MESSAGES: Continue existing conversations
|
|
46
|
+
// =========================================================================
|
|
47
|
+
if (isInThread) {
|
|
48
|
+
const thread = message.channel;
|
|
49
|
+
|
|
50
|
+
// Look up session ID for this thread
|
|
51
|
+
const mapping = db.query('SELECT session_id FROM threads WHERE thread_id = ?')
|
|
52
|
+
.get(thread.id) as { session_id: string } | null;
|
|
53
|
+
|
|
54
|
+
if (!mapping) {
|
|
55
|
+
// Not a thread we created, ignore
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
log(`Thread message from ${message.author.tag}`);
|
|
60
|
+
|
|
61
|
+
// Show typing indicator
|
|
62
|
+
await thread.sendTyping();
|
|
63
|
+
|
|
64
|
+
// Extract message content (strip @mentions)
|
|
65
|
+
const content = message.content.replace(/<@!?\d+>/g, '').trim();
|
|
66
|
+
|
|
67
|
+
// Queue for Claude processing with session resume
|
|
68
|
+
await claudeQueue.add('process', {
|
|
69
|
+
prompt: content,
|
|
70
|
+
threadId: thread.id,
|
|
71
|
+
sessionId: mapping.session_id,
|
|
72
|
+
resume: true,
|
|
73
|
+
userId: message.author.id,
|
|
74
|
+
username: message.author.tag,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// =========================================================================
|
|
81
|
+
// NEW MENTIONS: Start new conversations
|
|
82
|
+
// =========================================================================
|
|
83
|
+
if (!isMentioned) return;
|
|
84
|
+
|
|
85
|
+
log(`New mention from ${message.author.tag}`);
|
|
86
|
+
|
|
87
|
+
// Create a thread for the conversation
|
|
88
|
+
let thread;
|
|
89
|
+
try {
|
|
90
|
+
// Generate thread name from message content
|
|
91
|
+
const rawText = message.content.replace(/<@!?\d+>/g, '').trim();
|
|
92
|
+
const threadName = rawText.length > 50
|
|
93
|
+
? rawText.slice(0, 47) + '...'
|
|
94
|
+
: rawText || 'New conversation';
|
|
95
|
+
|
|
96
|
+
thread = await message.startThread({
|
|
97
|
+
name: threadName,
|
|
98
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
log(`Failed to create thread: ${error}`);
|
|
102
|
+
await message.reply('Failed to start thread. Try again?');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Generate a new session ID for this conversation
|
|
107
|
+
const sessionId = crypto.randomUUID();
|
|
108
|
+
|
|
109
|
+
// Store the thread → session mapping
|
|
110
|
+
db.run(
|
|
111
|
+
'INSERT INTO threads (thread_id, session_id) VALUES (?, ?)',
|
|
112
|
+
[thread.id, sessionId]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
log(`Created thread ${thread.id} with session ${sessionId}`);
|
|
116
|
+
|
|
117
|
+
// Show typing indicator
|
|
118
|
+
await thread.sendTyping();
|
|
119
|
+
|
|
120
|
+
// Extract message content
|
|
121
|
+
const content = message.content.replace(/<@!?\d+>/g, '').trim();
|
|
122
|
+
|
|
123
|
+
// Queue for Claude processing
|
|
124
|
+
await claudeQueue.add('process', {
|
|
125
|
+
prompt: content,
|
|
126
|
+
threadId: thread.id,
|
|
127
|
+
sessionId,
|
|
128
|
+
resume: false,
|
|
129
|
+
userId: message.author.id,
|
|
130
|
+
username: message.author.tag,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Start the bot
|
|
135
|
+
const token = process.env.DISCORD_BOT_TOKEN;
|
|
136
|
+
if (!token) {
|
|
137
|
+
console.error('DISCORD_BOT_TOKEN required');
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
client.login(token);
|
|
142
|
+
|
|
143
|
+
// Export for external use
|
|
144
|
+
export { client };
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database - SQLite for thread → session mappings
|
|
3
|
+
*
|
|
4
|
+
* Simple key-value store:
|
|
5
|
+
* - thread_id (Discord thread ID)
|
|
6
|
+
* - session_id (Claude session UUID)
|
|
7
|
+
*
|
|
8
|
+
* When a follow-up message comes in a thread, we look up
|
|
9
|
+
* the session ID to use --resume.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Database } from 'bun:sqlite';
|
|
13
|
+
|
|
14
|
+
const DB_PATH = process.env.DB_PATH || './data/threads.db';
|
|
15
|
+
|
|
16
|
+
// Ensure data directory exists
|
|
17
|
+
import { mkdirSync } from 'fs';
|
|
18
|
+
import { dirname } from 'path';
|
|
19
|
+
try {
|
|
20
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
21
|
+
} catch {}
|
|
22
|
+
|
|
23
|
+
// Open database
|
|
24
|
+
export const db = new Database(DB_PATH);
|
|
25
|
+
|
|
26
|
+
// Create tables if they don't exist
|
|
27
|
+
db.run(`
|
|
28
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
29
|
+
thread_id TEXT PRIMARY KEY,
|
|
30
|
+
session_id TEXT NOT NULL,
|
|
31
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
32
|
+
)
|
|
33
|
+
`);
|
|
34
|
+
|
|
35
|
+
// Create index for faster lookups
|
|
36
|
+
db.run(`
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_threads_session
|
|
38
|
+
ON threads(session_id)
|
|
39
|
+
`);
|
|
40
|
+
|
|
41
|
+
console.log(`[db] SQLite database ready at ${DB_PATH}`);
|
package/src/discord.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord utilities - Helper functions for posting to Discord
|
|
3
|
+
*
|
|
4
|
+
* Separated from bot.ts so the worker can send messages
|
|
5
|
+
* without importing the full client.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
|
9
|
+
|
|
10
|
+
if (!DISCORD_BOT_TOKEN) {
|
|
11
|
+
console.warn('[discord] DISCORD_BOT_TOKEN not set - sendToThread will fail');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Send a message to a Discord thread
|
|
16
|
+
*
|
|
17
|
+
* Uses the REST API directly so we don't need the gateway client
|
|
18
|
+
*/
|
|
19
|
+
export async function sendToThread(threadId: string, content: string): Promise<void> {
|
|
20
|
+
// Discord message limit is 2000 chars
|
|
21
|
+
const MAX_LENGTH = 2000;
|
|
22
|
+
|
|
23
|
+
// Split long messages
|
|
24
|
+
const chunks: string[] = [];
|
|
25
|
+
let remaining = content;
|
|
26
|
+
|
|
27
|
+
while (remaining.length > 0) {
|
|
28
|
+
if (remaining.length <= MAX_LENGTH) {
|
|
29
|
+
chunks.push(remaining);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Find a good split point (newline or space)
|
|
34
|
+
let splitAt = remaining.lastIndexOf('\n', MAX_LENGTH);
|
|
35
|
+
if (splitAt === -1 || splitAt < MAX_LENGTH / 2) {
|
|
36
|
+
splitAt = remaining.lastIndexOf(' ', MAX_LENGTH);
|
|
37
|
+
}
|
|
38
|
+
if (splitAt === -1 || splitAt < MAX_LENGTH / 2) {
|
|
39
|
+
splitAt = MAX_LENGTH;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
43
|
+
remaining = remaining.slice(splitAt).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Send each chunk
|
|
47
|
+
for (const chunk of chunks) {
|
|
48
|
+
const response = await fetch(
|
|
49
|
+
`https://discord.com/api/v10/channels/${threadId}/messages`,
|
|
50
|
+
{
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Authorization': `Bot ${DISCORD_BOT_TOKEN}`,
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({ content: chunk }),
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const error = await response.text();
|
|
62
|
+
throw new Error(`Discord API error: ${response.status} ${error}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Send typing indicator to a thread
|
|
69
|
+
*/
|
|
70
|
+
export async function sendTyping(channelId: string): Promise<void> {
|
|
71
|
+
await fetch(
|
|
72
|
+
`https://discord.com/api/v10/channels/${channelId}/typing`,
|
|
73
|
+
{
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Authorization': `Bot ${DISCORD_BOT_TOKEN}`,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
}
|
package/src/queue.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue - BullMQ job queue for Claude processing
|
|
3
|
+
*
|
|
4
|
+
* Why a queue?
|
|
5
|
+
* 1. Claude can take minutes to respond - Discord would timeout
|
|
6
|
+
* 2. Rate limiting - don't spawn 100 Claude processes at once
|
|
7
|
+
* 3. Persistence - if the bot crashes, jobs aren't lost
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Queue } from 'bullmq';
|
|
11
|
+
import IORedis from 'ioredis';
|
|
12
|
+
|
|
13
|
+
// Redis connection for BullMQ
|
|
14
|
+
const connection = new IORedis({
|
|
15
|
+
host: process.env.REDIS_HOST || 'localhost',
|
|
16
|
+
port: parseInt(process.env.REDIS_PORT || '6379'),
|
|
17
|
+
maxRetriesPerRequest: null, // Required for BullMQ
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// The queue that holds Claude processing jobs
|
|
21
|
+
export const claudeQueue = new Queue('claude', {
|
|
22
|
+
connection,
|
|
23
|
+
defaultJobOptions: {
|
|
24
|
+
attempts: 3,
|
|
25
|
+
backoff: {
|
|
26
|
+
type: 'exponential',
|
|
27
|
+
delay: 1000,
|
|
28
|
+
},
|
|
29
|
+
removeOnComplete: 100, // Keep last 100 completed jobs
|
|
30
|
+
removeOnFail: 50, // Keep last 50 failed jobs
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Job data structure
|
|
35
|
+
export interface ClaudeJob {
|
|
36
|
+
prompt: string;
|
|
37
|
+
threadId: string;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
resume: boolean;
|
|
40
|
+
userId: string;
|
|
41
|
+
username: string;
|
|
42
|
+
}
|
package/src/spawner.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawner - The Claude CLI integration
|
|
3
|
+
*
|
|
4
|
+
* THIS IS THE CORE OF THE SYSTEM.
|
|
5
|
+
*
|
|
6
|
+
* Key flags:
|
|
7
|
+
* - `--print`: Non-interactive mode, returns output
|
|
8
|
+
* - `--session-id UUID`: Set session ID for new sessions
|
|
9
|
+
* - `--resume UUID`: Resume an existing session (for follow-ups)
|
|
10
|
+
* - `--append-system-prompt`: Inject context that survives compaction
|
|
11
|
+
* - `-p "prompt"`: The actual prompt to send
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const log = (msg: string) => process.stdout.write(`[spawner] ${msg}\n`);
|
|
15
|
+
|
|
16
|
+
// Timezone for datetime injection (set via TZ env var)
|
|
17
|
+
const TIMEZONE = process.env.TZ || 'UTC';
|
|
18
|
+
|
|
19
|
+
interface SpawnOptions {
|
|
20
|
+
prompt: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
resume: boolean;
|
|
23
|
+
systemPrompt?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get current datetime in user's timezone
|
|
28
|
+
* Claude Code doesn't know the time - we inject it
|
|
29
|
+
*/
|
|
30
|
+
function getDatetimeContext(): string {
|
|
31
|
+
const now = new Date();
|
|
32
|
+
return now.toLocaleString('en-US', {
|
|
33
|
+
weekday: 'long',
|
|
34
|
+
year: 'numeric',
|
|
35
|
+
month: 'long',
|
|
36
|
+
day: 'numeric',
|
|
37
|
+
hour: 'numeric',
|
|
38
|
+
minute: '2-digit',
|
|
39
|
+
hour12: true,
|
|
40
|
+
timeZone: TIMEZONE,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Spawn Claude CLI and return the response
|
|
46
|
+
*/
|
|
47
|
+
export async function spawnClaude(options: SpawnOptions): Promise<string> {
|
|
48
|
+
const { prompt, sessionId, resume, systemPrompt } = options;
|
|
49
|
+
|
|
50
|
+
log(`Spawning Claude - Session: ${sessionId}, Resume: ${resume}`);
|
|
51
|
+
|
|
52
|
+
// Build CLI arguments
|
|
53
|
+
const args = ['claude'];
|
|
54
|
+
|
|
55
|
+
// Non-interactive mode
|
|
56
|
+
args.push('--print');
|
|
57
|
+
|
|
58
|
+
// Session handling
|
|
59
|
+
if (resume) {
|
|
60
|
+
// Resume existing session for follow-up messages
|
|
61
|
+
args.push('--resume', sessionId);
|
|
62
|
+
} else {
|
|
63
|
+
// New session - set the ID upfront
|
|
64
|
+
args.push('--session-id', sessionId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Inject datetime context (survives session compaction)
|
|
68
|
+
const datetimeContext = `Current date/time: ${getDatetimeContext()}`;
|
|
69
|
+
const fullSystemPrompt = systemPrompt
|
|
70
|
+
? `${datetimeContext}\n\n${systemPrompt}`
|
|
71
|
+
: datetimeContext;
|
|
72
|
+
|
|
73
|
+
args.push('--append-system-prompt', fullSystemPrompt);
|
|
74
|
+
|
|
75
|
+
// The actual prompt
|
|
76
|
+
args.push('-p', prompt);
|
|
77
|
+
|
|
78
|
+
log(`Command: ${args.join(' ').slice(0, 100)}...`);
|
|
79
|
+
|
|
80
|
+
// Spawn the process
|
|
81
|
+
const proc = Bun.spawn(args, {
|
|
82
|
+
cwd: process.env.CLAUDE_WORKING_DIR || process.cwd(),
|
|
83
|
+
env: {
|
|
84
|
+
...process.env,
|
|
85
|
+
TZ: TIMEZONE,
|
|
86
|
+
},
|
|
87
|
+
stdout: 'pipe',
|
|
88
|
+
stderr: 'pipe',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Collect output
|
|
92
|
+
const stdout = await new Response(proc.stdout).text();
|
|
93
|
+
const stderr = await new Response(proc.stderr).text();
|
|
94
|
+
|
|
95
|
+
// Wait for process to exit
|
|
96
|
+
const exitCode = await proc.exited;
|
|
97
|
+
|
|
98
|
+
if (exitCode !== 0) {
|
|
99
|
+
log(`Claude exited with code ${exitCode}`);
|
|
100
|
+
log(`stderr: ${stderr}`);
|
|
101
|
+
throw new Error(`Claude failed: ${stderr || 'Unknown error'}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
log(`Claude responded (${stdout.length} chars)`);
|
|
105
|
+
|
|
106
|
+
return stdout.trim();
|
|
107
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker - Processes Claude jobs from the queue
|
|
3
|
+
*
|
|
4
|
+
* This is where the magic happens:
|
|
5
|
+
* 1. Pulls jobs from the queue
|
|
6
|
+
* 2. Spawns Claude CLI with the right flags
|
|
7
|
+
* 3. Posts the response back to Discord
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Worker, Job } from 'bullmq';
|
|
11
|
+
import IORedis from 'ioredis';
|
|
12
|
+
import { spawnClaude } from './spawner.js';
|
|
13
|
+
import { sendToThread } from './discord.js';
|
|
14
|
+
import type { ClaudeJob } from './queue.js';
|
|
15
|
+
|
|
16
|
+
const log = (msg: string) => process.stdout.write(`[worker] ${msg}\n`);
|
|
17
|
+
|
|
18
|
+
const connection = new IORedis({
|
|
19
|
+
host: process.env.REDIS_HOST || 'localhost',
|
|
20
|
+
port: parseInt(process.env.REDIS_PORT || '6379'),
|
|
21
|
+
maxRetriesPerRequest: null,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const worker = new Worker<ClaudeJob>(
|
|
25
|
+
'claude',
|
|
26
|
+
async (job: Job<ClaudeJob>) => {
|
|
27
|
+
const { prompt, threadId, sessionId, resume, username } = job.data;
|
|
28
|
+
|
|
29
|
+
log(`Processing job ${job.id} for ${username}`);
|
|
30
|
+
log(`Session: ${sessionId}, Resume: ${resume}`);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// Spawn Claude and get response
|
|
34
|
+
const response = await spawnClaude({
|
|
35
|
+
prompt,
|
|
36
|
+
sessionId,
|
|
37
|
+
resume,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Send response to Discord thread
|
|
41
|
+
await sendToThread(threadId, response);
|
|
42
|
+
|
|
43
|
+
log(`Job ${job.id} completed`);
|
|
44
|
+
return { success: true, responseLength: response.length };
|
|
45
|
+
|
|
46
|
+
} catch (error) {
|
|
47
|
+
log(`Job ${job.id} failed: ${error}`);
|
|
48
|
+
|
|
49
|
+
// Send error message to thread
|
|
50
|
+
await sendToThread(
|
|
51
|
+
threadId,
|
|
52
|
+
`Something went wrong. Try again?\n\`\`\`${error}\`\`\``
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
throw error; // Re-throw for BullMQ retry logic
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
connection,
|
|
60
|
+
concurrency: 2, // Process up to 2 jobs at once
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
worker.on('completed', (job) => {
|
|
65
|
+
log(`Job ${job?.id} completed`);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
worker.on('failed', (job, err) => {
|
|
69
|
+
log(`Job ${job?.id} failed: ${err.message}`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
log('Worker started, waiting for jobs...');
|
|
73
|
+
|
|
74
|
+
export { worker };
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|