Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
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
15 changes: 8 additions & 7 deletions config.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
{
"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,
"threadMode": {
"enabled": false,
"autoArchiveMinutes": 60,
"reuseWindowMinutes": 30
}
},
"blockedChannelIds": []
},
"triage": {
"enabled": true,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -220,19 +221,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"
}
]
},
Expand Down
1 change: 1 addition & 0 deletions src/api/utils/configValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const CONFIG_SCHEMA = {
enabled: { type: 'boolean' },
systemPrompt: { type: 'string' },
channels: { type: 'array' },
blockedChannelIds: { type: 'array' },
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

blockedChannelIds validation is too loose.

Allowing any array element type can persist invalid config and silently break block matching.

Suggested schema fix
-      blockedChannelIds: { type: 'array' },
+      blockedChannelIds: { type: 'array', items: { type: 'string' } },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
blockedChannelIds: { type: 'array' },
blockedChannelIds: { type: 'array', items: { type: 'string' } },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/utils/configValidation.js` at line 20, The schema entry for
blockedChannelIds is too permissive and should only accept an array of channel
ID strings; update the config validation schema (the blockedChannelIds property
in src/api/utils/configValidation.js) to enforce items: { type: 'string' } and
add uniqueItems: true (and optionally a pattern or minLength if you need
numeric/string format constraints) so invalid element types no longer persist
and block matching remains reliable.

historyLength: { type: 'number' },
historyTTLDays: { type: 'number' },
threadMode: {
Expand Down
26 changes: 26 additions & 0 deletions src/modules/ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +37 to +42
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

isChannelBlocked(channelId, parentId = null, guildId) has a required guildId parameter after an optional parameter. This makes the API easy to misuse (e.g., a 2-arg call can’t supply guildId without passing a null placeholder) and conflicts with the JSDoc implying guildId is required. Consider reordering parameters, using an options object, or making guildId explicitly optional in both the signature and docs (with a documented fallback behavior).

Suggested change
* @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);
* @param {string} [guildId] - Optional guild ID for per-guild configuration. When omitted,
* the global/default configuration is used via {@link getConfig} with no arguments.
* @returns {boolean} `true` when the channel is blocked, `false` otherwise.
*/
export function isChannelBlocked(channelId, parentId = null, guildId = null) {
try {
// When guildId is provided, use per-guild configuration; otherwise fall back
// to the global/default configuration, matching the behavior of other helpers
// such as getHistoryLength and getHistoryTTLDays.
const config = guildId ? getConfig(guildId) : getConfig();

Copilot uses AI. Check for mistakes.
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
Expand Down
8 changes: 8 additions & 0 deletions src/modules/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Comment on lines +224 to +228
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Run the blocklist guard before reply-reference fetch logic.

This early return is correct, but moving it earlier in the AI branch avoids unnecessary Discord fetches in blocked channels.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/events.js` around lines 224 - 228, Move the channel blocklist
guard earlier inside the AI handling branch so it runs before any
reply-reference or message-fetch logic; specifically, compute parentId (using
message.channel.isThread?.() and message.channel.parentId) and call
isChannelBlocked(message.channel.id, parentId, message.guild.id) as the first
early-return check in the AI path to avoid performing any Discord fetches when
the channel is blocked.

Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

isChannelBlocked() calls getConfig(guildId) internally, but this handler already computed guildConfig = getConfig(message.guild.id) for the same message. This introduces an extra getConfig() + deep clone on the hot message path. Consider reading guildConfig.ai?.blockedChannelIds directly here, or refactor isChannelBlocked to accept the already-resolved guildConfig/blocklist array instead of re-fetching config.

Suggested change
if (isChannelBlocked(message.channel.id, parentId, message.guild.id)) return;
const blockedChannels = guildConfig.ai?.blockedChannelIds || [];
const isBlockedChannel =
blockedChannels.includes(message.channel.id) ||
(parentId !== null && blockedChannels.includes(parentId));
if (isBlockedChannel) {
return;
}

Copilot uses AI. Check for mistakes.

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
Expand Down Expand Up @@ -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) => {
Expand Down
53 changes: 53 additions & 0 deletions tests/modules/ai.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getConversationHistory,
getHistoryAsync,
initConversationHistory,
isChannelBlocked,
setConversationHistory,
setPool,
startConversationCleanup,
Expand Down Expand Up @@ -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);
});
Comment on lines +210 to +245
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

