• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 25213279233

01 May 2026 11:50AM UTC coverage: 90.17% (-0.03%) from 90.197%
25213279233

push

github

web-flow
Merge pull request #647 from VolvoxLLC/bill/add-sentry-to-project

9985 of 11705 branches covered (85.31%)

Branch coverage included in aggregate %.

607 of 669 new or added lines in 9 files covered. (90.73%)

1 existing line in 1 file now uncovered.

15737 of 16821 relevant lines covered (93.56%)

168.28 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.54
/src/index.js
1
/**
2
 * Volvox.Bot - Volvox Discord Bot
3
 * Main entry point - orchestrates modules
4
 *
5
 * Features:
6
 * - AI chat powered by the configured provider (see src/data/providers.json)
7
 * - Welcome messages for new members
8
 * - Spam/scam detection and moderation
9
 * - Health monitoring and status command
10
 * - Graceful shutdown handling
11
 * - Structured logging
12
 */
13

14
// Sentry loads dotenv/config before initialization and must be imported before
15
// application modules to instrument them.
16
import './sentry.js';
17

18
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
import { dirname, join } from 'node:path';
20
import { fileURLToPath } from 'node:url';
21
import { Client, Collection, GatewayIntentBits, Partials } from 'discord.js';
22
import { startServer, stopServer, updateServerDbPool } from './api/server.js';
23
import { registerConfigListeners, removeLoggingTransport } from './config-listeners.js';
24
import { closeDb, getPool, initDb } from './db.js';
25
import { addWebSocketTransport, error, info, removeWebSocketTransport, warn } from './logger.js';
26
import {
27
  getConversationHistory,
28
  initConversationHistory,
29
  setConversationHistory,
30
  setPool,
31
  startConversationCleanup,
32
  stopConversationCleanup,
33
} from './modules/ai.js';
34
import { startBotStatus, stopBotStatus } from './modules/botStatus.js';
35
import { loadConfig } from './modules/config.js';
36
import { startEngagementFlushInterval, stopEngagementFlushInterval } from './modules/engagement.js';
37

38
import { registerEventHandlers } from './modules/events.js';
39
import { startGithubFeed, stopGithubFeed } from './modules/githubFeed.js';
40
import { checkMem0Health, markUnavailable } from './modules/memory.js';
41
import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js';
42
import { loadOptOuts } from './modules/optout.js';
43
import { seedBuiltinTemplates } from './modules/roleMenuTemplates.js';
44
import { startScheduler, stopScheduler } from './modules/scheduler.js';
45
import { startTriage, stopTriage } from './modules/triage.js';
46
import {
47
  startWarningExpiryScheduler,
48
  stopWarningExpiryScheduler,
49
} from './modules/warningEngine.js';
50
import { closeRedisClient as closeRedis, initRedis } from './redis.js';
51
import { preloadSDK } from './utils/aiClient.js';
52
import { stopCacheCleanup } from './utils/cache.js';
53
import { HealthMonitor } from './utils/health.js';
54
import { loadCommandsFromDirectory } from './utils/loadCommands.js';
55
import { recordRestart, updateUptimeOnShutdown } from './utils/restartTracker.js';
56

57
// ES module dirname equivalent
58
const __filename = fileURLToPath(import.meta.url);
23✔
59
const __dirname = dirname(__filename);
23✔
60

61
// State persistence path
62
const dataDir = join(__dirname, '..', 'data');
23✔
63
const statePath = join(dataDir, 'state.json');
23✔
64
const TELEMETRY_SHUTDOWN_FLUSH_TIMEOUT_MS = 2_000;
23✔
65

66
// Package version (for restart tracking)
67
let BOT_VERSION = 'unknown';
23✔
68
try {
23✔
69
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
23✔
70
  BOT_VERSION = pkg.version;
23✔
71
} catch {
72
  // package.json unreadable — version stays 'unknown'
73
}
74

75
// Config is loaded asynchronously after DB init (see startup below).
76
// After loadConfig() resolves, `config` points to the same object as
77
// configCache inside modules/config.js, so in-place mutations from
78
// setConfigValue() propagate here automatically without re-assignment.
79
let config = {};
23✔
80

