diff --git a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts index bec4ae56..5eb27830 100644 --- a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts @@ -7,8 +7,11 @@ import type { AnalysisQueueStatus } from "../message-capture/types.js"; import { activeRequests, buildAgeRestrictedSkipResult, + buildSkipAnalysisUserResult, isAgeRestrictedMessage, + isSkipAnalysisUser, skipAgeRestrictedMessages, + skipAnalysisUserMessages, } from "./batchProcessor.js"; import { scheduleConversationAnalysis } from "./batchScheduler.js"; import { getConversationKey } from "./circuitBreaker.js"; @@ -84,6 +87,21 @@ export async function queueMessageAnalysis(messageId: string): Promise { return; } + if (isSkipAnalysisUser(message)) { + const updated = await messageStore.updateMessageAIAnalysis( + message.id, + buildSkipAnalysisUserResult(), + ); + if (updated) { + broadcastAnalysisCompleted(updated); + } + logger.debug( + { messageId, userId: message.user_id }, + "Skipped AI analysis for configured skip-list user", + ); + return; + } + queueConversationAnalysis(getConversationKey(message)); } catch (error) { logger.error( @@ -240,8 +258,9 @@ export function startPendingAIAnalysisWorker( messageStore .getIncompleteMessagesByConversation(key, 500) .then(async (msgs) => { - const processableMessages = - await skipAgeRestrictedMessages(msgs); + const processableMessages = await skipAnalysisUserMessages( + await skipAgeRestrictedMessages(msgs), + ); return processableMessages; }) .then((msgs) => { diff --git a/services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts b/services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts index 50ca0204..33078574 100644 --- a/services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts +++ b/services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts @@ -23,6 +23,9 @@ import { const logger = createChildLogger("batch-processor"); +/** User IDs whose messages are captured but never AI-analyzed (config). */ +const AI_SKIP_ANALYSIS_USER_IDS = new Set(config.AI_SKIP_ANALYSIS_USER_IDS); + export interface AnalysisWorkerResponse { ok: boolean; conversationKey: string; @@ -67,6 +70,11 @@ export function isAgeRestrictedMessage(message: MessageRecord): boolean { return isAgeRestrictedMetadata(message.metadata); } +/** True when the author's user ID is in the AI_SKIP_ANALYSIS_USER_IDS set. */ +export function isSkipAnalysisUser(message: MessageRecord): boolean { + return AI_SKIP_ANALYSIS_USER_IDS.has(message.user_id); +} + export function buildAgeRestrictedSkipResult(): { status: "clean"; flags: string | null; @@ -93,6 +101,33 @@ export function buildAgeRestrictedSkipResult(): { }; } +/** Skip-result for authors in the AI_SKIP_ANALYSIS_USER_IDS set (music bots). */ +export function buildSkipAnalysisUserResult(): { + status: "clean"; + flags: string | null; + score: number; + analysis: string; + categories: string[]; + severity: "none"; + confidence: number; + recommendedAction: "none"; + analyzedAt: number; + error: null; +} { + return { + status: "clean", + flags: JSON.stringify(["skip_analysis_user"]), + score: 0, + analysis: "Skipped moderation for bot author (configured skip list).", + categories: ["skip_analysis_user"], + severity: "none", + confidence: 1, + recommendedAction: "none", + analyzedAt: Date.now(), + error: null, + }; +} + export async function skipAgeRestrictedMessages( messages: MessageRecord[], ): Promise { @@ -118,6 +153,30 @@ export async function skipAgeRestrictedMessages( return messages.filter((message) => !skippedIds.has(message.id)); } +/** Filter out messages from AI_SKIP_ANALYSIS_USER_IDS authors (no LLM call). */ +export async function skipAnalysisUserMessages( + messages: MessageRecord[], +): Promise { + const skipUsers = messages.filter(isSkipAnalysisUser); + if (skipUsers.length === 0) { + return messages; + } + + const skippedRows = await messageStore.updateMessagesAIAnalysisBulk( + skipUsers.map((message) => ({ + messageId: message.id, + result: buildSkipAnalysisUserResult(), + })), + ); + + for (const row of skippedRows) { + broadcastAnalysisCompleted(row); + } + + const skippedIds = new Set(skipUsers.map((message) => message.id)); + return messages.filter((message) => !skippedIds.has(message.id)); +} + // --------------------------------------------------------------------------- // Batch pipeline // --------------------------------------------------------------------------- diff --git a/services/discord-gateway/src/modules/ai-moderation/batchScheduler.ts b/services/discord-gateway/src/modules/ai-moderation/batchScheduler.ts index 51f9978a..fab9216f 100644 --- a/services/discord-gateway/src/modules/ai-moderation/batchScheduler.ts +++ b/services/discord-gateway/src/modules/ai-moderation/batchScheduler.ts @@ -6,6 +6,7 @@ import { pickBatchWithinBudget, processBatch, skipAgeRestrictedMessages, + skipAnalysisUserMessages, } from "./batchProcessor.js"; import { conversationConsecutiveErrors, @@ -81,7 +82,9 @@ export function scheduleConversationAnalysis(conversationKey: string): void { return; } - const processableMessages = await skipAgeRestrictedMessages(messages); + const processableMessages = await skipAnalysisUserMessages( + await skipAgeRestrictedMessages(messages), + ); if (processableMessages.length === 0) { if ( conversationProcessing.get(conversationKey) === processingStartedAt diff --git a/services/discord-gateway/src/shared/config/index.ts b/services/discord-gateway/src/shared/config/index.ts index 6477a94e..40dc4a4a 100644 --- a/services/discord-gateway/src/shared/config/index.ts +++ b/services/discord-gateway/src/shared/config/index.ts @@ -39,6 +39,15 @@ export const configSchema = z .describe( "Channel IDs where bot messages are NOT captured/analyzed (bot detection stays on everywhere else)", ), + // User IDs whose messages are captured but NEVER AI-analyzed (skip result + // directly, like age-restricted). Used for high-volume music/reaction + // bots that spam the chat log (e.g. Jockie Music) — their now-playing + // embeds carry no moderation signal. + AI_SKIP_ANALYSIS_USER_IDS: z + .string() + .default("411916947773587456") + .transform((v) => v.split(",").filter(Boolean)) + .describe("User IDs to skip AI analysis for (captured but not analyzed)"), // ── Legacy voice ───────────────────────────────────────────────────── VOICE_GUILD_ID: z.string().min(1).optional(),