diff --git a/config.json b/config.json index 4d3158a1..c2340292 100644 --- a/config.json +++ b/config.json @@ -10,6 +10,7 @@ "autoArchiveMinutes": 60, "reuseWindowMinutes": 30 }, + "blockedChannelIds": [], "feedback": { "enabled": false } diff --git a/src/api/utils/configValidation.js b/src/api/utils/configValidation.js index b7320cb3..bef5ceea 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 a2b3aad9..90989757 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 254a43d4..c5cbb5e5 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 { deleteFeedback, FEEDBACK_EMOJI, isAiMessage, recordFeedback } from './aiFeedback.js'; import { handleHintButton, handleSolveButton } from './challengeScheduler.js'; import { getConfig } from './config.js'; @@ -220,6 +221,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 diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index 2ac91ce6..9b675cf2 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 5b33cbb5..eb887ffc 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 1763b95f..bf617ca0 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'; @@ -413,6 +414,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) => { @@ -747,6 +758,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 acd2f612..a0c4dcfb 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;