Compare commits

..

14 Commits

Author SHA1 Message Date
MythEclipse
81bb9cc6ab perf: maximize AI batches by token budget
Batch AI moderation by estimated token budget instead of fixed message count. Send as many messages as fit within an 80k token request budget while keeping one concurrent API request. Include message metadata and chronological conversation context so the model can judge provocation and replies from surrounding discussion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:42:28 +07:00
MythEclipse
4ff79bea73 chore: relax moderation prompt for casual chat
Remove unclear-message and low-quality-message warning criteria because this is a casual group. Keep short, ambiguous, informal, and light profanity messages clean unless they target someone or provoke conflict.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:36:59 +07:00
MythEclipse
9ff1261239 feat: merge warn and flagged messages into one review panel
Combine the right-side Warned and Flagged panels into a single Needs Review panel. Keep ordering by the existing newest-first message list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:33:48 +07:00
MythEclipse
bb7e3885ac chore: remove channel topic rule from moderation prompt
Remove the channel topic/OOT rule from AI moderation criteria and renumber the remaining rules. WARN criteria no longer includes OOT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:28:21 +07:00
MythEclipse
c31c4df15e docs: add detailed community rules to AI moderation prompt
- Expand system prompt with complete community rules (9 sections)
- Add specific examples for each rule category
- Clarify WARN vs FLAGGED decision criteria
- Include all prohibited content types and behaviors
- Provide clear guidance for AI analyzer on rule enforcement

Community rules now cover:
1. Jaga Sikap dan Hormati Sesama
2. Hindari Konflik
3. Gunakan Channel Sesuai Topik
4. Konten Eksplisit Dilarang
5. Jaga Privasi
6. Profil yang Sopan
7. Dilarang Spam dan Penipuan
8. Langsung ke Inti Pertanyaan
9. Diskusi Berkualitas

This ensures AI analyzer makes consistent moderation decisions based on actual community rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:24:19 +07:00
MythEclipse
93eb2303c7 feat: add warn category for minor rule violations
- Add "warn" status between "clean" and "flagged" for minor violations
- Update AI analyzer system prompt with community rules and warn category
- Warn: profanity, OOT, tone issues - requires warning but not deletion
- Flagged: NSFW, illegal, hacking, scam, harassment, violence, SARA - requires review/deletion
- Update types to support warn status in MessageRecord and AIAnalysisUpdate
- Update client UI to show three panels: All Messages, Warned, Flagged
- Warned messages show in right-top panel for quick review
- Flagged messages show in right-bottom panel for moderation action

This resolves:
- Need to distinguish between minor and severe violations
- Moderators can now warn users before taking action
- Better moderation workflow with three-tier system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:23:11 +07:00
MythEclipse
54a4096323 feat: add two-column text tab layout with flagged messages panel
- Split text tab into two columns: All Messages (left) and Flagged (right)
- Left panel shows all captured messages
- Right panel shows only AI-flagged messages for quick review
- Flagged panel auto-populates when messages are analyzed
- Improves moderation workflow by separating flagged content

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:16:53 +07:00
MythEclipse
1c945b9cac fix: trigger backlog sync when text channel is selected
- Call POST /api/backlog-sync when user selects a text channel
- Backlog sync now runs automatically on channel selection
- Fetches messages from last 24 hours for selected channel only
- Prevents empty message list on first channel selection

This resolves:
- Empty message list when selecting channel
- Backlog sync not being triggered
- Messages not loading until manual refresh

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:12:33 +07:00
MythEclipse
0060c4a097 feat: batch AI analysis messages for faster processing
- Change runLLMAnalysis to accept array of texts instead of single text
- Batch up to 5 messages per AI request instead of 1 message per request
- drainQueue now collects batch before sending to AI API
- Reduces API calls by 5x and speeds up analysis significantly
- System prompt updated to handle batch JSON array responses

This resolves:
- Slow AI analysis (3 messages every 15 seconds)
- Too many API calls (one per message)
- Long queue backlog

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:08:41 +07:00
MythEclipse
5aa57f884f feat: add API endpoint for syncing selected channel backlog 2026-05-14 04:02:25 +07:00
MythEclipse
d5977c8845 fix: handle streaming JSON response from AI LLM API
- Fix fetchJson to extract JSON from streaming response text
- API returns text/event-stream with complete JSON object embedded
- Extract JSON by finding first { and last } in response
- Prevents "Unexpected non-whitespace character after JSON" parse errors
- Streaming response now properly parsed and analyzed

