@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/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 };