diff --git a/AGENTS.md b/AGENTS.md index 822e0d9d..23f8b4a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,9 +76,9 @@ ### 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 +- Use `getConfig(guildId?)` from `src/modules/config.js` to read config +- Use `setConfigValue(path, value, guildId?)` to update at runtime +- Return semantics are intentional: `getConfig()` / `getConfig('global')` returns a live global reference, while `getConfig(guildId)` returns a detached merged clone (`global + guild overrides`) ## How to Add a Slash Command @@ -172,8 +172,8 @@ After every code change, check whether these files need updating: Runtime config changes (via `/config set`) are handled in two ways: -- **Per-request modules (AI, spam, moderation):** These modules call `getConfig()` on every invocation, so config changes take effect automatically on the next request. The `onConfigChange` listeners for these modules provide **observability only** (logging). -- **Stateful objects (logging transport):** The PostgreSQL logging transport is a long-lived Winston transport. It requires **reactive wiring** — `onConfigChange` listeners that add/remove/recreate the transport when `logging.database.*` settings change at runtime. This is implemented in `src/index.js` startup. +- **Per-request modules (AI, spam, moderation):** These modules call `getConfig(interaction.guildId)` on every invocation, so config changes take effect automatically on the next request. The `onConfigChange` listeners for these modules provide **observability only** (logging). +- **Stateful objects (logging transport):** The PostgreSQL logging transport is a long-lived Winston transport. It requires **reactive wiring** — `onConfigChange` listeners that add/remove/recreate the transport when `logging.database.*` settings change at runtime. This is implemented in `src/index.js` startup. `onConfigChange` callbacks receive `(newValue, oldValue, fullPath, guildId)`. When adding new modules, prefer the per-request `getConfig()` pattern. Only add reactive `onConfigChange` wiring for stateful resources that can't re-read config on each use. diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index a64595d4..5263a90f 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -279,12 +279,10 @@ router.get('/:id', requireGuildAdmin, validateGuild, (req, res) => { /** * GET /:id/config — Read guild config (safe keys only) - * Note: Config is global, not per-guild. The guild ID is accepted for - * API consistency but does not scope the returned config. - * Per-guild config is tracked in Issue #71. + * Returns per-guild config (global defaults merged with guild overrides). */ -router.get('/:id/config', requireGuildAdmin, validateGuild, (_req, res) => { - const config = getConfig(); +router.get('/:id/config', requireGuildAdmin, validateGuild, (req, res) => { + const config = getConfig(req.params.id); const safeConfig = {}; for (const key of READABLE_CONFIG_KEYS) { if (key in config) { @@ -292,18 +290,15 @@ router.get('/:id/config', requireGuildAdmin, validateGuild, (_req, res) => { } } res.json({ - scope: 'global', - note: 'Config is global, not per-guild. Per-guild config is tracked in Issue #71.', + guildId: req.params.id, ...safeConfig, }); }); /** - * PATCH /:id/config — Update a config value (safe keys only) + * PATCH /:id/config — Update a guild-specific config value (safe keys only) * Body: { path: "ai.model", value: "claude-3" } - * Note: Config is global, not per-guild. The guild ID is accepted for - * API consistency but does not scope the update. - * Per-guild config is tracked in Issue #71. + * Writes to the per-guild config overrides for the requested guild. */ router.patch('/:id/config', requireGuildAdmin, validateGuild, async (req, res) => { if (!req.body) { @@ -337,9 +332,11 @@ router.patch('/:id/config', requireGuildAdmin, validateGuild, async (req, res) = } try { - const updated = await setConfigValue(path, value); + await setConfigValue(path, value, req.params.id); + const effectiveConfig = getConfig(req.params.id); + const effectiveSection = effectiveConfig[topLevelKey] || {}; info('Config updated via API', { path, value, guild: req.params.id }); - res.json(updated); + res.json(effectiveSection); } catch (err) { error('Failed to update config via API', { path, error: err.message }); res.status(500).json({ error: 'Failed to update config' }); diff --git a/src/commands/ban.js b/src/commands/ban.js index 4f2b4585..1a4bce5e 100644 --- a/src/commands/ban.js +++ b/src/commands/ban.js @@ -41,7 +41,7 @@ export async function execute(interaction) { try { await interaction.deferReply({ ephemeral: true }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const user = interaction.options.getUser('user'); const reason = interaction.options.getString('reason'); const deleteMessageDays = interaction.options.getInteger('delete_messages') || 0; diff --git a/src/commands/case.js b/src/commands/case.js index 90d4ec60..44855b7a 100644 --- a/src/commands/case.js +++ b/src/commands/case.js @@ -219,7 +219,7 @@ async function handleReason(interaction) { // Try to edit the log message if it exists if (caseRow.log_message_id) { try { - const config = getConfig(); + const config = getConfig(interaction.guildId); const channels = config.moderation?.logging?.channels; if (channels) { const channelKey = ACTION_LOG_CHANNEL_KEY[caseRow.action]; diff --git a/src/commands/config.js b/src/commands/config.js index 4ca2c5e7..318cf172 100644 --- a/src/commands/config.js +++ b/src/commands/config.js @@ -122,7 +122,7 @@ function collectConfigPaths(source, prefix = '', paths = []) { export async function autocomplete(interaction) { const focusedOption = interaction.options.getFocused(true); const focusedValue = focusedOption.value.toLowerCase().trim(); - const config = getConfig(); + const config = getConfig(interaction.guildId); let choices; if (focusedOption.name === 'section') { @@ -196,7 +196,7 @@ const EMBED_CHAR_LIMIT = 6000; */ async function handleView(interaction) { try { - const config = getConfig(); + const config = getConfig(interaction.guildId); const section = interaction.options.getString('section'); const embed = new EmbedBuilder() @@ -285,7 +285,7 @@ async function handleSet(interaction) { // Validate section exists in live config const section = path.split('.')[0]; - const validSections = Object.keys(getConfig()); + const validSections = Object.keys(getConfig(interaction.guildId)); if (!validSections.includes(section)) { const safeSection = escapeInlineCode(section); return await safeReply(interaction, { @@ -297,7 +297,7 @@ async function handleSet(interaction) { try { await interaction.deferReply({ ephemeral: true }); - const updatedSection = await setConfigValue(path, value); + const updatedSection = await setConfigValue(path, value, interaction.guildId); // Traverse to the actual leaf value for display const leafValue = path @@ -341,15 +341,15 @@ async function handleReset(interaction) { try { await interaction.deferReply({ ephemeral: true }); - await resetConfig(section || undefined); + await resetConfig(section || undefined, interaction.guildId); const embed = new EmbedBuilder() .setColor(0xfee75c) .setTitle('🔄 Config Reset') .setDescription( section - ? `Section **${escapeInlineCode(section)}** has been reset to defaults from config.json.` - : 'All configuration has been reset to defaults from config.json.', + ? `Guild overrides for section **${escapeInlineCode(section)}** have been cleared; global defaults will apply.` + : 'All guild overrides have been cleared; global defaults will apply.', ) .setFooter({ text: 'Changes take effect immediately' }) .setTimestamp(); diff --git a/src/commands/kick.js b/src/commands/kick.js index 84f8112e..8731675a 100644 --- a/src/commands/kick.js +++ b/src/commands/kick.js @@ -33,7 +33,7 @@ export async function execute(interaction) { try { await interaction.deferReply({ ephemeral: true }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const target = interaction.options.getMember('user'); if (!target) { return await safeEditReply(interaction, '❌ User is not in this server.'); diff --git a/src/commands/lock.js b/src/commands/lock.js index 026352b8..17e32b34 100644 --- a/src/commands/lock.js +++ b/src/commands/lock.js @@ -52,7 +52,7 @@ export async function execute(interaction) { .setTimestamp(); await safeSend(channel, { embeds: [notifyEmbed] }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const caseData = await createCase(interaction.guild.id, { action: 'lock', targetId: channel.id, diff --git a/src/commands/memory.js b/src/commands/memory.js index 58d2c9a4..f06fb863 100644 --- a/src/commands/memory.js +++ b/src/commands/memory.js @@ -101,6 +101,7 @@ export async function execute(interaction) { const subcommand = interaction.options.getSubcommand(); const userId = interaction.user.id; const username = interaction.user.username; + const guildId = interaction.guildId; // Handle admin subcommand group if (subcommandGroup === 'admin') { @@ -114,7 +115,7 @@ export async function execute(interaction) { return; } - if (!checkAndRecoverMemory()) { + if (!checkAndRecoverMemory(guildId)) { await safeReply(interaction, { content: '🧠 Memory system is currently unavailable. The bot still works, just without long-term memory.', @@ -124,13 +125,13 @@ export async function execute(interaction) { } if (subcommand === 'view') { - await handleView(interaction, userId, username); + await handleView(interaction, userId, username, guildId); } else if (subcommand === 'forget') { const topic = interaction.options.getString('topic'); if (topic) { - await handleForgetTopic(interaction, userId, username, topic); + await handleForgetTopic(interaction, userId, username, topic, guildId); } else { - await handleForgetAll(interaction, userId, username); + await handleForgetAll(interaction, userId, username, guildId); } } } @@ -165,11 +166,12 @@ async function handleOptOut(interaction, userId) { * @param {import('discord.js').ChatInputCommandInteraction} interaction * @param {string} userId * @param {string} username + * @param {string} [guildId] */ -async function handleView(interaction, userId, username) { +async function handleView(interaction, userId, username, guildId) { await interaction.deferReply({ ephemeral: true }); - const memories = await getMemories(userId); + const memories = await getMemories(userId, guildId); if (memories.length === 0) { await safeEditReply(interaction, { @@ -193,8 +195,9 @@ async function handleView(interaction, userId, username) { * @param {import('discord.js').ChatInputCommandInteraction} interaction * @param {string} userId * @param {string} username + * @param {string} [guildId] */ -async function handleForgetAll(interaction, userId, username) { +async function handleForgetAll(interaction, userId, username, guildId) { const confirmButton = new ButtonBuilder() .setCustomId('memory_forget_confirm') .setLabel('Confirm') @@ -222,7 +225,7 @@ async function handleForgetAll(interaction, userId, username) { }); if (buttonInteraction.customId === 'memory_forget_confirm') { - const success = await deleteAllMemories(userId); + const success = await deleteAllMemories(userId, guildId); if (success) { await safeUpdate(buttonInteraction, { @@ -258,8 +261,9 @@ async function handleForgetAll(interaction, userId, username) { * @param {string} userId * @param {string} username * @param {string} topic + * @param {string} [guildId] */ -async function handleForgetTopic(interaction, userId, username, topic) { +async function handleForgetTopic(interaction, userId, username, topic, guildId) { await interaction.deferReply({ ephemeral: true }); const BATCH_SIZE = 100; @@ -271,7 +275,7 @@ async function handleForgetTopic(interaction, userId, username, topic) { // Loop to delete all matching memories (not just the first batch) while (iterations < MAX_ITERATIONS) { iterations++; - const { memories: matches } = await searchMemories(userId, topic, BATCH_SIZE); + const { memories: matches } = await searchMemories(userId, topic, BATCH_SIZE, guildId); if (matches.length === 0) break; totalFound += matches.length; @@ -282,7 +286,9 @@ async function handleForgetTopic(interaction, userId, username, topic) { if (matchesWithIds.length === 0) break; - const results = await Promise.allSettled(matchesWithIds.map((m) => deleteMemory(m.id))); + const results = await Promise.allSettled( + matchesWithIds.map((m) => deleteMemory(m.id, guildId)), + ); const batchDeleted = results.filter((r) => r.status === 'fulfilled' && r.value === true).length; totalDeleted += batchDeleted; @@ -329,7 +335,9 @@ async function handleAdmin(interaction, subcommand) { return; } - if (!checkAndRecoverMemory()) { + const guildId = interaction.guildId; + + if (!checkAndRecoverMemory(guildId)) { await safeReply(interaction, { content: '🧠 Memory system is currently unavailable. The bot still works, just without long-term memory.', @@ -343,9 +351,9 @@ async function handleAdmin(interaction, subcommand) { const targetUsername = targetUser.username; if (subcommand === 'view') { - await handleAdminView(interaction, targetId, targetUsername); + await handleAdminView(interaction, targetId, targetUsername, guildId); } else if (subcommand === 'clear') { - await handleAdminClear(interaction, targetId, targetUsername); + await handleAdminClear(interaction, targetId, targetUsername, guildId); } } @@ -354,11 +362,12 @@ async function handleAdmin(interaction, subcommand) { * @param {import('discord.js').ChatInputCommandInteraction} interaction * @param {string} targetId * @param {string} targetUsername + * @param {string} [guildId] */ -async function handleAdminView(interaction, targetId, targetUsername) { +async function handleAdminView(interaction, targetId, targetUsername, guildId) { await interaction.deferReply({ ephemeral: true }); - const memories = await getMemories(targetId); + const memories = await getMemories(targetId, guildId); const optedOutStatus = isOptedOut(targetId) ? ' *(opted out)*' : ''; if (memories.length === 0) { @@ -387,8 +396,9 @@ async function handleAdminView(interaction, targetId, targetUsername) { * @param {import('discord.js').ChatInputCommandInteraction} interaction * @param {string} targetId * @param {string} targetUsername + * @param {string} [guildId] */ -async function handleAdminClear(interaction, targetId, targetUsername) { +async function handleAdminClear(interaction, targetId, targetUsername, guildId) { const adminId = interaction.user.id; const confirmButton = new ButtonBuilder() @@ -417,7 +427,7 @@ async function handleAdminClear(interaction, targetId, targetUsername) { }); if (buttonInteraction.customId === 'memory_admin_clear_confirm') { - const success = await deleteAllMemories(targetId); + const success = await deleteAllMemories(targetId, guildId); if (success) { await safeUpdate(buttonInteraction, { diff --git a/src/commands/modlog.js b/src/commands/modlog.js index 6da75474..30a56edd 100644 --- a/src/commands/modlog.js +++ b/src/commands/modlog.js @@ -135,8 +135,16 @@ async function handleSetup(interaction) { if (i.customId === 'modlog_channel' && selectedCategory) { const channelId = i.values[0]; - await setConfigValue(`moderation.logging.channels.${selectedCategory}`, channelId); - info('Modlog channel configured', { category: selectedCategory, channelId }); + await setConfigValue( + `moderation.logging.channels.${selectedCategory}`, + channelId, + interaction.guildId, + ); + info('Modlog channel configured', { + category: selectedCategory, + channelId, + guildId: interaction.guildId, + }); await i.update({ embeds: [ embed.setDescription( @@ -176,7 +184,7 @@ async function handleSetup(interaction) { */ async function handleView(interaction) { try { - const config = getConfig(); + const config = getConfig(interaction.guildId); const channels = config.moderation?.logging?.channels || {}; const embed = new EmbedBuilder() @@ -209,10 +217,10 @@ async function handleDisable(interaction) { try { const keys = ['default', 'warns', 'bans', 'kicks', 'timeouts', 'purges', 'locks']; for (const key of keys) { - await setConfigValue(`moderation.logging.channels.${key}`, null); + await setConfigValue(`moderation.logging.channels.${key}`, null, interaction.guildId); } - info('Mod logging disabled', { moderator: interaction.user.tag }); + info('Mod logging disabled', { moderator: interaction.user.tag, guildId: interaction.guildId }); await safeEditReply( interaction, '✅ Mod logging has been disabled. All log channels have been cleared.', diff --git a/src/commands/purge.js b/src/commands/purge.js index 745459d9..8639b38e 100644 --- a/src/commands/purge.js +++ b/src/commands/purge.js @@ -152,7 +152,7 @@ export async function execute(interaction) { scanned: fetched.size, }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const caseData = await createCase(interaction.guild.id, { action: 'purge', targetId: channel.id, diff --git a/src/commands/slowmode.js b/src/commands/slowmode.js index 1d16e4dc..ea7a417e 100644 --- a/src/commands/slowmode.js +++ b/src/commands/slowmode.js @@ -64,7 +64,7 @@ export async function execute(interaction) { await channel.setRateLimitPerUser(seconds); - const config = getConfig(); + const config = getConfig(interaction.guildId); const caseData = await createCase(interaction.guild.id, { action: 'slowmode', targetId: channel.id, diff --git a/src/commands/softban.js b/src/commands/softban.js index b1e843a4..b3c9ee38 100644 --- a/src/commands/softban.js +++ b/src/commands/softban.js @@ -41,7 +41,7 @@ export async function execute(interaction) { try { await interaction.deferReply({ ephemeral: true }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const target = interaction.options.getMember('user'); if (!target) { return await safeEditReply(interaction, '❌ User is not in this server.'); diff --git a/src/commands/tempban.js b/src/commands/tempban.js index 07b6f79c..612dd96b 100644 --- a/src/commands/tempban.js +++ b/src/commands/tempban.js @@ -46,7 +46,7 @@ export async function execute(interaction) { try { await interaction.deferReply({ ephemeral: true }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const user = interaction.options.getUser('user'); const durationStr = interaction.options.getString('duration'); const reason = interaction.options.getString('reason'); diff --git a/src/commands/timeout.js b/src/commands/timeout.js index c2b221f7..bb4261c9 100644 --- a/src/commands/timeout.js +++ b/src/commands/timeout.js @@ -37,7 +37,7 @@ export async function execute(interaction) { try { await interaction.deferReply({ ephemeral: true }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const target = interaction.options.getMember('user'); if (!target) { return await safeEditReply(interaction, '❌ User is not in this server.'); diff --git a/src/commands/unban.js b/src/commands/unban.js index 033557a8..03d80f32 100644 --- a/src/commands/unban.js +++ b/src/commands/unban.js @@ -28,7 +28,7 @@ export const adminOnly = true; export async function execute(interaction) { try { await interaction.deferReply({ ephemeral: true }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const userId = interaction.options.getString('user_id'); const reason = interaction.options.getString('reason'); diff --git a/src/commands/unlock.js b/src/commands/unlock.js index 5214c772..4bf79f03 100644 --- a/src/commands/unlock.js +++ b/src/commands/unlock.js @@ -52,7 +52,7 @@ export async function execute(interaction) { .setTimestamp(); await safeSend(channel, { embeds: [notifyEmbed] }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const caseData = await createCase(interaction.guild.id, { action: 'unlock', targetId: channel.id, diff --git a/src/commands/untimeout.js b/src/commands/untimeout.js index e74812ad..4aee68a9 100644 --- a/src/commands/untimeout.js +++ b/src/commands/untimeout.js @@ -27,7 +27,7 @@ export async function execute(interaction) { try { await interaction.deferReply({ ephemeral: true }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const target = interaction.options.getMember('user'); if (!target) { return await safeEditReply(interaction, '❌ User is not in this server.'); diff --git a/src/commands/warn.js b/src/commands/warn.js index 25314e41..0a96f4ac 100644 --- a/src/commands/warn.js +++ b/src/commands/warn.js @@ -34,7 +34,7 @@ export async function execute(interaction) { try { await interaction.deferReply({ ephemeral: true }); - const config = getConfig(); + const config = getConfig(interaction.guildId); const target = interaction.options.getMember('user'); if (!target) { return await safeEditReply(interaction, '❌ User is not in this server.'); diff --git a/src/db.js b/src/db.js index 8cd0c3aa..9af48eeb 100644 --- a/src/db.js +++ b/src/db.js @@ -91,12 +91,40 @@ export async function initDb() { // Create schema await pool.query(` CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, + guild_id TEXT NOT NULL DEFAULT 'global', + key TEXT NOT NULL, value JSONB NOT NULL, - updated_at TIMESTAMPTZ DEFAULT NOW() + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (guild_id, key) ) `); + // Migrate existing config table: add guild_id column and composite PK. + // Looks up the actual PK constraint name from pg_constraint instead of + // assuming 'config_pkey', which may differ across environments. + await pool.query(` + DO $$ + DECLARE + pk_name TEXT; + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'config' AND column_name = 'guild_id' + ) THEN + ALTER TABLE config ADD COLUMN guild_id TEXT NOT NULL DEFAULT 'global'; + SELECT conname INTO pk_name FROM pg_constraint + WHERE conrelid = 'config'::regclass AND contype = 'p'; + IF pk_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE config DROP CONSTRAINT %I', pk_name); + END IF; + ALTER TABLE config ADD PRIMARY KEY (guild_id, key); + END IF; + END $$ + `); + + // Note: No standalone guild_id index needed — the composite PK (guild_id, key) + // already covers guild_id-only queries via leftmost prefix. + await pool.query(` CREATE TABLE IF NOT EXISTS conversations ( id SERIAL PRIMARY KEY, diff --git a/src/index.js b/src/index.js index 364f5aeb..8cd38aae 100644 --- a/src/index.js +++ b/src/index.js @@ -27,7 +27,7 @@ import { startConversationCleanup, stopConversationCleanup, } from './modules/ai.js'; -import { loadConfig, onConfigChange } from './modules/config.js'; +import { getConfig, loadConfig, onConfigChange } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; import { checkMem0Health, markUnavailable } from './modules/memory.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; @@ -185,8 +185,9 @@ client.on('interactionCreate', async (interaction) => { info('Slash command received', { command: commandName, user: interaction.user.tag }); // Permission check - if (!hasPermission(member, commandName, config)) { - const permLevel = config.permissions?.allowedCommands?.[commandName] || 'administrator'; + const guildConfig = getConfig(interaction.guildId); + if (!hasPermission(member, commandName, guildConfig)) { + const permLevel = guildConfig.permissions?.allowedCommands?.[commandName] || 'administrator'; await safeReply(interaction, { content: getPermissionError(commandName, permLevel), ephemeral: true, @@ -374,7 +375,9 @@ async function startup() { 'logging.database.flushIntervalMs', 'logging.database.minLevel', ]) { - onConfigChange(key, async (_newValue, _oldValue, path) => { + onConfigChange(key, async (_newValue, _oldValue, path, guildId) => { + // Per-guild config changes should not affect the global logging transport + if (guildId && guildId !== 'global') return; transportLock = transportLock .then(() => updateLoggingTransport(path)) .catch((err) => @@ -384,16 +387,16 @@ async function startup() { }); } - // AI, spam, and moderation modules call getConfig() per-request, so config + // AI, spam, and moderation modules call getConfig(guildId) per-request, so config // changes take effect automatically. Listeners provide observability only. - onConfigChange('ai.*', (newValue, _oldValue, path) => { - info('AI config updated', { path, newValue }); + onConfigChange('ai.*', (newValue, _oldValue, path, guildId) => { + info('AI config updated', { path, newValue, guildId }); }); - onConfigChange('spam.*', (newValue, _oldValue, path) => { - info('Spam config updated', { path, newValue }); + onConfigChange('spam.*', (newValue, _oldValue, path, guildId) => { + info('Spam config updated', { path, newValue, guildId }); }); - onConfigChange('moderation.*', (newValue, _oldValue, path) => { - info('Moderation config updated', { path, newValue }); + onConfigChange('moderation.*', (newValue, _oldValue, path, guildId) => { + info('Moderation config updated', { path, newValue, guildId }); }); // Set up AI module's DB pool reference diff --git a/src/modules/ai.js b/src/modules/ai.js index ca4780e4..dfe2cf1a 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -28,11 +28,12 @@ const pendingHydrations = new Map(); /** * Get the configured history length from config + * @param {string} [guildId] - Guild ID for per-guild config * @returns {number} History length */ -function getHistoryLength() { +function getHistoryLength(guildId) { try { - const config = getConfig(); + const config = getConfig(guildId); const len = config?.ai?.historyLength; if (typeof len === 'number' && len > 0) return len; } catch { @@ -43,11 +44,12 @@ function getHistoryLength() { /** * Get the configured TTL days from config + * @param {string} [guildId] - Guild ID for per-guild config * @returns {number} TTL in days */ -function getHistoryTTLDays() { +function getHistoryTTLDays(guildId) { try { - const config = getConfig(); + const config = getConfig(guildId); const ttl = config?.ai?.historyTTLDays; if (typeof ttl === 'number' && ttl > 0) return ttl; } catch { @@ -115,10 +117,12 @@ export const OPENCLAW_TOKEN = process.env.OPENCLAW_API_KEY || process.env.OPENCL /** * Hydrate conversation history for a channel from DB. * Dedupes concurrent hydrations and merges DB rows with in-flight in-memory writes. + * * @param {string} channelId - Channel ID + * @param {string} [guildId] - Guild ID for per-guild config * @returns {Promise} Conversation history */ -function hydrateHistory(channelId) { +function hydrateHistory(channelId, guildId) { const pending = pendingHydrations.get(channelId); if (pending) { return pending; @@ -134,7 +138,7 @@ function hydrateHistory(channelId) { return Promise.resolve(historyRef); } - const limit = getHistoryLength(); + const limit = getHistoryLength(guildId); const hydrationPromise = pool .query( `SELECT role, content FROM conversations @@ -186,9 +190,10 @@ function hydrateHistory(channelId) { /** * Async version of history retrieval that waits for in-flight hydration. * @param {string} channelId - Channel ID + * @param {string} [guildId] - Guild ID for per-guild config * @returns {Promise} Conversation history */ -export async function getHistoryAsync(channelId) { +export async function getHistoryAsync(channelId, guildId) { if (conversationHistory.has(channelId)) { const pending = pendingHydrations.get(channelId); if (pending) { @@ -197,7 +202,7 @@ export async function getHistoryAsync(channelId) { return conversationHistory.get(channelId); } - return hydrateHistory(channelId); + return hydrateHistory(channelId, guildId); } /** @@ -216,7 +221,7 @@ export function addToHistory(channelId, role, content, username, guildId) { const history = conversationHistory.get(channelId); history.push({ role, content }); - const maxHistory = getHistoryLength(); + const maxHistory = getHistoryLength(guildId); // Trim old messages from in-memory cache while (history.length > maxHistory) { @@ -244,8 +249,13 @@ export function addToHistory(channelId, role, content, username, guildId) { } /** - * Initialize conversation history from DB on startup - * Loads last N messages per active channel + * Initialize conversation history from DB on startup. + * Loads last N messages per active channel. + * + * Note: Uses global config defaults for history length and TTL intentionally — + * this runs at startup across all channels/guilds and guildId is not available. + * The guild-aware config path is through generateResponse(), which passes guildId. + * * @returns {Promise} */ export async function initConversationHistory() { @@ -343,7 +353,12 @@ export function stopConversationCleanup() { } /** - * Run a single cleanup pass + * Run a single cleanup pass. + * + * Note: Uses global config default for TTL intentionally — cleanup runs + * across all guilds/channels and guildId is not available in this context. + * The guild-aware config path is through generateResponse(), which passes guildId. + * * @returns {Promise} */ async function runCleanup() { @@ -379,7 +394,6 @@ async function runCleanup() { * @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) * @param {string} [userId] - Discord user ID for memory scoping * @param {string} [guildId] - Discord guild ID for conversation scoping @@ -389,15 +403,17 @@ export async function generateResponse( channelId, userMessage, username, - config, healthMonitor = null, userId = null, guildId = null, ) { - const history = await getHistoryAsync(channelId); + // Use guild-aware config for AI settings (systemPrompt, model, maxTokens) + // so per-guild overrides via /config are respected. + const guildConfig = getConfig(guildId); + const history = await getHistoryAsync(channelId, guildId); let systemPrompt = - config.ai?.systemPrompt || + guildConfig.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). @@ -407,7 +423,7 @@ You can use Discord markdown formatting.`; if (userId) { try { const memoryContext = await Promise.race([ - buildMemoryContext(userId, username, userMessage), + buildMemoryContext(userId, username, userMessage, guildId), new Promise((_, reject) => setTimeout(() => reject(new Error('Memory context timeout')), 5000), ), @@ -439,8 +455,8 @@ You can use Discord markdown formatting.`; ...(OPENCLAW_TOKEN && { Authorization: `Bearer ${OPENCLAW_TOKEN}` }), }, body: JSON.stringify({ - model: config.ai?.model || 'claude-sonnet-4-20250514', - max_tokens: config.ai?.maxTokens || 1024, + model: guildConfig.ai?.model || 'claude-sonnet-4-20250514', + max_tokens: guildConfig.ai?.maxTokens || 1024, messages: messages, }), }); @@ -470,7 +486,7 @@ You can use Discord markdown formatting.`; // Post-response: extract and store memorable facts (fire-and-forget) if (userId) { - extractAndStoreMemories(userId, username, userMessage, reply).catch((err) => { + extractAndStoreMemories(userId, username, userMessage, reply, guildId).catch((err) => { logWarn('Memory extraction failed', { userId, error: err.message }); }); } diff --git a/src/modules/config.js b/src/modules/config.js index 4fbfe134..5ee65472 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,26 +1,82 @@ /** * Configuration Module * Loads config from PostgreSQL with config.json as the seed/fallback + * Supports per-guild config overrides merged onto global defaults */ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; 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'); +/** Maximum number of guild entries (excluding 'global') kept in configCache */ +const MAX_GUILD_CACHE_SIZE = 500; + /** @type {Array<{path: string, callback: Function}>} Registered change listeners */ const listeners = []; -/** @type {Object} In-memory config cache */ -let configCache = {}; +/** + * Authoritative per-guild/global overrides loaded from the database. + * Intentionally unbounded: entries here are source-of-truth snapshots that are + * not cheap to rebuild without re-querying PostgreSQL. + * Hot-path memory/performance pressure is handled separately by mergedConfigCache, + * which stores computed global+guild views with LRU eviction. + * + * Expected upper bound: bounded by the number of guilds that have customized + * config via /config set or the PATCH API, which mirrors the distinct guild_id + * rows in the database. Each entry is small (only the override keys, not full + * config). For deployments with >1000 guilds with overrides, consider adding + * a size warning log or lazy-loading from DB on cache miss. + * @type {Map} + */ +let configCache = new Map(); + +/** @type {Map} Cached merged (global + guild override) config per guild */ +const mergedConfigCache = new Map(); + +/** + * Monotonically increasing counter bumped every time global config changes + * through setConfigValue, resetConfig, or loadConfig. Used to detect stale + * merged cache entries — if a cached entry's generation doesn't match, it + * is treated as a cache miss and rebuilt from the current global config. + * + * ⚠️ This does NOT detect in-place mutations to the live global config + * reference returned by getConfig() (no args). Such mutations are DEPRECATED + * and should use setConfigValue() instead, which properly increments this + * counter and invalidates the merged cache. + * @type {number} + */ +let globalConfigGeneration = 0; /** @type {Object|null} Cached config.json contents (loaded once, never invalidated) */ let fileConfigCache = null; +/** + * Deep merge guild overrides onto global defaults. + * For each key, if both source and target have plain objects, merge recursively. + * Otherwise the source (guild override) value wins. + * @param {Object} target - Cloned global defaults (mutated in place) + * @param {Object} source - Guild overrides + * @returns {Object} The merged target + */ +function deepMerge(target, source) { + for (const key of Object.keys(source)) { + if (DANGEROUS_KEYS.has(key)) continue; + + if (isPlainObject(target[key]) && isPlainObject(source[key])) { + deepMerge(target[key], source[key]); + } else { + target[key] = structuredClone(source[key]); + } + } + return target; +} + /** * Load config.json from disk (used as seed/fallback) * @returns {Object} Configuration object from file @@ -45,9 +101,14 @@ export function loadConfigFromFile() { /** * Load config from PostgreSQL, seeding from config.json if empty * Falls back to config.json if database is unavailable - * @returns {Promise} Configuration object + * @returns {Promise} Global configuration object (for backward compat) */ export async function loadConfig() { + // Clear stale merged cache — configCache is about to be rebuilt, so any + // previously merged guild snapshots are invalid. + mergedConfigCache.clear(); + globalConfigGeneration++; + // Try loading config.json — DB may have valid config even if file is missing let fileConfig; try { @@ -69,14 +130,22 @@ export async function loadConfig() { ); } info('Database not available, using config.json'); - configCache = structuredClone(fileConfig); - return configCache; + configCache = new Map(); + configCache.set('global', structuredClone(fileConfig)); + return configCache.get('global'); } - // Check if config table has any rows - const { rows } = await pool.query('SELECT key, value FROM config'); + // NOTE: This fetches all config rows (all guilds) into memory at startup. + // For large deployments with many guilds, consider lazy-loading guild configs + // on first access or paginating this query. Currently acceptable for <1000 guilds. + const { rows } = await pool.query('SELECT guild_id, key, value FROM config'); + + // Separate global rows from guild-specific rows. + // Treat rows with missing/undefined guild_id as 'global' (handles unmigrated DBs). + const globalRows = rows.filter((r) => !r.guild_id || r.guild_id === 'global'); + const guildRows = rows.filter((r) => r.guild_id && r.guild_id !== 'global'); - if (rows.length === 0) { + if (globalRows.length === 0) { if (!fileConfig) { throw new Error( 'No configuration source available: database is empty and config.json is missing', @@ -89,13 +158,36 @@ export async function loadConfig() { 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)], + 'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()', + ['global', key, JSON.stringify(value)], ); } await client.query('COMMIT'); info('Config seeded to database'); - configCache = structuredClone(fileConfig); + configCache = new Map(); + configCache.set('global', structuredClone(fileConfig)); + + // Load any preexisting guild overrides that were already in the DB. + // Without this, guild rows fetched above would be silently dropped. + for (const row of guildRows) { + if (DANGEROUS_KEYS.has(row.key)) { + logWarn('Skipping dangerous config key from database', { + key: row.key, + guildId: row.guild_id, + }); + continue; + } + + if (!configCache.has(row.guild_id)) { + configCache.set(row.guild_id, {}); + } + configCache.get(row.guild_id)[row.key] = row.value; + } + if (guildRows.length > 0) { + info('Loaded guild overrides during seed', { + guildCount: new Set(guildRows.map((r) => r.guild_id)).size, + }); + } } catch (txErr) { try { await client.query('ROLLBACK'); @@ -107,12 +199,44 @@ export async function loadConfig() { client.release(); } } else { - // Load from database - configCache = {}; - for (const row of rows) { - configCache[row.key] = row.value; + // Build config map from database rows + configCache = new Map(); + + // Build global config + const globalConfig = {}; + for (const row of globalRows) { + if (DANGEROUS_KEYS.has(row.key)) { + logWarn('Skipping dangerous config key from database', { + key: row.key, + guildId: row.guild_id, + }); + continue; + } + + globalConfig[row.key] = row.value; } - info('Config loaded from database'); + configCache.set('global', globalConfig); + + // Build per-guild configs (overrides only) + for (const row of guildRows) { + if (DANGEROUS_KEYS.has(row.key)) { + logWarn('Skipping dangerous config key from database', { + key: row.key, + guildId: row.guild_id, + }); + continue; + } + + if (!configCache.has(row.guild_id)) { + configCache.set(row.guild_id, {}); + } + configCache.get(row.guild_id)[row.key] = row.value; + } + + info('Config loaded from database', { + globalKeys: globalRows.length, + guildCount: new Set(guildRows.map((r) => r.guild_id)).size, + }); } } catch (err) { if (!fileConfig) { @@ -120,18 +244,83 @@ export async function loadConfig() { throw err; } logError('Failed to load config from database, using config.json', { error: err.message }); - configCache = structuredClone(fileConfig); + configCache = new Map(); + configCache.set('global', structuredClone(fileConfig)); + } + + return configCache.get('global'); +} + +/** + * Get the current config (from cache). + * + * **Return semantics differ by path (intentional):** + * - **Global path** (no guildId or guildId='global'): Returns a LIVE MUTABLE reference + * to the cached global config object. Mutations are visible to all subsequent callers. + * This is intentional for backward compatibility — existing code relies on mutating the + * returned object and having changes propagate. + * - **Guild path** (guildId provided): Returns a deep-cloned merged copy of global defaults + * + guild overrides. Each call returns a fresh object; mutations do NOT affect the cache. + * This prevents cross-guild contamination. + * + * **⚠️ IMPORTANT: In-place mutation caveat:** + * Direct mutation of the global config object (e.g. `getConfig().ai.model = "new"`) does + * NOT invalidate `mergedConfigCache` or bump `globalConfigGeneration`. Guild-specific calls + * to `getConfig(guildId)` may return stale merged data that still reflects the old global + * defaults until the merged cache entry expires or is rebuilt. Use `setConfigValue()` for + * proper cache invalidation. This asymmetry is intentional for backward compatibility with + * legacy code that relies on mutating the returned global reference. + * + * @param {string} [guildId] - Guild ID, or omit / 'global' for global defaults + * @returns {Object} Configuration object (live reference for global, cloned copy for guild) + */ +export function getConfig(guildId) { + if (!guildId || guildId === 'global') { + // ⚠️ Returns live cache reference — callers must NOT mutate the returned object + return configCache.get('global') || {}; + } + + // Return clone from cached merged result if available and still valid. + // Entries are stamped with the globalConfigGeneration at merge time — + // if global config changed since then, the entry is stale and must be rebuilt. + const cached = mergedConfigCache.get(guildId); + if (cached && cached.generation === globalConfigGeneration) { + // Refresh access order for LRU tracking (Maps preserve insertion order) + mergedConfigCache.delete(guildId); + mergedConfigCache.set(guildId, cached); + // Guild path: returns deep clone to prevent cross-guild contamination (see JSDoc above) + return structuredClone(cached.data); + } + + const globalConfig = configCache.get('global') || {}; + const guildOverrides = configCache.get(guildId); + + if (!guildOverrides) { + // Cache a reference to global defaults and return a detached clone. + // This avoids an extra clone on cache-miss while preserving isolation for callers. + cacheMergedResult(guildId, globalConfig); + return structuredClone(globalConfig); } - return configCache; + const merged = deepMerge(structuredClone(globalConfig), guildOverrides); + cacheMergedResult(guildId, merged); + return structuredClone(merged); } /** - * Get the current config (from cache) - * @returns {Object} Configuration object + * Store a merged config result and enforce the LRU guild cache cap. + * Evicts the least-recently-used guild entries when the cap is exceeded. + * @param {string} guildId - Guild ID + * @param {Object} merged - Merged config object */ -export function getConfig() { - return configCache; +function cacheMergedResult(guildId, merged) { + mergedConfigCache.set(guildId, { generation: globalConfigGeneration, data: merged }); + + // Evict least-recently-used guild entries when cap is exceeded + if (mergedConfigCache.size > MAX_GUILD_CACHE_SIZE) { + const firstKey = mergedConfigCache.keys().next().value; + mergedConfigCache.delete(firstKey); + } } /** @@ -154,7 +343,7 @@ function getNestedValue(obj, pathParts) { * Register a listener for config changes. * Use exact paths (e.g. "ai.model") or prefix wildcards (e.g. "ai.*"). * @param {string} pathOrPrefix - Dot-notation path or prefix with wildcard - * @param {Function} callback - Called with (newValue, oldValue, fullPath) + * @param {Function} callback - Called with (newValue, oldValue, fullPath, guildId) */ export function onConfigChange(pathOrPrefix, callback) { listeners.push({ path: pathOrPrefix, callback }); @@ -183,8 +372,9 @@ export function clearConfigListeners() { * @param {string} fullPath - The full dot-notation path that changed * @param {*} newValue - The new value * @param {*} oldValue - The previous value + * @param {string} guildId - The guild ID that changed ('global' for global) */ -async function emitConfigChangeEvents(fullPath, newValue, oldValue) { +async function emitConfigChangeEvents(fullPath, newValue, oldValue, guildId) { for (const listener of [...listeners]) { const isExact = listener.path === fullPath; const isPrefix = @@ -193,7 +383,7 @@ async function emitConfigChangeEvents(fullPath, newValue, oldValue) { fullPath.startsWith(listener.path.replace(/\.\*$/, '.')); if (isExact || isPrefix) { try { - const result = listener.callback(newValue, oldValue, fullPath); + const result = listener.callback(newValue, oldValue, fullPath, guildId); if (result && typeof result.then === 'function') { await result.catch((err) => { logWarn('Async config change listener error', { @@ -212,14 +402,82 @@ async function emitConfigChangeEvents(fullPath, newValue, oldValue) { } } +/** + * Clone a value for safe event payload emission. + * @param {*} value - Value to clone when object-like + * @returns {*} + */ +function cloneForEvent(value) { + return value !== null && typeof value === 'object' ? structuredClone(value) : value; +} + +/** + * Collect leaf values from an object into a dot-notation map. + * Plain-object leaves are flattened; arrays and primitives are treated as terminal values. + * @param {*} value - Root value + * @param {string} prefix - Current dot-notation prefix + * @param {Map} out - Output map + */ +function collectLeafValues(value, prefix, out) { + if (isPlainObject(value)) { + for (const key of Object.keys(value)) { + if (DANGEROUS_KEYS.has(key)) continue; + const path = prefix ? `${prefix}.${key}` : key; + collectLeafValues(value[key], path, out); + } + return; + } + + if (prefix) { + out.set(prefix, cloneForEvent(value)); + } +} + +/** + * Build path-level changed leaf events for a reset scope. + * @param {Object} beforeConfig - Effective config before reset + * @param {Object} afterConfig - Effective config after reset + * @param {string|undefined} scopePath - Optional section path scope + * @returns {Array<{path: string, newValue: *, oldValue: *}>} + */ +function getChangedLeafEvents(beforeConfig, afterConfig, scopePath) { + const scopeParts = scopePath ? scopePath.split('.') : []; + const beforeScoped = scopePath ? getNestedValue(beforeConfig, scopeParts) : beforeConfig; + const afterScoped = scopePath ? getNestedValue(afterConfig, scopeParts) : afterConfig; + + const beforeLeaves = new Map(); + const afterLeaves = new Map(); + + if (beforeScoped !== undefined) { + collectLeafValues(beforeScoped, scopePath || '', beforeLeaves); + } + if (afterScoped !== undefined) { + collectLeafValues(afterScoped, scopePath || '', afterLeaves); + } + + const allPaths = new Set([...beforeLeaves.keys(), ...afterLeaves.keys()]); + const changed = []; + + for (const path of allPaths) { + const oldValue = beforeLeaves.has(path) ? beforeLeaves.get(path) : undefined; + const newValue = afterLeaves.has(path) ? afterLeaves.get(path) : undefined; + if (!isDeepStrictEqual(oldValue, newValue)) { + changed.push({ path, newValue, oldValue }); + } + } + + return changed; +} + /** * 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) + * @param {string} [guildId='global'] - Guild ID, or 'global' for global defaults * @returns {Promise} Updated section config */ -export async function setConfigValue(path, value) { +export async function setConfigValue(path, value, guildId = 'global') { const parts = path.split('.'); if (parts.length < 2) { throw new Error('Path must include section and key (e.g., "ai.model")'); @@ -232,8 +490,11 @@ export async function setConfigValue(path, value) { const nestedParts = parts.slice(1); const parsedVal = parseValue(value); + // Get the current guild entry from cache (or empty object for new guild) + const guildConfig = configCache.get(guildId) || {}; + // Deep clone the section for the INSERT case (new section that doesn't exist yet) - const sectionClone = structuredClone(configCache[section] || {}); + const sectionClone = structuredClone(guildConfig[section] || {}); setNestedValue(sectionClone, nestedParts, parsedVal); // Write to database first, then update cache. @@ -257,24 +518,25 @@ 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 guild_id = $1 AND key = $2 FOR UPDATE', + [guildId, 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 guild_id = $2 AND key = $3', + [JSON.stringify(dbSection), guildId, 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)], + 'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()', + [guildId, section, JSON.stringify(sectionClone)], ); } await client.query('COMMIT'); @@ -291,31 +553,103 @@ export async function setConfigValue(path, value) { } } - // Capture old value before mutating cache (deep clone objects to preserve snapshot) - const rawOld = getNestedValue(configCache[section], nestedParts); + // Ensure guild entry exists in cache + if (!configCache.has(guildId)) { + configCache.set(guildId, {}); + } + const cacheEntry = configCache.get(guildId); + + // Note: oldValue is captured from the guild's override cache, not the effective (merged) value. + // This means listeners see the previous override value (or undefined if no prior override existed), + // not the previous merged value that getConfig(guildId) would have returned. + const rawOld = getNestedValue(cacheEntry[section], nestedParts); const oldValue = rawOld !== null && typeof rawOld === 'object' ? structuredClone(rawOld) : rawOld; // Update in-memory cache (mutate in-place for reference propagation) if ( - !configCache[section] || - typeof configCache[section] !== 'object' || - Array.isArray(configCache[section]) + !cacheEntry[section] || + typeof cacheEntry[section] !== 'object' || + Array.isArray(cacheEntry[section]) ) { - configCache[section] = {}; + cacheEntry[section] = {}; + } + setNestedValue(cacheEntry[section], nestedParts, parsedVal); + + // Invalidate merged config cache for this guild (will be rebuilt on next getConfig) + // When global config changes, ALL merged entries are stale (they depend on global) + if (guildId === 'global') { + mergedConfigCache.clear(); + globalConfigGeneration++; + } else { + mergedConfigCache.delete(guildId); } - setNestedValue(configCache[section], nestedParts, parsedVal); - info('Config updated', { path, value: parsedVal, persisted: dbPersisted }); - await emitConfigChangeEvents(path, parsedVal, oldValue); - return configCache[section]; + info('Config updated', { path, value: parsedVal, guildId, persisted: dbPersisted }); + await emitConfigChangeEvents(path, parsedVal, oldValue, guildId); + return cacheEntry[section]; } /** - * Reset a config section to config.json defaults + * Reset a config section to defaults. + * For global: resets to config.json defaults. + * For guild: deletes guild overrides (falls back to global). * @param {string} [section] - Section to reset, or all if omitted - * @returns {Promise} Reset config + * @param {string} [guildId='global'] - Guild ID, or 'global' for global defaults + * @returns {Promise} Reset config (global config object for global, or remaining guild overrides) */ -export async function resetConfig(section) { +export async function resetConfig(section, guildId = 'global') { + // Guild reset — just delete overrides + if (guildId !== 'global') { + const beforeEffective = getConfig(guildId); + + let pool = null; + try { + pool = getPool(); + } catch { + logWarn('Database unavailable for config reset — updating in-memory only'); + } + + if (pool) { + try { + if (section) { + await pool.query('DELETE FROM config WHERE guild_id = $1 AND key = $2', [ + guildId, + section, + ]); + } else { + await pool.query('DELETE FROM config WHERE guild_id = $1', [guildId]); + } + } catch (err) { + logError('Database error during guild config reset — updating in-memory only', { + guildId, + section, + error: err.message, + }); + } + } + + const guildConfig = configCache.get(guildId); + if (guildConfig) { + if (section) { + delete guildConfig[section]; + } else { + configCache.delete(guildId); + } + } + + mergedConfigCache.delete(guildId); + + const afterEffective = getConfig(guildId); + const changedEvents = getChangedLeafEvents(beforeEffective, afterEffective, section); + for (const { path, newValue, oldValue } of changedEvents) { + await emitConfigChangeEvents(path, newValue, oldValue, guildId); + } + + info('Guild config reset', { guildId, section: section || 'all' }); + return section ? configCache.get(guildId) || {} : {}; + } + + // Global reset — same logic as before, resets to config.json defaults let fileConfig; try { fileConfig = loadConfigFromFile(); @@ -333,6 +667,9 @@ export async function resetConfig(section) { logWarn('Database unavailable for config reset — updating in-memory only'); } + const globalConfig = configCache.get('global') || {}; + const beforeGlobal = structuredClone(globalConfig); + if (section) { if (!fileConfig[section]) { throw new Error(`Section '${section}' not found in config.json defaults`); @@ -341,8 +678,8 @@ export async function resetConfig(section) { 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])], + 'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()', + ['global', section, JSON.stringify(fileConfig[section])], ); } catch (err) { logError('Database error during section reset — updating in-memory only', { @@ -353,12 +690,12 @@ export async function resetConfig(section) { } // Mutate in-place so references stay valid (deep clone to avoid shared refs) - const sectionData = configCache[section]; + const sectionData = globalConfig[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]) + globalConfig[section] = isPlainObject(fileConfig[section]) ? structuredClone(fileConfig[section]) : fileConfig[section]; } @@ -371,14 +708,30 @@ export async function resetConfig(section) { 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)], + 'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()', + ['global', key, JSON.stringify(value)], ); } - // Remove stale keys that exist in DB but not in config.json + // Remove stale global 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 guild_id = $1 AND key != ALL($2::text[])', [ + 'global', + fileKeys, + ]); + + // Warn about orphaned per-guild rows that reference keys no longer in global defaults + const orphanResult = await client.query( + 'SELECT DISTINCT guild_id, key FROM config WHERE guild_id != $1 AND key != ALL($2::text[])', + ['global', fileKeys], + ); + if (orphanResult.rows?.length > 0) { + const orphanSummary = orphanResult.rows.map((r) => `${r.guild_id}:${r.key}`).join(', '); + logWarn('Orphaned per-guild config rows reference keys no longer in global defaults', { + orphanedEntries: orphanSummary, + count: orphanResult.rows.length, + }); + } } await client.query('COMMIT'); } catch (txErr) { @@ -396,23 +749,32 @@ export async function resetConfig(section) { } // Mutate in-place and remove stale keys from cache (deep clone to avoid shared refs) - for (const key of Object.keys(configCache)) { + for (const key of Object.keys(globalConfig)) { if (!(key in fileConfig)) { - delete configCache[key]; + delete globalConfig[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)); + if (globalConfig[key] && isPlainObject(globalConfig[key]) && isPlainObject(value)) { + for (const k of Object.keys(globalConfig[key])) delete globalConfig[key][k]; + Object.assign(globalConfig[key], structuredClone(value)); } else { - configCache[key] = isPlainObject(value) ? structuredClone(value) : value; + globalConfig[key] = isPlainObject(value) ? structuredClone(value) : value; } } info('All config reset to defaults'); } - return configCache; + // Global config changed — all guild merged entries are stale + mergedConfigCache.clear(); + globalConfigGeneration++; + + const changedEvents = getChangedLeafEvents(beforeGlobal, globalConfig, section); + for (const { path, newValue, oldValue } of changedEvents) { + await emitConfigChangeEvents(path, newValue, oldValue, 'global'); + } + + return globalConfig; } /** Keys that must never be used as path segments (prototype pollution vectors) */ diff --git a/src/modules/events.js b/src/modules/events.js index f029cf0a..acdf7044 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -13,6 +13,7 @@ import { safeReply, safeSend } from '../utils/safeSend.js'; import { needsSplitting, splitMessage } from '../utils/splitMessage.js'; import { generateResponse } from './ai.js'; import { accumulate, resetCounter } from './chimeIn.js'; +import { getConfig } from './config.js'; import { isSpam, sendSpamAlert } from './spam.js'; import { getOrCreateThread, shouldUseThread } from './threading.js'; import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js'; @@ -23,7 +24,7 @@ let processHandlersRegistered = false; /** * Register bot ready event handler * @param {Client} client - Discord client - * @param {Object} config - Bot configuration + * @param {Object} config - Startup/global bot configuration used only for one-time feature-gate logging (not per-guild) * @param {Object} healthMonitor - Health monitor instance */ export function registerReadyHandler(client, config, healthMonitor) { @@ -50,45 +51,49 @@ export function registerReadyHandler(client, config, healthMonitor) { /** * Register guild member add event handler * @param {Client} client - Discord client - * @param {Object} config - Bot configuration + * @param {Object} _config - Unused (kept for API compatibility); handler resolves per-guild config via getConfig(). */ -export function registerGuildMemberAddHandler(client, config) { +export function registerGuildMemberAddHandler(client, _config) { client.on(Events.GuildMemberAdd, async (member) => { - await sendWelcomeMessage(member, client, config); + const guildConfig = getConfig(member.guild.id); + await sendWelcomeMessage(member, client, guildConfig); }); } /** * Register the MessageCreate event handler that processes incoming messages for spam detection, community activity recording, AI-driven replies (mentions/replies, optional threading, channel whitelisting), and organic chime-in accumulation. * @param {Client} client - Discord client instance used to listen and respond to message events. - * @param {Object} config - Bot configuration (reads moderation.enabled, ai.enabled, ai.channels and other settings referenced by handlers). + * @param {Object} _config - Unused (kept for API compatibility); handler resolves per-guild config via getConfig(). * @param {Object} healthMonitor - Optional health monitor used when generating AI responses to record metrics. */ -export function registerMessageCreateHandler(client, config, healthMonitor) { +export function registerMessageCreateHandler(client, _config, healthMonitor) { client.on(Events.MessageCreate, async (message) => { // Ignore bots and DMs if (message.author.bot) return; if (!message.guild) return; + // Resolve per-guild config so feature gates respect guild overrides + const guildConfig = getConfig(message.guild.id); + // Spam detection - if (config.moderation?.enabled && isSpam(message.content)) { + if (guildConfig.moderation?.enabled && isSpam(message.content)) { warn('Spam detected', { userId: message.author.id, contentPreview: '[redacted]' }); - await sendSpamAlert(message, client, config); + await sendSpamAlert(message, client, guildConfig); return; } // Feed welcome-context activity tracker - recordCommunityActivity(message, config); + recordCommunityActivity(message, guildConfig); // AI chat - respond when mentioned (checked BEFORE accumulate to prevent double responses) - if (config.ai?.enabled) { + if (guildConfig.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) // When inside a thread, check the parent channel ID against the allowlist // so thread replies aren't blocked by the whitelist. - const allowedChannels = config.ai?.channels || []; + const allowedChannels = guildConfig.ai?.channels || []; const channelIdToCheck = message.channel.isThread?.() ? message.channel.parentId : message.channel.id; @@ -131,7 +136,6 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { historyId, cleanContent, message.author.username, - config, healthMonitor, message.author.id, message.guild?.id, @@ -168,7 +172,7 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { } // Chime-in: accumulate message for organic participation (fire-and-forget) - accumulate(message, config).catch((err) => { + accumulate(message, guildConfig).catch((err) => { logError('ChimeIn accumulate error', { error: err?.message }); }); }); diff --git a/src/modules/memory.js b/src/modules/memory.js index f9ad1627..3af3795e 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -134,11 +134,12 @@ function getClient() { /** * Get memory config from bot config + * @param {string} [guildId] - Guild ID for per-guild config * @returns {Object} Memory configuration with defaults applied */ -export function getMemoryConfig() { +export function getMemoryConfig(guildId) { try { - const config = getConfig(); + const config = getConfig(guildId); return { enabled: config?.memory?.enabled ?? true, maxContextMemories: config?.memory?.maxContextMemories ?? DEFAULT_MAX_CONTEXT_MEMORIES, @@ -158,10 +159,11 @@ export function getMemoryConfig() { * Returns true only if memory is both enabled in config and currently marked available. * Does NOT trigger auto-recovery. Use {@link checkAndRecoverMemory} when you want * the cooldown-based recovery logic. + * @param {string} [guildId] - Guild ID for per-guild config * @returns {boolean} */ -export function isMemoryAvailable() { - const memConfig = getMemoryConfig(); +export function isMemoryAvailable(guildId) { + const memConfig = getMemoryConfig(guildId); if (!memConfig.enabled) return false; return mem0Available; } @@ -173,10 +175,11 @@ export function isMemoryAvailable() { * if the service has recovered. * * Use this instead of {@link isMemoryAvailable} when you want the recovery side effect. + * @param {string} [guildId] - Guild ID for per-guild config * @returns {boolean} */ -export function checkAndRecoverMemory() { - const memConfig = getMemoryConfig(); +export function checkAndRecoverMemory(guildId) { + const memConfig = getMemoryConfig(guildId); if (!memConfig.enabled) return false; if (mem0Available) return true; @@ -244,6 +247,8 @@ export function _setClient(newClient) { /** * Run a health check against the mem0 platform on startup. + * Intentionally uses getMemoryConfig() without guildId — this is a startup + * health check that verifies global mem0 connectivity, not guild-specific config. * Verifies the API key is configured and the SDK client can actually * communicate with the hosted platform by performing a lightweight search. * @param {object} [options] @@ -306,10 +311,11 @@ export async function checkMem0Health({ signal } = {}) { * @param {string} userId - Discord user ID * @param {string} text - The memory text to store * @param {Object} [metadata] - Optional metadata + * @param {string} [guildId] - Guild ID for per-guild config * @returns {Promise} true if stored successfully */ -export async function addMemory(userId, text, metadata = {}) { - if (!checkAndRecoverMemory()) return false; +export async function addMemory(userId, text, metadata = {}, guildId) { + if (!checkAndRecoverMemory(guildId)) return false; try { const c = getClient(); @@ -338,12 +344,13 @@ export async function addMemory(userId, text, metadata = {}) { * @param {string} userId - Discord user ID * @param {string} query - Search query * @param {number} [limit] - Max results (defaults to config maxContextMemories) + * @param {string} [guildId] - Guild ID for per-guild config * @returns {Promise<{memories: Array<{memory: string, score?: number}>, relations: Array}>} */ -export async function searchMemories(userId, query, limit) { - if (!checkAndRecoverMemory()) return { memories: [], relations: [] }; +export async function searchMemories(userId, query, limit, guildId) { + if (!checkAndRecoverMemory(guildId)) return { memories: [], relations: [] }; - const memConfig = getMemoryConfig(); + const memConfig = getMemoryConfig(guildId); const maxResults = limit ?? memConfig.maxContextMemories; try { @@ -378,10 +385,11 @@ export async function searchMemories(userId, query, limit) { /** * Get all memories for a user. * @param {string} userId - Discord user ID + * @param {string} [guildId] - Guild ID for per-guild config * @returns {Promise>} All user memories */ -export async function getMemories(userId) { - if (!checkAndRecoverMemory()) return []; +export async function getMemories(userId, guildId) { + if (!checkAndRecoverMemory(guildId)) return []; try { const c = getClient(); @@ -409,10 +417,11 @@ export async function getMemories(userId) { /** * Delete all memories for a user. * @param {string} userId - Discord user ID + * @param {string} [guildId] - Guild ID for per-guild config * @returns {Promise} true if deleted successfully */ -export async function deleteAllMemories(userId) { - if (!checkAndRecoverMemory()) return false; +export async function deleteAllMemories(userId, guildId) { + if (!checkAndRecoverMemory(guildId)) return false; try { const c = getClient(); @@ -431,10 +440,11 @@ export async function deleteAllMemories(userId) { /** * Delete a specific memory by ID. * @param {string} memoryId - Memory ID to delete + * @param {string} [guildId] - Guild ID for per-guild config * @returns {Promise} true if deleted successfully */ -export async function deleteMemory(memoryId) { - if (!checkAndRecoverMemory()) return false; +export async function deleteMemory(memoryId, guildId) { + if (!checkAndRecoverMemory(guildId)) return false; try { const c = getClient(); @@ -477,13 +487,14 @@ const MAX_MEMORY_CONTEXT_CHARS = 2000; * @param {string} userId - Discord user ID * @param {string} username - Display name * @param {string} query - The user's current message (for relevance search) + * @param {string} [guildId] - Guild ID for per-guild config * @returns {Promise} Context string or empty string */ -export async function buildMemoryContext(userId, username, query) { - if (!checkAndRecoverMemory()) return ''; +export async function buildMemoryContext(userId, username, query, guildId) { + if (!checkAndRecoverMemory(guildId)) return ''; if (isOptedOut(userId)) return ''; - const { memories, relations } = await searchMemories(userId, query); + const { memories, relations } = await searchMemories(userId, query, undefined, guildId); if (memories.length === 0 && (!relations || relations.length === 0)) return ''; @@ -515,13 +526,20 @@ export async function buildMemoryContext(userId, username, query) { * @param {string} username - Display name * @param {string} userMessage - What the user said * @param {string} assistantReply - What the bot replied + * @param {string} [guildId] - Guild ID for per-guild config * @returns {Promise} true if any memories were stored */ -export async function extractAndStoreMemories(userId, username, userMessage, assistantReply) { - if (!checkAndRecoverMemory()) return false; +export async function extractAndStoreMemories( + userId, + username, + userMessage, + assistantReply, + guildId, +) { + if (!checkAndRecoverMemory(guildId)) return false; if (isOptedOut(userId)) return false; - const memConfig = getMemoryConfig(); + const memConfig = getMemoryConfig(guildId); if (!memConfig.autoExtract) return false; try { diff --git a/src/modules/moderation.js b/src/modules/moderation.js index c6afb36b..cf55b4f5 100644 --- a/src/modules/moderation.js +++ b/src/modules/moderation.js @@ -374,7 +374,7 @@ async function pollTempbans(client) { const targetUser = await client.users.fetch(row.target_id).catch(() => null); // Create unban case - const config = getConfig(); + const config = getConfig(row.guild_id); const unbanCase = await createCase(row.guild_id, { action: 'unban', targetId: row.target_id, diff --git a/src/modules/threading.js b/src/modules/threading.js index 613c7ef2..3e6ea116 100644 --- a/src/modules/threading.js +++ b/src/modules/threading.js @@ -12,7 +12,7 @@ import { info, error as logError, warn } from '../logger.js'; import { getConfig } from './config.js'; /** - * Active thread tracker: Map<`${userId}:${channelId}`, { threadId, lastActive, threadName }> + * Active thread tracker: Map<`${userId}:${channelId}`, { threadId, lastActive, threadName, guildId }> * Tracks which thread to reuse for a given user+channel combination. * Entries are evicted by a periodic sweep and a max-size cap. */ @@ -59,11 +59,12 @@ export function snapAutoArchiveDuration(minutes) { /** * Retrieve threading configuration derived from the bot config, falling back to sensible defaults. + * @param {string} [guildId] - Guild ID for per-guild config * @returns {{ enabled: boolean, autoArchiveMinutes: number, reuseWindowMs: number }} An object where `enabled` is `true` if threading is enabled; `autoArchiveMinutes` is the thread auto-archive duration in minutes; and `reuseWindowMs` is the thread reuse window in milliseconds. */ -export function getThreadConfig() { +export function getThreadConfig(guildId) { try { - const config = getConfig(); + const config = getConfig(guildId); const threadMode = config?.ai?.threadMode; const rawArchive = threadMode?.autoArchiveMinutes; @@ -97,7 +98,7 @@ export function getThreadConfig() { * @returns {boolean} `true` if the message is eligible for thread handling, `false` otherwise. */ export function shouldUseThread(message) { - const threadConfig = getThreadConfig(); + const threadConfig = getThreadConfig(message.guild?.id); if (!threadConfig.enabled) return false; // Don't create threads in DMs @@ -200,7 +201,7 @@ export function buildThreadKey(userId, channelId) { * @returns {Promise} `ThreadChannel` if a reusable thread was found and prepared, `null` otherwise. */ export async function findExistingThread(message) { - const threadConfig = getThreadConfig(); + const threadConfig = getThreadConfig(message.guild?.id); const key = buildThreadKey(message.author.id, message.channel.id); const entry = activeThreads.get(key); @@ -239,8 +240,9 @@ export async function findExistingThread(message) { } } - // Update last active time + // Update last active time and ensure guildId is stored entry.lastActive = now; + entry.guildId = message.guild?.id ?? entry.guildId ?? null; return thread; } catch (_err) { // Thread not found or inaccessible @@ -256,7 +258,7 @@ export async function findExistingThread(message) { * @returns {Promise} The created thread channel. */ export async function createThread(message, cleanContent) { - const threadConfig = getThreadConfig(); + const threadConfig = getThreadConfig(message.guild?.id); const threadName = generateThreadName( message.author.displayName || message.author.username, cleanContent, @@ -273,6 +275,7 @@ export async function createThread(message, cleanContent) { threadId: thread.id, lastActive: Date.now(), threadName, + guildId: message.guild?.id ?? null, }); info('Created conversation thread', { @@ -357,15 +360,15 @@ async function _getOrCreateThreadInner(message, cleanContent) { /** * Sweep expired entries from the activeThreads cache. - * Removes entries older than the configured reuse window and + * Removes entries older than the guild-specific configured reuse window and * enforces the MAX_CACHE_SIZE cap by evicting oldest entries. */ export function sweepExpiredThreads() { - const config = getThreadConfig(); const now = Date.now(); - // Remove expired entries + // Remove expired entries using per-guild config for (const [key, entry] of activeThreads) { + const config = getThreadConfig(entry.guildId); if (now - entry.lastActive > config.reuseWindowMs) { activeThreads.delete(key); } diff --git a/tests/api/routes/guilds.test.js b/tests/api/routes/guilds.test.js index ba1758f6..e9a4d46f 100644 --- a/tests/api/routes/guilds.test.js +++ b/tests/api/routes/guilds.test.js @@ -10,7 +10,7 @@ vi.mock('../../../src/logger.js', () => ({ vi.mock('../../../src/modules/config.js', () => ({ getConfig: vi.fn().mockReturnValue({ - ai: { model: 'claude-3' }, + ai: { enabled: true, model: 'claude-3', historyLength: 20 }, welcome: { enabled: true }, spam: { enabled: true }, moderation: { enabled: true }, @@ -387,11 +387,11 @@ describe('guilds routes', () => { .set('x-api-secret', SECRET); expect(res.status).toBe(200); - expect(res.body.ai).toEqual({ model: 'claude-3' }); + expect(res.body.ai).toEqual({ enabled: true, model: 'claude-3', historyLength: 20 }); expect(res.body.welcome).toEqual({ enabled: true }); expect(res.body.database).toBeUndefined(); expect(res.body.token).toBeUndefined(); - expect(getConfig).toHaveBeenCalled(); + expect(getConfig).toHaveBeenCalledWith('guild1'); }); it('should return moderation config as readable', async () => { @@ -406,13 +406,19 @@ describe('guilds routes', () => { describe('PATCH /:id/config', () => { it('should update config value', async () => { + getConfig.mockReturnValueOnce({ + ai: { enabled: true, model: 'claude-4', historyLength: 20 }, + }); + const res = await request(app) .patch('/api/v1/guilds/guild1/config') .set('x-api-secret', SECRET) .send({ path: 'ai.model', value: 'claude-4' }); expect(res.status).toBe(200); - expect(setConfigValue).toHaveBeenCalledWith('ai.model', 'claude-4'); + expect(res.body).toEqual({ enabled: true, model: 'claude-4', historyLength: 20 }); + expect(setConfigValue).toHaveBeenCalledWith('ai.model', 'claude-4', 'guild1'); + expect(getConfig).toHaveBeenCalledWith('guild1'); }); it('should return 400 when request body is missing', async () => { diff --git a/tests/commands/config.test.js b/tests/commands/config.test.js index e0a692fa..8dc87914 100644 --- a/tests/commands/config.test.js +++ b/tests/commands/config.test.js @@ -110,6 +110,7 @@ describe('config command', () => { it('should autocomplete section names', async () => { const mockRespond = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getFocused: vi.fn().mockReturnValue({ name: 'section', value: 'ai' }), }, @@ -126,6 +127,7 @@ describe('config command', () => { it('should autocomplete dot-notation paths', async () => { const mockRespond = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getFocused: vi.fn().mockReturnValue({ name: 'path', value: 'ai.' }), }, @@ -144,6 +146,7 @@ describe('config command', () => { it('should display all config sections', async () => { const mockReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('view'), getString: vi.fn().mockReturnValue(null), @@ -160,6 +163,7 @@ describe('config command', () => { it('should display specific section', async () => { const mockReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('view'), getString: vi.fn().mockReturnValue('ai'), @@ -176,6 +180,7 @@ describe('config command', () => { it('should error for unknown section', async () => { const mockReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('view'), getString: vi.fn().mockReturnValue('nonexistent'), @@ -212,6 +217,7 @@ describe('config command', () => { .mockReturnValueOnce(largeConfig); const mockReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('view'), getString: vi.fn().mockReturnValue(null), @@ -239,6 +245,7 @@ describe('config command', () => { }); const mockReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('view'), getString: vi.fn().mockReturnValue(null), @@ -268,6 +275,7 @@ describe('config command', () => { }); const mockReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('view'), getString: vi.fn().mockReturnValue(null), @@ -293,6 +301,7 @@ describe('config command', () => { it('should set a config value', async () => { const mockEditReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('set'), getString: vi.fn().mockImplementation((name) => { @@ -306,13 +315,14 @@ describe('config command', () => { }; await execute(interaction); - expect(setConfigValue).toHaveBeenCalledWith('ai.model', 'new-model'); + expect(setConfigValue).toHaveBeenCalledWith('ai.model', 'new-model', 'test-guild'); expect(mockEditReply).toHaveBeenCalled(); }); it('should reject invalid section', async () => { const mockReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('set'), getString: vi.fn().mockImplementation((name) => { @@ -337,6 +347,7 @@ describe('config command', () => { setConfigValue.mockRejectedValueOnce(new Error('DB error')); const mockEditReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('set'), getString: vi.fn().mockImplementation((name) => { @@ -364,6 +375,7 @@ describe('config command', () => { setConfigValue.mockRejectedValueOnce(new Error('error')); const mockReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('set'), getString: vi.fn().mockImplementation((name) => { @@ -392,6 +404,7 @@ describe('config command', () => { it('should reset specific section', async () => { const mockEditReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('reset'), getString: vi.fn().mockReturnValue('ai'), @@ -401,13 +414,14 @@ describe('config command', () => { }; await execute(interaction); - expect(resetConfig).toHaveBeenCalledWith('ai'); + expect(resetConfig).toHaveBeenCalledWith('ai', 'test-guild'); expect(mockEditReply).toHaveBeenCalled(); }); it('should reset all when no section specified', async () => { const mockEditReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('reset'), getString: vi.fn().mockReturnValue(null), @@ -417,13 +431,14 @@ describe('config command', () => { }; await execute(interaction); - expect(resetConfig).toHaveBeenCalledWith(undefined); + expect(resetConfig).toHaveBeenCalledWith(undefined, 'test-guild'); }); it('should handle reset error with deferred reply', async () => { resetConfig.mockRejectedValueOnce(new Error('reset failed')); const mockEditReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('reset'), getString: vi.fn().mockReturnValue('ai'), @@ -443,6 +458,7 @@ describe('config command', () => { resetConfig.mockRejectedValueOnce(new Error('reset failed')); const mockReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('reset'), getString: vi.fn().mockReturnValue('ai'), @@ -466,6 +482,7 @@ describe('config command', () => { it('should reply with error for unknown subcommand', async () => { const mockReply = vi.fn(); const interaction = { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue('unknown') }, reply: mockReply, }; diff --git a/tests/commands/memory.test.js b/tests/commands/memory.test.js index 3422ec6c..717b2b6f 100644 --- a/tests/commands/memory.test.js +++ b/tests/commands/memory.test.js @@ -230,6 +230,7 @@ function createMockInteraction({ userId = '123456', username = 'testuser', targetUser = null, + guildId = 'guild-123', hasManageGuild = false, hasAdmin = false, } = {}) { @@ -245,6 +246,7 @@ function createMockInteraction({ getUser: () => targetUser, }, user: { id: userId, username }, + guildId, memberPermissions: { has: (perm) => { if (perm === PermissionFlagsBits.ManageGuild) return hasManageGuild; @@ -424,7 +426,7 @@ describe('memory command', () => { await execute(interaction); - expect(deleteAllMemories).toHaveBeenCalledWith('123456'); + expect(deleteAllMemories).toHaveBeenCalledWith('123456', 'guild-123'); expect(mockUpdate).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('cleared'), @@ -522,8 +524,8 @@ describe('memory command', () => { await execute(interaction); expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); - expect(searchMemories).toHaveBeenCalledWith('123456', 'Rust', 100); - expect(deleteMemory).toHaveBeenCalledWith('mem-1'); + expect(searchMemories).toHaveBeenCalledWith('123456', 'Rust', 100, 'guild-123'); + expect(deleteMemory).toHaveBeenCalledWith('mem-1', 'guild-123'); expect(interaction.editReply).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('1 memory'), @@ -588,8 +590,8 @@ describe('memory command', () => { await execute(interaction); expect(deleteMemory).toHaveBeenCalledTimes(2); - expect(deleteMemory).toHaveBeenCalledWith(0); - expect(deleteMemory).toHaveBeenCalledWith('mem-2'); + expect(deleteMemory).toHaveBeenCalledWith(0, 'guild-123'); + expect(deleteMemory).toHaveBeenCalledWith('mem-2', 'guild-123'); }); it('should skip memories with empty string, null, or undefined IDs', async () => { @@ -612,7 +614,7 @@ describe('memory command', () => { await execute(interaction); expect(deleteMemory).toHaveBeenCalledTimes(1); - expect(deleteMemory).toHaveBeenCalledWith('valid-id'); + expect(deleteMemory).toHaveBeenCalledWith('valid-id', 'guild-123'); }); it('should loop to delete all matching memories across multiple batches', async () => { @@ -669,8 +671,8 @@ describe('memory command', () => { expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); expect(deleteMemory).toHaveBeenCalledTimes(2); - expect(deleteMemory).toHaveBeenCalledWith('mem-1'); - expect(deleteMemory).toHaveBeenCalledWith('mem-2'); + expect(deleteMemory).toHaveBeenCalledWith('mem-1', 'guild-123'); + expect(deleteMemory).toHaveBeenCalledWith('mem-2', 'guild-123'); expect(interaction.editReply).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('2 memories'), @@ -711,7 +713,7 @@ describe('memory command', () => { await execute(interaction); expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); - expect(getMemories).toHaveBeenCalledWith('999'); + expect(getMemories).toHaveBeenCalledWith('999', 'guild-123'); expect(interaction.editReply).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('targetuser'), @@ -856,7 +858,7 @@ describe('memory command', () => { await execute(interaction); - expect(deleteAllMemories).toHaveBeenCalledWith('999'); + expect(deleteAllMemories).toHaveBeenCalledWith('999', 'guild-123'); expect(mockUpdate).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('targetuser'), diff --git a/tests/commands/modlog.test.js b/tests/commands/modlog.test.js index 3f5d462c..1e57bb67 100644 --- a/tests/commands/modlog.test.js +++ b/tests/commands/modlog.test.js @@ -41,6 +41,7 @@ import { hasPermission } from '../../src/utils/permissions.js'; function createInteraction(subcommand) { const collectHandlers = {}; return { + guildId: 'test-guild', options: { getSubcommand: vi.fn().mockReturnValue(subcommand), }, @@ -169,13 +170,41 @@ describe('modlog command', () => { await execute(interaction); expect(setConfigValue).toHaveBeenCalledTimes(7); - expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.default', null); - expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.warns', null); - expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.bans', null); - expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.kicks', null); - expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.timeouts', null); - expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.purges', null); - expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.locks', null); + expect(setConfigValue).toHaveBeenCalledWith( + 'moderation.logging.channels.default', + null, + 'test-guild', + ); + expect(setConfigValue).toHaveBeenCalledWith( + 'moderation.logging.channels.warns', + null, + 'test-guild', + ); + expect(setConfigValue).toHaveBeenCalledWith( + 'moderation.logging.channels.bans', + null, + 'test-guild', + ); + expect(setConfigValue).toHaveBeenCalledWith( + 'moderation.logging.channels.kicks', + null, + 'test-guild', + ); + expect(setConfigValue).toHaveBeenCalledWith( + 'moderation.logging.channels.timeouts', + null, + 'test-guild', + ); + expect(setConfigValue).toHaveBeenCalledWith( + 'moderation.logging.channels.purges', + null, + 'test-guild', + ); + expect(setConfigValue).toHaveBeenCalledWith( + 'moderation.logging.channels.locks', + null, + 'test-guild', + ); expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('disabled')); }); @@ -252,7 +281,11 @@ describe('modlog command', () => { }; await collectHandler(channelInteraction); - expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.warns', '999'); + expect(setConfigValue).toHaveBeenCalledWith( + 'moderation.logging.channels.warns', + '999', + 'test-guild', + ); expect(channelInteraction.update).toHaveBeenCalled(); }); diff --git a/tests/index.test.js b/tests/index.test.js index dd80d1dd..e629274a 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -45,6 +45,7 @@ const mocks = vi.hoisted(() => ({ config: { loadConfig: vi.fn(), + getConfig: vi.fn().mockReturnValue({}), onConfigChangeCallbacks: {}, }, @@ -158,6 +159,7 @@ vi.mock('../src/modules/ai.js', () => ({ })); vi.mock('../src/modules/config.js', () => ({ + getConfig: mocks.config.getConfig, loadConfig: mocks.config.loadConfig, onConfigChange: vi.fn((path, cb) => { if (!mocks.config.onConfigChangeCallbacks[path]) { diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index 5a271b3c..f6452da6 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -157,6 +157,22 @@ describe('ai module', () => { expect(history[0].content).toBe('message 5'); }); + it('should pass guildId to getHistoryLength when provided', async () => { + getConfig.mockReturnValue({ ai: { historyLength: 3, historyTTLDays: 30 } }); + + for (let i = 0; i < 5; i++) { + addToHistory('ch-guild', 'user', `msg ${i}`, undefined, 'guild-123'); + } + + // getConfig should have been called with guildId + expect(getConfig).toHaveBeenCalledWith('guild-123'); + + // Verify history was actually trimmed to the configured length of 3 + const history = await getHistoryAsync('ch-guild'); + expect(history.length).toBe(3); + expect(history[0].content).toBe('msg 2'); + }); + it('should write to DB when pool is available', () => { const mockQuery = vi.fn().mockResolvedValue({}); const mockPool = { query: mockQuery }; @@ -226,8 +242,7 @@ describe('ai module', () => { }; vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); - const config = { ai: { model: 'test-model' } }; - const reply = await generateResponse('ch1', 'Hi', 'user1', config); + const reply = await generateResponse('ch1', 'Hi', 'user1'); expect(reply).toBe('Hello there!'); expect(globalThis.fetch).toHaveBeenCalled(); @@ -242,8 +257,7 @@ describe('ai module', () => { }; vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); - const config = { ai: {} }; - await generateResponse('ch1', 'Hi', 'user', config); + await generateResponse('ch1', 'Hi', 'user'); const fetchCall = globalThis.fetch.mock.calls[0]; expect(fetchCall[1].headers['Content-Type']).toBe('application/json'); @@ -260,20 +274,13 @@ describe('ai module', () => { }; vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); - const config = { ai: { systemPrompt: 'You are a bot.' } }; - await generateResponse( - 'ch1', - 'What do you know about me?', - 'testuser', - config, - null, - 'user-123', - ); + await generateResponse('ch1', 'What do you know about me?', 'testuser', null, 'user-123'); expect(buildMemoryContext).toHaveBeenCalledWith( 'user-123', 'testuser', 'What do you know about me?', + null, ); // Verify the system prompt includes memory context @@ -292,8 +299,7 @@ describe('ai module', () => { }; vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); - const config = { ai: { systemPrompt: 'You are a bot.' } }; - await generateResponse('ch1', 'Hi', 'user', config, null, null); + await generateResponse('ch1', 'Hi', 'user', null, null); expect(buildMemoryContext).not.toHaveBeenCalled(); }); @@ -308,8 +314,7 @@ describe('ai module', () => { }; vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); - const config = { ai: {} }; - await generateResponse('ch1', "I'm learning Rust", 'testuser', config, null, 'user-123'); + await generateResponse('ch1', "I'm learning Rust", 'testuser', null, 'user-123'); // extractAndStoreMemories is fire-and-forget, wait for it await vi.waitFor(() => { @@ -318,6 +323,7 @@ describe('ai module', () => { 'testuser', "I'm learning Rust", 'Nice!', + null, ); }); }); @@ -336,8 +342,9 @@ describe('ai module', () => { }; vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); - const config = { ai: { systemPrompt: 'You are a bot.' } }; - const replyPromise = generateResponse('ch1', 'Hi', 'user', config, null, 'user-123'); + // generateResponse reads AI settings from getConfig(guildId) + getConfig.mockReturnValue({ ai: { systemPrompt: 'You are a bot.' } }); + const replyPromise = generateResponse('ch1', 'Hi', 'user', null, 'user-123'); // Advance past the 5s timeout await vi.advanceTimersByTimeAsync(5000); @@ -364,12 +371,52 @@ describe('ai module', () => { }; vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); - const config = { ai: { systemPrompt: 'You are a bot.' } }; - const reply = await generateResponse('ch1', 'Hi', 'user', config, null, 'user-123'); + const reply = await generateResponse('ch1', 'Hi', 'user', null, 'user-123'); expect(reply).toBe('Still working!'); }); + it('should pass guildId to buildMemoryContext and extractAndStoreMemories', async () => { + buildMemoryContext.mockResolvedValue(''); + extractAndStoreMemories.mockResolvedValue(true); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Reply!' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + await generateResponse('ch1', 'Hi', 'testuser', null, 'user-123', 'guild-456'); + + expect(buildMemoryContext).toHaveBeenCalledWith('user-123', 'testuser', 'Hi', 'guild-456'); + + await vi.waitFor(() => { + expect(extractAndStoreMemories).toHaveBeenCalledWith( + 'user-123', + 'testuser', + 'Hi', + 'Reply!', + 'guild-456', + ); + }); + }); + + it('should call getConfig(guildId) for history-length lookup in generateResponse', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'OK' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + await generateResponse('ch1', 'Hi', 'user', null, null, 'guild-789'); + + // getConfig should have been called with guildId for history length lookup + expect(getConfig).toHaveBeenCalledWith('guild-789'); + }); + it('should not call memory extraction when userId is not provided', async () => { const mockResponse = { ok: true, @@ -379,8 +426,7 @@ describe('ai module', () => { }; vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); - const config = { ai: {} }; - await generateResponse('ch1', 'Hi', 'user', config); + await generateResponse('ch1', 'Hi', 'user'); expect(extractAndStoreMemories).not.toHaveBeenCalled(); }); diff --git a/tests/modules/config-events.test.js b/tests/modules/config-events.test.js index 3dc522c5..e94bd1bb 100644 --- a/tests/modules/config-events.test.js +++ b/tests/modules/config-events.test.js @@ -70,7 +70,7 @@ describe('config change events', () => { await configModule.setConfigValue('ai.model', 'new-model'); expect(cb).toHaveBeenCalledOnce(); - expect(cb).toHaveBeenCalledWith('new-model', 'test-model', 'ai.model'); + expect(cb).toHaveBeenCalledWith('new-model', 'test-model', 'ai.model', 'global'); }); it('should fire callback on prefix wildcard match', async () => { @@ -80,7 +80,7 @@ describe('config change events', () => { await configModule.setConfigValue('ai.model', 'new-model'); expect(cb).toHaveBeenCalledOnce(); - expect(cb).toHaveBeenCalledWith('new-model', 'test-model', 'ai.model'); + expect(cb).toHaveBeenCalledWith('new-model', 'test-model', 'ai.model', 'global'); }); it('should not fire callback for non-matching paths', async () => { @@ -164,7 +164,7 @@ describe('config change events', () => { await configModule.setConfigValue('ai.enabled', 'false'); - expect(cb).toHaveBeenCalledWith(false, true, 'ai.enabled'); + expect(cb).toHaveBeenCalledWith(false, true, 'ai.enabled', 'global'); }); it('should pass undefined as oldValue for new keys', async () => { @@ -173,7 +173,7 @@ describe('config change events', () => { await configModule.setConfigValue('ai.newKey', 'hello'); - expect(cb).toHaveBeenCalledWith('hello', undefined, 'ai.newKey'); + expect(cb).toHaveBeenCalledWith('hello', undefined, 'ai.newKey', 'global'); }); it('should deep clone object oldValues', async () => { @@ -209,7 +209,7 @@ describe('config change events', () => { await configModule.setConfigValue('ai.deep.nested.key', 'value'); - expect(cb).toHaveBeenCalledWith('value', undefined, 'ai.deep.nested.key'); + expect(cb).toHaveBeenCalledWith('value', undefined, 'ai.deep.nested.key', 'global'); }); it('should not skip listeners when one calls offConfigChange during callback', async () => { @@ -254,6 +254,37 @@ describe('config change events', () => { expect(cb3).toHaveBeenCalledOnce(); }); + describe('guild-scoped config changes', () => { + it('should pass guildId to listener callbacks for per-guild changes', async () => { + const cb = vi.fn(); + configModule.onConfigChange('logging.database.enabled', cb); + + await configModule.setConfigValue('logging.database.enabled', 'true', 'guild-123'); + + expect(cb).toHaveBeenCalledWith(true, undefined, 'logging.database.enabled', 'guild-123'); + }); + + it('should allow listeners to filter by guildId for global-only operations', async () => { + // Simulates what index.js does: skip per-guild config changes for the logging transport + const transportUpdater = vi.fn(); + configModule.onConfigChange( + 'logging.database.enabled', + (_newValue, _oldValue, _path, guildId) => { + if (guildId && guildId !== 'global') return; + transportUpdater(); + }, + ); + + // Per-guild change should NOT trigger the transport update + await configModule.setConfigValue('logging.database.enabled', 'true', 'guild-456'); + expect(transportUpdater).not.toHaveBeenCalled(); + + // Global change should trigger the transport update + await configModule.setConfigValue('logging.database.enabled', 'true', 'global'); + expect(transportUpdater).toHaveBeenCalledOnce(); + }); + }); + it('should catch async listener rejections without unhandled promise rejection', async () => { const asyncBadCb = vi.fn().mockRejectedValue(new Error('async boom')); const goodCb = vi.fn(); diff --git a/tests/modules/config-guild.test.js b/tests/modules/config-guild.test.js new file mode 100644 index 00000000..6e5e9b8c --- /dev/null +++ b/tests/modules/config-guild.test.js @@ -0,0 +1,408 @@ +/** + * Per-Guild Configuration Tests + * Tests guild isolation, deep merge, fallback to global, and multi-tenancy behavior + */ + +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 +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +// Mock fs +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +describe('per-guild configuration', () => { + let configModule; + + beforeEach(async () => { + vi.resetModules(); + + const { existsSync: mockExists, readFileSync: mockRead } = await import('node:fs'); + mockExists.mockReturnValue(true); + mockRead.mockReturnValue( + JSON.stringify({ + ai: { enabled: true, model: 'claude-3', historyLength: 20 }, + spam: { enabled: false, threshold: 5 }, + moderation: { enabled: true, logging: { channels: {} } }, + welcome: { enabled: false, channelId: null }, + }), + ); + + // DB not available — in-memory only + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockImplementation(() => { + throw new Error('no db'); + }); + + configModule = await import('../../src/modules/config.js'); + await configModule.loadConfig(); + }); + + afterEach(() => { + configModule.clearConfigListeners(); + vi.restoreAllMocks(); + }); + + describe('getConfig backward compatibility', () => { + it('should return global config when called with no arguments', () => { + const config = configModule.getConfig(); + expect(config).toBeDefined(); + expect(config.ai.model).toBe('claude-3'); + expect(config.ai.enabled).toBe(true); + }); + + it('should return global config when called with "global"', () => { + const config = configModule.getConfig('global'); + expect(config.ai.model).toBe('claude-3'); + }); + + it('should return global config when called with undefined', () => { + const config = configModule.getConfig(undefined); + expect(config.ai.model).toBe('claude-3'); + }); + + it('should return mutable cache reference for global path (intentional)', () => { + const config1 = configModule.getConfig(); + config1.ai.model = 'mutated-model'; + + const config2 = configModule.getConfig(); + // Global returns live reference — mutations are visible (documented contract) + expect(config2.ai.model).toBe('mutated-model'); + }); + }); + + describe('merged cache generation tracking', () => { + it('should invalidate guild merged cache when global config changes via setConfigValue', async () => { + // Populate merged cache for guild-x + const before = configModule.getConfig('guild-x'); + expect(before.ai.model).toBe('claude-3'); + + // Change global config through the proper API + await configModule.setConfigValue('ai.model', 'claude-4'); + + // Guild merged cache should reflect the new global value + const after = configModule.getConfig('guild-x'); + expect(after.ai.model).toBe('claude-4'); + }); + + it('should invalidate guild merged cache when global config is reset', async () => { + await configModule.setConfigValue('ai.model', 'temporary-model'); + // Populate merged cache + const before = configModule.getConfig('guild-y'); + expect(before.ai.model).toBe('temporary-model'); + + // Reset global to defaults + await configModule.resetConfig('ai'); + + // Guild should see the reset value + const after = configModule.getConfig('guild-y'); + expect(after.ai.model).toBe('claude-3'); + }); + }); + + describe('guild isolation', () => { + it('should isolate changes between guilds', async () => { + await configModule.setConfigValue('ai.model', 'guild-a-model', 'guild-a'); + await configModule.setConfigValue('ai.model', 'guild-b-model', 'guild-b'); + + const configA = configModule.getConfig('guild-a'); + const configB = configModule.getConfig('guild-b'); + const configGlobal = configModule.getConfig(); + + expect(configA.ai.model).toBe('guild-a-model'); + expect(configB.ai.model).toBe('guild-b-model'); + expect(configGlobal.ai.model).toBe('claude-3'); + }); + + it('should not leak guild changes to global config', async () => { + await configModule.setConfigValue('ai.maxTokens', '2048', 'guild-a'); + + const global = configModule.getConfig(); + expect(global.ai.maxTokens).toBeUndefined(); + + const guildA = configModule.getConfig('guild-a'); + expect(guildA.ai.maxTokens).toBe(2048); + }); + + it('should not leak guild changes to other guilds', async () => { + await configModule.setConfigValue('spam.threshold', '10', 'guild-a'); + + const guildB = configModule.getConfig('guild-b'); + expect(guildB.spam.threshold).toBe(5); // Global default + }); + }); + + describe('deep merge behavior', () => { + it('should deep merge guild overrides with global defaults', async () => { + // Set only one key in the ai section for guild-a + await configModule.setConfigValue('ai.model', 'guild-model', 'guild-a'); + + const config = configModule.getConfig('guild-a'); + // Overridden key + expect(config.ai.model).toBe('guild-model'); + // Non-overridden keys from global + expect(config.ai.enabled).toBe(true); + expect(config.ai.historyLength).toBe(20); + }); + + it('should not replace entire sections with guild overrides', async () => { + await configModule.setConfigValue('moderation.logging.channels.default', '12345', 'guild-a'); + + const config = configModule.getConfig('guild-a'); + // The moderation section should still have enabled from global + expect(config.moderation.enabled).toBe(true); + }); + + it('should return a new object for each getConfig call (no shared refs)', async () => { + await configModule.setConfigValue('ai.model', 'guild-model', 'guild-a'); + + const config1 = configModule.getConfig('guild-a'); + const config2 = configModule.getConfig('guild-a'); + + expect(config1).toEqual(config2); + expect(config1).not.toBe(config2); + }); + }); + + describe('prototype pollution protection', () => { + it('should skip dangerous keys during deep merge', async () => { + const guildId = 'guild-proto-pollution'; + delete Object.prototype.polluted; + + try { + // Directly inject a guild override with __proto__ key into the cache + // to simulate a malicious value that bypassed path validation + await configModule.setConfigValue('ai.model', 'safe-model', guildId); + + // Set a value whose parsed JSON contains __proto__ — this is the attack vector. + // When deepMerge iterates the guild override, it must skip __proto__. + await configModule.setConfigValue( + 'ai.threadMode', + '{"__proto__":{"polluted":"yes"}}', + guildId, + ); + + // Trigger deepMerge by requesting guild config + configModule.getConfig(guildId); + + // Object.prototype should NOT be polluted + expect(Object.prototype.polluted).toBeUndefined(); + } finally { + await configModule.resetConfig('ai', guildId); + delete Object.prototype.polluted; + } + }); + + it('should skip constructor and prototype keys during deep merge', async () => { + const guildId = 'guild-constructor-pollution'; + + try { + const dangerousJson = '{"constructor":{"polluted":true},"prototype":{"evil":true}}'; + await configModule.setConfigValue('ai.threadMode', dangerousJson, guildId); + + const config = configModule.getConfig(guildId); + + // The dangerous keys should not appear in the merged result's ai section + expect(config.ai.constructor).toBe(Object); // Should be the native constructor, not overridden + expect(config.ai.prototype).toBeUndefined(); + } finally { + await configModule.resetConfig('ai', guildId); + } + }); + + it('should filter dangerous nested keys in recursive deepMerge branches', async () => { + const guildId = 'guild-recursive-pollution'; + delete Object.prototype.polluted; + + try { + await configModule.setConfigValue( + 'ai.threadMode', + '{"nested":{"baseline":"global","safeGlobal":true}}', + ); + await configModule.setConfigValue( + 'ai.threadMode', + '{"nested":{"safeGuild":true,"__proto__":{"polluted":"yes"},"constructor":{"prototype":{"polluted":"yes"}},"prototype":{"polluted":true}}}', + guildId, + ); + + const config = configModule.getConfig(guildId); + const nested = config.ai.threadMode.nested; + + expect(nested.baseline).toBe('global'); + expect(nested.safeGlobal).toBe(true); + expect(nested.safeGuild).toBe(true); + + expect(Object.prototype.hasOwnProperty.call(nested, '__proto__')).toBe(false); + expect(nested.constructor).toBe(Object); + expect(nested.prototype).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + } finally { + await configModule.resetConfig('ai', guildId); + await configModule.resetConfig('ai'); + delete Object.prototype.polluted; + } + }); + }); + + describe('fallback to global defaults', () => { + it('should return global defaults for unknown guild', () => { + const config = configModule.getConfig('unknown-guild'); + expect(config.ai.model).toBe('claude-3'); + expect(config.spam.threshold).toBe(5); + }); + + it('should return cloned global for guild with no overrides', () => { + const guildConfig = configModule.getConfig('no-overrides-guild'); + const globalConfig = configModule.getConfig(); + + expect(guildConfig).toEqual(globalConfig); + // Should be a clone, not the same reference + expect(guildConfig).not.toBe(globalConfig); + }); + }); + + describe('setConfigValue with guildId', () => { + it('should default to global when no guildId provided', async () => { + await configModule.setConfigValue('ai.model', 'new-global-model'); + + const global = configModule.getConfig(); + expect(global.ai.model).toBe('new-global-model'); + }); + + it('should write to guild-specific config', async () => { + await configModule.setConfigValue('ai.model', 'guild-model', 'guild-123'); + + const guildConfig = configModule.getConfig('guild-123'); + expect(guildConfig.ai.model).toBe('guild-model'); + }); + + it('should emit config change events with guildId', async () => { + const cb = vi.fn(); + configModule.onConfigChange('ai.model', cb); + + await configModule.setConfigValue('ai.model', 'new-model', 'guild-123'); + + expect(cb).toHaveBeenCalledWith('new-model', undefined, 'ai.model', 'guild-123'); + }); + + it('should emit global guildId for global changes', async () => { + const cb = vi.fn(); + configModule.onConfigChange('ai.model', cb); + + await configModule.setConfigValue('ai.model', 'new-model'); + + expect(cb).toHaveBeenCalledWith('new-model', 'claude-3', 'ai.model', 'global'); + }); + }); + + describe('resetConfig with guildId', () => { + it('should reset guild section overrides', async () => { + await configModule.setConfigValue('ai.model', 'guild-model', 'guild-a'); + await configModule.setConfigValue('ai.maxTokens', '2048', 'guild-a'); + + await configModule.resetConfig('ai', 'guild-a'); + + const config = configModule.getConfig('guild-a'); + // Should fall back to global defaults + expect(config.ai.model).toBe('claude-3'); + expect(config.ai.maxTokens).toBeUndefined(); + }); + + it('should reset all guild overrides', async () => { + await configModule.setConfigValue('ai.model', 'guild-model', 'guild-a'); + await configModule.setConfigValue('spam.threshold', '10', 'guild-a'); + + await configModule.resetConfig(undefined, 'guild-a'); + + const config = configModule.getConfig('guild-a'); + expect(config.ai.model).toBe('claude-3'); + expect(config.spam.threshold).toBe(5); + }); + + it('should not affect other guilds when resetting one guild', async () => { + await configModule.setConfigValue('ai.model', 'guild-a-model', 'guild-a'); + await configModule.setConfigValue('ai.model', 'guild-b-model', 'guild-b'); + + await configModule.resetConfig(undefined, 'guild-a'); + + const configA = configModule.getConfig('guild-a'); + const configB = configModule.getConfig('guild-b'); + + expect(configA.ai.model).toBe('claude-3'); // Reset to global + expect(configB.ai.model).toBe('guild-b-model'); // Unchanged + }); + + it('should reset global config to config.json defaults', async () => { + await configModule.setConfigValue('ai.model', 'modified-model'); + + await configModule.resetConfig('ai'); + + const config = configModule.getConfig(); + expect(config.ai.model).toBe('claude-3'); + }); + + it('should emit path-level events for guild section reset', async () => { + await configModule.setConfigValue('ai.model', 'guild-model', 'guild-a'); + await configModule.setConfigValue('ai.historyLength', '30', 'guild-a'); + + const exactCb = vi.fn(); + const prefixCb = vi.fn(); + configModule.onConfigChange('ai.model', exactCb); + configModule.onConfigChange('ai.*', prefixCb); + + await configModule.resetConfig('ai', 'guild-a'); + + expect(exactCb).toHaveBeenCalledWith('claude-3', 'guild-model', 'ai.model', 'guild-a'); + expect(prefixCb).toHaveBeenCalledWith('claude-3', 'guild-model', 'ai.model', 'guild-a'); + expect(prefixCb).toHaveBeenCalledWith(20, 30, 'ai.historyLength', 'guild-a'); + }); + + it('should emit path-level events for global full reset', async () => { + await configModule.setConfigValue('ai.model', 'modified-model'); + await configModule.setConfigValue('spam.threshold', '99'); + + const aiCb = vi.fn(); + const spamCb = vi.fn(); + configModule.onConfigChange('ai.*', aiCb); + configModule.onConfigChange('spam.threshold', spamCb); + + await configModule.resetConfig(); + + expect(aiCb).toHaveBeenCalledWith('claude-3', 'modified-model', 'ai.model', 'global'); + expect(spamCb).toHaveBeenCalledWith(5, 99, 'spam.threshold', 'global'); + }); + }); + + describe('multiple guilds simultaneously', () => { + it('should handle many guilds without interference', async () => { + const guildIds = Array.from({ length: 10 }, (_, i) => `guild-${i}`); + + // Set different models for each guild + for (const guildId of guildIds) { + await configModule.setConfigValue('ai.model', `model-${guildId}`, guildId); + } + + // Verify each guild has its own model + for (const guildId of guildIds) { + const config = configModule.getConfig(guildId); + expect(config.ai.model).toBe(`model-${guildId}`); + } + + // Verify global is untouched + expect(configModule.getConfig().ai.model).toBe('claude-3'); + }); + }); +}); diff --git a/tests/modules/config.test.js b/tests/modules/config.test.js index cadabb3f..56f27a82 100644 --- a/tests/modules/config.test.js +++ b/tests/modules/config.test.js @@ -140,6 +140,71 @@ describe('modules/config', () => { expect(mockClient.query).toHaveBeenCalledWith('COMMIT'); }); + it('should load guild overrides during fallback seeding', async () => { + const mockClient = { + query: vi.fn().mockResolvedValue({}), + release: vi.fn(), + }; + const mockPool = { + query: vi.fn().mockResolvedValue({ + rows: [ + // No global rows — triggers seeding from config.json + // But guild overrides already exist in DB + { guild_id: 'guild-99', key: 'ai', value: { model: 'guild-override-model' } }, + ], + }), + connect: vi.fn().mockResolvedValue(mockClient), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + await configModule.loadConfig(); + + // Guild override should be loaded, not dropped + const guildConfig = configModule.getConfig('guild-99'); + expect(guildConfig.ai.model).toBe('guild-override-model'); + // Other keys should still come from global (seeded from file) + expect(guildConfig.ai.enabled).toBe(true); + }); + + it('should filter dangerous nested keys during recursive deepMerge of guild overrides', async () => { + delete Object.prototype.polluted; + try { + const guildAiOverride = { + model: 'guild-model', + }; + Object.defineProperty(guildAiOverride, '__proto__', { + value: { polluted: 'yes' }, + enumerable: true, + }); + guildAiOverride.constructor = { polluted: true }; + guildAiOverride.prototype = { polluted: true }; + + const mockPool = { + query: vi.fn().mockResolvedValue({ + rows: [ + { guild_id: 'global', key: 'ai', value: { enabled: true, model: 'global-model' } }, + { guild_id: 'guild-danger', key: 'ai', value: guildAiOverride }, + ], + }), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + await configModule.loadConfig(); + const guildConfig = configModule.getConfig('guild-danger'); + + expect(guildConfig.ai.model).toBe('guild-model'); + expect(guildConfig.ai.enabled).toBe(true); + expect(guildConfig.ai.constructor).toBe(Object); + expect(guildConfig.ai.prototype).toBeUndefined(); + expect(guildConfig.ai.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + } finally { + delete Object.prototype.polluted; + } + }); + it('should load config from DB when rows exist', async () => { const mockPool = { query: vi.fn().mockResolvedValue({ @@ -188,6 +253,33 @@ describe('modules/config', () => { const config = await configModule.loadConfig(); expect(config.ai.enabled).toBe(true); }); + + it('should clear merged guild cache on reload', async () => { + const mockPool = { + query: vi + .fn() + .mockResolvedValueOnce({ + rows: [ + { guild_id: 'global', key: 'ai', value: { enabled: true, model: 'global-v1' } }, + { guild_id: 'guild-1', key: 'ai', value: { model: 'guild-v1' } }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { guild_id: 'global', key: 'ai', value: { enabled: true, model: 'global-v2' } }, + { guild_id: 'guild-1', key: 'ai', value: { model: 'guild-v2' } }, + ], + }), + }; + const { getPool: mockGetPool } = await import('../../src/db.js'); + mockGetPool.mockReturnValue(mockPool); + + await configModule.loadConfig(); + expect(configModule.getConfig('guild-1').ai.model).toBe('guild-v1'); + + await configModule.loadConfig(); + expect(configModule.getConfig('guild-1').ai.model).toBe('guild-v2'); + }); }); describe('setConfigValue', () => { diff --git a/tests/modules/events.test.js b/tests/modules/events.test.js index cbb4f85e..a84f11c1 100644 --- a/tests/modules/events.test.js +++ b/tests/modules/events.test.js @@ -54,8 +54,14 @@ vi.mock('../../src/modules/threading.js', () => ({ getOrCreateThread: vi.fn().mockResolvedValue({ thread: null, isNew: false }), })); +// Mock config module — getConfig returns per-guild config +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({}), +})); + import { generateResponse } from '../../src/modules/ai.js'; import { accumulate, resetCounter } from '../../src/modules/chimeIn.js'; +import { getConfig } from '../../src/modules/config.js'; import { registerErrorHandlers, registerEventHandlers, @@ -124,17 +130,20 @@ describe('events module', () => { expect(on).toHaveBeenCalledWith('guildMemberAdd', expect.any(Function)); }); - it('should call sendWelcomeMessage on member add', async () => { + it('should call sendWelcomeMessage on member add with per-guild config', async () => { const on = vi.fn(); const client = { on }; const config = {}; + const guildConfig = { welcome: { enabled: true } }; + getConfig.mockReturnValue(guildConfig); registerGuildMemberAddHandler(client, config); const callback = on.mock.calls[0][1]; - const member = { user: { tag: 'User#1234' } }; + const member = { user: { tag: 'User#1234' }, guild: { id: 'guild-123' } }; await callback(member); - expect(sendWelcomeMessage).toHaveBeenCalledWith(member, client, config); + expect(getConfig).toHaveBeenCalledWith('guild-123'); + expect(sendWelcomeMessage).toHaveBeenCalledWith(member, client, guildConfig); }); }); @@ -157,6 +166,9 @@ describe('events module', () => { ...configOverrides, }; + // Wire getConfig mock to return the test config for any guild + getConfig.mockReturnValue(config); + registerMessageCreateHandler(client, config, null); } @@ -428,7 +440,6 @@ describe('events module', () => { 'thread-123', 'hello from channel', 'user', - config, null, 'author-123', 'g1', diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index e4e05be9..a484072e 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -118,6 +118,11 @@ describe('memory module', () => { expect(config.autoExtract).toBe(false); }); + it('should pass guildId to getConfig', () => { + getMemoryConfig('guild-123'); + expect(getConfig).toHaveBeenCalledWith('guild-123'); + }); + it('should respect custom config values', () => { getConfig.mockReturnValue({ memory: { @@ -150,6 +155,12 @@ describe('memory module', () => { expect(isMemoryAvailable()).toBe(false); }); + it('should pass guildId to getMemoryConfig', () => { + _setMem0Available(true); + isMemoryAvailable('guild-123'); + expect(getConfig).toHaveBeenCalledWith('guild-123'); + }); + it('should NOT auto-recover (no side effects)', async () => { vi.useFakeTimers(); _setMem0Available(true); @@ -193,6 +204,12 @@ describe('memory module', () => { expect(checkAndRecoverMemory()).toBe(false); }); + it('should pass guildId to getMemoryConfig', () => { + _setMem0Available(true); + checkAndRecoverMemory('guild-456'); + expect(getConfig).toHaveBeenCalledWith('guild-456'); + }); + it('should auto-recover after cooldown period expires', async () => { vi.useFakeTimers(); _setMem0Available(true); @@ -586,6 +603,17 @@ describe('memory module', () => { expect(result.relations).toEqual([]); }); + it('should pass guildId to getMemoryConfig and checkAndRecoverMemory', async () => { + _setMem0Available(true); + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue({ results: [], relations: [] }), + }); + _setClient(mockClient); + + await searchMemories('user123', 'test', undefined, 'guild-789'); + expect(getConfig).toHaveBeenCalledWith('guild-789'); + }); + it('should respect custom limit parameter', async () => { _setMem0Available(true); const mockClient = createMockClient({ @@ -997,6 +1025,17 @@ describe('memory module', () => { expect(result).not.toMatch(/\.\.\.$/); }); + it('should pass guildId through to searchMemories and config', async () => { + _setMem0Available(true); + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue({ results: [], relations: [] }), + }); + _setClient(mockClient); + + await buildMemoryContext('user123', 'testuser', 'hello', 'guild-abc'); + expect(getConfig).toHaveBeenCalledWith('guild-abc'); + }); + it('should return context with only memories when no relations found', async () => { _setMem0Available(true); const mockClient = createMockClient({ @@ -1072,6 +1111,15 @@ describe('memory module', () => { ); }); + it('should pass guildId through to config lookups', async () => { + _setMem0Available(true); + const mockClient = createMockClient(); + _setClient(mockClient); + + await extractAndStoreMemories('user123', 'testuser', 'hi', 'hello', 'guild-xyz'); + expect(getConfig).toHaveBeenCalledWith('guild-xyz'); + }); + it('should return false on SDK failure but NOT mark unavailable (fire-and-forget safety)', async () => { _setMem0Available(true); const mockClient = createMockClient({ diff --git a/tests/modules/threading.test.js b/tests/modules/threading.test.js index 8e52f91c..7371b3eb 100644 --- a/tests/modules/threading.test.js +++ b/tests/modules/threading.test.js @@ -602,6 +602,37 @@ describe('threading module', () => { expect(tracked.threadId).toBe('new-thread-1'); }); + it('should store guildId in activeThreads entry', async () => { + const mockThread = { id: 'new-thread-guild' }; + const message = { + author: { id: 'user1', displayName: 'Alice', username: 'alice' }, + channel: { id: 'ch1' }, + guild: { id: 'guild-123' }, + startThread: vi.fn().mockResolvedValue(mockThread), + }; + + await createThread(message, 'Hello'); + + const key = buildThreadKey('user1', 'ch1'); + const tracked = getActiveThreads().get(key); + expect(tracked.guildId).toBe('guild-123'); + }); + + it('should store null guildId when no guild (DM context)', async () => { + const mockThread = { id: 'new-thread-dm' }; + const message = { + author: { id: 'user1', displayName: 'Alice', username: 'alice' }, + channel: { id: 'ch1' }, + startThread: vi.fn().mockResolvedValue(mockThread), + }; + + await createThread(message, 'Hello'); + + const key = buildThreadKey('user1', 'ch1'); + const tracked = getActiveThreads().get(key); + expect(tracked.guildId).toBeNull(); + }); + it('should use username when displayName is not available', async () => { const mockThread = { id: 'new-thread-2' }; const message = { @@ -779,6 +810,43 @@ describe('threading module', () => { expect(getActiveThreads().has('fresh')).toBe(true); }); + it('should use per-guild config for eviction', () => { + const now = Date.now(); + + // Guild A has a short reuse window (10 minutes) + // Guild B has a long reuse window (60 minutes) + getConfig.mockImplementation((guildId) => { + if (guildId === 'guild-a') { + return { ai: { threadMode: { enabled: true, reuseWindowMinutes: 10 } } }; + } + if (guildId === 'guild-b') { + return { ai: { threadMode: { enabled: true, reuseWindowMinutes: 60 } } }; + } + return { ai: { threadMode: { enabled: true, reuseWindowMinutes: 30 } } }; + }); + + // Entry from guild-a, 15 min old — should be evicted (10 min window) + getActiveThreads().set('guild-a-entry', { + threadId: 't1', + lastActive: now - 15 * 60 * 1000, + threadName: 'Guild A thread', + guildId: 'guild-a', + }); + + // Entry from guild-b, 15 min old — should survive (60 min window) + getActiveThreads().set('guild-b-entry', { + threadId: 't2', + lastActive: now - 15 * 60 * 1000, + threadName: 'Guild B thread', + guildId: 'guild-b', + }); + + sweepExpiredThreads(); + + expect(getActiveThreads().has('guild-a-entry')).toBe(false); + expect(getActiveThreads().has('guild-b-entry')).toBe(true); + }); + it('should enforce max-size cap by evicting oldest entries', () => { const now = Date.now(); // Add 1002 entries (over the 1000 cap), all fresh