This resolves:
- AI analysis stuck on "[Streaming in progress...]"
- JSON parse failures on streaming responses
- AI analysis now completes successfully

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:00:31 +07:00
MythEclipse
6dc6a31ea7 fix: enforce max 1 concurrent AI LLM request
- Add activeRequests counter to track in-flight AI requests
- Limit concurrent requests to 1 (MAX_CONCURRENT_REQUESTS)
- drainQueue now waits if at max concurrency before processing next message
- Prevents overwhelming streaming LLM API with multiple concurrent requests

This resolves:
- AI LLM API overload from concurrent requests
- Streaming response conflicts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:54:12 +07:00
MythEclipse
54534fe84c perf: parallelize backlog sync with concurrency limit
- Sync channels in parallel with concurrency limit of 3 instead of sequentially
- Reduces backlog sync time from O(n) to O(n/3) for n channels
- Prevents overwhelming Discord API with too many concurrent requests
- Maintains per-channel message batching for memory efficiency

This resolves:
- Slow backlog sync performance
- Sequential channel processing bottleneck

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:52:21 +07:00
MythEclipse
eb27d36cce fix: remove Picser upload, use Discord URLs directly
- Skip attachment download/upload to Picser (was failing with 400 errors)
- Store Discord's original attachment URLs directly as uploaded_url
- Mark attachments as immediately uploaded with Discord URL
- Remove processAttachmentUpload call and unused attachmentUploader import
- Eliminates slow upload cycle and API failures

This resolves:
- Attachment upload 400 errors
- Performance slowdown from failed upload retries
- Unnecessary network overhead

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:51:48 +07:00
7 changed files with 281 additions and 168 deletions

View File

