Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3e40252
feat: add DB-backed AI conversation persistence
BillChirico Feb 11, 2026
08d1da7
fix: prevent race condition in async DB hydration
BillChirico Feb 11, 2026
804d662
chore: remove stray file
BillChirico Feb 11, 2026
8a58e8c
fix: expand biome.json includes to src/**/*.js and fix all lint errors
BillChirico Feb 11, 2026
92b2c19
fix: add CHECK constraint on role column in conversations table
BillChirico Feb 11, 2026
7313073
docs: add migration TODO for loadState in DB startup path
BillChirico Feb 11, 2026
4955beb
fix: harden async history hydration and DB persistence behavior
BillChirico Feb 11, 2026
010c8e3
test: cover hydration race handling and align header test naming
BillChirico Feb 11, 2026
59991e7
fix: guard config set return-shape traversal
BillChirico Feb 11, 2026
afa64bc
fix: add created_at index for conversation ttl cleanup
BillChirico Feb 11, 2026
8fd72d7
fix: dedupe concurrent async history hydrations per channel
BillChirico Feb 11, 2026
6a099f4
fix: include stack traces in welcome error logging
BillChirico Feb 11, 2026
cb40ef3
test: harden hydration race assertion with waitFor
BillChirico Feb 11, 2026
ebb6b44
test: assert DB write failure logging in ai history persistence
BillChirico Feb 11, 2026
c797b66
fix: replace channel history on DB hydration to avoid duplicates
BillChirico Feb 11, 2026
36baa91
chore: sort config command imports for biome
BillChirico Feb 11, 2026
468df96
fix(ai): coordinate sync/async history hydration
BillChirico Feb 11, 2026
ed5283a
fix(ai): unref conversation cleanup timer
BillChirico Feb 11, 2026
929dd5b
test(db): cover initDb re-entrancy guard
BillChirico Feb 11, 2026
3c5fa44
refactor: remove dead code getHistory and update tests to use getHist…
BillChirico Feb 11, 2026
e306ff9
fix: remove duplicate error handlers in index.js
BillChirico Feb 11, 2026
6d9ce1b
fix: add TTL filter to conversation history initialization
BillChirico Feb 11, 2026
eb513b3
chore: resolve merge conflicts with main
BillChirico Feb 11, 2026
252cd9d
fix: align tests with persistence architecture and resolve merge conf…
BillChirico Feb 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ logs/security/
data/*
!data/.gitkeep

# Test and coverage outputs
coverage/
Comment on lines +21 to +22
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Duplicate coverage/ entry.

coverage/ is already listed on Line 5. This second entry is redundant.

🧹 Proposed fix
-# Test and coverage outputs
-coverage/
-
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Test and coverage outputs
coverage/
🤖 Prompt for AI Agents
In @.gitignore around lines 21 - 22, Remove the redundant duplicate entry
"coverage/" from .gitignore (the duplicate found between lines shown in the
diff); keep the original listing and delete the second "coverage/" line so the
file only contains one entry for coverage.


# Verification scripts
verify-*.js
VERIFICATION_GUIDE.md
34 changes: 34 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"files": {
"includes": [
"src/**/*.js",
"tests/**/*.js",
"!node_modules/**",
"!coverage/**",
"!logs/**",
"!data/**"
]
},
"linter": {
"enabled": true,
"rules": {
"suspicious": {
"noConsole": "error"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}
4 changes: 3 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"model": "claude-sonnet-4-20250514",
"maxTokens": 1024,
"systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\n⚠️ CRITICAL RULES:\n- NEVER type @.everyone or @.here (remove the dots) - these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.",
"channels": []
"channels": [],
"historyLength": 20,
"historyTTLDays": 30
},
"chimeIn": {
"enabled": false,
Expand Down
15 changes: 13 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"deploy": "node src/deploy-commands.js"
"deploy": "node src/deploy-commands.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "biome check src/ tests/",
"lint:fix": "biome check --write src/ tests/",
"format": "biome format --write src/ tests/"
},
"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"
Expand All @@ -24,5 +30,10 @@
},
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.14",
"@vitest/coverage-v8": "^4.0.18",
"vitest": "^4.0.18"
}
}
86 changes: 48 additions & 38 deletions src/commands/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -169,7 +171,7 @@ export async function execute(interaction) {
default:
await interaction.reply({
content: `❌ Unknown subcommand: \`${subcommand}\``,
ephemeral: true
ephemeral: true,
});
break;
}
Expand All @@ -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) {
Expand All @@ -198,15 +202,15 @@ async function handleView(interaction) {
const safeSection = escapeInlineCode(section);
return await interaction.reply({
content: `❌ Section \`${safeSection}\` not found in config`,
ephemeral: true
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```'
value: `\`\`\`json\n${sectionJson.length > 1000 ? `${sectionJson.slice(0, 997)}...` : sectionJson}\n\`\`\``,
});
} else {
embed.setDescription('Current bot configuration');
Expand All @@ -217,7 +221,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;

Expand All @@ -226,7 +230,7 @@ async function handleView(interaction) {
embed.addFields({
name: '⚠️ Truncated',
value: 'Use `/config view section:<name>` to see remaining sections.',
inline: false
inline: false,
});
truncated = true;
break;
Expand All @@ -236,20 +240,22 @@ 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:<name> for details' });
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
ephemeral: true,
});
}
}
Expand All @@ -268,7 +274,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,
});
}

Expand All @@ -278,17 +284,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();
Expand Down Expand Up @@ -316,12 +326,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();
Expand Down
8 changes: 2 additions & 6 deletions src/commands/ping.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Loading