@thesammykins/tether 1.0.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 +44 -0
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/bin/tether.ts +1010 -0
- package/index.ts +32 -0
- package/package.json +64 -0
- package/src/adapters/claude.ts +107 -0
- package/src/adapters/codex.ts +77 -0
- package/src/adapters/opencode.ts +83 -0
- package/src/adapters/registry.ts +23 -0
- package/src/adapters/types.ts +17 -0
- package/src/api.ts +494 -0
- package/src/bot.ts +653 -0
- package/src/db.ts +123 -0
- package/src/discord.ts +80 -0
- package/src/features/ack.ts +10 -0
- package/src/features/brb.ts +79 -0
- package/src/features/channel-context.ts +23 -0
- package/src/features/pause-resume.ts +86 -0
- package/src/features/session-limits.ts +48 -0
- package/src/features/thread-naming.ts +33 -0
- package/src/middleware/allowlist.ts +64 -0
- package/src/middleware/rate-limiter.ts +46 -0
- package/src/queue.ts +43 -0
- package/src/spawner.ts +110 -0
- package/src/worker.ts +97 -0
package/src/bot.ts
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
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
|
+
DMChannel,
|
|
18
|
+
ChannelType,
|
|
19
|
+
Partials,
|
|
20
|
+
ThreadAutoArchiveDuration,
|
|
21
|
+
SlashCommandBuilder,
|
|
22
|
+
type Interaction,
|
|
23
|
+
} from 'discord.js';
|
|
24
|
+
import { existsSync } from 'fs';
|
|
25
|
+
import { homedir } from 'os';
|
|
26
|
+
import { resolve } from 'path';
|
|
27
|
+
import { claudeQueue } from './queue.js';
|
|
28
|
+
import { db, getChannelConfigCached, setChannelConfig } from './db.js';
|
|
29
|
+
import { startApiServer, buttonHandlers } from './api.js';
|
|
30
|
+
import { checkAllowlist } from './middleware/allowlist.js';
|
|
31
|
+
import { checkRateLimit } from './middleware/rate-limiter.js';
|
|
32
|
+
import { acknowledgeMessage } from './features/ack.js';
|
|
33
|
+
import { getChannelContext } from './features/channel-context.js';
|
|
34
|
+
import { generateThreadName } from './features/thread-naming.js';
|
|
35
|
+
import { checkSessionLimits } from './features/session-limits.js';
|
|
36
|
+
import { handlePauseResume } from './features/pause-resume.js';
|
|
37
|
+
import { isBrbMessage, isBackMessage, setBrb, setBack } from './features/brb.js';
|
|
38
|
+
import { questionResponses, pendingTypedAnswers } from './api.js';
|
|
39
|
+
|
|
40
|
+
// DM support - opt-in via env var (disabled by default for security)
|
|
41
|
+
const ENABLE_DMS = process.env.ENABLE_DMS === 'true';
|
|
42
|
+
|
|
43
|
+
// Allowed working directories (configurable via env, comma-separated)
|
|
44
|
+
// If not set, any existing directory is allowed (backward compatible)
|
|
45
|
+
const ALLOWED_DIRS = process.env.CORD_ALLOWED_DIRS
|
|
46
|
+
? process.env.CORD_ALLOWED_DIRS.split(',').map(d => resolve(d.trim()))
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate that a path is within the allowed directories.
|
|
51
|
+
* Returns null if valid, or an error message if invalid.
|
|
52
|
+
*/
|
|
53
|
+
function validateWorkingDir(dir: string): string | null {
|
|
54
|
+
// Resolve to absolute path
|
|
55
|
+
const resolved = resolve(dir);
|
|
56
|
+
|
|
57
|
+
// If no allowlist configured, just check existence
|
|
58
|
+
if (!ALLOWED_DIRS) {
|
|
59
|
+
if (!existsSync(resolved)) {
|
|
60
|
+
return `Directory not found: \`${dir}\``;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check against allowlist
|
|
66
|
+
const isAllowed = ALLOWED_DIRS.some(allowed =>
|
|
67
|
+
resolved === allowed || resolved.startsWith(allowed + '/')
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!isAllowed) {
|
|
71
|
+
return `Directory not in allowed list. Allowed: ${ALLOWED_DIRS.join(', ')}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!existsSync(resolved)) {
|
|
75
|
+
return `Directory not found: \`${dir}\``;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Force unbuffered logging
|
|
82
|
+
const log = (msg: string) => process.stdout.write(`[bot] ${msg}\n`);
|
|
83
|
+
|
|
84
|
+
// Helper function to resolve working directory from message or channel config
|
|
85
|
+
function resolveWorkingDir(message: string, channelId: string): { workingDir: string; cleanedMessage: string; error?: string } {
|
|
86
|
+
// Check for [/path] prefix override
|
|
87
|
+
const pathMatch = message.match(/^\[([^\]]+)\]\s*/);
|
|
88
|
+
if (pathMatch && pathMatch[1]) {
|
|
89
|
+
let dir = pathMatch[1];
|
|
90
|
+
// Expand ~ to home directory
|
|
91
|
+
if (dir.startsWith('~')) {
|
|
92
|
+
dir = dir.replace('~', homedir());
|
|
93
|
+
}
|
|
94
|
+
const validationError = validateWorkingDir(dir);
|
|
95
|
+
if (validationError) {
|
|
96
|
+
return {
|
|
97
|
+
workingDir: '',
|
|
98
|
+
cleanedMessage: message.slice(pathMatch[0].length),
|
|
99
|
+
error: validationError
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
workingDir: resolve(dir),
|
|
104
|
+
cleanedMessage: message.slice(pathMatch[0].length)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check channel config (cached)
|
|
109
|
+
const channelConfig = getChannelConfigCached(channelId);
|
|
110
|
+
if (channelConfig?.working_dir) {
|
|
111
|
+
return { workingDir: channelConfig.working_dir, cleanedMessage: message };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Fall back to env or cwd
|
|
115
|
+
return {
|
|
116
|
+
workingDir: process.env.CLAUDE_WORKING_DIR || process.cwd(),
|
|
117
|
+
cleanedMessage: message
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const client = new Client({
|
|
122
|
+
intents: [
|
|
123
|
+
GatewayIntentBits.Guilds,
|
|
124
|
+
GatewayIntentBits.GuildMessages,
|
|
125
|
+
GatewayIntentBits.MessageContent,
|
|
126
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
127
|
+
// DM support — partials required because DM channels are uncached
|
|
128
|
+
...(ENABLE_DMS ? [
|
|
129
|
+
GatewayIntentBits.DirectMessages,
|
|
130
|
+
GatewayIntentBits.DirectMessageReactions,
|
|
131
|
+
] : []),
|
|
132
|
+
],
|
|
133
|
+
// Partials.Channel is required for DMs — DM channels aren't cached by default
|
|
134
|
+
partials: ENABLE_DMS ? [Partials.Channel] : [],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
client.once(Events.ClientReady, async (c) => {
|
|
138
|
+
log(`Logged in as ${c.user.tag}`);
|
|
139
|
+
|
|
140
|
+
// Register slash commands (only if not already registered)
|
|
141
|
+
const existingCommands = await c.application?.commands.fetch();
|
|
142
|
+
const cordCommand = existingCommands?.find(cmd => cmd.name === 'cord');
|
|
143
|
+
|
|
144
|
+
if (!cordCommand) {
|
|
145
|
+
const command = new SlashCommandBuilder()
|
|
146
|
+
.setName('cord')
|
|
147
|
+
.setDescription('Configure Cord bot')
|
|
148
|
+
.addSubcommand(sub =>
|
|
149
|
+
sub.setName('config')
|
|
150
|
+
.setDescription('Configure channel settings')
|
|
151
|
+
.addStringOption(opt =>
|
|
152
|
+
opt.setName('dir')
|
|
153
|
+
.setDescription('Working directory for Claude in this channel')
|
|
154
|
+
.setRequired(true)
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
await c.application?.commands.create(command);
|
|
159
|
+
log('Slash commands registered');
|
|
160
|
+
} else {
|
|
161
|
+
log('Slash commands already registered');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Start HTTP API server
|
|
165
|
+
const apiPort = parseInt(process.env.API_PORT || '2643');
|
|
166
|
+
startApiServer(client, apiPort);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Handle slash command and button interactions
|
|
170
|
+
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
|
171
|
+
// Handle /cord slash command
|
|
172
|
+
if (interaction.isChatInputCommand() && interaction.commandName === 'cord') {
|
|
173
|
+
const subcommand = interaction.options.getSubcommand();
|
|
174
|
+
if (subcommand === 'config') {
|
|
175
|
+
let dir = interaction.options.getString('dir', true);
|
|
176
|
+
|
|
177
|
+
// Expand ~ to home directory
|
|
178
|
+
if (dir.startsWith('~')) {
|
|
179
|
+
dir = dir.replace('~', homedir());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Validate path against allowlist and check existence
|
|
183
|
+
const validationError = validateWorkingDir(dir);
|
|
184
|
+
if (validationError) {
|
|
185
|
+
await interaction.reply({
|
|
186
|
+
content: validationError,
|
|
187
|
+
ephemeral: true
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Resolve to absolute path before storing
|
|
193
|
+
dir = resolve(dir);
|
|
194
|
+
|
|
195
|
+
setChannelConfig(interaction.channelId, dir);
|
|
196
|
+
await interaction.reply({
|
|
197
|
+
content: `Working directory set to \`${dir}\` for this channel.`,
|
|
198
|
+
ephemeral: true
|
|
199
|
+
});
|
|
200
|
+
log(`Channel ${interaction.channelId} configured with working dir: ${dir}`);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!interaction.isButton()) return;
|
|
206
|
+
|
|
207
|
+
log(`Looking up handler for: ${interaction.customId}`);
|
|
208
|
+
log(`Available handlers: ${Array.from(buttonHandlers.keys()).join(', ') || 'none'}`);
|
|
209
|
+
const handler = buttonHandlers.get(interaction.customId);
|
|
210
|
+
if (!handler) {
|
|
211
|
+
log(`No handler found for: ${interaction.customId}`);
|
|
212
|
+
await interaction.reply({ content: 'This button has expired.', ephemeral: true });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
if (handler.type === 'inline') {
|
|
218
|
+
await interaction.reply({
|
|
219
|
+
content: handler.content,
|
|
220
|
+
ephemeral: handler.ephemeral ?? false,
|
|
221
|
+
});
|
|
222
|
+
} else if (handler.type === 'webhook') {
|
|
223
|
+
await interaction.deferReply({ ephemeral: true });
|
|
224
|
+
const response = await fetch(handler.url, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'Content-Type': 'application/json' },
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
customId: interaction.customId,
|
|
229
|
+
userId: interaction.user.id,
|
|
230
|
+
channelId: interaction.channelId,
|
|
231
|
+
data: handler.data,
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
const result = await response.json() as { content?: string };
|
|
235
|
+
|
|
236
|
+
// If this was a "Type answer" button, prompt the user to type below
|
|
237
|
+
const isTypeAnswer = handler.data && (handler.data as Record<string, unknown>).option === '__type__';
|
|
238
|
+
await interaction.editReply({
|
|
239
|
+
content: isTypeAnswer
|
|
240
|
+
? '✏️ Type your answer below in this thread — your next message will be captured.'
|
|
241
|
+
: (result.content || 'Done.'),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
log(`Button handler error: ${error}`);
|
|
246
|
+
if (!interaction.replied && !interaction.deferred) {
|
|
247
|
+
await interaction.reply({ content: 'An error occurred.', ephemeral: true });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
client.on(Events.MessageCreate, async (message: Message) => {
|
|
253
|
+
// Ignore bots
|
|
254
|
+
if (message.author.bot) return;
|
|
255
|
+
|
|
256
|
+
// =========================================================================
|
|
257
|
+
// DM MESSAGES: Direct messages to the bot
|
|
258
|
+
// =========================================================================
|
|
259
|
+
const isDM = message.channel.type === ChannelType.DM;
|
|
260
|
+
|
|
261
|
+
if (isDM) {
|
|
262
|
+
if (!ENABLE_DMS) return; // DMs disabled — silently ignore
|
|
263
|
+
|
|
264
|
+
// Middleware: Check allowlist (user only — no roles/channels in DMs)
|
|
265
|
+
if (!checkAllowlist(message)) return;
|
|
266
|
+
|
|
267
|
+
// Middleware: Check rate limits
|
|
268
|
+
if (!checkRateLimit(message.author.id)) {
|
|
269
|
+
await message.reply('⏳ Rate limit exceeded. Please wait a moment before trying again.');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Feature: Acknowledge message
|
|
274
|
+
acknowledgeMessage(message).catch(err => log(`Failed to acknowledge DM: ${err}`));
|
|
275
|
+
|
|
276
|
+
const content = message.content.trim();
|
|
277
|
+
if (!content) return;
|
|
278
|
+
|
|
279
|
+
// BRB/back detection for DM channels
|
|
280
|
+
const dmChannelId = message.channel.id;
|
|
281
|
+
if (isBrbMessage(content)) {
|
|
282
|
+
setBrb(dmChannelId);
|
|
283
|
+
await message.reply("👋 Got it — I'll send questions here when I need your input. Say **back** when you return.");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (isBackMessage(content)) {
|
|
287
|
+
setBack(dmChannelId);
|
|
288
|
+
await message.reply('👋 Welcome back! Normal prompts from here.');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Typed answer capture — if a pending typed answer exists for this DM channel,
|
|
293
|
+
// store the user's message as the real response instead of queuing it
|
|
294
|
+
if (pendingTypedAnswers.has(dmChannelId)) {
|
|
295
|
+
const requestId = pendingTypedAnswers.get(dmChannelId)!;
|
|
296
|
+
questionResponses.set(requestId, { answer: content, optionIndex: -1 });
|
|
297
|
+
pendingTypedAnswers.delete(dmChannelId);
|
|
298
|
+
await message.reply('✅ Answer received.');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
log(`DM from ${message.author.tag}: ${content.slice(0, 80)}...`);
|
|
303
|
+
|
|
304
|
+
// DMs use a synthetic "thread" ID based on the DM channel for session tracking.
|
|
305
|
+
// Each DM channel maps 1:1 to a user, so channelId is the session key.
|
|
306
|
+
|
|
307
|
+
// Look up existing session for this DM channel
|
|
308
|
+
const mapping = db.query('SELECT session_id, working_dir FROM threads WHERE thread_id = ?')
|
|
309
|
+
.get(dmChannelId) as { session_id: string; working_dir: string | null } | null;
|
|
310
|
+
|
|
311
|
+
// Show typing indicator
|
|
312
|
+
await (message.channel as DMChannel).sendTyping();
|
|
313
|
+
|
|
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
|
|
322
|
+
if (content.toLowerCase() === '!reset') {
|
|
323
|
+
db.run('DELETE FROM threads WHERE thread_id = ?', [dmChannelId]);
|
|
324
|
+
await message.reply('🔄 Session reset. Your next message starts a new conversation.');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Resume existing session
|
|
329
|
+
const workingDir = mapping.working_dir ||
|
|
330
|
+
process.env.CLAUDE_WORKING_DIR ||
|
|
331
|
+
process.cwd();
|
|
332
|
+
|
|
333
|
+
await claudeQueue.add('process', {
|
|
334
|
+
prompt: content,
|
|
335
|
+
threadId: dmChannelId,
|
|
336
|
+
sessionId: mapping.session_id,
|
|
337
|
+
resume: true,
|
|
338
|
+
userId: message.author.id,
|
|
339
|
+
username: message.author.tag,
|
|
340
|
+
workingDir,
|
|
341
|
+
});
|
|
342
|
+
} else {
|
|
343
|
+
// New DM session
|
|
344
|
+
const sessionId = crypto.randomUUID();
|
|
345
|
+
const { workingDir, cleanedMessage, error: workingDirError } = resolveWorkingDir(content, dmChannelId);
|
|
346
|
+
|
|
347
|
+
if (workingDirError) {
|
|
348
|
+
await message.reply(workingDirError);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Store DM channel → session mapping
|
|
353
|
+
db.run(
|
|
354
|
+
'INSERT INTO threads (thread_id, session_id, working_dir) VALUES (?, ?, ?)',
|
|
355
|
+
[dmChannelId, sessionId, workingDir]
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
log(`New DM session ${sessionId} for ${message.author.tag}`);
|
|
359
|
+
|
|
360
|
+
await claudeQueue.add('process', {
|
|
361
|
+
prompt: cleanedMessage,
|
|
362
|
+
threadId: dmChannelId,
|
|
363
|
+
sessionId,
|
|
364
|
+
resume: false,
|
|
365
|
+
userId: message.author.id,
|
|
366
|
+
username: message.author.tag,
|
|
367
|
+
workingDir,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Middleware: Check allowlist (users, roles, channels)
|
|
375
|
+
if (!checkAllowlist(message)) {
|
|
376
|
+
return; // Silently ignore messages from non-allowed users/channels
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Middleware: Check rate limits
|
|
380
|
+
if (!checkRateLimit(message.author.id)) {
|
|
381
|
+
await message.reply('⏳ Rate limit exceeded. Please wait a moment before trying again.');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Feature: Handle pause/resume
|
|
386
|
+
const pauseState = handlePauseResume(message);
|
|
387
|
+
if (pauseState.paused) {
|
|
388
|
+
// Message will be held in held_messages table
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Feature: Acknowledge message (fire and forget)
|
|
393
|
+
acknowledgeMessage(message).catch(err => log(`Failed to acknowledge message: ${err}`));
|
|
394
|
+
|
|
395
|
+
// Typed answer capture — if a pending typed answer exists for this channel,
|
|
396
|
+
// store the user's message as the real response instead of normal processing.
|
|
397
|
+
// This handles the "✏️ Type answer" flow from `tether ask` in regular channels.
|
|
398
|
+
const channelId = message.channel.id;
|
|
399
|
+
if (pendingTypedAnswers.has(channelId)) {
|
|
400
|
+
const requestId = pendingTypedAnswers.get(channelId)!;
|
|
401
|
+
const content = message.content.trim();
|
|
402
|
+
if (content) {
|
|
403
|
+
questionResponses.set(requestId, { answer: content, optionIndex: -1 });
|
|
404
|
+
pendingTypedAnswers.delete(channelId);
|
|
405
|
+
await message.reply('✅ Answer received.');
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const isMentioned = client.user && message.mentions.has(client.user);
|
|
411
|
+
const isInThread = message.channel.isThread();
|
|
412
|
+
|
|
413
|
+
// =========================================================================
|
|
414
|
+
// THREAD MESSAGES: Continue existing conversations
|
|
415
|
+
// =========================================================================
|
|
416
|
+
if (isInThread) {
|
|
417
|
+
const thread = message.channel;
|
|
418
|
+
|
|
419
|
+
// Look up session ID and working dir for this thread
|
|
420
|
+
const mapping = db.query('SELECT session_id, working_dir FROM threads WHERE thread_id = ?')
|
|
421
|
+
.get(thread.id) as { session_id: string; working_dir: string | null } | null;
|
|
422
|
+
|
|
423
|
+
if (!mapping) {
|
|
424
|
+
// Not a thread we created, ignore
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Extract message content (strip @mentions)
|
|
429
|
+
const threadContent = message.content.replace(/<@!?\d+>/g, '').trim();
|
|
430
|
+
|
|
431
|
+
// BRB/back detection for threads
|
|
432
|
+
if (isBrbMessage(threadContent)) {
|
|
433
|
+
setBrb(thread.id);
|
|
434
|
+
await message.reply("👋 Got it — I'll send questions here when I need your input. Say **back** when you return.");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (isBackMessage(threadContent)) {
|
|
438
|
+
setBack(thread.id);
|
|
439
|
+
await message.reply('👋 Welcome back! Normal prompts from here.');
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Typed answer capture — if a pending typed answer exists for this thread,
|
|
444
|
+
// store the user's message as the real response instead of queuing it
|
|
445
|
+
if (pendingTypedAnswers.has(thread.id)) {
|
|
446
|
+
const requestId = pendingTypedAnswers.get(thread.id)!;
|
|
447
|
+
questionResponses.set(requestId, { answer: threadContent, optionIndex: -1 });
|
|
448
|
+
pendingTypedAnswers.delete(thread.id);
|
|
449
|
+
await message.reply('✅ Answer received.');
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
log(`Thread message from ${message.author.tag}`);
|
|
454
|
+
|
|
455
|
+
// Show typing indicator
|
|
456
|
+
await thread.sendTyping();
|
|
457
|
+
|
|
458
|
+
// Use stored working dir or fall back to channel config / env / cwd
|
|
459
|
+
const workingDir = mapping.working_dir ||
|
|
460
|
+
getChannelConfigCached(thread.parentId || '')?.working_dir ||
|
|
461
|
+
process.env.CLAUDE_WORKING_DIR ||
|
|
462
|
+
process.cwd();
|
|
463
|
+
|
|
464
|
+
// Queue for Claude processing with session resume
|
|
465
|
+
await claudeQueue.add('process', {
|
|
466
|
+
prompt: threadContent,
|
|
467
|
+
threadId: thread.id,
|
|
468
|
+
sessionId: mapping.session_id,
|
|
469
|
+
resume: true,
|
|
470
|
+
userId: message.author.id,
|
|
471
|
+
username: message.author.tag,
|
|
472
|
+
workingDir,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// =========================================================================
|
|
479
|
+
// NEW MENTIONS: Start new conversations
|
|
480
|
+
// =========================================================================
|
|
481
|
+
if (!isMentioned) return;
|
|
482
|
+
|
|
483
|
+
log(`New mention from ${message.author.tag}`);
|
|
484
|
+
|
|
485
|
+
// Extract message content and resolve working directory
|
|
486
|
+
const rawText = message.content.replace(/<@!?\d+>/g, '').trim();
|
|
487
|
+
const { workingDir, cleanedMessage, error: workingDirError } = resolveWorkingDir(rawText, message.channelId);
|
|
488
|
+
|
|
489
|
+
// If path override validation failed, reply with error
|
|
490
|
+
if (workingDirError) {
|
|
491
|
+
await message.reply(workingDirError);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
log(`Working directory: ${workingDir}`);
|
|
496
|
+
|
|
497
|
+
// Post status message in channel, then create thread from it
|
|
498
|
+
// This allows us to update the status message later (Processing... → Done)
|
|
499
|
+
let statusMessage;
|
|
500
|
+
let thread;
|
|
501
|
+
try {
|
|
502
|
+
// Post status message in the channel
|
|
503
|
+
statusMessage = await (message.channel as TextChannel).send('Processing...');
|
|
504
|
+
|
|
505
|
+
// Generate thread name from cleaned message content
|
|
506
|
+
const threadName = generateThreadName(cleanedMessage);
|
|
507
|
+
|
|
508
|
+
// Create thread from the status message
|
|
509
|
+
thread = await statusMessage.startThread({
|
|
510
|
+
name: threadName,
|
|
511
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Feature: Get channel context for new conversations
|
|
515
|
+
const channelContext = await getChannelContext(message.channel as TextChannel);
|
|
516
|
+
if (channelContext) {
|
|
517
|
+
log(`Channel context: ${channelContext.slice(0, 100)}...`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Copy the original message content into the thread for context
|
|
521
|
+
// (excluding the bot mention and the status message)
|
|
522
|
+
const originalMessages = await message.channel.messages.fetch({ limit: 10 });
|
|
523
|
+
const userMessage = originalMessages.find(m => m.id === message.id);
|
|
524
|
+
if (userMessage) {
|
|
525
|
+
await thread.send(`**${message.author.tag}:** ${cleanedMessage}`);
|
|
526
|
+
}
|
|
527
|
+
} catch (error) {
|
|
528
|
+
log(`Failed to create thread: ${error}`);
|
|
529
|
+
await message.reply('Failed to start thread. Try again?');
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Generate a new session ID for this conversation
|
|
534
|
+
const sessionId = crypto.randomUUID();
|
|
535
|
+
|
|
536
|
+
// Feature: Check session limits before spawning
|
|
537
|
+
if (!checkSessionLimits(thread.id)) {
|
|
538
|
+
await thread.send('⚠️ Session limit reached. Please wait before starting a new conversation.');
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Store the thread → session mapping with working directory
|
|
543
|
+
// Note: thread.id === statusMessage.id because thread was created from that message
|
|
544
|
+
db.run(
|
|
545
|
+
'INSERT INTO threads (thread_id, session_id, working_dir) VALUES (?, ?, ?)',
|
|
546
|
+
[thread.id, sessionId, workingDir]
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
log(`Created thread ${thread.id} with session ${sessionId}`);
|
|
550
|
+
|
|
551
|
+
// Show typing indicator
|
|
552
|
+
await thread.sendTyping();
|
|
553
|
+
|
|
554
|
+
// Queue for Claude processing
|
|
555
|
+
await claudeQueue.add('process', {
|
|
556
|
+
prompt: cleanedMessage,
|
|
557
|
+
threadId: thread.id,
|
|
558
|
+
sessionId,
|
|
559
|
+
resume: false,
|
|
560
|
+
userId: message.author.id,
|
|
561
|
+
username: message.author.tag,
|
|
562
|
+
workingDir,
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// =========================================================================
|
|
567
|
+
// REACTION HANDLER: ✅ on last message marks thread as done
|
|
568
|
+
// =========================================================================
|
|
569
|
+
client.on(Events.MessageReactionAdd, async (reaction, user) => {
|
|
570
|
+
// Ignore bot reactions
|
|
571
|
+
if (user.bot) return;
|
|
572
|
+
|
|
573
|
+
// Only handle ✅ reactions
|
|
574
|
+
if (reaction.emoji.name !== '✅') return;
|
|
575
|
+
|
|
576
|
+
// Only handle reactions in threads
|
|
577
|
+
const channel = reaction.message.channel;
|
|
578
|
+
if (!channel.isThread()) return;
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
const thread = channel;
|
|
582
|
+
const parentChannelId = thread.parentId;
|
|
583
|
+
if (!parentChannelId) return;
|
|
584
|
+
|
|
585
|
+
// Check if this is the last message in the thread
|
|
586
|
+
const messages = await thread.messages.fetch({ limit: 1 });
|
|
587
|
+
const lastMessage = messages.first();
|
|
588
|
+
|
|
589
|
+
if (!lastMessage || lastMessage.id !== reaction.message.id) {
|
|
590
|
+
// Reaction is not on the last message, ignore
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
log(`✅ reaction on last message in thread ${thread.id}`);
|
|
595
|
+
|
|
596
|
+
// Update thread starter message to "Done"
|
|
597
|
+
// The thread ID equals the starter message ID (thread was created from that message)
|
|
598
|
+
const parentChannel = await client.channels.fetch(parentChannelId);
|
|
599
|
+
if (parentChannel?.isTextBased()) {
|
|
600
|
+
const starterMessage = await (parentChannel as TextChannel).messages.fetch(thread.id);
|
|
601
|
+
await starterMessage.edit('✅ Done');
|
|
602
|
+
log(`Thread ${thread.id} marked as Done`);
|
|
603
|
+
}
|
|
604
|
+
} catch (error) {
|
|
605
|
+
log(`Failed to mark thread done: ${error}`);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// Start the bot with exponential backoff
|
|
610
|
+
const token = process.env.DISCORD_BOT_TOKEN;
|
|
611
|
+
if (!token) {
|
|
612
|
+
console.error('DISCORD_BOT_TOKEN required');
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Exponential backoff for Discord gateway connection (Issue #7)
|
|
617
|
+
async function connectWithBackoff() {
|
|
618
|
+
let attempt = 0;
|
|
619
|
+
let delay = 1000; // Start at 1 second
|
|
620
|
+
const maxDelay = 30000; // Cap at 30 seconds
|
|
621
|
+
|
|
622
|
+
while (true) {
|
|
623
|
+
try {
|
|
624
|
+
log(`Connecting to Discord gateway (attempt ${attempt + 1})...`);
|
|
625
|
+
await client.login(token);
|
|
626
|
+
break; // Connection successful
|
|
627
|
+
} catch (error: any) {
|
|
628
|
+
// Fatal errors - don't retry
|
|
629
|
+
if (error.code === 'TokenInvalid' ||
|
|
630
|
+
error.message?.includes('invalid token') ||
|
|
631
|
+
error.message?.includes('Incorrect login') ||
|
|
632
|
+
error.code === 'DisallowedIntents' ||
|
|
633
|
+
error.message?.includes('intents')) {
|
|
634
|
+
log(`Fatal error: ${error.message}`);
|
|
635
|
+
process.exit(1);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Transient errors - retry with backoff
|
|
639
|
+
log(`Connection failed: ${error.message}`);
|
|
640
|
+
attempt++;
|
|
641
|
+
log(`Retrying in ${delay}ms...`);
|
|
642
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
643
|
+
|
|
644
|
+
// Exponential backoff: double the delay, cap at maxDelay
|
|
645
|
+
delay = Math.min(delay * 2, maxDelay);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
connectWithBackoff();
|
|
651
|
+
|
|
652
|
+
// Export for external use
|
|
653
|
+
export { client };
|