Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
17 changes: 6 additions & 11 deletions src/api/routes/guilds.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,31 +93,26 @@ router.get('/:id', (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', (req, res) => {
const config = getConfig();
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', async (req, res) => {
if (!req.body) {
Expand Down Expand Up @@ -151,7 +146,7 @@ router.patch('/:id/config', async (req, res) => {
}

try {
const updated = await setConfigValue(path, value);
const updated = await setConfigValue(path, value, req.params.id);
info('Config updated via API', { path, value, guild: req.params.id });
res.json(updated);
} catch (err) {
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
10 changes: 5 additions & 5 deletions src/commands/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,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 @@ -186,7 +186,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 @@ -275,7 +275,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 @@ -287,7 +287,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 @@ -331,7 +331,7 @@ 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)
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
18 changes: 13 additions & 5 deletions src/commands/modlog.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,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 @@ -166,7 +174,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 @@ -199,10 +207,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
2 changes: 1 addition & 1 deletion src/commands/tempban.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion src/commands/timeout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
2 changes: 1 addition & 1 deletion src/commands/unban.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
2 changes: 1 addition & 1 deletion src/commands/unlock.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: 'unlock',
targetId: channel.id,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/untimeout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
2 changes: 1 addition & 1 deletion src/commands/warn.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
32 changes: 30 additions & 2 deletions src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ 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 });
Expand Down
31 changes: 24 additions & 7 deletions src/modules/ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -115,6 +117,11 @@ 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.
*
* Note: Uses global config defaults for history length intentionally — this operates
* at the channel level across all guilds and guildId is not available in this context.
* The guild-aware config path is through generateResponse(), which passes guildId.
*
* @param {string} channelId - Channel ID
* @returns {Promise<Array>} Conversation history
*/
Expand Down Expand Up @@ -244,8 +251,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<void>}
*/
export async function initConversationHistory() {
Expand Down Expand Up @@ -343,7 +355,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<void>}
*/
async function runCleanup() {
Expand Down
Loading
Loading