feat(gateway): capture NSFW/age-restricted messages but skip AI analysis + exclude from public archive
Previously isAgeRestrictedMessage() early-returned in messageCreate/messageUpdate, so NSFW-channel messages were never stored at all. Now they are captured like any message (visible in dashboard), while the existing age-restricted skip path (queueMessageAnalysis -> buildAgeRestrictedSkipResult) marks them clean with flag age_restricted WITHOUT calling the LLM. NSFW content is also deliberately kept OUT of the Qdrant public semantic-search archive (archiveMessageEmbedded skips when isAgeRestricted), so it can't be found via public web search. No schema change needed (metadata already carries channel.nsfw).
This commit is contained in:
@@ -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.
|
||||||
@@ -16,6 +16,10 @@ export interface ArchiveMessage {
|
|||||||
channel_id: string;
|
channel_id: string;
|
||||||
guild_id: string;
|
guild_id: string;
|
||||||
created_at: number;
|
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
|
* 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
|
* for capture or moderation. The message text is kept in the payload so the
|
||||||
* search endpoint can return results even for deleted messages.
|
* 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 {
|
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
|
if (!config.AI_LLM_EMBEDDING_MODEL) return; // embeddings disabled → skip
|
||||||
const text = message.content?.trim();
|
const text = message.content?.trim();
|
||||||
if (!text || text.length < 3) return;
|
if (!text || text.length < 3) return;
|
||||||
|
|||||||
@@ -215,8 +215,12 @@ export async function captureMessage(
|
|||||||
|
|
||||||
// Fire-and-forget: make the captured message searchable in the persistent
|
// Fire-and-forget: make the captured message searchable in the persistent
|
||||||
// archive (public semantic search). Never blocks capture/moderation.
|
// archive (public semantic search). Never blocks capture/moderation.
|
||||||
|
// NSFW/age-restricted messages are kept OUT of the public archive.
|
||||||
if (!isBacklog && messageRecord.content) {
|
if (!isBacklog && messageRecord.content) {
|
||||||
archiveMessageEmbedded(messageRecord);
|
archiveMessageEmbedded({
|
||||||
|
...messageRecord,
|
||||||
|
isAgeRestricted: isAgeRestrictedMessage(message),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_eventBroadcaster && !isBacklog) {
|
if (_eventBroadcaster && !isBacklog) {
|
||||||
@@ -288,7 +292,6 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
client.on("messageCreate", async (message) => {
|
client.on("messageCreate", async (message) => {
|
||||||
if (!shouldCaptureForAnyTarget(message, targets)) return;
|
if (!shouldCaptureForAnyTarget(message, targets)) return;
|
||||||
if (isBotExcludedChannel(message)) return;
|
if (isBotExcludedChannel(message)) return;
|
||||||
if (isAgeRestrictedMessage(message)) return;
|
|
||||||
if (isExcludedThread(message)) return;
|
if (isExcludedThread(message)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -307,7 +310,6 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
||||||
if (!shouldCaptureForAnyTarget(newMessage, targets)) return;
|
if (!shouldCaptureForAnyTarget(newMessage, targets)) return;
|
||||||
if (isBotExcludedChannel(newMessage as Message)) return;
|
if (isBotExcludedChannel(newMessage as Message)) return;
|
||||||
if (isAgeRestrictedMessage(newMessage as Message)) return;
|
|
||||||
if (isExcludedThread(newMessage)) return;
|
if (isExcludedThread(newMessage)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user