Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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 @@ -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"
}
]
},
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' },
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);
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;

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);
});

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', () => {
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 @@ -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) => {
Expand Down Expand Up @@ -721,6 +732,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"
/>
) : (
<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
Loading