diff --git a/src/commands/rolemenu.js b/src/commands/rolemenu.js index 7ac8df027..3bffeab96 100644 --- a/src/commands/rolemenu.js +++ b/src/commands/rolemenu.js @@ -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 }); } @@ -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 diff --git a/src/modules/config.js b/src/modules/config.js index 70e170d05..30d7c92fc 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -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'); @@ -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 diff --git a/src/modules/roleMenuTemplates.js b/src/modules/roleMenuTemplates.js index f9e0cf727..d1d68dab5 100644 --- a/src/modules/roleMenuTemplates.js +++ b/src/modules/roleMenuTemplates.js @@ -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.`; diff --git a/src/modules/triage.js b/src/modules/triage.js index 712c6b79f..b29abed3a 100644 --- a/src/modules/triage.js +++ b/src/modules/triage.js @@ -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, @@ -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; @@ -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(); diff --git a/tests/modules/triage-budget.test.js b/tests/modules/triage-budget.test.js index da157cb01..813019aaa 100644 --- a/tests/modules/triage-budget.test.js +++ b/tests/modules/triage-budget.test.js @@ -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(), diff --git a/tests/modules/triage.test.js b/tests/modules/triage.test.js index d4c550ed2..8902a9656 100644 --- a/tests/modules/triage.test.js +++ b/tests/modules/triage.test.js @@ -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()), @@ -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); @@ -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',