diff --git a/.hermes/plans/2026-08-30_nsfw-capture-skip-ai-spec.md b/.hermes/plans/2026-08-30_nsfw-capture-skip-ai-spec.md new file mode 100644 index 00000000..ebb60b56 --- /dev/null +++ b/.hermes/plans/2026-08-30_nsfw-capture-skip-ai-spec.md @@ -0,0 +1,52 @@ +# Spec: Simpan pesan NSFW tanpa analisis AI (Request 1) + +Date: 2026-08-30 + +## Goal +Saat ini GMW **skip capture** untuk pesan di channel age-restricted/NSFW +(`messageCapture.ts` baris 291 & 310 memanggil `isAgeRestrictedMessage` lalu +`return`). User ingin pesan NSFW tetap **disimpan** ke database (jadi terlihat +di dashboard), tetapi **tidak dianalisis AI** (tidak dipanggil LLM). + +## Behavior target +1. Pesan NSFW/age-restricted di `messageCreate` → DISIMPAN (capture normal). +2. Pesan NSFW di `messageUpdate`/`messageDelete` → tetap diproses seperti pesan + biasa (edit/deleted state tercatat). +3. AI analysis TIDAK berjalan untuk pesan NSFW. Sudah ada jalur yang benar: + `queueMessageAnalysis` → `isAgeRestrictedMessage(message)` → + `buildAgeRestrictedSkipResult()` yang menulis `status=clean` + flag + `age_restricted` + `action=none` TANPA memanggil LLM. Jalur ini sudah ada dan + dipakai di `aiAnalyzer.queueMessageAnalysis` (baris 72-84) dan + `batchProcessor.skipAgeRestrictedMessages`. Jadi tinggal membuka capture. +4. Konten NSFW TIDAK masuk ke Qdrant public archive (semantic search publik). + `archiveMessageEmbedded` harus di-guard untuk pesan age-restricted. + +## Files touched +- `services/discord-gateway/src/modules/message-capture/messageCapture.ts` + - Hapus guard `isAgeRestrictedMessage` di `messageCreate` (baris 291) dan + `messageUpdate` (baris 310). Jangan hapus guard `isExcludedThread`, + `isBotExcludedChannel`, `shouldCaptureForAnyTarget`. +- `services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts` + - `archiveMessageEmbedded` terima flag/cek metadata age-restricted → skip + embed untuk NSFW. + - Call-site di `messageCapture.ts` `captureMessage` (baris 218) pass isNSFW + atau cek dulu. + +## Schema/type changes +- TIDAK ada perubahan DB schema. Metadata pesan sudah membawa `channel.nsfw` + (`getMessageLocation`). Tidak perlu kolom baru — flag `age_restricted` sudah + ditulis ke `ai_moderation_flags` lewat skip-result. + +## Verification +- `pnpm typecheck`, `pnpm lint` (biome), `pnpm build` di `services/discord-gateway`. +- Unit test: pastikan ada test untuk age-restricted skip (sudah ada + `tests/conversationContext.test.ts` ref nsfw; cek apakah ada test sesuai). +- Deploy via GHA push; verify pesan NSFW muncul di DB dan `ai_status` = clean + dengan flag age_restricted, dan TIDAK ada panggilan LLM (log llm-caller tidak + menampilkan id pesan NSFW). + +## Request 2 (video) — NOT in this change +Fitur record video kamera/screenshare orang lain. @discordjs/voice hanya +mendukung **audio** receive. Video orang lain butuh WebRTC viewer baru +(SDP answer, decrypt H264/VP8, decode frame, mux MP4/WebM). Diluar scope +changeset ini; dicatat untuk desain lanjutan. diff --git a/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts b/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts index c27dc3ca..e33875d4 100644 --- a/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts +++ b/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts @@ -16,6 +16,10 @@ export interface ArchiveMessage { channel_id: string; guild_id: string; created_at: number; + /** True when the message came from an age-restricted (NSFW) channel. NSFW + * content is deliberately NOT embedded into the public archive so it can't + * be found via public semantic search. Defaults to false. */ + isAgeRestricted?: boolean; } /** @@ -25,8 +29,12 @@ export interface ArchiveMessage { * Failures are swallowed — searching is a nice-to-have, never a precondition * for capture or moderation. The message text is kept in the payload so the * search endpoint can return results even for deleted messages. + * + * NSFW/age-restricted messages are skipped (never embedded) — they are stored + * in the database for the dashboard but kept out of the public search archive. */ export function archiveMessageEmbedded(message: ArchiveMessage): void { + if (message.isAgeRestricted) return; // never surface NSFW in public archive if (!config.AI_LLM_EMBEDDING_MODEL) return; // embeddings disabled → skip const text = message.content?.trim(); if (!text || text.length < 3) return; diff --git a/services/discord-gateway/src/modules/message-capture/messageCapture.ts b/services/discord-gateway/src/modules/message-capture/messageCapture.ts index 8feeca82..239b5a77 100644 --- a/services/discord-gateway/src/modules/message-capture/messageCapture.ts +++ b/services/discord-gateway/src/modules/message-capture/messageCapture.ts @@ -215,8 +215,12 @@ export async function captureMessage( // Fire-and-forget: make the captured message searchable in the persistent // archive (public semantic search). Never blocks capture/moderation. + // NSFW/age-restricted messages are kept OUT of the public archive. if (!isBacklog && messageRecord.content) { - archiveMessageEmbedded(messageRecord); + archiveMessageEmbedded({ + ...messageRecord, + isAgeRestricted: isAgeRestrictedMessage(message), + }); } if (_eventBroadcaster && !isBacklog) { @@ -288,7 +292,6 @@ export function registerMessageCapture(client: Client): void { client.on("messageCreate", async (message) => { if (!shouldCaptureForAnyTarget(message, targets)) return; if (isBotExcludedChannel(message)) return; - if (isAgeRestrictedMessage(message)) return; if (isExcludedThread(message)) return; try { @@ -307,7 +310,6 @@ export function registerMessageCapture(client: Client): void { client.on("messageUpdate", async (_oldMessage, newMessage) => { if (!shouldCaptureForAnyTarget(newMessage, targets)) return; if (isBotExcludedChannel(newMessage as Message)) return; - if (isAgeRestrictedMessage(newMessage as Message)) return; if (isExcludedThread(newMessage)) return; try {