Skip to content
Merged
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
5 changes: 3 additions & 2 deletions src/commands/rolemenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ async function handleList(interaction) {
.join(' · ');
return `**${t.name}** — ${t.description || 'no description'} *(${badges})*`;
});
const fieldValue = lines.join('\n').slice(0, 1020) + (lines.join('\n').length > 1020 ? '...' : '');
const fieldValue =
lines.join('\n').slice(0, 1020) + (lines.join('\n').length > 1020 ? '...' : '');
embed.addFields({ name: `📂 ${cat}`, value: fieldValue, inline: false });
}

Expand Down Expand Up @@ -210,7 +211,7 @@ async function handleApply(interaction) {
const newOptions = applyTemplateToOptions(tpl, existingOptions);

// Filter out options with empty roleIds - Discord rejects empty select values
const validOptions = newOptions.filter(opt => opt.roleId && opt.roleId.trim());
const validOptions = newOptions.filter((opt) => opt.roleId && opt.roleId.trim());
const hasInvalidOptions = validOptions.length !== newOptions.length;

// Only enable role menu for non-built-in templates with valid roleIds
Expand Down
3 changes: 1 addition & 2 deletions src/modules/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
import { DANGEROUS_KEYS } from '../api/utils/dangerousKeys.js';
import { getPool } from '../db.js';
import { info, error as logError, warn as logWarn } from '../logger.js';
import { DANGEROUS_KEYS } from '../api/utils/dangerousKeys.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, '..', '..', 'config.json');
Expand Down Expand Up @@ -794,7 +794,6 @@ export async function resetConfig(section, guildId = 'global') {
return globalConfig;
}


/**
* Validate that no path segment is a prototype-pollution vector.
* @param {string[]} segments - Path segments to check
Expand Down
3 changes: 1 addition & 2 deletions src/modules/roleMenuTemplates.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,7 @@ export function validateTemplateOptions(options) {
if (!opt || typeof opt !== 'object') return `Option ${i + 1} is not a valid object.`;
if (typeof opt.label !== 'string' || !opt.label.trim())
return `Option ${i + 1} must have a non-empty label.`;
if (opt.label.trim().length > 100)
return `Option ${i + 1} label must be ≤100 characters.`;
if (opt.label.trim().length > 100) return `Option ${i + 1} label must be ≤100 characters.`;
// Validate optional description
if (opt.description !== undefined && typeof opt.description !== 'string')
return `Option ${i + 1} description must be a string.`;
Expand Down
24 changes: 23 additions & 1 deletion src/modules/triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { buildMemoryContext, extractAndStoreMemories } from './memory.js';

// ── Sub-module imports ───────────────────────────────────────────────────────

import { addToHistory } from './ai.js';
import { addToHistory, isChannelBlocked } from './ai.js';
import { getConfig } from './config.js';
import {
channelBuffers,
Expand Down Expand Up @@ -635,6 +635,11 @@ export async function accumulateMessage(message, msgConfig) {
if (!triageConfig?.enabled) return;
if (!isChannelEligible(message.channel.id, triageConfig)) return;

// Skip blocked channels (no triage processing)
// Only check parentId for threads - for regular channels, parentId is the category ID
const parentId = message.channel.isThread?.() ? message.channel.parentId : null;
if (isChannelBlocked(message.channel.id, parentId, message.guild?.id)) return;

// Skip empty or attachment-only messages
if (!message.content || message.content.trim() === '') return;

Expand Down Expand Up @@ -720,6 +725,23 @@ export async function evaluateNow(channelId, evalConfig, evalClient, evalMonitor
const buf = channelBuffers.get(channelId);
if (!buf || buf.messages.length === 0) return;

// Check if channel is blocked before processing buffered messages.
// This guards against the case where a channel is blocked AFTER messages
// were buffered but BEFORE evaluateNow runs.
const usedClient = evalClient || client;
try {
const ch = await fetchChannelCached(usedClient, channelId);
const guildId = ch?.guildId ?? null;
// Only check parentId for threads - for regular channels, parentId is the category ID
const parentId = ch?.isThread?.() ? ch.parentId : null;
if (isChannelBlocked(channelId, parentId, guildId)) {
debug('evaluateNow skipping blocked channel with buffered messages', { channelId, guildId });
return;
}
} catch (err) {
debug('Failed to fetch channel for blocked check, continuing', { channelId, error: err?.message });
}

// Cancel any existing in-flight evaluation (abort before checking guard)
if (buf.abortController) {
buf.abortController.abort();
Expand Down
1 change: 1 addition & 0 deletions tests/modules/triage-budget.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ vi.mock('../../src/logger.js', () => ({

vi.mock('../../src/modules/ai.js', () => ({
addToHistory: vi.fn(),
isChannelBlocked: vi.fn().mockReturnValue(false),
getHistoryAsync: vi.fn().mockResolvedValue([]),
initConversationHistory: vi.fn().mockResolvedValue(undefined),
startConversationCleanup: vi.fn(),
Expand Down
85 changes: 85 additions & 0 deletions tests/modules/triage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ vi.mock('../../src/logger.js', () => ({
}));
vi.mock('../../src/modules/ai.js', () => ({
addToHistory: vi.fn(),
isChannelBlocked: vi.fn().mockReturnValue(false),
_setPoolGetter: vi.fn(),
setPool: vi.fn(),
getConversationHistory: vi.fn().mockReturnValue(new Map()),
Expand Down Expand Up @@ -332,6 +333,60 @@ describe('triage module', () => {
expect(mockClassifierSend).toHaveBeenCalled();
});

it('should skip blocked channels (early return, no addToHistory)', async () => {
const { isChannelBlocked } = await import('../../src/modules/ai.js');
isChannelBlocked.mockReturnValueOnce(true);

const msg = makeMessage('blocked-ch', 'hello world', {
id: 'msg-blocked',
username: 'alice',
userId: 'u99',
guild: { id: 'g1' },
});
accumulateMessage(msg, config);

// Should NOT call addToHistory when channel is blocked
expect(addToHistory).not.toHaveBeenCalled();
// Should NOT trigger any classifier activity
await evaluateNow('blocked-ch', config, client, healthMonitor);
expect(mockClassifierSend).not.toHaveBeenCalled();
});

it('should only check parentId for threads, not category channels', async () => {
const { isChannelBlocked } = await import('../../src/modules/ai.js');

// Regular text channel in a category - parentId is category ID
const categoryChannelMsg = makeMessage('ch1', 'hello', {
id: 'msg-cat',
guild: { id: 'g1' },
});
// Simulate a regular channel with a category parent
categoryChannelMsg.channel.parentId = 'category-123';
categoryChannelMsg.channel.isThread = () => false;

accumulateMessage(categoryChannelMsg, config);

// isChannelBlocked should be called with null parentId for non-thread channels
expect(isChannelBlocked).toHaveBeenCalledWith('ch1', null, 'g1');
});

it('should pass parentId for threads to isChannelBlocked', async () => {
const { isChannelBlocked } = await import('../../src/modules/ai.js');

// Thread - parentId is the parent channel ID
const threadMsg = makeMessage('thread-1', 'hello', {
id: 'msg-thread',
guild: { id: 'g1' },
});
threadMsg.channel.parentId = 'parent-channel-456';
threadMsg.channel.isThread = () => true;

accumulateMessage(threadMsg, config);

// isChannelBlocked should be called with the parent channel ID for threads
expect(isChannelBlocked).toHaveBeenCalledWith('thread-1', 'parent-channel-456', 'g1');
});

it('should skip empty messages', async () => {
accumulateMessage(makeMessage('ch1', ''), config);
await evaluateNow('ch1', config, client, healthMonitor);
Expand Down Expand Up @@ -477,6 +532,36 @@ describe('triage module', () => {
expect(mockClassifierSend).not.toHaveBeenCalled();
});

it('should skip evaluation when channel becomes blocked after buffering', async () => {
const { isChannelBlocked } = await import('../../src/modules/ai.js');

// First, buffer a message while channel is NOT blocked
accumulateMessage(
makeMessage('ch-becomes-blocked', 'hello world', {
id: 'msg-buffered',
username: 'alice',
userId: 'u99',
guild: { id: 'g1' },
}),
config,
);

// Verify message was added to history (channel wasn't blocked at accumulate time)
expect(addToHistory).toHaveBeenCalled();

// Now block the channel
isChannelBlocked.mockReturnValue(true);

// Call evaluateNow - it should check blocked status and skip
await evaluateNow('ch-becomes-blocked', config, client, healthMonitor);

// Classifier should NOT have been called despite buffered messages
expect(mockClassifierSend).not.toHaveBeenCalled();

// Reset the mock to not affect subsequent tests
isChannelBlocked.mockReturnValue(false);
});

it('should set pendingReeval when concurrent evaluation requested', async () => {
const classResult = {
classification: 'respond',
Expand Down
Loading