@@ -47,7 +47,7 @@
<div class="content-card" style="margin-top:18px"><div class="card-title"><h2>Participants</h2><span class="mini">speaking now</span></div><div id="userList" class="participants"></div></div>
</section>
<section id="text" class="tab-content"><div class="content-card"><div class="card-title"><h2>Text Watch</h2><span class="mini">create / edit / delete</span></div><div id="textList" class="feed"><div class="empty">Select channel to view text captures</div></div></div></section>
<section id="text" class="tab-content"><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;height:100%"><div class="content-card"><div class="card-title"><h2>All Messages</h2><span class="mini">all captures</span></div><div id="textList" class="feed"><div class="empty">Select channel to view text captures</div></div></div><div class="content-card"><div class="card-title"><h2>Needs Review</h2><span class="mini">warn + flagged</span></div><div id="reviewList" class="feed"><div class="empty">No warned or flagged messages</div></div></div></div></section>
</main>
<script>
@@ -71,7 +71,7 @@
const SAMPLE_RATE = 24000;
const CHANNELS = 1;
const el = {
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), guildSelect: document.getElementById('guildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList')
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), guildSelect: document.getElementById('guildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList'), reviewList: document.getElementById('reviewList')
};
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
const bars = [...document.querySelectorAll('.bar')];
@@ -110,7 +110,15 @@
el.channelSelect.value = state.selectedVoiceChannel;
el.channelFilter.value = state.selectedTextChannel;
applyActiveTab(state.activeTab);
if (textChanged || state.activeTab === 'text') await fetchText().catch((error) => showError(error.message));
if (textChanged || state.activeTab === 'text') {
if (state.selectedTextChannel && state.selectedGuild) {
await apiRequest('/api/backlog-sync', {
method: 'POST',
body: JSON.stringify({ guildId: state.selectedGuild, channelId: state.selectedTextChannel }),
}).catch((error) => logger.warn('Backlog sync failed:', error.message));
}
await fetchText().catch((error) => showError(error.message));
}
await reconcileListenState();
await reconcileStreamingState();
state.applyingServerState = false;
@@ -122,7 +130,7 @@
function renderUsers(users) { el.userList.replaceChildren(); if (users.length === 0) return appendEmpty(el.userList, 'No active speakers'); for (const user of users) { const row = document.createElement('div'); row.className = `user-item${user.speaking ? ' speaking' : ''}`; const img = document.createElement('img'); img.src = user.avatar || ''; img.alt = ''; const name = document.createElement('span'); name.textContent = user.username; row.append(img, name); el.userList.appendChild(row); } }
async function fetchText() { if (!state.selectedTextChannel) return renderText(); const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedTextChannel)}&type=text&limit=80`); state.text = result.data || []; renderText(); }
function renderText() { el.textList.replaceChildren(); if (!state.selectedTextChannel) return appendEmpty(el.textList, 'Select channel to view text captures'); if (state.text.length === 0) return appendEmpty(el.textList, 'No text captures yet'); for (const msg of state.text) { const metadata = parseMetadata(msg.metadata); const card = document.createElement('article'); card.className = 'event-card'; const head = document.createElement('div'); head.className = 'event-head'; const author = document.createElement('div'); author.className = 'author'; const avatar = document.createElement('div'); avatar.className = 'avatar'; if (msg.avatar_url) { const img = document.createElement('img'); img.src = msg.avatar_url; img.alt = ''; avatar.appendChild(img); } const name = document.createElement('div'); name.className = 'name'; name.textContent = msg.username || msg.user_id; author.append(avatar, name); const time = document.createElement('div'); time.className = 'time'; time.textContent = new Date(msg.created_at).toLocaleString(); head.append(author, time); const text = document.createElement('div'); text.className = 'message-text'; text.textContent = msg.edited_content || msg.content || '(empty message)'; card.append(head, text); appendAIAnalysis(card, msg); appendMedia(card, metadata); const badges = document.createElement('div'); badges.className = 'badges'; if (metadata.reference?.messageId) appendBadge(badges, 'reply', ''); if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', ''); if (msg.edited_at) appendBadge(badges, 'edited', 'edit'); if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete'); card.appendChild(badges); el.textList.appendChild(card); } }
function renderText() { el.textList.replaceChildren(); el.reviewList.replaceChildren(); if (!state.selectedTextChannel) { appendEmpty(el.textList, 'Select channel to view text captures'); appendEmpty(el.reviewList, 'No warned or flagged messages'); return; } if (state.text.length === 0) { appendEmpty(el.textList, 'No text captures yet'); appendEmpty(el.reviewList, 'No warned or flagged messages'); return; } const reviewMessages = []; for (const msg of state.text) { const metadata = parseMetadata(msg.metadata); const card = document.createElement('article'); card.className = 'event-card'; const head = document.createElement('div'); head.className = 'event-head'; const author = document.createElement('div'); author.className = 'author'; const avatar = document.createElement('div'); avatar.className = 'avatar'; if (msg.avatar_url) { const img = document.createElement('img'); img.src = msg.avatar_url; img.alt = ''; avatar.appendChild(img); } const name = document.createElement('div'); name.className = 'name'; name.textContent = msg.username || msg.user_id; author.append(avatar, name); const time = document.createElement('div'); time.className = 'time'; time.textContent = new Date(msg.created_at).toLocaleString(); head.append(author, time); const text = document.createElement('div'); text.className = 'message-text'; text.textContent = msg.edited_content || msg.content || '(empty message)'; card.append(head, text); appendAIAnalysis(card, msg); appendMedia(card, metadata); const badges = document.createElement('div'); badges.className = 'badges'; if (metadata.reference?.messageId) appendBadge(badges, 'reply', ''); if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', ''); if (msg.edited_at) appendBadge(badges, 'edited', 'edit'); if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete'); card.appendChild(badges); el.textList.appendChild(card); if (msg.ai_status === 'warn' || msg.ai_status === 'flagged') reviewMessages.push(card.cloneNode(true)); } if (reviewMessages.length === 0) { appendEmpty(el.reviewList, 'No warned or flagged messages'); } else { reviewMessages.forEach((card) => el.reviewList.appendChild(card)); } }
function appendAIAnalysis(card, msg) { const status = msg.ai_status || 'pending'; const wrap = document.createElement('div'); wrap.className = 'badges'; const badge = document.createElement('span'); badge.className = `badge ${status === 'flagged' ? 'delete' : status === 'clean' ? 'edit' : ''}`; badge.textContent = `AI: ${status}`; wrap.appendChild(badge); if (msg.ai_moderation_flags) { const flags = document.createElement('span'); flags.className = 'badge delete'; try { flags.textContent = JSON.parse(msg.ai_moderation_flags).join(', '); } catch { flags.textContent = msg.ai_moderation_flags; } wrap.appendChild(flags); } card.appendChild(wrap); if (msg.ai_analysis) { const analysis = document.createElement('div'); analysis.className = 'embed-description'; analysis.textContent = msg.ai_analysis; card.appendChild(analysis); } if (msg.ai_error) { const error = document.createElement('div'); error.className = 'embed-description'; error.textContent = `AI error: ${msg.ai_error}`; card.appendChild(error); } }
function appendMedia(card, metadata) { const stickers = document.createElement('div'); stickers.className = 'sticker-strip'; for (const sticker of metadata.stickers || []) { const img = document.createElement('img'); img.className = 'sticker-img'; img.src = sticker.url; img.alt = sticker.name; stickers.appendChild(img); } if (stickers.childElementCount) card.appendChild(stickers); const embeds = document.createElement('div'); embeds.className = 'feed'; for (const embed of metadata.embeds || []) { const item = document.createElement('div'); item.className = 'embed-card'; if (embed.title) { const title = document.createElement(embed.url ? 'a' : 'div'); title.className = 'embed-title'; title.textContent = embed.title; if (embed.url) { title.href = embed.url; title.target = '_blank'; title.rel = 'noreferrer'; } item.appendChild(title); } if (embed.description) { const desc = document.createElement('div'); desc.className = 'embed-description'; desc.textContent = embed.description; item.appendChild(desc); } if (embed.image || embed.thumbnail) { const img = document.createElement('img'); img.className = 'embed-image'; img.src = embed.image || embed.thumbnail; img.alt = embed.title || 'embed image'; item.appendChild(img); } embeds.appendChild(item); } if (embeds.childElementCount) card.appendChild(embeds); const attachments = document.createElement('div'); attachments.className = 'attachment-strip'; for (const attachment of metadata.attachments || []) { const link = document.createElement('a'); link.className = 'attachment-chip'; link.href = attachment.url; link.target = '_blank'; link.rel = 'noreferrer'; link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`; attachments.appendChild(link); } if (attachments.childElementCount) card.appendChild(attachments); }