81
// Initialize Discord client with required intents.
82
//
83
// INTENTIONAL DESIGN: allowedMentions restricts which mention types Discord
84
// will parse. Only 'users' is allowed — @everyone, @here, and role mentions
85
// are ALL blocked globally at the Client level. This is a defense-in-depth
86
// measure to prevent the bot from ever mass-pinging, even if AI-generated
87
// or user-supplied content contains @everyone/@here or <@&roleId>.
88
//
89
// To opt-in to role mentions in the future, add 'roles' to the parse array
90
// below (e.g. { parse: ['users', 'roles'] }). You would also need to update
91
// SAFE_ALLOWED_MENTIONS in src/utils/safeSend.js to match.
92
//
93
// See: https://github.com/VolvoxLLC/volvox-bot/issues/61
94
const client = new Client({
23✔
95
  intents: [
96
    GatewayIntentBits.Guilds,
97
    GatewayIntentBits.GuildMessages,
98
    GatewayIntentBits.MessageContent,
99
    GatewayIntentBits.GuildMembers,
100
    GatewayIntentBits.GuildVoiceStates,
101
    GatewayIntentBits.GuildMessageReactions,
102
    GatewayIntentBits.GuildPresences,
103
  ],
104
  partials: [Partials.Message, Partials.Reaction],
105
  allowedMentions: { parse: ['users'] },
106
});
107

108
// Initialize command collection
109
client.commands = new Collection();
23✔
110

111
// Initialize health monitor
112
const healthMonitor = HealthMonitor.getInstance();
23✔
113

114
/**
115
 * Save conversation history to disk
116
 */
117
function saveState() {
118
  try {
6✔
119
    // Ensure data directory exists
120
    if (!existsSync(dataDir)) {
6!
121
      mkdirSync(dataDir, { recursive: true });
6✔
122
    }
123

124
    const conversationHistory = getConversationHistory();
6✔
125
    const stateData = {
6✔
126
      conversationHistory: Array.from(conversationHistory.entries()),
127
      timestamp: new Date().toISOString(),
128
    };
129
    writeFileSync(statePath, JSON.stringify(stateData, null, 2), 'utf-8');
6✔
130
    info('State saved successfully');
6✔
131
  } catch (err) {
132
    error('Failed to save state', { error: err.message });
1✔
133
  }
134
}
135

136
/**
137
 * Load conversation history from disk
138
 */
139
function loadState() {
140
  try {
19✔
141
    if (!existsSync(statePath)) {
19✔
142
      return;
16✔
143
    }
144
    const stateData = JSON.parse(readFileSync(statePath, 'utf-8'));
3✔
145
    if (stateData.conversationHistory) {
3✔
146
      setConversationHistory(new Map(stateData.conversationHistory));
1✔
147
      info('State loaded successfully');
1✔
148
    }
149
  } catch (err) {
150
    error('Failed to load state', { error: err.message });
2✔
151
  }
152
}
153

154
/**
155
 * Await a promise with a bounded timeout, clearing the timer when either side settles.
156
 * @param {Promise<unknown>} promise - Promise to await.
157
 * @param {number} timeoutMs - Timeout in milliseconds.
158
 * @param {string} timeoutMessage - Error message to use if the timeout wins.
159
 * @returns {Promise<unknown>} The original promise resolution.
160
 */
161
async function withTimeout(promise, timeoutMs, timeoutMessage) {
162
  let timeoutId;
163
  try {
6✔
164
    return await Promise.race([
6✔
165
      promise,
166
      new Promise((_, reject) => {
167
        timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
6✔
168
      }),
169
    ]);
170
  } finally {
171
    clearTimeout(timeoutId);
6✔
172
  }
173
}
174

175
/**
176
 * Load all commands from the commands directory
177
 */
178
async function loadCommands() {
179
  const commandsPath = join(__dirname, 'commands');
18✔
180

181
  await loadCommandsFromDirectory({
18✔
182
    commandsPath,
183
    onCommandLoaded: (command) => {
184
      client.commands.set(command.data.name, command);
×
185
    },
186
  });
187
}
188