The new isChannelBlocked behavior is intended to be per-guild (it accepts guildId and calls getConfig(guildId)), but the added unit tests only call it without guildId. Add at least one test that passes a guildId and asserts getConfig is called with that guild ID (and that the returned value is respected), so regressions don’t silently fall back to global config.

Copilot generated this review using guidance from organization custom instructions.

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);
});
Comment on lines +210 to +257
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add guild-scoped assertions for isChannelBlocked.

These cases validate matching logic, but they don’t verify guild scoping. Since the helper now accepts guildId, please add at least one case that passes a guild ID and asserts getConfig is called with it, so global-fallback regressions are caught.

✅ Suggested test addition
+    it('uses guild-scoped config lookup', () => {
+      getConfig.mockReturnValue({ ai: { blockedChannelIds: ['ch1'] } });
+      expect(isChannelBlocked('ch1', null, 'guild-1')).toBe(true);
+      expect(getConfig).toHaveBeenCalledWith('guild-1');
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
});
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);
});
it('uses guild-scoped config lookup', () => {
getConfig.mockReturnValue({ ai: { blockedChannelIds: ['ch1'] } });
expect(isChannelBlocked('ch1', null, 'guild-1')).toBe(true);
expect(getConfig).toHaveBeenCalledWith('guild-1');
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/modules/ai.test.js` around lines 210 - 257, Add a test that exercises
isChannelBlocked with a guildId to verify guild-scoped configs: mock getConfig
to return { ai: { blockedChannelIds: [...] } } when called with that guildId,
call isChannelBlocked(channelId, parentId, 'guild-123'), assert the returned
boolean matches expectation (true/false based on blockedChannelIds), and assert
getConfig was called with 'guild-123' (e.g.,
expect(getConfig).toHaveBeenCalledWith('guild-123')). Ensure the test references
isChannelBlocked, getConfig, and blockedChannelIds so guild-specific fallback
regressions are detected.

});

// ── cleanup scheduler ─────────────────────────────────────────────────

describe('cleanup scheduler', () => {
Expand Down
83 changes: 83 additions & 0 deletions tests/modules/events.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
36 changes: 36 additions & 0 deletions web/src/components/dashboard/config-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -747,6 +758,31 @@ export function ConfigEditor() {
maxLength={SYSTEM_PROMPT_MAX_LENGTH}
/>

{/* AI Blocked Channels */}
<Card>
<CardHeader>
<CardTitle className="text-base">Blocked Channels</CardTitle>
<CardDescription>
The AI will not respond in these channels (or their threads).
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{guildId ? (
<ChannelSelector
id="ai-blocked-channels"
guildId={guildId}
selected={(draftConfig.ai?.blockedChannelIds ?? []) as string[]}
onChange={updateAiBlockedChannels}
placeholder="Select channels to block AI in..."
disabled={saving}
filter="text"
/>
Comment on lines +771 to +779
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Use the store-backed channel source for this picker.

This newly added blocked-channel selector should follow the dashboard’s cached Discord-entities flow (Zustand) rather than a direct-fetch path, to avoid repeated fetches and stale channel lists across sections.

As per coding guidelines, "Component files should integrate with Zustand stores for state management (e.g., discord-entities store for caching Discord channels and roles per guild)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/dashboard/config-editor.tsx` around lines 771 - 779,
Replace the direct-fetch ChannelSelector usage with the dashboard's
Zustand-backed channel source: read the cached channels for guildId from the
discord-entities store (e.g., useDiscordEntitiesStore or discordEntitiesStore
selector that returns channels for a guild) and pass that channel list into
ChannelSelector instead of allowing it to perform its own fetch; keep the same
props (id "ai-blocked-channels", selected={(draftConfig.ai?.blockedChannelIds ??
[]) as string[]}, onChange={updateAiBlockedChannels}, placeholder,
disabled={saving}, filter="text") but supply the store-backed channels via the
component’s channels/options prop (or a source prop used by our other selectors)
so ChannelSelector uses the cached Zustand data for guildId.

) : (
<p className="text-muted-foreground text-sm">Select a server first</p>
)}
</CardContent>
</Card>

{/* Welcome section */}
<Card>
<CardHeader>
Expand Down
1 change: 1 addition & 0 deletions web/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface AiConfig {
enabled: boolean;
systemPrompt: string;
channels: string[];
blockedChannelIds: string[];
historyLength: number;
historyTTLDays: number;
threadMode: AiThreadMode;
Expand Down