@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.
@@ -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.0.0",
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
- const buffer = Buffer.from(body.fileContent, 'utf-8');
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
- const buffer = Buffer.from(body.fileContent, 'utf-8');
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(styleMap[b.style] || ButtonStyle.Primary);
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 port ${port}`);
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.API_PORT || '2643');
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
- // Check session limits for ongoing DM session
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
- const channelContext = await getChannelContext(message.channel as TextChannel);
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