189
// Event handlers (including slash commands, errors, and shard disconnect)
190
// are registered via registerEventHandlers() after config loads — see startup below.
191

192
/**
193
 * Perform an orderly shutdown of the bot: stop background services, persist runtime state, close external resources, and exit the process.
194
 * @param {string} signal - The OS signal that triggered shutdown (e.g., "SIGINT" or "SIGTERM").
195
 */
196
async function gracefulShutdown(signal) {
197
  info('Shutdown initiated', { signal });
6✔
198

199
  // 1. Stop triage, conversation cleanup timer, tempban scheduler, announcement scheduler, and GitHub feed
200
  stopTriage();
6✔
201
  stopConversationCleanup();
6✔
202
  stopTempbanScheduler();
6✔
203
  stopWarningExpiryScheduler();
6✔
204
  stopScheduler();
6✔
205
  stopGithubFeed();
6✔
206
  stopBotStatus();
6✔
207

208
  // 1.5. Stop API server (drain in-flight HTTP requests before closing DB)
209
  try {
6✔
210
    await stopServer();
6✔
211
  } catch (err) {
212
    error('Failed to stop API server', { error: err.message });
×
213
  }
214

215
  // 2. Save state
216
  info('Saving conversation state');
6✔
217
  saveState();
6✔
218

219
  // 3. Run the legacy logging transport shutdown hook.
220
  try {
6✔
221
    await removeLoggingTransport();
6✔
222
  } catch (err) {
223
    error('Failed to close logging transport', { error: err.message });
×
224
  }
225

226
  // 3.5. Flush any buffered engagement writes (messages_sent / reactions) before closing DB
227
  try {
6✔
228
    await stopEngagementFlushInterval();
6✔
229
  } catch (err) {
230
    warn('Failed to flush engagement buffer on shutdown', { error: err.message });
×
231
  }
232

233
  // 3.6. Record uptime before closing the pool
234
  try {
6✔
235
    const pool = getPool();
6✔
236
    await updateUptimeOnShutdown(pool);
6✔
237
  } catch (err) {
238
    warn('Failed to record uptime on shutdown', { error: err.message, module: 'shutdown' });
6✔
239
  }
240

241
  // 4. Close database pool
242
  info('Closing database connection');
6✔
243
  try {
6✔
244
    await closeDb();
6✔
245
  } catch (err) {
246
    error('Failed to close database pool', { error: err.message });
×
247
  }
248

249
  // 4.5. Close Redis connection (no-op if Redis was never configured)
250
  try {
6✔
251
    stopCacheCleanup();
6✔
252
    await closeRedis();
6✔
253
  } catch (err) {
254
    error('Failed to close Redis connection', { error: err.message });
×
255
  }
256

257
  // 5. Destroy Discord client
258
  info('Disconnecting from Discord');
6✔
259
  client.destroy();
6✔
260

261
  // 6. Log clean exit before the final telemetry flush so shutdown logs are delivered
262
  info('Shutdown complete');
6✔
263

264
  // 7. Flush telemetry events after final shutdown logs and before exit (no-op if disabled)
265
  try {
6✔
266
    const amplitude = await import('./amplitude.js');
6✔
267
    const amplitudeFlushed = await withTimeout(
6✔
268
      amplitude.flushAmplitude(),
269
      TELEMETRY_SHUTDOWN_FLUSH_TIMEOUT_MS,
270
      'Amplitude flush timed out',
271
    );
272

273
    if (amplitude.isAmplitudeEnabled() && !amplitudeFlushed) {
5✔
274
      warn('Failed to flush Amplitude events on shutdown', { error: 'Amplitude flush failed' });
1✔
275
    }
276
  } catch (err) {
277
    warn('Failed to flush Amplitude events on shutdown', { error: err.message });
1✔
278
  }
279

280
  try {
6✔
281
    const { Sentry } = await import('./sentry.js');
6✔
282
    await Sentry.flush(TELEMETRY_SHUTDOWN_FLUSH_TIMEOUT_MS);
6✔
283
  } catch (err) {
NEW
284
    warn('Failed to flush Sentry events on shutdown', { error: err.message });
×
285
  }
286

287
  process.exit(0);
6✔
288
}
289