View File

@@ -8,6 +8,10 @@ import type { MessageRecord } from "./types";
const logger = createChildLogger("ai-analyzer");
const queuedMessageIds = new Set<string>();
let isProcessing = false;
let activeRequests = 0;
const MAX_CONCURRENT_REQUESTS = 1;
const MAX_AI_REQUEST_TOKENS = 80_000;
const AI_PROMPT_TOKEN_RESERVE = 6_000;
interface ChatCompletionResponse {
choices?: Array<{
@@ -18,7 +22,7 @@ interface ChatCompletionResponse {
}
interface LLMAnalysis {
status: "clean" | "flagged";
status: "clean" | "warn" | "flagged";
flags: string[];
score: number;
analysis: string;
@@ -28,20 +32,47 @@ function getAnalysisText(message: MessageRecord): string {
return (message.edited_content || message.content || "").trim();
}
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
function formatMessageForAnalysis(message: MessageRecord, index: number): string {
const text = getAnalysisText(message);
const time = new Date(message.created_at).toISOString();
return `${index + 1}. id=${message.id} time=${time} user=${message.username}: ${text}`;
}
function estimateMessageTokens(message: MessageRecord): number {
return estimateTokens(formatMessageForAnalysis(message, 0)) + 16;
}
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.AI_ANALYSIS_TIMEOUT_MS);
try {
const response = await fetch(url, { ...init, signal: controller.signal });
const body = await response.json().catch(() => ({}));
const text = await response.text();
if (!response.ok) {
const message = typeof body === "object" && body && "error" in body
? JSON.stringify(body)
: response.statusText;
const message = text.includes("{")
? JSON.stringify(JSON.parse(text.substring(text.indexOf("{"))))
: text;
throw new Error(`AI request failed (${response.status}): ${message}`);
}
return body;
// Handle streaming response: extract JSON from response text
const jsonStart = text.indexOf("{");
const jsonEnd = text.lastIndexOf("}");
if (jsonStart >= 0 && jsonEnd > jsonStart) {
try {
return JSON.parse(text.substring(jsonStart, jsonEnd + 1));
} catch {
// Fall through to parse full text
}
}
return JSON.parse(text);
} finally {
clearTimeout(timeout);
}
@@ -53,7 +84,7 @@ function parseLLMAnalysis(content: string): LLMAnalysis {
if (jsonStart >= 0 && jsonEnd > jsonStart) {
try {
const parsed = JSON.parse(content.slice(jsonStart, jsonEnd + 1));
const status = parsed.status === "flagged" ? "flagged" : "clean";
const status = parsed.status === "flagged" ? "flagged" : parsed.status === "warn" ? "warn" : "clean";
const flags = Array.isArray(parsed.flags) ? parsed.flags.map(String) : [];
const score = Math.max(0, Math.min(1, Number(parsed.score) || 0));
const analysis = typeof parsed.analysis === "string" ? parsed.analysis : content;
@@ -64,14 +95,14 @@ function parseLLMAnalysis(content: string): LLMAnalysis {
}
return {
status: /flagged|bahaya|berisiko|toxic|hate|harassment|violence|sexual|self-harm/i.test(content) ? "flagged" : "clean",
status: /flagged|bahaya|berisiko|toxic|hate|harassment|violence|sexual|self-harm|illegal|scam|hacking/i.test(content) ? "flagged" : /warn|provokasi|hinaan|menyerang/i.test(content) ? "warn" : "clean",
flags: [],
score: 0,
analysis: content.trim() || "Tidak ada analisis dari LLM.",
};
}
async function runLLMAnalysis(text: string): Promise<{ result: LLMAnalysis; raw: unknown }> {
async function runLLMAnalysis(messages: MessageRecord[]): Promise<{ results: LLMAnalysis[]; raw: unknown }> {
const response = await retryWithBackoff(
() => fetchJson(`${config.AI_LLM_BASE_URL}/chat/completions`, {
method: "POST",
@@ -84,31 +115,119 @@ async function runLLMAnalysis(text: string): Promise<{ result: LLMAnalysis; raw:
messages: [
{
role: "system",
content: "Kamu analis moderation Discord. Nilai pesan untuk toxic, harassment, hate, violence, sexual, self-harm, spam, scam, atau unsafe content. Balas JSON valid saja dengan schema: {\"status\":\"clean|flagged\",\"flags\":[\"...\"],\"score\":0..1,\"analysis\":\"ringkasan singkat Bahasa Indonesia + alasan + aksi disarankan\"}.",
content: `Kamu moderator Discord komunitas. Analisis setiap pesan dengan 3 kategori:
- CLEAN: Pesan normal, tidak melanggar aturan
- WARN: Melanggar aturan minor yang menarget orang lain (tone menyerang, hinaan ringan, konflik kecil) - butuh peringatan tapi tidak dihapus
- FLAGGED: Melanggar aturan berat (NSFW, ilegal, hacking, scam, harassment, violence, SARA, gore, spam, promosi judi) - butuh review moderator untuk penghapusan
ATURAN KOMUNITAS LENGKAP:
1. JAGA SIKAP DAN HORMATI SESAMA
- Gunakan bahasa yang sopan dan menghormati semua anggota
- Tanpa memandang latar belakang, usia, gender, atau pandangan
- Dilarang keras: pelecehan, rasisme, seksisme, diskriminasi
2. HINDARI KONFLIK
- Dilarang memancing keributan atau drama
- Jika ada masalah personal, selesaikan secara pribadi
- Jangan melibatkan anggota lain di channel umum
3. KONTEN EKSPLISIT DILARANG
- Dilarang keras: NSFW, ilegal, pornografi, kekerasan (gore), SARA
- Tidak ada tempat untuk penyimpangan atau LGBT
- Tidak ada promosi aktivitas atau ideologi LGBT
4. JAGA PRIVASI
- Dilarang menyebarkan informasi pribadi milik anggota lain tanpa izin
5. PROFIL YANG SOPAN
- Username, foto profil, dan server tag harus pantas
- Jangan gunakan unsur ofensif atau vulgar
6. DILARANG SPAM DAN PENIPUAN
- Dilarang: hoaks, link berbahaya (phishing/scam), spam
- Dilarang: promosi, judi, link referral
7. DISKUSI BERKUALITAS
- Berikan jawaban yang relevan, akurat, dan tidak menyesatkan
- Di channel "Area Serius", pertahankan standar tinggi
KONTEKS KOMUNITAS:
- Ini grup bercanda/santai, jadi slang, candaan ringan, kata kasar ringan tanpa target, pesan pendek seperti "." atau "P", dan pertanyaan tidak jelas tetap CLEAN
- Jangan beri WARN hanya karena pesan singkat, informal, ambigu, low-quality, atau kurang konteks
- Pahami alur pembahasan antar pesan: pesan yang sendiri terlihat normal bisa WARN/FLAGGED jika dalam konteks percakapan sedang memancing konflik, menormalisasi pelanggaran, atau melanjutkan provokasi
- Jangan menghukum orang yang sedang menasehati, menjelaskan bahaya, mengutip, atau menolak tindakan buruk; nilai maksud dan konteksnya
- WARN hanya jika ada orang/kelompok yang diserang, dihina, diprovokasi, atau konflik mulai dipancing
PENENTUAN STATUS:
- WARN jika: hinaan ringan yang menarget orang/kelompok, provokasi konflik kecil, username/profil kurang pantas
- FLAGGED jika: profanity berat, harassment, threats, violence, illegal activity, hacking, scam, NSFW, SARA, gore, spam, judi, LGBT content
Balas JSON array dengan schema: [{"status":"clean|warn|flagged","flags":["..."],"score":0..1,"analysis":"ringkasan Bahasa Indonesia + alasan + aksi disarankan"}]
Satu JSON object per pesan dalam array.`,
},
{
role: "user",
content: text,
content: `Analisis ${messages.length} pesan berikut sebagai satu alur percakapan. Tetap kembalikan satu hasil per pesan dengan urutan yang sama:\n${messages.map(formatMessageForAnalysis).join("\n")}`,
},
],
temperature: 0.2,
}),
signal: AbortSignal.timeout(config.AI_ANALYSIS_TIMEOUT_MS),
}),
{ retries: 2, logger },
) as ChatCompletionResponse;
const content = response.choices?.[0]?.message?.content?.trim() || "";
return { result: parseLLMAnalysis(content), raw: response };
// Extract JSON array from response
const jsonStart = content.indexOf("[");
const jsonEnd = content.lastIndexOf("]");
let results: LLMAnalysis[] = [];
if (jsonStart >= 0 && jsonEnd > jsonStart) {
try {
const parsed = JSON.parse(content.substring(jsonStart, jsonEnd + 1));
if (Array.isArray(parsed)) {
results = parsed.map((item: any) => {
const status = item.status === "flagged" ? "flagged" : item.status === "warn" ? "warn" : "clean";
return {
status,
flags: Array.isArray(item.flags) ? item.flags.map(String) : [],
score: Math.max(0, Math.min(1, Number(item.score) || 0)),
analysis: typeof item.analysis === "string" ? item.analysis : content,
};
});
}
} catch {
// Fall through to individual parsing
}
}
async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Promise<void> {
const text = getAnalysisText(message);
if (!config.AI_ANALYSIS_ENABLED || text.length === 0) return;
// If batch parsing failed, parse as individual responses
if (results.length === 0) {
results = messages.map(() => parseLLMAnalysis(content));
}
return { results, raw: response };
}
async function analyzeAndStoreBatch(db: SqliteDatabase, messages: MessageRecord[]): Promise<void> {
if (messages.length === 0) return;
const analyzableMessages = messages.filter((message) => getAnalysisText(message).length > 0);
if (analyzableMessages.length === 0) return;
activeRequests++;
try {
const { result, raw } = await runLLMAnalysis(text);
const { results, raw } = await runLLMAnalysis(analyzableMessages);
for (let i = 0; i < analyzableMessages.length; i++) {
const message = analyzableMessages[i];
const result = results[i] || parseLLMAnalysis("");
const row = updateMessageAIAnalysis(db, message.id, {
status: result.status,
status: result.status as "pending" | "clean" | "warn" | "flagged" | "error",
flags: JSON.stringify(result.flags),
score: result.score,
raw: JSON.stringify(raw),
@@ -117,7 +236,10 @@ async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Prom
error: null,
});
if (row) (globalThis as any).broadcastMessageAnalyzed?.(row);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
for (const message of analyzableMessages) {
const row = updateMessageAIAnalysis(db, message.id, {
status: "error",
flags: null,
@@ -125,10 +247,13 @@ async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Prom
raw: null,
analysis: null,
analyzedAt: Date.now(),
error: error instanceof Error ? error.message : String(error),
error: errorMsg,
});
if (row) (globalThis as any).broadcastMessageAnalyzed?.(row);
logger.warn({ messageId: message.id, error }, "AI analysis failed");
}
logger.warn({ count: messages.length, error }, "AI batch analysis failed");
} finally {
activeRequests--;
}
}
@@ -136,12 +261,34 @@ async function drainQueue(db: SqliteDatabase): Promise<void> {
if (isProcessing) return;
isProcessing = true;
try {
const batchTokenLimit = MAX_AI_REQUEST_TOKENS - AI_PROMPT_TOKEN_RESERVE;
while (queuedMessageIds.size > 0) {
const messageId = queuedMessageIds.values().next().value as string | undefined;
if (!messageId) break;
queuedMessageIds.delete(messageId);
while (activeRequests >= MAX_CONCURRENT_REQUESTS) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
const batch: MessageRecord[] = [];
let tokenEstimate = 0;
for (const messageId of Array.from(queuedMessageIds)) {
const message = getMessageById(db, messageId);
if (message) await analyzeAndStore(db, message);
queuedMessageIds.delete(messageId);
if (!message) continue;
const messageTokens = estimateMessageTokens(message);
if (batch.length > 0 && tokenEstimate + messageTokens > batchTokenLimit) {
queuedMessageIds.add(messageId);
break;
}
batch.push(message);
tokenEstimate += messageTokens;
}
if (batch.length > 0) {
logger.info({ count: batch.length, tokenEstimate }, "Processing AI analysis batch");
await analyzeAndStoreBatch(db, batch);
}
}
} finally {
isProcessing = false;
@@ -166,7 +313,7 @@ export function startPendingAIAnalysisWorker(db: SqliteDatabase): void {
logger.info("AI analysis worker started");
setInterval(() => {
if (isProcessing) return;
const pendingMessages = getPendingAIAnalysisMessages(db, 3);
const pendingMessages = getPendingAIAnalysisMessages(db, 500);
if (pendingMessages.length === 0) return;
logger.info({ count: pendingMessages.length }, "Queueing pending AI analysis messages");
for (const message of pendingMessages) {

View File

@@ -6,65 +6,6 @@ import { captureMessage } from "./messageCapture";
const logger = createChildLogger("backlog-sync");
function isWatchableChannel(channel: { type?: string; messages?: unknown }): boolean {
return Boolean(
channel.messages &&
["GUILD_TEXT", "GUILD_PUBLIC_THREAD", "GUILD_PRIVATE_THREAD"].includes(
channel.type ?? "",
),
);
}
async function collectWatchableChannels(guild: any): Promise<any[]> {
const channels: any[] = [];
// Fast pass: collect text channels from cache only
for (const channel of guild.channels.cache.values()) {
if (isWatchableChannel(channel)) {
channels.push(channel);
}
}
// Slow pass: discover threads with timeout per channel (non-blocking to message sync)
const threadPromises: Promise<void>[] = [];
for (const channel of guild.channels.cache.values()) {
if (!channel.threads?.fetch) continue;
threadPromises.push(
(async () => {
for (const archived of [false, true]) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const fetched = await Promise.race([
channel.threads.fetch({ archived, limit: 100 }),
new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('timeout')))),
]).catch(() => null);
clearTimeout(timeout);
if (!fetched?.threads) continue;
for (const thread of fetched.threads.values()) {
if (isWatchableChannel(thread)) channels.push(thread);
}
} catch {
// Skip this channel's threads on timeout/error
}
}
})()
);
}
// Wait for all thread discoveries with overall timeout
await Promise.race([
Promise.all(threadPromises),
new Promise((resolve) => setTimeout(resolve, 30000)),
]).catch(() => {
logger.warn("Thread discovery timeout, proceeding with cached channels");
});
return Array.from(new Map(channels.map((channel) => [channel.id, channel])).values());
}
async function syncChannelMessages(
db: SqliteDatabase,
channel: any,
@@ -116,45 +57,45 @@ export async function syncBacklogMessages(
return;
}
logger.info({ guildId: guild.id }, "Backlog sync ready (will sync on-demand per selected channel)");
}
export async function syncSelectedChannelBacklog(
client: Client,
db: SqliteDatabase,
guildId: string,
channelId: string,
): Promise<number> {
const guild = client.guilds.cache.get(guildId);
if (!guild) {
logger.warn({ guildId }, "Guild not found for backlog sync");
return 0;
}
const channel = guild.channels.cache.get(channelId);
if (!channel) {
logger.warn({ guildId, channelId }, "Channel not found for backlog sync");
return 0;
}
const cutoffTime = Date.now() - config.BACKLOG_SYNC_HOURS * 60 * 60 * 1000;
logger.info(
{ guildId: guild.id, hours: config.BACKLOG_SYNC_HOURS },
"Starting message backlog sync",
{ guildId, channelId, hours: config.BACKLOG_SYNC_HOURS },
"Starting backlog sync for selected channel",
);
logger.info({ guildId: guild.id }, "Fetching guild channels for backlog sync");
await guild.channels.fetch().catch((error) => {
logger.warn(
{ guildId: guild.id, error: error instanceof Error ? error.message : String(error) },
"Failed to fetch guild channels before backlog sync",
);
return null;
});
logger.info({ guildId: guild.id }, "Collecting watchable channels for backlog sync");
const channels = await collectWatchableChannels(guild);
let total = 0;
logger.info(
{ guildId: guild.id, channels: channels.length, hours: config.BACKLOG_SYNC_HOURS },
"Watchable channels collected for backlog sync",
);
for (const channel of channels) {
try {
const count = await syncChannelMessages(db, channel as any, cutoffTime);
total += count;
logger.info({ channelId: channel.id, count }, "Backlog channel sync completed");
logger.info({ channelId, count }, "Backlog sync completed for selected channel");
return count;
} catch (error) {
logger.warn(
{
channelId: channel.id,
channelId,
error: error instanceof Error ? error.message : String(error),
},
"Backlog channel sync failed",
"Backlog sync failed for selected channel",
);
return 0;
}
}
logger.info({ total }, "Message backlog sync completed");
}

View File

@@ -1,9 +1,8 @@
import type { Client, Message } from "discord.js-selfbot-v13";
import { createChildLogger } from "../logger";
import type { SqliteDatabase } from "../muxer-queue";
import { config } from "../config";
import type { SqliteDatabase } from "../muxer-queue";
import { insertMessage, insertAttachment } from "./messageStore";
import { processAttachmentUpload } from "./attachmentUploader";
import { getDisplayContent, getMessageLocation, getMessageMetadata } from "./messageMetadata";
import { queueMessageAnalysis } from "./aiAnalyzer";
import type { MessageRecord, AttachmentRecord } from "./types";
@@ -59,17 +58,15 @@ export async function captureMessage(
size: attachment.size,
type: attachment.contentType || "application/octet-stream",
discord_url: attachment.url,
uploaded_url: null,
upload_status: "pending",
uploaded_url: attachment.url,
upload_status: "uploaded",
upload_error: null,
created_at: Date.now(),
uploaded_at: null,
uploaded_at: Date.now(),
};
insertAttachment(db, attachmentRecord);
processAttachmentUpload(db, attachment.id, attachment.url, attachment.name || "unknown")
.then(() => {
if (broadcaster.broadcastAttachmentUploaded) {
broadcaster.broadcastAttachmentUploaded({
id: attachment.id,
@@ -79,13 +76,6 @@ export async function captureMessage(
created_at: Date.now(),
});
}
})
.catch((error) => {
logger.error(
{ attachmentId: attachment.id, error: error instanceof Error ? error.message : String(error) },
"Background attachment upload failed",
);
});
}
}

View File

@@ -222,7 +222,7 @@ export function updateAttachmentAsFailedUpload(
}
interface AIAnalysisUpdate {
status: "pending" | "clean" | "flagged" | "error";
status: "pending" | "clean" | "warn" | "flagged" | "error";
flags?: string | null;
score?: number | null;
raw?: string | null;

View File

@@ -13,7 +13,7 @@ export interface MessageRecord {
deleted_at: number | null;
type: "text" | "edited" | "deleted";
metadata: string | null;
ai_status?: "pending" | "clean" | "flagged" | "error" | null;
ai_status?: "pending" | "clean" | "warn" | "flagged" | "error" | null;
ai_moderation_flags?: string | null;
ai_moderation_score?: number | null;
ai_moderation_raw?: string | null;

View File

@@ -12,6 +12,7 @@ import { discordPlayer } from "./player";
import type { VoiceController } from "./voiceController";
import { getDatabase, getPersistedValue, setPersistedValue } from "./muxer-queue";
import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore";
import { syncSelectedChannelBacklog } from "./moderation/backlogSync";
const wsLogger = createChildLogger("webserver");
@@ -277,6 +278,32 @@ export function startWebserver(
}
});
app.post("/api/backlog-sync", async (req, res, next) => {
try {
const { guildId, channelId } = req.body as {
guildId?: string;
channelId?: string;
};
if (!guildId || !channelId) {
throw new AppError(
"guildId and channelId are required",
"MISSING_BACKLOG_PARAMS",
400,
);
}
const count = await syncSelectedChannelBacklog(_client, getDatabase(), guildId, channelId);
res.json({
success: true,
channelId,
messagesSync: count,
});
} catch (error) {
next(error);
}
});
// Inbound: Discord PCM → tagged chunks → browser
(global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => {
let hash = 0;