Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c355e53
feat: per-guild configuration with deep merge and guild isolation
BillChirico Feb 17, 2026
57d78a6
fix: add LRU cache cap and merged config caching for guild entries
BillChirico Feb 17, 2026
8e6fccb
fix: document intentional use of global config in ai.js channel-level…
BillChirico Feb 17, 2026
f0f3b45
fix: document asymmetric return semantics in getConfig
BillChirico Feb 17, 2026
315fda7
fix: look up actual PK constraint name in config table migration
BillChirico Feb 17, 2026
46ad327
fix: document scaling concern for loadConfig eager fetch
BillChirico Feb 17, 2026
997a5cd
fix: add guildId to modlog disable log entry
BillChirico Feb 17, 2026
ea38be2
fix: remove redundant idx_config_guild_id index
BillChirico Feb 17, 2026
2e79b6b
fix: document oldValue capture semantics in setConfigValue
BillChirico Feb 17, 2026
adce7dd
fix: warn about orphaned per-guild config rows on full reset
BillChirico Feb 17, 2026
57f643b
fix: add mutation safety test for global config path
BillChirico Feb 17, 2026
eecf123
fix: return clones from merged cache and guard orphan query result
BillChirico Feb 17, 2026
0e4bce1
fix: apply biome lint fix for mergedConfigCache const declaration
BillChirico Feb 17, 2026
ef56576
fix: thread guildId through AI/memory/threading internal callers
BillChirico Feb 17, 2026
9aae5e1
fix: address PR #74 review comments
BillChirico Feb 17, 2026
0f53822
fix: resolve review comments on per-guild config PR
BillChirico Feb 17, 2026
41fa503
fix: clear mergedConfigCache on loadConfig() reload
BillChirico Feb 17, 2026
b73267f
fix: load guild overrides during fallback seeding in loadConfig()
BillChirico Feb 17, 2026
214cfdf
fix: track global config generation to detect stale merged cache
BillChirico Feb 17, 2026
244598c
fix: eliminate double structuredClone in guild config cache miss path
BillChirico Feb 17, 2026
cea74f6
fix: address remaining PR #74 review feedback
BillChirico Feb 17, 2026
4c62040
fix: address remaining review comments on per-guild config
BillChirico Feb 17, 2026
a247577
fix: use getConfig(guildId) for AI settings, document configCache bounds
BillChirico Feb 17, 2026
45ebc00
fix: propagate guildId to remaining memory functions
BillChirico Feb 17, 2026
240d1de
refactor: remove dead config parameter from generateResponse
BillChirico Feb 17, 2026
b950452
fix: use per-guild config in event handler feature gates
BillChirico Feb 17, 2026
8b9510c
fix: convert GuildMemberAdd handler to per-guild config
BillChirico Feb 17, 2026
763aa1c
fix: address remaining review comments on per-guild config
BillChirico Feb 17, 2026
cb7ae74
fix: guard deepMerge against prototype pollution keys
BillChirico Feb 17, 2026
f5f4dd0
fix: address remaining PR #74 review feedback
BillChirico Feb 17, 2026
31948cc
merge: resolve conflicts with main for PR #74
BillChirico Feb 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
23 changes: 10 additions & 13 deletions src/api/routes/guilds.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,31 +279,26 @@ 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) {
safeConfig[key] = config[key];
}
}
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) {
Expand Down Expand Up @@ -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' });
Expand Down
2 changes: 1 addition & 1 deletion src/commands/ban.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/case.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
14 changes: 7 additions & 7 deletions src/commands/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link

Choose a reason for hiding this comment

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

Bug: The autocomplete handler was correctly updated to use getConfig(interaction.guildId) here, but the execute() function's permission check at line 161 was not updated — it still calls getConfig() (no guildId). This means the permission gate always evaluates against global config, ignoring any per-guild permissions.adminRoleId or permissions.allowedCommands.config overrides.

Line 161 should be:

const config = getConfig(interaction.guildId);


let choices;
if (focusedOption.name === 'section') {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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, {
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/commands/kick.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
2 changes: 1 addition & 1 deletion src/commands/lock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 28 additions & 18 deletions src/commands/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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.',
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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, {
Expand All @@ -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')
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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.',
Expand All @@ -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);
}
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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, {
Expand Down
18 changes: 13 additions & 5 deletions src/commands/modlog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -176,7 +184,7 @@ async function handleSetup(interaction) {
*/
Copy link

Choose a reason for hiding this comment

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

Bug (same pattern as config.js): The permission check at the top of execute() (line 35) still calls getConfig() without interaction.guildId, so it always evaluates permissions against the global config. This was missed when the sub-handlers (handleView, handleSetup, handleDisable) were correctly updated to use getConfig(interaction.guildId).

If a guild has customized permissions.adminRoleId or permissions.allowedCommands.modlog, the override is ignored during the permission gate, potentially denying or granting access incorrectly.

The fix is the same as what needs to happen in config.js:161:

const config = getConfig(interaction.guildId);

async function handleView(interaction) {
try {
const config = getConfig();
const config = getConfig(interaction.guildId);
const channels = config.moderation?.logging?.channels || {};

const embed = new EmbedBuilder()
Expand Down Expand Up @@ -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.',
Expand Down
2 changes: 1 addition & 1 deletion src/commands/purge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/slowmode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/softban.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
Loading
Loading