290
// Handle shutdown signals
291
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
23✔
292
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
23✔
293

294
// Start bot
295
const token = process.env.DISCORD_TOKEN;
23✔
296
if (!token) {
23✔
297
  error('DISCORD_TOKEN not set');
1✔
298
  process.exit(1);
1✔
299
}
300

301
/**
302
 * Determine whether the application may continue startup when database initialization fails.
303
 *
304
 * @returns {boolean} `true` if database startup failures are allowed — either `ALLOW_DATABASE_STARTUP_FAILURE` is `'true'` or `RAILWAY_ENVIRONMENT_NAME` matches the `volvox-bot-pr-<number>` pattern (case-insensitive); `false` otherwise.
305
 */
306
function canContinueWithoutDatabase() {
307
  const environmentName = process.env.RAILWAY_ENVIRONMENT_NAME ?? '';
1✔
308
  const isRailwayPullRequestEnvironment = /^volvox-bot-pr-\d+$/i.test(environmentName);
1✔
309
  return process.env.ALLOW_DATABASE_STARTUP_FAILURE === 'true' || isRailwayPullRequestEnvironment;
1✔
310
}
311

312
async function startRestApiWithWebSocketTransport() {
313
  let wsTransport = null;
22✔
314
  try {
22✔
315
    wsTransport = addWebSocketTransport();
22✔
316
    await startServer(client, null, { wsTransport });
22✔
317
  } catch (err) {
318
    // Clean up orphaned transport if startServer failed after it was created.
319
    if (wsTransport) {
22!
NEW
320
      removeWebSocketTransport(wsTransport);
×
321
    }
322
    error('REST API server failed to start — continuing without API', { error: err.message });
22✔
323
  }
324
}
325

326
async function initializeDatabaseForStartup() {
327
  if (!process.env.DATABASE_URL) {
22✔
328
    warn('DATABASE_URL not set — using config.json only (no persistence)');
16✔
329
    return null;
16✔
330
  }
331

332
  try {
6✔
333
    const dbPool = await initDb();
6✔
334
    updateServerDbPool(dbPool);
5✔
335
    initRedis();
5✔
336
    info('Database initialized');
5✔
337

338
    await recordRestart(dbPool, 'startup', BOT_VERSION);
5✔
339
    await seedBuiltinTemplates().catch((err) =>
5✔
340
      warn('Failed to seed built-in role menu templates', { error: err.message }),
341
    );
342

343
    return dbPool;
5✔
344
  } catch (err) {
345
    if (!canContinueWithoutDatabase()) {
1!
346
      throw err;
1✔
347
    }
348

NEW
349
    warn('Database initialization failed — continuing without persistence for preview deployment', {
×
350
      error: err.message,
351
      railwayEnvironment: process.env.RAILWAY_ENVIRONMENT_NAME ?? null,
×
352
    });
353
    return null;
1✔
354
  }
355
}
356

357
async function initializePostgresLogging(dbPool, currentConfig) {
358
  if (!dbPool || !currentConfig.logging?.database?.enabled) {
19✔
359
    return;
16✔
360
  }
361

362
  try {
3✔
363
    const transport = addPostgresTransport(dbPool, currentConfig.logging.database);
3✔
364
    setInitialTransport(transport);
3✔
365
    info('PostgreSQL logging transport enabled');
3✔
366

367
    const retentionDays = currentConfig.logging.database.retentionDays ?? 30;
3!
368
    const pruned = await pruneOldLogs(dbPool, retentionDays);
19✔
NEW
369
    if (pruned > 0) {
×
NEW
370
      info('Pruned old log entries', { pruned, retentionDays });
×
371
    }
372
  } catch (err) {
373
    error('Failed to initialize PostgreSQL logging transport', { error: err.message });
3✔
374
  }
375
}
376

