@thesammykins/tether 1.0.0 → 1.2.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/.env.example +14 -0
- package/README.md +28 -274
- package/bin/tether.ts +366 -28
- package/docs/agents.md +205 -0
- package/docs/architecture.md +117 -0
- package/docs/cli.md +277 -0
- package/docs/configuration.md +158 -0
- package/docs/discord-setup.md +122 -0
- package/docs/installation.md +151 -0
- package/docs/troubleshooting.md +74 -0
- package/package.json +2 -1
- package/src/api.ts +89 -6
- package/src/bot.ts +47 -9
- package/src/config.ts +390 -0
- package/src/db.ts +6 -0
- package/src/features/pause-resume.ts +3 -3
- package/src/queue.ts +1 -0
- package/src/worker.ts +14 -2
- package/src/spawner.ts +0 -110
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Installation
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
| Requirement | Version | Check |
|
|
6
|
+
|-------------|---------|-------|
|
|
7
|
+
| [Bun](https://bun.sh) | 1.1.0+ | `bun --version` |
|
|
8
|
+
| [Redis](https://redis.io) | 6+ | `redis-cli ping` |
|
|
9
|
+
| Discord bot token | — | [Create one](discord-setup.md) |
|
|
10
|
+
| An AI agent CLI | — | [Setup guides](agents.md) |
|
|
11
|
+
|
|
12
|
+
## Install Tether
|
|
13
|
+
|
|
14
|
+
### From npm (recommended)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun add -g @thesammykins/tether
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This installs the `tether` CLI globally. Verify:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
tether help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### From source
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/thesammykins/tether.git
|
|
30
|
+
cd tether
|
|
31
|
+
bun install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When running from source, use `bun run bin/tether.ts` instead of `tether`.
|
|
35
|
+
|
|
36
|
+
## Quick Setup
|
|
37
|
+
|
|
38
|
+
### 1. Create your Discord bot
|
|
39
|
+
|
|
40
|
+
Follow the [Discord Bot Setup](discord-setup.md) guide to create a bot and get your token.
|
|
41
|
+
|
|
42
|
+
### 2. Install your AI agent
|
|
43
|
+
|
|
44
|
+
Tether bridges Discord to a CLI agent. You need at least one installed:
|
|
45
|
+
|
|
46
|
+
| Agent | Install command | Verify |
|
|
47
|
+
|-------|----------------|--------|
|
|
48
|
+
| [Claude Code](agents.md#claude-code) | `curl -fsSL https://claude.ai/install.sh \| bash` | `claude --version` |
|
|
49
|
+
| [OpenCode](agents.md#opencode) | `curl -fsSL https://opencode.ai/install \| bash` | `opencode --version` |
|
|
50
|
+
| [Codex](agents.md#codex) | `npm install -g @openai/codex` | `codex --version` |
|
|
51
|
+
|
|
52
|
+
See [Agent Setup](agents.md) for full instructions including API keys and authentication.
|
|
53
|
+
|
|
54
|
+
### 3. Configure
|
|
55
|
+
|
|
56
|
+
**Option A — Interactive setup:**
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
tether setup
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This walks you through token, agent type, and channel configuration.
|
|
63
|
+
|
|
64
|
+
**Option B — Import from `.env`:**
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cp .env.example .env
|
|
68
|
+
# Edit .env with your values
|
|
69
|
+
tether config import .env
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Option C — Set values directly:**
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
tether config set DISCORD_BOT_TOKEN # prompts for value (hidden input)
|
|
76
|
+
tether config set AGENT_TYPE opencode
|
|
77
|
+
tether config set ALLOWED_CHANNELS 123456789
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
See [Configuration](configuration.md) for all options.
|
|
81
|
+
|
|
82
|
+
### 4. Start Redis
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
redis-server
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Or if using Homebrew on macOS:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
brew services start redis
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 5. Start Tether
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
tether start
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
You should see:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
[bot] Connecting to Discord gateway (attempt 1)...
|
|
104
|
+
[bot] Logged in as YourBot#1234
|
|
105
|
+
[worker] Worker started, waiting for jobs...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 6. Test it
|
|
109
|
+
|
|
110
|
+
In your Discord server, `@mention` the bot:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
@Tether what time is it?
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The bot will react with 👀, create a thread, and post the agent's response.
|
|
117
|
+
|
|
118
|
+
## Letting the Agent Set Itself Up
|
|
119
|
+
|
|
120
|
+
If you're already running Claude Code or OpenCode in a project, the agent can set up Tether for you:
|
|
121
|
+
|
|
122
|
+
> "Install @thesammykins/tether and configure it with my Discord bot token. Use opencode as the agent type. My allowed channel is 123456789."
|
|
123
|
+
|
|
124
|
+
The agent will:
|
|
125
|
+
1. Run `bun add -g @thesammykins/tether`
|
|
126
|
+
2. Run `tether config set DISCORD_BOT_TOKEN` (you'll need to provide the token)
|
|
127
|
+
3. Run `tether config set AGENT_TYPE opencode`
|
|
128
|
+
4. Run `tether config set ALLOWED_CHANNELS 123456789`
|
|
129
|
+
5. Start the bot with `tether start`
|
|
130
|
+
|
|
131
|
+
See [Agent Setup](agents.md) for agent-specific instructions on how to have the agent bootstrap Tether.
|
|
132
|
+
|
|
133
|
+
## Updating
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# npm install
|
|
137
|
+
bun add -g @thesammykins/tether@latest
|
|
138
|
+
|
|
139
|
+
# From source
|
|
140
|
+
git pull && bun install
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Uninstall
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# Remove the package
|
|
147
|
+
bun remove -g @thesammykins/tether
|
|
148
|
+
|
|
149
|
+
# Remove config (optional)
|
|
150
|
+
rm -rf ~/.config/tether
|
|
151
|
+
```
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
## Common Issues
|
|
4
|
+
|
|
5
|
+
| Problem | Solution |
|
|
6
|
+
|---------|----------|
|
|
7
|
+
| Bot connects but doesn't respond | Enable **Message Content Intent** in Developer Portal → Bot tab. This is the #1 setup issue. |
|
|
8
|
+
| `TokenInvalid` error | Regenerate your bot token in the Developer Portal and update: `tether config set DISCORD_BOT_TOKEN` |
|
|
9
|
+
| `DisallowedIntents` error | Enable the required intents in Developer Portal → Bot tab (see [Discord Setup](discord-setup.md#3-configure-privileged-intents)) |
|
|
10
|
+
| Bot doesn't receive DMs | Set `ENABLE_DMS=true`: `tether config set ENABLE_DMS true` |
|
|
11
|
+
| "Rate limit exceeded" | Adjust `RATE_LIMIT_REQUESTS` / `RATE_LIMIT_WINDOW_MS` (see [Configuration](configuration.md#limits)) |
|
|
12
|
+
| Agent command not found | Ensure `claude`/`opencode`/`codex` is installed and on PATH (see [Agent Setup](agents.md)) |
|
|
13
|
+
| Redis connection refused | Start Redis: `redis-server` (or `brew services start redis` on macOS) |
|
|
14
|
+
| Bot can't create threads | Check bot has **Create Public Threads** permission in your server |
|
|
15
|
+
| `tether start` hangs / does nothing | Check for a stale PID file: `rm -f .tether.pid` then try again |
|
|
16
|
+
| Bot responds to wrong channels | Set `ALLOWED_CHANNELS` to restrict which channels the bot listens in |
|
|
17
|
+
|
|
18
|
+
## Checking Status
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Is tether running?
|
|
22
|
+
tether status
|
|
23
|
+
|
|
24
|
+
# Is Discord connected?
|
|
25
|
+
tether health
|
|
26
|
+
|
|
27
|
+
# What config is active?
|
|
28
|
+
tether config list
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Logs
|
|
32
|
+
|
|
33
|
+
`tether start` logs to stdout. If running in the background:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Run with logging
|
|
37
|
+
nohup bun run bin/tether.ts start > /tmp/tether.log 2>&1 &
|
|
38
|
+
|
|
39
|
+
# View logs
|
|
40
|
+
tail -f /tmp/tether.log
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Agent-Specific Issues
|
|
44
|
+
|
|
45
|
+
### Claude Code
|
|
46
|
+
|
|
47
|
+
| Problem | Solution |
|
|
48
|
+
|---------|----------|
|
|
49
|
+
| `claude: command not found` | Install: `curl -fsSL https://claude.ai/install.sh \| bash` |
|
|
50
|
+
| Authentication expired | Run `claude` interactively to re-authenticate |
|
|
51
|
+
| Session resume fails | Sessions may expire — new messages will start fresh automatically |
|
|
52
|
+
|
|
53
|
+
### OpenCode
|
|
54
|
+
|
|
55
|
+
| Problem | Solution |
|
|
56
|
+
|---------|----------|
|
|
57
|
+
| `opencode: command not found` | Install: `curl -fsSL https://opencode.ai/install \| bash` |
|
|
58
|
+
| API key not set | Set your provider key: `export ANTHROPIC_API_KEY=sk-ant-...` or `export OPENAI_API_KEY=sk-...` |
|
|
59
|
+
|
|
60
|
+
### Codex
|
|
61
|
+
|
|
62
|
+
| Problem | Solution |
|
|
63
|
+
|---------|----------|
|
|
64
|
+
| `codex: command not found` | Install: `npm install -g @openai/codex` (requires Node.js 22+) |
|
|
65
|
+
| `OPENAI_API_KEY` not set | `export OPENAI_API_KEY=sk-...` |
|
|
66
|
+
|
|
67
|
+
## Getting Help
|
|
68
|
+
|
|
69
|
+
If you're stuck:
|
|
70
|
+
|
|
71
|
+
1. Check `tether config list` — verify your settings and their sources
|
|
72
|
+
2. Check `tether health` — verify Discord connectivity
|
|
73
|
+
3. Look at the logs for error messages
|
|
74
|
+
4. [Open an issue](https://github.com/thesammykins/tether/issues) with the error output
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thesammykins/tether",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Discord bot that bridges messages to AI agent sessions (Claude, OpenCode, Codex)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "thesammykins",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"src/",
|
|
37
37
|
"bin/tether.ts",
|
|
38
38
|
"index.ts",
|
|
39
|
+
"docs/",
|
|
39
40
|
"README.md",
|
|
40
41
|
"LICENSE",
|
|
41
42
|
".env.example"
|
package/src/api.ts
CHANGED
|
@@ -28,16 +28,50 @@ export const questionResponses = new Map<string, { answer: string; optionIndex:
|
|
|
28
28
|
// Track which threads are waiting for a typed answer
|
|
29
29
|
export const pendingTypedAnswers = new Map<string, string>(); // threadId → requestId
|
|
30
30
|
|
|
31
|
+
// Pending questions with TTL tracking
|
|
32
|
+
type PendingQuestion = {
|
|
33
|
+
timeoutId: NodeJS.Timeout;
|
|
34
|
+
};
|
|
35
|
+
export const pendingQuestions = new Map<string, PendingQuestion>();
|
|
36
|
+
|
|
37
|
+
// Binary file extensions - files with these extensions should be base64 encoded
|
|
38
|
+
const BINARY_EXTENSIONS = new Set([
|
|
39
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.svg',
|
|
40
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
41
|
+
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
|
|
42
|
+
'.mp3', '.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv',
|
|
43
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
44
|
+
'.bin', '.exe', '.dll', '.so', '.dylib',
|
|
45
|
+
]);
|
|
46
|
+
|
|
31
47
|
/**
|
|
32
48
|
* Start the HTTP API server
|
|
33
49
|
*/
|
|
34
50
|
export function startApiServer(client: Client, port: number = 2643) {
|
|
51
|
+
// Optional API token authentication
|
|
52
|
+
const apiToken = process.env.API_TOKEN;
|
|
53
|
+
const hostname = process.env.TETHER_API_HOST || '127.0.0.1';
|
|
54
|
+
|
|
35
55
|
const server = Bun.serve({
|
|
36
56
|
port,
|
|
57
|
+
hostname,
|
|
37
58
|
async fetch(req) {
|
|
38
59
|
const url = new URL(req.url);
|
|
39
60
|
const headers = { 'Content-Type': 'application/json' };
|
|
40
61
|
|
|
62
|
+
// Authentication check - skip for /health endpoint
|
|
63
|
+
if (apiToken && url.pathname !== '/health') {
|
|
64
|
+
const authHeader = req.headers.get('Authorization');
|
|
65
|
+
const expectedAuth = `Bearer ${apiToken}`;
|
|
66
|
+
|
|
67
|
+
if (!authHeader || authHeader !== expectedAuth) {
|
|
68
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
69
|
+
status: 401,
|
|
70
|
+
headers,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
41
75
|
// Health check
|
|
42
76
|
if (url.pathname === '/health' && req.method === 'GET') {
|
|
43
77
|
return new Response(JSON.stringify({
|
|
@@ -74,6 +108,7 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
74
108
|
fileName: string;
|
|
75
109
|
fileContent: string;
|
|
76
110
|
content?: string;
|
|
111
|
+
isBase64?: boolean;
|
|
77
112
|
};
|
|
78
113
|
|
|
79
114
|
const channel = await client.channels.fetch(body.channelId);
|
|
@@ -84,7 +119,12 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
84
119
|
});
|
|
85
120
|
}
|
|
86
121
|
|
|
87
|
-
|
|
122
|
+
// Determine encoding - check if file is binary based on extension
|
|
123
|
+
const ext = body.fileName.toLowerCase().match(/\.[^.]+$/)?.[0];
|
|
124
|
+
const isBinary = ext ? BINARY_EXTENSIONS.has(ext) : false;
|
|
125
|
+
const encoding = body.isBase64 || isBinary ? 'base64' : 'utf-8';
|
|
126
|
+
|
|
127
|
+
const buffer = Buffer.from(body.fileContent, encoding);
|
|
88
128
|
const message = await (channel as TextChannel).send({
|
|
89
129
|
content: body.content || undefined,
|
|
90
130
|
files: [{
|
|
@@ -114,10 +154,17 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
114
154
|
fileName: string;
|
|
115
155
|
fileContent: string;
|
|
116
156
|
content?: string;
|
|
157
|
+
isBase64?: boolean;
|
|
117
158
|
};
|
|
118
159
|
|
|
119
160
|
const user = await client.users.fetch(body.userId);
|
|
120
|
-
|
|
161
|
+
|
|
162
|
+
// Determine encoding - check if file is binary based on extension
|
|
163
|
+
const ext = body.fileName.toLowerCase().match(/\.[^.]+$/)?.[0];
|
|
164
|
+
const isBinary = ext ? BINARY_EXTENSIONS.has(ext) : false;
|
|
165
|
+
const encoding = body.isBase64 || isBinary ? 'base64' : 'utf-8';
|
|
166
|
+
|
|
167
|
+
const buffer = Buffer.from(body.fileContent, encoding);
|
|
121
168
|
const message = await user.send({
|
|
122
169
|
content: body.content || undefined,
|
|
123
170
|
files: [{
|
|
@@ -156,7 +203,7 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
156
203
|
buttons: Array<{
|
|
157
204
|
label: string;
|
|
158
205
|
customId: string;
|
|
159
|
-
style: 'primary' | 'secondary' | 'success' | 'danger';
|
|
206
|
+
style: number | 'primary' | 'secondary' | 'success' | 'danger';
|
|
160
207
|
handler?: ButtonHandler;
|
|
161
208
|
}>;
|
|
162
209
|
};
|
|
@@ -180,7 +227,7 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
180
227
|
return embed;
|
|
181
228
|
});
|
|
182
229
|
|
|
183
|
-
// Build button row
|
|
230
|
+
// Build button row - handle both number and string styles
|
|
184
231
|
const styleMap: Record<string, ButtonStyle> = {
|
|
185
232
|
primary: ButtonStyle.Primary,
|
|
186
233
|
secondary: ButtonStyle.Secondary,
|
|
@@ -196,10 +243,19 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
196
243
|
} else {
|
|
197
244
|
log(`No handler for button: ${b.customId}`);
|
|
198
245
|
}
|
|
246
|
+
|
|
247
|
+
// Handle both number styles (from CLI) and string styles (legacy)
|
|
248
|
+
let buttonStyle: ButtonStyle;
|
|
249
|
+
if (typeof b.style === 'number') {
|
|
250
|
+
buttonStyle = b.style as ButtonStyle;
|
|
251
|
+
} else {
|
|
252
|
+
buttonStyle = styleMap[b.style] || ButtonStyle.Primary;
|
|
253
|
+
}
|
|
254
|
+
|
|
199
255
|
return new ButtonBuilder()
|
|
200
256
|
.setCustomId(b.customId)
|
|
201
257
|
.setLabel(b.label)
|
|
202
|
-
.setStyle(
|
|
258
|
+
.setStyle(buttonStyle);
|
|
203
259
|
});
|
|
204
260
|
|
|
205
261
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buttons);
|
|
@@ -210,6 +266,26 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
210
266
|
components: [row],
|
|
211
267
|
});
|
|
212
268
|
|
|
269
|
+
// Register question buttons with TTL (5 minutes)
|
|
270
|
+
// Extract requestId from button customIds that match "ask_<uuid>_*" pattern
|
|
271
|
+
body.buttons.forEach(b => {
|
|
272
|
+
const match = b.customId.match(/^ask_([a-f0-9-]+)_/);
|
|
273
|
+
if (match) {
|
|
274
|
+
const requestId = match[1];
|
|
275
|
+
// Only register once per requestId
|
|
276
|
+
if (!pendingQuestions.has(requestId)) {
|
|
277
|
+
const timeoutId = setTimeout(() => {
|
|
278
|
+
pendingQuestions.delete(requestId);
|
|
279
|
+
questionResponses.delete(requestId);
|
|
280
|
+
log(`Question ${requestId} expired after 5 minutes`);
|
|
281
|
+
}, 300_000); // 5 minutes
|
|
282
|
+
|
|
283
|
+
pendingQuestions.set(requestId, { timeoutId });
|
|
284
|
+
log(`Registered question ${requestId} with 5-minute TTL`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
213
289
|
return new Response(JSON.stringify({
|
|
214
290
|
success: true,
|
|
215
291
|
messageId: message.id,
|
|
@@ -256,6 +332,13 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
256
332
|
pendingTypedAnswers.set(body.data.threadId, requestId);
|
|
257
333
|
}
|
|
258
334
|
|
|
335
|
+
// Clear TTL timeout if it exists
|
|
336
|
+
const pending = pendingQuestions.get(requestId);
|
|
337
|
+
if (pending) {
|
|
338
|
+
clearTimeout(pending.timeoutId);
|
|
339
|
+
pendingQuestions.delete(requestId);
|
|
340
|
+
}
|
|
341
|
+
|
|
259
342
|
// Auto-cleanup after 10 minutes
|
|
260
343
|
setTimeout(() => questionResponses.delete(requestId), 600_000);
|
|
261
344
|
|
|
@@ -310,7 +393,7 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
310
393
|
},
|
|
311
394
|
});
|
|
312
395
|
|
|
313
|
-
log(`HTTP API server listening on
|
|
396
|
+
log(`HTTP API server listening on ${hostname}:${port}`);
|
|
314
397
|
return server;
|
|
315
398
|
}
|
|
316
399
|
|
package/src/bot.ts
CHANGED
|
@@ -162,7 +162,7 @@ client.once(Events.ClientReady, async (c) => {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// Start HTTP API server
|
|
165
|
-
const apiPort = parseInt(process.env.
|
|
165
|
+
const apiPort = parseInt(process.env.TETHER_API_PORT || '2643');
|
|
166
166
|
startApiServer(client, apiPort);
|
|
167
167
|
});
|
|
168
168
|
|
|
@@ -312,19 +312,19 @@ client.on(Events.MessageCreate, async (message: Message) => {
|
|
|
312
312
|
await (message.channel as DMChannel).sendTyping();
|
|
313
313
|
|
|
314
314
|
if (mapping) {
|
|
315
|
-
//
|
|
316
|
-
if (!checkSessionLimits(dmChannelId)) {
|
|
317
|
-
await message.reply('⚠️ Session limit reached. Send `!reset` to start a new session.');
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Handle !reset to start fresh DM session
|
|
315
|
+
// Handle !reset to start fresh DM session (BEFORE session limit check)
|
|
322
316
|
if (content.toLowerCase() === '!reset') {
|
|
323
317
|
db.run('DELETE FROM threads WHERE thread_id = ?', [dmChannelId]);
|
|
324
318
|
await message.reply('🔄 Session reset. Your next message starts a new conversation.');
|
|
325
319
|
return;
|
|
326
320
|
}
|
|
327
321
|
|
|
322
|
+
// Check session limits for ongoing DM session
|
|
323
|
+
if (!checkSessionLimits(dmChannelId)) {
|
|
324
|
+
await message.reply('⚠️ Session limit reached. Send `!reset` to start a new session.');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
328
|
// Resume existing session
|
|
329
329
|
const workingDir = mapping.working_dir ||
|
|
330
330
|
process.env.CLAUDE_WORKING_DIR ||
|
|
@@ -388,6 +388,42 @@ client.on(Events.MessageCreate, async (message: Message) => {
|
|
|
388
388
|
// Message will be held in held_messages table
|
|
389
389
|
return;
|
|
390
390
|
}
|
|
391
|
+
|
|
392
|
+
// If resumed, replay held messages
|
|
393
|
+
if (pauseState.resumed) {
|
|
394
|
+
const heldCount = pauseState.heldMessages?.length || 0;
|
|
395
|
+
if (heldCount > 0) {
|
|
396
|
+
await message.reply(`✅ Resuming — replaying ${heldCount} held message${heldCount !== 1 ? 's' : ''}...`);
|
|
397
|
+
|
|
398
|
+
// Look up session for this thread
|
|
399
|
+
const threadId = message.channel.id;
|
|
400
|
+
const mapping = db.query('SELECT session_id, working_dir FROM threads WHERE thread_id = ?')
|
|
401
|
+
.get(threadId) as { session_id: string; working_dir: string | null } | null;
|
|
402
|
+
|
|
403
|
+
if (mapping) {
|
|
404
|
+
const workingDir = mapping.working_dir ||
|
|
405
|
+
getChannelConfigCached(message.channel.isThread() ? message.channel.parentId || '' : '')?.working_dir ||
|
|
406
|
+
process.env.CLAUDE_WORKING_DIR ||
|
|
407
|
+
process.cwd();
|
|
408
|
+
|
|
409
|
+
// Replay each held message in order
|
|
410
|
+
for (const held of pauseState.heldMessages || []) {
|
|
411
|
+
await claudeQueue.add('process', {
|
|
412
|
+
prompt: held.content,
|
|
413
|
+
threadId,
|
|
414
|
+
sessionId: mapping.session_id,
|
|
415
|
+
resume: true,
|
|
416
|
+
userId: held.author_id,
|
|
417
|
+
username: 'held-message', // We don't have username stored
|
|
418
|
+
workingDir,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
await message.reply('✅ Resumed (no held messages).');
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
391
427
|
|
|
392
428
|
// Feature: Acknowledge message (fire and forget)
|
|
393
429
|
acknowledgeMessage(message).catch(err => log(`Failed to acknowledge message: ${err}`));
|
|
@@ -498,6 +534,7 @@ client.on(Events.MessageCreate, async (message: Message) => {
|
|
|
498
534
|
// This allows us to update the status message later (Processing... → Done)
|
|
499
535
|
let statusMessage;
|
|
500
536
|
let thread;
|
|
537
|
+
let channelContext = '';
|
|
501
538
|
try {
|
|
502
539
|
// Post status message in the channel
|
|
503
540
|
statusMessage = await (message.channel as TextChannel).send('Processing...');
|
|
@@ -512,7 +549,7 @@ client.on(Events.MessageCreate, async (message: Message) => {
|
|
|
512
549
|
});
|
|
513
550
|
|
|
514
551
|
// Feature: Get channel context for new conversations
|
|
515
|
-
|
|
552
|
+
channelContext = await getChannelContext(message.channel as TextChannel);
|
|
516
553
|
if (channelContext) {
|
|
517
554
|
log(`Channel context: ${channelContext.slice(0, 100)}...`);
|
|
518
555
|
}
|
|
@@ -560,6 +597,7 @@ client.on(Events.MessageCreate, async (message: Message) => {
|
|
|
560
597
|
userId: message.author.id,
|
|
561
598
|
username: message.author.tag,
|
|
562
599
|
workingDir,
|
|
600
|
+
channelContext,
|
|
563
601
|
});
|
|
564
602
|
});
|
|
565
603
|
|