diff --git a/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts b/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts index 8841a95..6079e8b 100644 --- a/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts +++ b/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts @@ -165,6 +165,7 @@ export function buildLocationContext(targets: MessageRecord[]): string { channel?: { channelName?: string | null; threadName?: string | null; + topic?: string | null; nsfw?: boolean; ageRestricted?: boolean; nsfwLevel?: string | null; @@ -181,6 +182,13 @@ export function buildLocationContext(targets: MessageRecord[]): string { if (ch.threadName) attrs.push(`thread_name="${escapeXml(ch.threadName)}"`); } + if (typeof ch.topic === "string" && ch.topic.trim().length > 0) { + const topic = + ch.topic.length > 200 + ? `${ch.topic.slice(0, 200).trimEnd()}…` + : ch.topic; + attrs.push(`topic="${escapeXml(topic)}"`); + } if (typeof ch.nsfw === "boolean") attrs.push(`nsfw="${ch.nsfw}"`); if (typeof ch.ageRestricted === "boolean") { attrs.push(`age_restricted="${ch.ageRestricted}"`); diff --git a/services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts b/services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts index 4ee5120..1e799ef 100644 --- a/services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts +++ b/services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts @@ -66,13 +66,22 @@ export async function runMediaBatch( }); // Gather user profiles ONCE for the whole batch and emit a deduplicated - // map; per-message blocks (from prepareMediaMessage) - // reference it via . - const profileByUser = new Map(); + // map (with last-generated timestamp); per-message blocks + // (from prepareMediaMessage) reference it via . + const profileByUser = new Map< + string, + { + text: string; + asOf?: number | null; + } + >(); for (const t of targets) { if (profileByUser.has(t.user_id)) continue; const profile = await getUserProfile(t.user_id); - profileByUser.set(t.user_id, profile?.profile_summary ?? ""); + profileByUser.set(t.user_id, { + text: profile?.profile_summary ?? "", + asOf: profile?.last_analyzed_at ?? null, + }); } const userProfilesBlock = buildUserProfilesBlock(profileByUser); diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts b/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts index 85d51cb..0d0840d 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts @@ -95,18 +95,29 @@ export function truncateForAi(content: string): string { // entries per message with . // --------------------------------------------------------------------------- +export interface UserProfileEntry { + /** Profile summary text (from user_profiles.profile_summary). */ + text: string; + /** Epoch ms when the profile was last generated — staleness signal for + * the LLM (a profile from months ago may not reflect current behavior). */ + asOf?: number | null; +} + /** Build a deduplicated `` map block, keyed by Discord user id. */ export function buildUserProfilesBlock( - profiles: ReadonlyMap, + profiles: ReadonlyMap, ): string { const entries = Array.from(profiles.entries()).filter( - ([, text]) => text.trim().length > 0, + ([, entry]) => entry.text.trim().length > 0, ); if (entries.length === 0) return ""; - const lines = entries.map( - ([userId, text]) => - ` ${sanitizeAiContent(text)}`, - ); + const lines = entries.map(([userId, entry]) => { + const asOfAttr = + typeof entry.asOf === "number" && entry.asOf > 0 + ? ` as_of="${new Date(entry.asOf).toISOString()}"` + : ""; + return ` ${sanitizeAiContent(entry.text)}`; + }); return `\n${lines.join("\n")}\n`; } @@ -115,6 +126,110 @@ export function buildUserProfileRef(userId: string): string { return ``; } +// --------------------------------------------------------------------------- +// User reputation — richer than a bare trust score. +// +// The trust model tracks total_infractions, a clean-message streak and the +// last infraction timestamp. Feeding all of it to the LLM lets it tell a +// first-timer (same score, 1 infraction) from a repeat offender (score 50, +// 3 infractions, last one yesterday) — the same score means very different +// things in those two contexts. +// --------------------------------------------------------------------------- + +export interface ReputationAttrsSource { + trust_score: number; + total_infractions: number; + clean_message_streak: number; + last_infraction_at: number | null; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const REPEAT_OFFENSE_WINDOW_MS = 7 * DAY_MS; + +/** + * Formats reputation fields into XML attributes for ``. + * Derived signals: last_offense_days_ago (0 = today) and repeat_offender + * (infraction within the last 7 days) are computed here so both the text and + * media paths emit the exact same shape. + */ +export function formatReputationAttrs( + rep: ReputationAttrsSource, + now: number = Date.now(), +): string { + const attrs = [ + `trust_score="${rep.trust_score}"`, + `total_infractions="${rep.total_infractions}"`, + `clean_streak="${rep.clean_message_streak}"`, + ]; + if ( + typeof rep.last_infraction_at === "number" && + rep.last_infraction_at > 0 + ) { + const daysAgo = Math.max( + 0, + Math.floor((now - rep.last_infraction_at) / DAY_MS), + ); + attrs.push(`last_offense_days_ago="${daysAgo}"`); + const isRepeat = + rep.total_infractions > 0 && + now - rep.last_infraction_at <= REPEAT_OFFENSE_WINDOW_MS; + if (isRepeat) attrs.push(`repeat_offender="true"`); + } + return attrs.join(" "); +} + +/** + * Builds an optional `` block (last flagged messages) from + * getUserRecentInfractions rows. Only emitted when there is real history — + * lets the LLM see the PATTERN (e.g. the same scam link posted repeatedly) + * without treating old flags as proof for the current message. + */ +export function buildUserHistoryXml( + history: Array<{ + content: string; + severity: string | null; + created_at: number; + }>, + now: number = Date.now(), +): string { + const filtered = history.filter((h) => h.content?.trim()); + if (filtered.length === 0) return ""; + const lines = filtered.map((h) => { + const daysAgo = Math.max(0, Math.floor((now - h.created_at) / DAY_MS)); + const severityAttr = h.severity + ? ` severity="${escapeXml(h.severity)}"` + : ""; + const snippet = + h.content.length > 100 + ? `${h.content.slice(0, 100).trimEnd()}…` + : h.content; + return ` ${escapeXml(snippet)}`; + }); + return `\n${lines.join("\n")}\n`; +} + +/** + * Whether the message author was a bot (captured in metadata.author.bot). + * Bot posts (logging bots, webhook-style automation) deserve different + * scrutiny than user posts — expose the flag instead of hiding it. + */ +export function resolveIsBot(msg: MessageRecord): boolean { + if (!msg.metadata) return false; + try { + const meta = JSON.parse(msg.metadata) as { + author?: { bot?: boolean } | null; + }; + return Boolean(meta?.author?.bot); + } catch { + return false; + } +} + +/** Whether the shown content is an EDIT of the original post (evasion signal). */ +export function resolveIsEdited(msg: MessageRecord): boolean { + return Boolean(msg.edited_content); +} + /** * Returns the real text content for AI analysis, stripping fallback text * that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]", diff --git a/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts b/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts index 1b02df6..a261eea 100644 --- a/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts +++ b/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts @@ -40,6 +40,7 @@ Data konteks tersedia: (peta ringkasan kepribadian, di pesan USE Gunakan untuk personalisasi analysis, tapi: - Profil adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran. - Perubahan perilaku mencolok (biasanya teknis tiba-tiba provokatif) layak dicatat di analysis. +- (kutipan pesan yang pernah di-flag) = pola pelanggaran lama. Gunakan untuk mendeteksi PENGULANGAN (mis. spam link yang sama, provokasi berulang), tapi JANGAN memflag pesan bersih hanya karena riwayat. - JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik. - Channel culture coding/teknis → pesan teknis lebih wajar; channel santai → slang lebih wajar. Jangan dipakai mengabaikan pelanggaran nyata. @@ -58,6 +59,7 @@ Contoh buruk: "Pesan berisi teks dan gambar tanpa pelanggaran." (mengabaikan buk - **conflict_instigation:** "Pengirim . . Diberi peringatan karena berpotensi memicu drama." - **Username ofensif (pesan bersih):** "Pengirim memiliki username yang . Isi pesan hanya . Diberi warning ringan." — (pesan memperkuat): " + isi pesan memperkuat tone kebencian. Pelanggaran berat." - **Evasi (zalgo/leetspeak):** "Pengirim menggunakan teknik obfuscation untuk menyembunyikan . . ." +- **Spam (repetitions > 1):** "Pengirim mengirim teks yang sama sebanyak N kali dalam waktu singkat. . Diberi peringatan karena spam berulang." — nilai tetap dari isi; pengulangan saja (mis. "ok" x5 dalam obrolan aktif) bukan pelanggaran. - **sexual_deviation:** "Pengirim . . Melanggar kebijakan server." - **SARA/penistaan agama:** "Pengirim . . Melanggar kebijakan SARA." — JANGAN gunakan kata "bercanda" untuk SARA. diff --git a/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts b/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts index 9846304..c0c9ead 100644 --- a/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts +++ b/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts @@ -105,17 +105,18 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { parts.push( `## Blok Data di Pesan USER\n` + `Semua data dinamis per-batch dikirim di pesan USER — system prompt ini TIDAK memuat data batch:\n` + - `- = metadata channel/thread (channel_id, channel_name, thread_name, nsfw, age_restricted).\n` + + `- = metadata channel/thread (channel_id, channel_name, thread_name, topic, nsfw, age_restricted). topic = deskripsi resmi channel — pakai untuk menilai kesesuaian pesan dengan tujuan channel.\n` + `- = obrolan SEBELUM pesan target. Baris "[context]" di dalamnya BUKAN yang dinilai.\n` + - `- = peta ringkasan kepribadian per user_id; setiap merujuk lewat .\n` + + `- = peta ringkasan kepribadian per user_id (attr as_of = kapan profil terakhir dibuat — profil lama mungkin tidak mencerminkan perilaku terkini); setiap merujuk lewat .\n` + `- / = bukti web (lihat "Web Sebagai Bukti Utama").\n` + - `- = pesan-pesan TARGET yang WAJIB dinilai.`, + `- = pesan-pesan TARGET yang WAJIB dinilai. Atribut : id, user (nama server), time (ISO — kapan pesan dikirim), repetitions (N = teks pendek sama muncul N kali di batch — sinyal spam), bot (true jika dari bot), edited (true jika konten adalah hasil edit setelah posting).`, ); parts.push( `## Konteks Pengguna (Referensi, Bukan Bukti)\n` + `Konteks per pengguna hanya indikator **referensi** untuk personalisasi analisis, BUKAN bukti pelanggaran:\n` + - `- = histori moderasi pengguna. Skor rendah BUKAN alasan memflag pesan bersih; skor tinggi BUKAN alasan mengabaikan pelanggaran nyata.\n` + + `- = histori moderasi pengguna. Skor rendah BUKAN alasan memflag pesan bersih; skor tinggi BUKAN alasan mengabaikan pelanggaran nyata. repeat_offender="true" = ada pelanggaran dalam 7 hari terakhir.\n` + + `- (di dalam ) = kutipan pesan-pesan pengguna yang PERNAH di-flag. Gunakan untuk mengenali POLA berulang (spam link sama, provokasi), tapi JANGAN memflag pesan bersih hanya karena riwayat.\n` + `- (di pesan USER) = peta ringkasan kepribadian per user_id. dalam sebuah pesan menunjuk ke peta itu. Tanpa ref = tidak ada profil untuk pengguna tersebut.\n` + `- Profil berguna untuk mengenali penyimpangan perilaku mencolok (mis. pengguna teknis tiba-tiba provokatif), tapi JANGAN memflag atau meloloskan hanya karena profil.\n` + `**Setiap pesan dinilai berdasarkan isinya sendiri.**`, @@ -126,7 +127,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { `- Baris dalam berformat "[context] id=... time= user=: isi", diurutkan paling lama → paling baru. Baris pertama biasanya "[conversation_flow] status=... context_msgs=... dropped=..." — metadata sistem tentang status percakapan (ongoing/sparse/cold_start), BUKAN pesan yang dinilai.\n` + `- berisi pesan-pesan TARGET yang WAJIB dinilai. Hasilkan SATU hasil per message_id — jangan menggabungkan beberapa pesan, jangan melewati, jangan mengarang id.\n` + `- Setiap target dinilai berdasarkan isinya sendiri; konteks percakapan memengaruhi interpretasi, bukan menggantikan isi pesan.\n` + - `- Marker "…[pesan dipotong: terlalu panjang]" = konten TARGET sengaja dipotong; marker "…[konteks dipotong: terlalu panjang]" = konten pesan KONTEKS dipotong. Nilai dari bagian yang terlihat; pemotongan BUKAN pelanggaran dan BUKAN teknik evasi.`, + `- Marker "…[pesan dipotong: terlalu panjang]" = konten TARGET sengaja dipotong; marker "…[konteks dipotong: terlalu panjang]" = konten pesan KONTEKS dipotong. Nilai dari bagian yang terlihat; pemotongan BUKAN pelanggaran dan BUKAN teknik evasi.\n` + + `- Atribut time= pada target = kapan pesan dikirim (ISO). Pakai untuk menilai kerelevanan waktu (mis. pesan lama di-bump, spam beruntun dalam menit yang sama).\n` + + `- repetitions="N" pada = teks pendek yang sama muncul N kali dalam batch — pertimbangkan sebagai sinyal spam, tapi nilai tetap dari isi pesan.\n` + + `- bot="true" = pengirim adalah bot (otomatisasi), bukan pengguna manusia — jangan perlakukan sebagai pelanggaran personal, tapi kontennya tetap dinilai.\n` + + `- edited="true" = konten yang ditampilkan adalah hasil edit setelah posting (sinyal potensi evasi), nilai konten saat ini apa adanya.`, ); parts.push(OUTPUT_INSTRUCTIONS); diff --git a/services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts b/services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts index fbb4524..eea7a8a 100644 --- a/services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts +++ b/services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts @@ -19,11 +19,15 @@ import { callModerationLLM } from "./llmCaller.js"; import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js"; import { buildReferenceXml, + buildUserHistoryXml, buildUserProfileRef, buildUserProfilesBlock, escapeXml, + formatReputationAttrs, getAnalysisContent, resolveDisplayName, + resolveIsBot, + resolveIsEdited, truncateForAi, } from "./moderationBuilders.js"; import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js"; @@ -36,7 +40,10 @@ import { import { getRecentCorrectedModerations } from "./textCacheStore.js"; import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js"; import { getUserProfile } from "./userProfileStore.js"; -import { initializeUserReputation } from "./userReputationStore.js"; +import { + getUserRecentInfractions, + initializeUserReputation, +} from "./userReputationStore.js"; import type { MessageImagePart } from "./visionAnalyzer.js"; const log = createChildLogger("textBatchProcessor"); @@ -191,18 +198,46 @@ export async function runTextOnlyBatch( // User reputation + profiles (raw summary text — deduplicated into a // single map per batch; messages only reference it). const userContexts = new Map(); - const userProfiles = new Map(); + const userProfiles = new Map< + string, + { + text: string; + asOf?: number | null; + } + >(); for (const msg of batch) { if (!userContexts.has(msg.user_id)) { const rep = await initializeUserReputation(msg.user_id, msg.guild_id); - userContexts.set( - msg.user_id, - ``, - ); + const repAttrs = formatReputationAttrs(rep); + let repXml = ``; + // Repeat offenders get their last flagged messages as + // so the LLM can recognize PATTERNS (same scam link, repeated + // provocation) — history is reference, never proof. Best-effort. + if (rep.total_infractions > 0) { + try { + const history = await getUserRecentInfractions(msg.user_id, 2); + const historyXml = buildUserHistoryXml( + history.map((h) => ({ + content: h.content ?? "", + severity: h.severity, + created_at: h.created_at, + })), + ); + if (historyXml) { + repXml = `\n${historyXml}\n`; + } + } catch { + // history is a bonus — fall back to attrs-only reputation + } + } + userContexts.set(msg.user_id, repXml); } if (!userProfiles.has(msg.user_id)) { const profile = await getUserProfile(msg.user_id); - userProfiles.set(msg.user_id, profile?.profile_summary ?? ""); + userProfiles.set(msg.user_id, { + text: profile?.profile_summary ?? "", + asOf: profile?.last_analyzed_at ?? null, + }); } } const userProfilesBlock = buildUserProfilesBlock(userProfiles); @@ -303,11 +338,16 @@ export async function runTextOnlyBatch( .map((line) => `\n${line}`) .join(""); const userCtx = userContexts.get(msg.user_id) ?? ""; - const userProfileRef = (userProfiles.get(msg.user_id) ?? "").trim() + const userProfileRef = ( + userProfiles.get(msg.user_id)?.text ?? "" + ).trim() ? buildUserProfileRef(msg.user_id) : ""; const refXml = await buildReferenceXml(msg); - return `\n ${userCtx}${userProfileRef ? `\n ${userProfileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n ${escapeXml(content)}${webContext}${mediaEvidenceCtx}\n`; + const repetitionCount = groupMapping.get(msg.id)?.length ?? 1; + const isBot = resolveIsBot(msg); + const isEdited = resolveIsEdited(msg); + return ` 1 ? ` repetitions="${repetitionCount}"` : ""}${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${userCtx}${userProfileRef ? `\n ${userProfileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n ${escapeXml(content)}${webContext}${mediaEvidenceCtx}\n`; }), ) ).join("\n"); diff --git a/services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts b/services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts index 080b0a4..d7a71b8 100644 --- a/services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts @@ -37,10 +37,14 @@ import { } from "./mediaDownloader.js"; import { buildReferenceXml, + buildUserHistoryXml, buildUserProfileRef, escapeXml, + formatReputationAttrs, getAnalysisContent, resolveDisplayName, + resolveIsBot, + resolveIsEdited, truncateForAi, } from "./moderationBuilders.js"; import { @@ -56,7 +60,10 @@ import { } from "./searxngSearch.js"; import { extractUrlsFromText } from "./urlFetcher.js"; import { getUserProfile } from "./userProfileStore.js"; -import { initializeUserReputation } from "./userReputationStore.js"; +import { + getUserRecentInfractions, + initializeUserReputation, +} from "./userReputationStore.js"; // --------------------------------------------------------------------------- // Types @@ -376,6 +383,30 @@ export async function prepareMediaMessage( ? buildUserProfileRef(target.user_id) : ""; - const messageBlock = `\n ${profileRef ? `\n ${profileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n ${escapeXml(truncateForAi(content))}${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n`; + // Rich reputation — same shape as the text path: attrs + optional + // with the last flagged messages for repeat offenders. + const repAttrs = formatReputationAttrs(rep); + let repXml = ``; + if (rep.total_infractions > 0) { + try { + const history = await getUserRecentInfractions(target.user_id, 2); + const historyXml = buildUserHistoryXml( + history.map((h) => ({ + content: h.content ?? "", + severity: h.severity, + created_at: h.created_at, + })), + ); + if (historyXml) { + repXml = `\n${historyXml}\n`; + } + } catch { + // history is a bonus — fall back to attrs-only reputation + } + } + + const isBot = resolveIsBot(target); + const isEdited = resolveIsEdited(target); + const messageBlock = `\n ${repXml}${profileRef ? `\n ${profileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n ${escapeXml(truncateForAi(content))}${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n`; return { targetId, messageBlock }; } diff --git a/services/discord-gateway/src/modules/message-capture/messageMetadata.ts b/services/discord-gateway/src/modules/message-capture/messageMetadata.ts index e054150..49f0e98 100644 --- a/services/discord-gateway/src/modules/message-capture/messageMetadata.ts +++ b/services/discord-gateway/src/modules/message-capture/messageMetadata.ts @@ -9,6 +9,10 @@ export interface MessageLocation { threadId: string | null; threadName: string | null; channelName: string | null; + /** Channel topic (resmi/deskripsi channel) — strong context for judging + * whether a message fits the channel's purpose. Guarded: some channel + * types (threads on older API builds) expose no topic. */ + topic?: string | null; nsfw?: boolean; nsfwLevel?: string | null; ageRestricted?: boolean; @@ -107,12 +111,17 @@ export function getMessageLocation(message: Message): MessageLocation { nsfw?: boolean; nsfwLevel?: string | null; }; + const topic = + "topic" in channel && typeof channel.topic === "string" + ? channel.topic + : null; if (!channel.isThread?.()) { return { channelId: message.channelId, threadId: null, threadName: null, channelName: "name" in channel ? channel.name : null, + topic, nsfw: typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw @@ -133,6 +142,7 @@ export function getMessageLocation(message: Message): MessageLocation { threadId: channel.id, threadName: channel.name, channelName: channel.parent?.name ?? null, + topic, nsfw: typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw : undefined, nsfwLevel: diff --git a/services/discord-gateway/tests/contextEnrichment.test.ts b/services/discord-gateway/tests/contextEnrichment.test.ts new file mode 100644 index 0000000..b539fc2 --- /dev/null +++ b/services/discord-gateway/tests/contextEnrichment.test.ts @@ -0,0 +1,209 @@ +// ═══════════════════════════════════════════════════════════════════════════ +// Context enrichment builders — rich attrs, , +// as_of, bot/edited detection (pure, no DB) +// ═══════════════════════════════════════════════════════════════════════════ +import { describe, expect, it } from "vitest"; +import { + buildUserHistoryXml, + buildUserProfilesBlock, + formatReputationAttrs, + resolveIsBot, + resolveIsEdited, +} from "../src/modules/ai-moderation/moderationBuilders.js"; +import type { MessageRecord } from "../src/modules/message-capture/types.js"; + +const NOW = 1_800_000_000_000; + +function msg(overrides: Partial = {}): MessageRecord { + return { + id: "m1", + guild_id: "g1", + channel_id: "c1", + thread_id: null, + user_id: "u1", + username: "user1", + avatar_url: null, + content: "hai", + edited_content: null, + created_at: NOW, + edited_at: null, + deleted_at: null, + type: "text", + is_reply: null, + is_forward: null, + is_crosspost: null, + reference_message_id: null, + reference_channel_id: null, + reference_guild_id: null, + metadata: null, + ...overrides, + }; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe("formatReputationAttrs — rich reputation signal", () => { + it("emits trust, infraction count and clean streak", () => { + const attrs = formatReputationAttrs({ + trust_score: 62, + total_infractions: 3, + clean_message_streak: 45, + last_infraction_at: null, + }); + expect(attrs).toContain('trust_score="62"'); + expect(attrs).toContain('total_infractions="3"'); + expect(attrs).toContain('clean_streak="45"'); + }); + + it("derives last_offense_days_ago and marks repeat offenders (7-day window)", () => { + const attrs = formatReputationAttrs( + { + trust_score: 50, + total_infractions: 2, + clean_message_streak: 0, + last_infraction_at: NOW - 2 * DAY_MS, + }, + NOW, + ); + expect(attrs).toContain('last_offense_days_ago="2"'); + expect(attrs).toContain('repeat_offender="true"'); + }); + + it("does NOT mark repeat offender when the last offense is older than 7 days", () => { + const attrs = formatReputationAttrs( + { + trust_score: 50, + total_infractions: 2, + clean_message_streak: 10, + last_infraction_at: NOW - 30 * DAY_MS, + }, + NOW, + ); + expect(attrs).toContain('last_offense_days_ago="30"'); + expect(attrs).not.toContain("repeat_offender"); + }); + + it("omits offense-derived attrs when the user has no recorded infraction date", () => { + const attrs = formatReputationAttrs({ + trust_score: 85, + total_infractions: 0, + clean_message_streak: 120, + last_infraction_at: null, + }); + expect(attrs).not.toContain("last_offense_days_ago"); + expect(attrs).not.toContain("repeat_offender"); + }); + + it("clamps a future/skewed timestamp to days_ago=0", () => { + const attrs = formatReputationAttrs( + { + trust_score: 50, + total_infractions: 1, + clean_message_streak: 0, + last_infraction_at: NOW + 5 * DAY_MS, + }, + NOW, + ); + expect(attrs).toContain('last_offense_days_ago="0"'); + }); +}); + +describe("buildUserHistoryXml — last flagged messages for repeat offenders", () => { + it("returns empty when there is no real history", () => { + expect(buildUserHistoryXml([])).toBe(""); + expect( + buildUserHistoryXml([{ content: " ", severity: "low", created_at: 1 }]), + ).toBe(""); + }); + + it("renders rows with severity and recency", () => { + const xml = buildUserHistoryXml( + [ + { + content: "beli barang murah disini https://scam.example", + severity: "high", + created_at: NOW - 3 * DAY_MS, + }, + ], + NOW, + ); + expect(xml).toContain(""); + expect(xml).toContain('severity="high"'); + expect(xml).toContain('time_ago_days="3"'); + expect(xml).toContain("beli barang murah disini"); + }); + + it("caps long snippets and XML-escapes content", () => { + const xml = buildUserHistoryXml( + [ + { + content: "x".repeat(300), + severity: "low", + created_at: NOW - DAY_MS, + }, + ], + NOW, + ); + expect(xml.length).toBeLessThan(250); + }); +}); + +describe("buildUserProfilesBlock — deduplicated map with staleness", () => { + it("emits as_of when the profile has a last-generated timestamp", () => { + const block = buildUserProfilesBlock( + new Map([ + [ + "u1", + { + text: "Developer teknis, bahasa Indonesia", + asOf: NOW - 3 * DAY_MS, + }, + ], + ]), + ); + expect(block).toContain(' { + const block = buildUserProfilesBlock( + new Map([ + ["u1", { text: "profil aktif", asOf: null }], + ["u2", { text: " " }], + ]), + ); + expect(block).toContain('user_id="u1"'); + expect(block).not.toContain("as_of"); + expect(block).not.toContain("u2"); + }); + + it("returns empty for no profiles", () => { + expect(buildUserProfilesBlock(new Map())).toBe(""); + }); +}); + +describe("resolveIsBot / resolveIsEdited — message flags", () => { + it("reads author.bot from captured metadata", () => { + const bot = msg({ + metadata: JSON.stringify({ + author: { id: "x", username: "bot", bot: true }, + }), + }); + const human = msg({ + metadata: JSON.stringify({ + author: { id: "y", username: "user", bot: false }, + }), + }); + expect(resolveIsBot(bot)).toBe(true); + expect(resolveIsBot(human)).toBe(false); + expect(resolveIsBot(msg())).toBe(false); + }); + + it("flags edited content only when edited_content is present (the edit path)", () => { + expect(resolveIsEdited(msg({ edited_content: "versi baru" }))).toBe(true); + expect(resolveIsEdited(msg())).toBe(false); + }); +}); diff --git a/services/discord-gateway/tests/conversationContext.test.ts b/services/discord-gateway/tests/conversationContext.test.ts index 5f9c7e0..66234ac 100644 --- a/services/discord-gateway/tests/conversationContext.test.ts +++ b/services/discord-gateway/tests/conversationContext.test.ts @@ -197,6 +197,36 @@ describe("buildLocationContext — channel/thread/nsfw enrichment", () => { expect(line).toContain('age_restricted="false"'); }); + it("includes the channel topic (escaped) when captured", () => { + const t = target(); + t.metadata = JSON.stringify({ + channel: { + channelName: "rules", + topic: "Diskusi coding & programming — no self-promo", + nsfw: false, + }, + }); + const line = buildLocationContext([t]); + expect(line).toContain( + 'topic="Diskusi coding & programming — no self-promo"', + ); + }); + + it("caps an oversized topic and omits empty/absent topic", () => { + const t = target(); + t.metadata = JSON.stringify({ + channel: { channelName: "general", topic: "x".repeat(500), nsfw: false }, + }); + const line = buildLocationContext([t]); + const match = line.match(/topic="([^"]*)"/); + expect(match).not.toBeNull(); + expect(match?.[1].length).toBeLessThanOrEqual(201); + + const t2 = target(); + t2.metadata = JSON.stringify({ channel: { channelName: "general" } }); + expect(buildLocationContext([t2])).not.toContain("topic="); + }); + it("returns empty when no metadata", () => { expect(buildLocationContext([target()])).toBe(""); }); diff --git a/services/frontend/src/lib/types/message.ts b/services/frontend/src/lib/types/message.ts index 0752c23..9005baa 100644 --- a/services/frontend/src/lib/types/message.ts +++ b/services/frontend/src/lib/types/message.ts @@ -71,6 +71,9 @@ export interface ChannelRef { channelName?: string | null; threadId?: string | null; threadName?: string | null; + /** Channel topic (captured in gateway metadata.channel.topic). */ + topic?: string | null; + nsfw?: boolean; } export interface ReferenceInfo {