377
async function checkMemoryAvailability() {
378
  const healthAbort = new AbortController();
19✔
379
  try {
19✔
380
    await Promise.race([
19✔
381
      checkMem0Health({ signal: healthAbort.signal }),
382
      new Promise((_, reject) =>
383
        setTimeout(() => {
19✔
384
          healthAbort.abort();
1✔
385
          reject(new Error('mem0 health check timed out'));
1✔
386
        }, 10_000),
387
      ),
388
    ]);
389
  } catch (err) {
390
    markUnavailable();
2✔
391
    warn('mem0 health check timed out or failed — continuing without memory features', {
2✔
392
      error: err.message,
393
    });
394
  }
395
}
396

397
function applySentryBotContext() {
398
  import('./sentry.js')
18✔
399
    .then(({ Sentry, sentryEnabled }) => {
400
      if (sentryEnabled) {
18!
NEW
401
        Sentry.setTag('bot.username', client.user?.tag || 'unknown');
×
NEW
402
        Sentry.setTag('bot.version', BOT_VERSION);
×
NEW
403
        info('Sentry error monitoring enabled', {
×
404
          environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production',
×
405
        });
406
      }
407
    })
408
    .catch(() => {});
409
}
410

411
/**
412
 * Perform full application startup: initialize the database, load configuration and conversation history, start background services (conversation cleanup, memory checks, triage, tempban scheduler), register event handlers, load slash commands, and log the Discord client in.
413
 */
414
async function startup() {
415
  // Pre-warm AI SDK in background (non-blocking) — avoids 6s import delay on first AI request
416
  preloadSDK();
22✔
417

418
  // Start REST API server immediately so Railway health checks can pass while
419
  // heavier startup work (DB migrations, config loading, Discord login) runs.
420
  await startRestApiWithWebSocketTransport();
22✔
421

422
  // Initialize database
423
  const dbPool = await initializeDatabaseForStartup();
22✔
424

425
  // Load config (from DB if available, else config.json)
426
  config = await loadConfig();
21✔
427
  info('Configuration loaded', { sections: Object.keys(config) });
19✔
428

429
  // Register config change listeners for hot-reload and observability.
430
  registerConfigListeners({ dbPool, config });
19✔
431

432
  // Set up AI module's DB pool reference
433
  if (dbPool) {
19✔
434
    setPool(dbPool);
4✔
435
  }
436
  await initializePostgresLogging(dbPool, config);
19✔
437

438
  // DEPRECATED: loadState() seeds conversation history from data/state.json for
439
  // non-DB environments. When a database is configured, initConversationHistory()
440
  // immediately overwrites this with DB data. Remove loadState/saveState and the
441
  // data/ directory once all environments use DATABASE_URL.
442
  loadState();
19✔
443

444
  // Hydrate conversation history from DB (overwrites file state if DB is available)
445
  await initConversationHistory();
19✔
446

447
  // Start periodic conversation cleanup
448
  startConversationCleanup();
19✔
449

450
  // Load opt-out preferences from DB before enabling memory features
451
  await loadOptOuts();
19✔
452

453
  // Check mem0 availability for user memory features (with timeout to avoid blocking startup).
454
  // AbortController prevents a late-resolving health check from calling markAvailable()
455
  // after the timeout has already called markUnavailable().
456
  await checkMemoryAvailability();
19✔
457

458
  // Register event handlers with live config reference
459
  registerEventHandlers(client, config, healthMonitor);
19✔
460

461
  // Start triage module (per-channel message classification + response)
462
  await startTriage(client, config, healthMonitor);
19✔
463

464
  // Start tempban scheduler for automatic unbans (DB required)
465
  if (dbPool) {
18✔
466
    startTempbanScheduler(client);
4✔
467
    startWarningExpiryScheduler();
4✔
468
    startScheduler(client);
4✔
469
    startGithubFeed(client);
4✔
470
    startEngagementFlushInterval();
4✔
471
  }
472

473
  // Load commands and login
474
  await loadCommands();
18✔
475
  await client.login(token);
18✔
476

477
  // Start configurable bot presence rotation after login so client.user is available
478
  startBotStatus(client);
18✔
479

480
  // Set Sentry context now that we know the bot identity (no-op if disabled)
481
  applySentryBotContext();
18✔
482
}
483

484
startup().catch((err) => {
22✔
485
  error('Startup failed', { error: err.message, stack: err.stack });
4✔
486
  process.exit(1);
4✔
487
});
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc