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;