diff --git a/config.json b/config.json index 90d791502..4c9825258 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,7 @@ { "ai": { "enabled": true, - "systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals — say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here — these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.", + "systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals \u2014 say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here \u2014 these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.", "channels": [], "historyLength": 20, "historyTTLDays": 30, @@ -9,7 +9,8 @@ "enabled": false, "autoArchiveMinutes": 60, "reuseWindowMinutes": 30 - } + }, + "blockedChannelIds": [] }, "triage": { "enabled": true, @@ -43,7 +44,7 @@ "welcome": { "enabled": true, "channelId": "1438631182379253814", - "message": "Welcome to Volvox, {user}! 🌱 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask — we're here to help. 💚", + "message": "Welcome to Volvox, {user}! \ud83c\udf31 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask \u2014 we're here to help. \ud83d\udc9a", "dynamic": { "enabled": true, "timezone": "America/New_York", @@ -213,19 +214,19 @@ "activityBadges": [ { "days": 90, - "label": "👑 Legend" + "label": "\ud83d\udc51 Legend" }, { "days": 30, - "label": "🌳 Veteran" + "label": "\ud83c\udf33 Veteran" }, { "days": 7, - "label": "🌿 Regular" + "label": "\ud83c\udf3f Regular" }, { "days": 0, - "label": "🌱 Newcomer" + "label": "\ud83c\udf31 Newcomer" } ] }, diff --git a/src/api/utils/configValidation.js b/src/api/utils/configValidation.js index 408ef0553..ec2e11223 100644 --- a/src/api/utils/configValidation.js +++ b/src/api/utils/configValidation.js @@ -17,6 +17,7 @@ export const CONFIG_SCHEMA = { enabled: { type: 'boolean' }, systemPrompt: { type: 'string' }, channels: { type: 'array' }, + blockedChannelIds: { type: 'array' }, historyLength: { type: 'number' }, historyTTLDays: { type: 'number' }, threadMode: { diff --git a/src/modules/ai.js b/src/modules/ai.js index a2b3aad96..909897571 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -25,6 +25,32 @@ let cleanupTimer = null; /** In-flight async hydrations keyed by channel ID (dedupes concurrent DB reads) */ const pendingHydrations = new Map(); +/** + * Check whether a channel (or its parent thread channel) is in the AI blocklist. + * + * When `parentId` is provided (i.e. the message is inside a thread), the parent + * channel ID is also checked so that blocking a channel implicitly blocks all + * threads that belong to it. + * + * @param {string} channelId - The channel ID to test. + * @param {string|null} [parentId] - Optional parent channel ID (for threads). + * @param {string} guildId - The guild ID for per-guild configuration. + * @returns {boolean} `true` when the channel is blocked, `false` otherwise. + */ +export function isChannelBlocked(channelId, parentId = null, guildId) { + try { + const config = getConfig(guildId); + const blocked = config?.ai?.blockedChannelIds; + if (!Array.isArray(blocked) || blocked.length === 0) return false; + if (blocked.includes(channelId)) return true; + if (parentId && blocked.includes(parentId)) return true; + return false; + } catch { + // Config not loaded yet — fail open (don't block) + return false; + } +} + /** * Get the configured history length from config * @returns {number} History length diff --git a/src/modules/events.js b/src/modules/events.js index 29e4e29b5..8e6344916 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -20,6 +20,7 @@ import { getUserFriendlyMessage } from '../utils/errors.js'; // safe wrapper applies identically to either target type. import { safeEditReply, safeReply } from '../utils/safeSend.js'; import { handleAfkMentions } from './afkHandler.js'; +import { isChannelBlocked } from './ai.js'; import { handleHintButton, handleSolveButton } from './challengeScheduler.js'; import { getConfig } from './config.js'; import { trackMessage, trackReaction } from './engagement.js'; @@ -219,6 +220,12 @@ export function registerMessageCreateHandler(client, _config, healthMonitor) { const isAllowedChannel = allowedChannels.length === 0 || allowedChannels.includes(channelIdToCheck); + // Check blocklist — blocked channels never get AI responses. + // For threads, parentId is also checked so blocking the parent channel + // blocks all its child threads. + const parentId = message.channel.isThread?.() ? message.channel.parentId : null; + if (isChannelBlocked(message.channel.id, parentId, message.guild.id)) return; + if ((isMentioned || isReply) && isAllowedChannel) { // Accumulate the message into the triage buffer (for context). // Even bare @mentions with no text go through triage so the classifier @@ -255,6 +262,7 @@ export function registerMessageCreateHandler(client, _config, healthMonitor) { // Gated on ai.enabled — this is the master kill-switch for all AI responses. // accumulateMessage also checks triage.enabled internally. if (guildConfig.ai?.enabled) { + try { const p = accumulateMessage(message, guildConfig); p?.catch((err) => { diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index 2ac91ce69..9b675cf28 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -18,6 +18,7 @@ import { getConversationHistory, getHistoryAsync, initConversationHistory, + isChannelBlocked, setConversationHistory, setPool, startConversationCleanup, @@ -204,6 +205,58 @@ describe('ai module', () => { }); }); + // ── isChannelBlocked ───────────────────────────────────────────────── + + describe('isChannelBlocked', () => { + it('should return false when blockedChannelIds is not set', () => { + getConfig.mockReturnValue({ ai: {} }); + expect(isChannelBlocked('ch1')).toBe(false); + }); + + it('should return false when blockedChannelIds is empty', () => { + getConfig.mockReturnValue({ ai: { blockedChannelIds: [] } }); + expect(isChannelBlocked('ch1')).toBe(false); + }); + + it('should return true when channelId is in blockedChannelIds', () => { + getConfig.mockReturnValue({ ai: { blockedChannelIds: ['ch1', 'ch2'] } }); + expect(isChannelBlocked('ch1')).toBe(true); + expect(isChannelBlocked('ch2')).toBe(true); + }); + + it('should return false when channelId is not in blockedChannelIds', () => { + getConfig.mockReturnValue({ ai: { blockedChannelIds: ['ch1'] } }); + expect(isChannelBlocked('ch3')).toBe(false); + }); + + it('should return true when parentId is in blockedChannelIds (thread in blocked parent)', () => { + getConfig.mockReturnValue({ ai: { blockedChannelIds: ['parent-ch'] } }); + expect(isChannelBlocked('thread-ch', 'parent-ch')).toBe(true); + }); + + it('should return true when channelId matches even if parentId is not blocked', () => { + getConfig.mockReturnValue({ ai: { blockedChannelIds: ['thread-ch'] } }); + expect(isChannelBlocked('thread-ch', 'parent-ch')).toBe(true); + }); + + it('should return false when neither channelId nor parentId is in blockedChannelIds', () => { + getConfig.mockReturnValue({ ai: { blockedChannelIds: ['other-ch'] } }); + expect(isChannelBlocked('thread-ch', 'parent-ch')).toBe(false); + }); + + it('should return false when parentId is null and channelId is not blocked', () => { + getConfig.mockReturnValue({ ai: { blockedChannelIds: ['ch1'] } }); + expect(isChannelBlocked('ch2', null)).toBe(false); + }); + + it('should fail open (return false) when getConfig throws', () => { + getConfig.mockImplementation(() => { + throw new Error('Config not loaded'); + }); + expect(isChannelBlocked('ch1')).toBe(false); + }); + }); + // ── cleanup scheduler ───────────────────────────────────────────────── describe('cleanup scheduler', () => { diff --git a/tests/modules/events.test.js b/tests/modules/events.test.js index 5b33cbb5a..eb887ffc4 100644 --- a/tests/modules/events.test.js +++ b/tests/modules/events.test.js @@ -355,6 +355,89 @@ describe('events module', () => { expect(evaluateNow).not.toHaveBeenCalled(); }); + // ── Blocked channels ────────────────────────────────────────────── + + it('should not send AI response in blocked channel (mention)', async () => { + setup({ ai: { enabled: true, channels: [], blockedChannelIds: ['blocked-ch'] } }); + const message = { + author: { bot: false, username: 'user', id: 'user-1' }, + guild: { id: 'g1' }, + content: '<@bot-user-id> help', + channel: { + id: 'blocked-ch', + sendTyping: vi.fn(), + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: vi.fn(), + }; + await onCallbacks.messageCreate(message); + expect(evaluateNow).not.toHaveBeenCalled(); + }); + + it('should not accumulate messages in blocked channel (non-mention)', async () => { + setup({ ai: { enabled: true, channels: [], blockedChannelIds: ['blocked-ch'] } }); + const message = { + author: { bot: false, username: 'user', id: 'user-1' }, + guild: { id: 'g1' }, + content: 'regular message', + channel: { + id: 'blocked-ch', + sendTyping: vi.fn(), + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, + mentions: { has: vi.fn().mockReturnValue(false), repliedUser: null }, + reference: null, + }; + await onCallbacks.messageCreate(message); + expect(accumulateMessage).not.toHaveBeenCalled(); + expect(evaluateNow).not.toHaveBeenCalled(); + }); + + it('should not send AI response in thread whose parent is blocked', async () => { + setup({ ai: { enabled: true, channels: [], blockedChannelIds: ['parent-ch'] } }); + const message = { + author: { bot: false, username: 'user', id: 'user-1' }, + guild: { id: 'g1' }, + content: '<@bot-user-id> help', + channel: { + id: 'thread-ch', + parentId: 'parent-ch', + sendTyping: vi.fn(), + send: vi.fn(), + isThread: vi.fn().mockReturnValue(true), + }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: vi.fn(), + }; + await onCallbacks.messageCreate(message); + expect(evaluateNow).not.toHaveBeenCalled(); + }); + + it('should allow AI in non-blocked channels when blocklist is configured', async () => { + setup({ ai: { enabled: true, channels: [], blockedChannelIds: ['blocked-ch'] } }); + const message = { + author: { bot: false, username: 'user', id: 'user-1' }, + guild: { id: 'g1' }, + content: '<@bot-user-id> help', + channel: { + id: 'allowed-ch', + sendTyping: vi.fn().mockResolvedValue(undefined), + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: vi.fn().mockResolvedValue(undefined), + }; + await onCallbacks.messageCreate(message); + expect(evaluateNow).toHaveBeenCalledWith('allowed-ch', config, client, null); + }); + // ── Non-mention ─────────────────────────────────────────────────── it('should call accumulateMessage only (not evaluateNow) for non-mention', async () => { diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 2c58845ca..1a8b0f745 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ChannelSelector } from '@/components/ui/channel-selector'; import { Input } from '@/components/ui/input'; import { RoleSelector } from '@/components/ui/role-selector'; import { GUILD_SELECTED_EVENT, SELECTED_GUILD_KEY } from '@/lib/guild-selection'; @@ -410,6 +411,16 @@ export function ConfigEditor() { [updateDraftConfig], ); + const updateAiBlockedChannels = useCallback( + (channels: string[]) => { + updateDraftConfig((prev) => { + if (!prev) return prev; + return { ...prev, ai: { ...prev.ai, blockedChannelIds: channels } } as GuildConfig; + }); + }, + [updateDraftConfig], + ); + const updateWelcomeEnabled = useCallback( (enabled: boolean) => { updateDraftConfig((prev) => { @@ -721,6 +732,31 @@ export function ConfigEditor() { maxLength={SYSTEM_PROMPT_MAX_LENGTH} /> + {/* AI Blocked Channels */} + + + Blocked Channels + + The AI will not respond in these channels (or their threads). + + + + {guildId ? ( + + ) : ( +

Select a server first

+ )} +
+
+ {/* Welcome section */} diff --git a/web/src/types/config.ts b/web/src/types/config.ts index 1af079fa4..acd0ca6c9 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -10,6 +10,7 @@ export interface AiConfig { enabled: boolean; systemPrompt: string; channels: string[]; + blockedChannelIds: string[]; historyLength: number; historyTTLDays: number; threadMode: AiThreadMode;