diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index aaf96e0..2772a57 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -107,7 +107,9 @@ async function skipAgeRestrictedMessages( getModerationBroadcaster()?.messageAnalyzed(row); } - const skippedIds = new Set(ageRestrictedMessages.map((message) => message.id)); + const skippedIds = new Set( + ageRestrictedMessages.map((message) => message.id), + ); return messages.filter((message) => !skippedIds.has(message.id)); } @@ -798,7 +800,10 @@ export async function queueMessageAnalysis(messageId: string): Promise { if (updated) { getModerationBroadcaster()?.messageAnalyzed(updated); } - logger.info({ messageId }, "Skipped AI analysis for age-restricted message"); + logger.info( + { messageId }, + "Skipped AI analysis for age-restricted message", + ); return; } @@ -902,7 +907,8 @@ export function startPendingAIAnalysisWorker(client?: Client): void { config.AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT, ) .then(async (msgs) => { - const processableMessages = await skipAgeRestrictedMessages(msgs); + const processableMessages = + await skipAgeRestrictedMessages(msgs); return processableMessages; }) .then((msgs) => { diff --git a/src/moderation/autoDeleteManager.ts b/src/moderation/autoDeleteManager.ts index 227212e..3ab37ea 100644 --- a/src/moderation/autoDeleteManager.ts +++ b/src/moderation/autoDeleteManager.ts @@ -1,8 +1,8 @@ import type { Client, PermissionString } from "discord.js-selfbot-v13"; import { config } from "../config.js"; import { createChildLogger } from "../logger.js"; -import type { MessageRecord } from "./types.js"; import { createModerationAction } from "./messageStore.js"; +import type { MessageRecord } from "./types.js"; const logger = createChildLogger("auto-delete-manager"); @@ -183,9 +183,7 @@ function isAlreadyDeletedError(error: unknown): boolean { return code === 10008 || code === 404 || code === "10008" || code === "404"; } -function hasChannelMessagesApi( - channel: unknown, -): channel is { +function hasChannelMessagesApi(channel: unknown): channel is { messages: { fetch: (id: string) => Promise<{ delete: () => Promise }>; }; @@ -200,9 +198,7 @@ function hasChannelMessagesApi( ); } -function hasPermissionApi( - channel: unknown, -): channel is { +function hasPermissionApi(channel: unknown): channel is { permissionsFor: ( member: unknown, ) => { has: (permission: string) => boolean } | null; diff --git a/src/moderation/indonesianTextNormalizer.ts b/src/moderation/indonesianTextNormalizer.ts index e33b0df..3640182 100644 --- a/src/moderation/indonesianTextNormalizer.ts +++ b/src/moderation/indonesianTextNormalizer.ts @@ -1,9 +1,9 @@ import axios from "axios"; import OpenAI from "openai"; import { config } from "../config.js"; -import { INDONESIAN_SLANG_LEXICON } from "./resources/indonesianSlangLexicon.js"; import { createChildLogger } from "../logger.js"; import { retryWithBackoff } from "../retry.js"; +import { INDONESIAN_SLANG_LEXICON } from "./resources/indonesianSlangLexicon.js"; const log = createChildLogger("indonesianTextNormalizer"); @@ -259,7 +259,10 @@ function getPrimaryModerationClient(): OpenAI | null { } function normalizePrimaryAiFlag(value: string): string | null { - const lower = value.trim().toLowerCase().replace(/[\s-]+/g, "_"); + const lower = value + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); if (!lower) return null; if (VALID_PRIMARY_AI_FLAGS.has(lower)) { @@ -337,7 +340,7 @@ async function callPrimaryAiModeration(text: string): Promise { role: "user", content: "Deteksi kata kasar / pelanggaran ringan dari teks Indonesia berikut. " + - "Balas hanya JSON object dengan format {\"flags\":[...]} dan gunakan hanya flag valid ini: " + + 'Balas hanya JSON object dengan format {"flags":[...]} dan gunakan hanya flag valid ini: ' + Array.from(VALID_PRIMARY_AI_FLAGS).join(", ") + ". Jika tidak ada pelanggaran, flags harus array kosong. Teks: " + text, @@ -478,9 +481,12 @@ export async function detectIndonesianBadwords( hits.add(hit); } } catch (error) { - const status = axios.isAxiosError(error) ? error.response?.status : null; + const status = axios.isAxiosError(error) + ? error.response?.status + : null; if (status === 429) { - nemotronUnavailableUntil = Date.now() + NEMOTRON_RATE_LIMIT_COOLDOWN_MS; + nemotronUnavailableUntil = + Date.now() + NEMOTRON_RATE_LIMIT_COOLDOWN_MS; } log.warn( { error }, @@ -497,7 +503,9 @@ export async function detectIndonesianBadwords( hits.add(hit); } } catch (error) { - const status = axios.isAxiosError(error) ? error.response?.status : null; + const status = axios.isAxiosError(error) + ? error.response?.status + : null; if (status === 429) { primaryAiUnavailableUntil = Date.now() + PRIMARY_AI_RATE_LIMIT_COOLDOWN_MS; diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 56675c4..4f9422a 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -6,16 +6,16 @@ import { createChildLogger } from "../logger.js"; import { retryWithBackoff } from "../retry.js"; import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js"; import { extractMessageMediaEvidence } from "./messageMetadata.js"; -import { - buildStickerTextOnlyWarning, - buildStickerVisionPrompt, -} from "./stickerPrompt.js"; import { getStickerFromCache, initStickerCache, isStickerCacheReady, setStickerInCache, } from "./stickerCache.js"; +import { + buildStickerTextOnlyWarning, + buildStickerVisionPrompt, +} from "./stickerPrompt.js"; import type { AnalysisResult, AttachmentRecord, @@ -244,7 +244,12 @@ export function parseModerationResponse( return ( Array.isArray(val) && val.length > 0 && - val.every((item: unknown) => typeof item === "object" && item !== null && "message_id" in (item as any)) + val.every( + (item: unknown) => + typeof item === "object" && + item !== null && + "message_id" in (item as any), + ) ); }); if (arrayKey) { @@ -311,7 +316,7 @@ export function parseModerationResponse( flags: flags ?? [], score: normalizedScore, analysis: coalescedAnalysis, - categories: categories ?? (flags ?? []), + categories: categories ?? flags ?? [], severity: normalizedSeverity, confidence: normalizedConfidence, recommendedAction: diff --git a/src/moderation/messageMetadata.ts b/src/moderation/messageMetadata.ts index f83d55a..d37a9d5 100644 --- a/src/moderation/messageMetadata.ts +++ b/src/moderation/messageMetadata.ts @@ -87,7 +87,10 @@ export function getMessageLocation(message: Message): MessageLocation { threadId: null, threadName: null, channelName: "name" in channel ? channel.name : null, - nsfw: typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw : undefined, + nsfw: + typeof safetyChannel.nsfw === "boolean" + ? safetyChannel.nsfw + : undefined, nsfwLevel: typeof safetyChannel.nsfwLevel === "string" ? safetyChannel.nsfwLevel @@ -104,7 +107,8 @@ export function getMessageLocation(message: Message): MessageLocation { threadId: channel.id, threadName: channel.name, channelName: channel.parent?.name ?? null, - nsfw: typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw : undefined, + nsfw: + typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw : undefined, nsfwLevel: typeof safetyChannel.nsfwLevel === "string" ? safetyChannel.nsfwLevel diff --git a/src/moderation/retentionManager.ts b/src/moderation/retentionManager.ts index 0da75fd..c46bc92 100644 --- a/src/moderation/retentionManager.ts +++ b/src/moderation/retentionManager.ts @@ -1,3 +1,4 @@ +import { and, eq, isNull, lt } from "drizzle-orm"; import { getDatabase } from "../database/drizzle.js"; import { attachmentsTable, @@ -6,9 +7,8 @@ import { voiceRecordingsTable, } from "../database/schema.js"; import { createChildLogger } from "../logger.js"; -import { getExpiredMessages, getRetentionPolicy } from "./messageStore.js"; +import { getRetentionPolicy } from "./messageStore.js"; import type { RetentionPolicy } from "./types.js"; -import { and, eq, isNull, lt, sql } from "drizzle-orm"; const logger = createChildLogger("retention-manager"); diff --git a/src/moderation/stickerCache.ts b/src/moderation/stickerCache.ts index f440446..8ebf93b 100644 --- a/src/moderation/stickerCache.ts +++ b/src/moderation/stickerCache.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile, unlink } from "node:fs/promises"; +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createChildLogger } from "../logger.js"; diff --git a/src/routes/analysisRoutes.ts b/src/routes/analysisRoutes.ts index 7171973..aafa685 100644 --- a/src/routes/analysisRoutes.ts +++ b/src/routes/analysisRoutes.ts @@ -5,7 +5,10 @@ import { getAnalysisQueueStatus, queueMessageAnalysis, } from "../moderation/aiAnalyzer.js"; -import { searchMessages, updateMessageAIAnalysis } from "../moderation/messageStore.js"; +import { + searchMessages, + updateMessageAIAnalysis, +} from "../moderation/messageStore.js"; import type { MessageRecord } from "../moderation/types.js"; export function createAnalysisRoutes(): Router { diff --git a/tests/helpers/testDatabase.ts b/tests/helpers/testDatabase.ts index b54686d..2f25eae 100644 --- a/tests/helpers/testDatabase.ts +++ b/tests/helpers/testDatabase.ts @@ -1,14 +1,15 @@ import process from "node:process"; -import { - getDatabase, - initializeDatabase, -} from "../../src/database/drizzle"; +import { Pool } from "pg"; +import { getDatabase, initializeDatabase } from "../../src/database/drizzle"; interface RunnableDatabase { run(sql: string): Promise; } const SAFE_TEST_DATABASE_NAME = /(^|[_-])(test|testing)([_-]|$)|gmw_test/i; +const DEFAULT_TEST_SCHEMA = "gmw_test"; +const SAFE_TEST_SCHEMA_NAME = + /^[a-zA-Z_][a-zA-Z0-9_]*(test|testing)[a-zA-Z0-9_]*$/i; function getDatabaseNameFromUrl(databaseUrl: string): string { try { @@ -26,6 +27,41 @@ function getConfiguredDatabaseName(): string { return process.env.POSTGRES_DB ?? ""; } +function getTestSchemaName(): string { + const schemaName = process.env.TEST_DATABASE_SCHEMA ?? DEFAULT_TEST_SCHEMA; + if (!SAFE_TEST_SCHEMA_NAME.test(schemaName)) { + throw new Error( + `Refusing to use unsafe test schema "${schemaName}". Schema name must contain "test" and use identifier-safe characters only.`, + ); + } + return schemaName; +} + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"`; +} + +async function ensureTestSchemaExists(): Promise { + assertSafeTestDatabaseUrl(); + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + const schemaName = getTestSchemaName(); + + try { + await pool.query( + `CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)}`, + ); + } finally { + await pool.end(); + } +} + +async function configureTestSearchPath(): Promise { + const db = getTestDatabase(); + await db.run( + `SET search_path TO ${quoteIdentifier(getTestSchemaName())}, public`, + ); +} + export function assertSafeTestDatabaseUrl(): void { if (process.env.NODE_ENV !== "test") { throw new Error( @@ -38,16 +74,24 @@ export function assertSafeTestDatabaseUrl(): void { } const databaseName = getConfiguredDatabaseName(); - if (!SAFE_TEST_DATABASE_NAME.test(databaseName)) { + const hasSafeSchema = Boolean(process.env.TEST_DATABASE_SCHEMA); + if (!SAFE_TEST_DATABASE_NAME.test(databaseName) && !hasSafeSchema) { throw new Error( - `Refusing to run destructive database test against non-test database "${databaseName || "unknown"}". Set TEST_DATABASE_URL or DATABASE_URL to a database whose name contains "test" (for example hub_test).`, + `Refusing to run destructive database test against non-test database "${databaseName || "unknown"}" without TEST_DATABASE_SCHEMA. Set TEST_DATABASE_SCHEMA to a safe test schema name or use a database whose name contains "test" (for example hub_test).`, ); } + + if (hasSafeSchema) { + getTestSchemaName(); + } } export async function initializeTestDatabase() { assertSafeTestDatabaseUrl(); - return initializeDatabase(); + await ensureTestSchemaExists(); + const database = await initializeDatabase(); + await configureTestSearchPath(); + return database; } export function getTestDatabase(): RunnableDatabase { diff --git a/tests/moderation/conversationContext.test.ts b/tests/moderation/conversationContext.test.ts index a063e94..87662fd 100644 --- a/tests/moderation/conversationContext.test.ts +++ b/tests/moderation/conversationContext.test.ts @@ -1,17 +1,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("../../src/moderation/indonesianTextNormalizer.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - formatModerationTextEvidenceForPrompt: vi.fn(async (content: string) => { - // Deterministic mock evidence — length tuned for the "tight budget" test: - // maxTokens=300, target ~88, c3 ~108, c2 ~108, c1 ~108 - // Expectation: target+c3 fits (196), target+c3+c2 overflows (304) - return "[text_evidence] categories=[\"offensive\",\"profanity\",\"sexual_violence\"] severity=high confidence=0.92 language=id detected=badword normalized=false metadata_v2=true context=true"; - }), - }; -}); +vi.mock( + "../../src/moderation/indonesianTextNormalizer.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../src/moderation/indonesianTextNormalizer.js") + >(); + return { + ...actual, + formatModerationTextEvidenceForPrompt: vi.fn(async (content: string) => { + // Deterministic mock evidence — length tuned for the "tight budget" test: + // maxTokens=300, target ~88, c3 ~108, c2 ~108, c1 ~108 + // Expectation: target+c3 fits (196), target+c3+c2 overflows (304) + return '[text_evidence] categories=["offensive","profanity","sexual_violence"] severity=high confidence=0.92 language=id detected=badword normalized=false metadata_v2=true context=true'; + }), + }; + }, +); import { buildConversationContext, diff --git a/tests/moderation/indonesianTextNormalizer.remote.test.ts b/tests/moderation/indonesianTextNormalizer.remote.test.ts index 9ce91f4..162f5fb 100644 --- a/tests/moderation/indonesianTextNormalizer.remote.test.ts +++ b/tests/moderation/indonesianTextNormalizer.remote.test.ts @@ -65,4 +65,4 @@ describe("detectIndonesianBadwords remote fallback", () => { expect(mocks.axiosPost).toHaveBeenCalledTimes(1); expect(mocks.openaiCreate).toHaveBeenCalledTimes(1); }); -}); \ No newline at end of file +}); diff --git a/tests/moderation/indonesianTextNormalizer.test.ts b/tests/moderation/indonesianTextNormalizer.test.ts index 2606615..2307072 100644 --- a/tests/moderation/indonesianTextNormalizer.test.ts +++ b/tests/moderation/indonesianTextNormalizer.test.ts @@ -1,4 +1,5 @@ import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { config } from "../../src/config"; import { buildModerationTextEvidence, detectIndonesianBadwords, @@ -6,7 +7,6 @@ import { normalizeDiscordCustomEmoji, normalizeIndonesianSlang, } from "../../src/moderation/indonesianTextNormalizer"; -import { config } from "../../src/config"; const originalNemotronKey = config.NVIDIA_NEMOTRON_API_KEY; const originalPrimaryAiKey = config.AI_LLM_API_KEY; @@ -105,7 +105,9 @@ describe("formatModerationTextEvidenceForPrompt", () => { expect(formatted).toContain("[emoji:hadeh]"); expect(formatted).toContain("[normalization_notes:"); // The NVIDIA API may or may not detect badwords for this input - expect(formatted).toMatch(/no Indonesian badword detected|Indonesian badword detected/); + expect(formatted).toMatch( + /no Indonesian badword detected|Indonesian badword detected/, + ); }); it("includes normalized text even for clean input", async () => { diff --git a/tests/moderation/messageCapture.test.ts b/tests/moderation/messageCapture.test.ts index 4fa90c8..09f12b6 100644 --- a/tests/moderation/messageCapture.test.ts +++ b/tests/moderation/messageCapture.test.ts @@ -8,13 +8,13 @@ import { vi, } from "vitest"; import { closeDatabase } from "../../src/database/drizzle"; +import { captureMessage } from "../../src/moderation/messageCapture"; +import type { ModerationBroadcaster } from "../../src/moderation/types"; import { clearTestTables, getTestDatabase, initializeTestDatabase, } from "../helpers/testDatabase"; -import { captureMessage } from "../../src/moderation/messageCapture"; -import type { ModerationBroadcaster } from "../../src/moderation/types"; const queueMessageAnalysis = vi.fn(); diff --git a/tests/moderation/messageStoreQueries.test.ts b/tests/moderation/messageStoreQueries.test.ts index 6ebda25..1e4a133 100644 --- a/tests/moderation/messageStoreQueries.test.ts +++ b/tests/moderation/messageStoreQueries.test.ts @@ -1,10 +1,5 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { closeDatabase } from "../../src/database/drizzle"; -import { - clearTestTables, - getTestDatabase, - initializeTestDatabase, -} from "../helpers/testDatabase"; import { createChildLogger } from "../../src/logger"; import { decodeCursor, @@ -18,6 +13,11 @@ import { updateMessageAsEdited, } from "../../src/moderation/messageStore"; import type { MessageRecord } from "../../src/moderation/types"; +import { + clearTestTables, + getTestDatabase, + initializeTestDatabase, +} from "../helpers/testDatabase"; const logger = createChildLogger("messageStoreQueries.test");