Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
efb4a87
feat: PostgreSQL-backed config persistence
BillChirico Feb 11, 2026
651fcd0
fix: address PR #14 review comments from CodeRabbit and Cursor Bugbot
BillChirico Feb 11, 2026
4c30455
fix: address PR #14 round 2 review comments
BillChirico Feb 11, 2026
3b07d57
fix: address remaining Bugbot issues on feat/db-config
BillChirico Feb 11, 2026
80a1516
fix: make config footer text dynamic based on DATABASE_URL
BillChirico Feb 11, 2026
6ac410e
fix: add re-entrancy guard to initDb() to prevent pool leaks
BillChirico Feb 11, 2026
70aa444
fix: guard ROLLBACK in seed transaction to prevent masking original e…
BillChirico Feb 11, 2026
c551c82
fix: remove stale config keys during full reset
BillChirico Feb 11, 2026
9fafde2
fix: wrap ROLLBACK in setConfigValue in try/catch for consistency
BillChirico Feb 11, 2026
873aa3e
fix: reject dangerous keys in config path to prevent prototype pollution
BillChirico Feb 11, 2026
4c0b882
fix: only suggest leaf paths in config autocomplete
BillChirico Feb 11, 2026
16d1fd8
refactor: extract setNestedValue helper to DRY nested-path traversal
BillChirico Feb 11, 2026
edbd73d
fix: distinguish pool init errors from DB transaction failures in set…
BillChirico Feb 11, 2026
63d8646
fix: wrap autocomplete handler in try/catch for expired interactions
BillChirico Feb 11, 2026
53cc6a9
fix: guard against empty pathParts in setNestedValue
BillChirico Feb 11, 2026
ebf8f40
fix: guard setNestedValue against null and array intermediate nodes
BillChirico Feb 11, 2026
4f531b0
docs: document parseValue coercion behavior and add JSON string escap…
BillChirico Feb 11, 2026
fec021f
fix: remove unused getConfig import from index.js
BillChirico Feb 11, 2026
6108259
fix: truncate 'New Value' embed field to prevent Discord API errors
BillChirico Feb 11, 2026
29d4a54
fix: handle missing config.json gracefully in resetConfig
BillChirico Feb 11, 2026
cddd69d
fix: handle floating-point edge cases in parseValue
BillChirico Feb 11, 2026
5529b5f
fix: align autocomplete suggestions with set command requirements
BillChirico Feb 11, 2026
2058658
refactor: remove dead section autocomplete branch
BillChirico Feb 11, 2026
9daa7f1
refactor: derive VALID_SECTIONS dynamically from config.json
BillChirico Feb 11, 2026
36442f8
fix: downgrade expected DB-unavailable log to warn in setConfigValue
BillChirico Feb 11, 2026
0bedbce
fix: add Array.isArray guard on section root in setConfigValue
BillChirico Feb 11, 2026
78a89a8
fix: downgrade expected DB-unavailable log to warn in resetConfig
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"pg": "^8.18.0",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
},
Expand Down
295 changes: 245 additions & 50 deletions src/commands/config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
/**
* Config Command
* View, set, and reset bot configuration via slash commands
*/

import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { getConfig, setConfigValue, resetConfig } from '../modules/config.js';

const VALID_SECTIONS = ['ai', 'chimeIn', 'welcome', 'moderation', 'logging', 'permissions'];

const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, '..', '..', 'config.json');
/** @type {Array<{name: string, value: string}>} Derived choices for section options */
const SECTION_CHOICES = VALID_SECTIONS.map(s => ({
name: s.charAt(0).toUpperCase() + s.slice(1).replace(/([A-Z])/g, ' $1'),
value: s,
}));

export const data = new SlashCommandBuilder()
.setName('config')
Expand All @@ -18,69 +26,256 @@ export const data = new SlashCommandBuilder()
.setName('section')
.setDescription('Specific config section to view')
.setRequired(false)
.addChoices(
{ name: 'AI Settings', value: 'ai' },
{ name: 'Welcome Messages', value: 'welcome' },
{ name: 'Moderation', value: 'moderation' },
{ name: 'Permissions', value: 'permissions' }
)
.addChoices(...SECTION_CHOICES)
)
)
.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 to set (strings, numbers, booleans, JSON arrays)')
.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)
.addChoices(...SECTION_CHOICES)
)
);

export const adminOnly = true;

/**
* Recursively flatten config keys into dot-notation paths
* @param {Object} obj - Object to flatten
* @param {string} prefix - Current path prefix
* @returns {string[]} Array of dot-notation paths
*/
function flattenConfigKeys(obj, prefix) {
const paths = [];
for (const [key, value] of Object.entries(obj)) {
const fullPath = `${prefix}.${key}`;
paths.push(fullPath);
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
paths.push(...flattenConfigKeys(value, fullPath));
}
}
return paths;
}

/**
* Handle autocomplete for config paths
* @param {Object} interaction - Discord interaction
*/
export async function autocomplete(interaction) {
const focusedValue = interaction.options.getFocused().toLowerCase();
const config = getConfig();

const paths = [];
for (const [section, value] of Object.entries(config)) {
if (typeof value === 'object' && value !== null) {
paths.push(...flattenConfigKeys(value, section));
}
}

const filtered = paths
.filter(p => p.toLowerCase().includes(focusedValue))
.slice(0, 25);

await interaction.respond(
filtered.map(p => ({ name: p, value: p }))
);
}

/**
* Execute the config command
* @param {Object} interaction - Discord interaction
*/
export async function execute(interaction) {
const subcommand = interaction.options.getSubcommand();

if (subcommand === 'view') {
try {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const section = interaction.options.getString('section');

const embed = new EmbedBuilder()
.setColor(0x5865F2)
.setTitle('⚙️ Bot Configuration')
.setTimestamp();

if (section) {
// Show specific section
const sectionData = config[section];
if (!sectionData) {
return await interaction.reply({
content: `❌ Section '${section}' not found in config`,
ephemeral: true
});
}
switch (subcommand) {
case 'view':
await handleView(interaction);
break;
case 'set':
await handleSet(interaction);
break;
case 'reset':
await handleReset(interaction);
break;
}
}

embed.setDescription(`**${section.toUpperCase()} Configuration**`);
embed.addFields({
name: 'Settings',
value: '```json\n' + JSON.stringify(sectionData, null, 2) + '\n```'
/** @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) {
return await interaction.reply({
content: `❌ Section '${section}' not found in config`,
ephemeral: true
});
} else {
// Show all sections
embed.setDescription('Current bot configuration');
}

for (const [key, value] of Object.entries(config)) {
const jsonStr = JSON.stringify(value, null, 2);
const truncated = jsonStr.length > 1000
? jsonStr.slice(0, 997) + '...'
: jsonStr;
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 + embed.data.description.length;
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: `${key.toUpperCase()}`,
value: '```json\n' + truncated + '\n```',
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
});
}

await interaction.reply({ embeds: [embed], ephemeral: true });
} catch (err) {
await interaction.reply({
content: `❌ Failed to load config: ${err.message}`,
ephemeral: true
});
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
const section = path.split('.')[0];
if (!VALID_SECTIONS.includes(section)) {
return await interaction.reply({
content: `❌ Invalid section '${section}'. Valid sections: ${VALID_SECTIONS.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 embed = new EmbedBuilder()
.setColor(0x57F287)
.setTitle('✅ Config Updated')
.addFields(
{ name: 'Path', value: `\`${path}\``, inline: true },
{ name: 'New Value', value: `\`${JSON.stringify(leafValue, null, 2) ?? value}\``, 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 });
}
}
}
Loading