feat(moderation): skip AI analysis for music bots via AI_SKIP_ANALYSIS_USER_IDS

Jockie Music (user 411916947773587456) posts now-playing embeds/spotify links
~1347 captured messages — every one consumed a moderation LLM call for zero
signal and contributed to batch timeouts. Config AI_SKIP_ANALYSIS_USER_IDS
(default=Jockie) skips them at ALL three analysis paths:
- queueMessageAnalysis entry (direct skip-result like age-restricted)
- batchScheduler processing (pre-batch filter)
- individual recovery path (no fallback spam for already-skipped authors)

Skip-result mirrors age_restricted: status=clean, flags=[skip_analysis_user],
action=none — stays visible in the dashboard, never analyzed.
This commit is contained in:
asepharyana
2026-09-09 23:38:32 +07:00
parent db4e84f057
commit 023217b260
4 changed files with 93 additions and 3 deletions
@@ -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<void> {
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) => {
@@ -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<MessageRecord[]> {
@@ -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<MessageRecord[]> {
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
// ---------------------------------------------------------------------------
@@ -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
@@ -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(),