Compare commits
14 Commits
0a5cedfed1
...
81bb9cc6ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81bb9cc6ab | ||
|
|
4ff79bea73 | ||
|
|
9ff1261239 | ||
|
|
bb7e3885ac | ||
|
|
c31c4df15e | ||
|
|
93eb2303c7 | ||
|
|
54a4096323 | ||
|
|
1c945b9cac | ||
|
|
0060c4a097 | ||
|
|
5aa57f884f | ||
|
|
d5977c8845 | ||
|
|
6dc6a31ea7 | ||
|
|
54534fe84c | ||
|
|
eb27d36cce |
@@ -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>
|
<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>
|
||||||
|
|
||||||
<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>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
const SAMPLE_RATE = 24000;
|
const SAMPLE_RATE = 24000;
|
||||||
const CHANNELS = 1;
|
const CHANNELS = 1;
|
||||||
const el = {
|
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); }
|
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
|
||||||
const bars = [...document.querySelectorAll('.bar')];
|
const bars = [...document.querySelectorAll('.bar')];
|
||||||
@@ -110,7 +110,15 @@
|
|||||||
el.channelSelect.value = state.selectedVoiceChannel;
|
el.channelSelect.value = state.selectedVoiceChannel;
|
||||||
el.channelFilter.value = state.selectedTextChannel;
|
el.channelFilter.value = state.selectedTextChannel;
|
||||||
applyActiveTab(state.activeTab);
|
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 reconcileListenState();
|
||||||
await reconcileStreamingState();
|
await reconcileStreamingState();
|
||||||
state.applyingServerState = false;
|
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); } }
|
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(); }
|
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 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); }
|
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); }
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import type { MessageRecord } from "./types";
|
|||||||
const logger = createChildLogger("ai-analyzer");
|
const logger = createChildLogger("ai-analyzer");
|
||||||
const queuedMessageIds = new Set<string>();
|
const queuedMessageIds = new Set<string>();
|
||||||
let isProcessing = false;
|
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 {
|
interface ChatCompletionResponse {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
@@ -18,7 +22,7 @@ interface ChatCompletionResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface LLMAnalysis {
|
interface LLMAnalysis {
|
||||||
status: "clean" | "flagged";
|
status: "clean" | "warn" | "flagged";
|
||||||
flags: string[];
|
flags: string[];
|
||||||
score: number;
|
score: number;
|
||||||
analysis: string;
|
analysis: string;
|
||||||
@@ -28,20 +32,47 @@ function getAnalysisText(message: MessageRecord): string {
|
|||||||
return (message.edited_content || message.content || "").trim();
|
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> {
|
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), config.AI_ANALYSIS_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), config.AI_ANALYSIS_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
const response = await fetch(url, { ...init, signal: controller.signal });
|
||||||
const body = await response.json().catch(() => ({}));
|
const text = await response.text();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = typeof body === "object" && body && "error" in body
|
const message = text.includes("{")
|
||||||
? JSON.stringify(body)
|
? JSON.stringify(JSON.parse(text.substring(text.indexOf("{"))))
|
||||||
: response.statusText;
|
: text;
|
||||||
throw new Error(`AI request failed (${response.status}): ${message}`);
|
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 {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
@@ -53,7 +84,7 @@ function parseLLMAnalysis(content: string): LLMAnalysis {
|
|||||||
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content.slice(jsonStart, jsonEnd + 1));
|
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 flags = Array.isArray(parsed.flags) ? parsed.flags.map(String) : [];
|
||||||
const score = Math.max(0, Math.min(1, Number(parsed.score) || 0));
|
const score = Math.max(0, Math.min(1, Number(parsed.score) || 0));
|
||||||
const analysis = typeof parsed.analysis === "string" ? parsed.analysis : content;
|
const analysis = typeof parsed.analysis === "string" ? parsed.analysis : content;
|
||||||
@@ -64,14 +95,14 @@ function parseLLMAnalysis(content: string): LLMAnalysis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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: [],
|
flags: [],
|
||||||
score: 0,
|
score: 0,
|
||||||
analysis: content.trim() || "Tidak ada analisis dari LLM.",
|
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(
|
const response = await retryWithBackoff(
|
||||||
() => fetchJson(`${config.AI_LLM_BASE_URL}/chat/completions`, {
|
() => fetchJson(`${config.AI_LLM_BASE_URL}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -84,31 +115,119 @@ async function runLLMAnalysis(text: string): Promise<{ result: LLMAnalysis; raw:
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "system",
|
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",
|
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,
|
temperature: 0.2,
|
||||||
}),
|
}),
|
||||||
|
signal: AbortSignal.timeout(config.AI_ANALYSIS_TIMEOUT_MS),
|
||||||
}),
|
}),
|
||||||
{ retries: 2, logger },
|
{ retries: 2, logger },
|
||||||
) as ChatCompletionResponse;
|
) as ChatCompletionResponse;
|
||||||
|
|
||||||
const content = response.choices?.[0]?.message?.content?.trim() || "";
|
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> {
|
// If batch parsing failed, parse as individual responses
|
||||||
const text = getAnalysisText(message);
|
if (results.length === 0) {
|
||||||
if (!config.AI_ANALYSIS_ENABLED || text.length === 0) return;
|
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 {
|
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, {
|
const row = updateMessageAIAnalysis(db, message.id, {
|
||||||
status: result.status,
|
status: result.status as "pending" | "clean" | "warn" | "flagged" | "error",
|
||||||
flags: JSON.stringify(result.flags),
|
flags: JSON.stringify(result.flags),
|
||||||
score: result.score,
|
score: result.score,
|
||||||
raw: JSON.stringify(raw),
|
raw: JSON.stringify(raw),
|
||||||
@@ -117,7 +236,10 @@ async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Prom
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
if (row) (globalThis as any).broadcastMessageAnalyzed?.(row);
|
if (row) (globalThis as any).broadcastMessageAnalyzed?.(row);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
for (const message of analyzableMessages) {
|
||||||
const row = updateMessageAIAnalysis(db, message.id, {
|
const row = updateMessageAIAnalysis(db, message.id, {
|
||||||
status: "error",
|
status: "error",
|
||||||
flags: null,
|
flags: null,
|
||||||
@@ -125,10 +247,13 @@ async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Prom
|
|||||||
raw: null,
|
raw: null,
|
||||||
analysis: null,
|
analysis: null,
|
||||||
analyzedAt: Date.now(),
|
analyzedAt: Date.now(),
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: errorMsg,
|
||||||
});
|
});
|
||||||
if (row) (globalThis as any).broadcastMessageAnalyzed?.(row);
|
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;
|
if (isProcessing) return;
|
||||||
isProcessing = true;
|
isProcessing = true;
|
||||||
try {
|
try {
|
||||||
|
const batchTokenLimit = MAX_AI_REQUEST_TOKENS - AI_PROMPT_TOKEN_RESERVE;
|
||||||
|
|
||||||
while (queuedMessageIds.size > 0) {
|
while (queuedMessageIds.size > 0) {
|
||||||
const messageId = queuedMessageIds.values().next().value as string | undefined;
|
while (activeRequests >= MAX_CONCURRENT_REQUESTS) {
|
||||||
if (!messageId) break;
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
queuedMessageIds.delete(messageId);
|
}
|
||||||
|
|
||||||
|
const batch: MessageRecord[] = [];
|
||||||
|
let tokenEstimate = 0;
|
||||||
|
for (const messageId of Array.from(queuedMessageIds)) {
|
||||||
const message = getMessageById(db, messageId);
|
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 {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
@@ -166,7 +313,7 @@ export function startPendingAIAnalysisWorker(db: SqliteDatabase): void {
|
|||||||
logger.info("AI analysis worker started");
|
logger.info("AI analysis worker started");
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (isProcessing) return;
|
if (isProcessing) return;
|
||||||
const pendingMessages = getPendingAIAnalysisMessages(db, 3);
|
const pendingMessages = getPendingAIAnalysisMessages(db, 500);
|
||||||
if (pendingMessages.length === 0) return;
|
if (pendingMessages.length === 0) return;
|
||||||
logger.info({ count: pendingMessages.length }, "Queueing pending AI analysis messages");
|
logger.info({ count: pendingMessages.length }, "Queueing pending AI analysis messages");
|
||||||
for (const message of pendingMessages) {
|
for (const message of pendingMessages) {
|
||||||
|
|||||||
@@ -6,65 +6,6 @@ import { captureMessage } from "./messageCapture";
|
|||||||
|
|
||||||
const logger = createChildLogger("backlog-sync");
|
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(
|
async function syncChannelMessages(
|
||||||
db: SqliteDatabase,
|
db: SqliteDatabase,
|
||||||
channel: any,
|
channel: any,
|
||||||
@@ -116,45 +57,45 @@ export async function syncBacklogMessages(
|
|||||||
return;
|
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;
|
const cutoffTime = Date.now() - config.BACKLOG_SYNC_HOURS * 60 * 60 * 1000;
|
||||||
logger.info(
|
logger.info(
|
||||||
{ guildId: guild.id, hours: config.BACKLOG_SYNC_HOURS },
|
{ guildId, channelId, hours: config.BACKLOG_SYNC_HOURS },
|
||||||
"Starting message backlog sync",
|
"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 {
|
try {
|
||||||
const count = await syncChannelMessages(db, channel as any, cutoffTime);
|
const count = await syncChannelMessages(db, channel as any, cutoffTime);
|
||||||
total += count;
|
logger.info({ channelId, count }, "Backlog sync completed for selected channel");
|
||||||
logger.info({ channelId: channel.id, count }, "Backlog channel sync completed");
|
return count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
channelId: channel.id,
|
channelId,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
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");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { Client, Message } from "discord.js-selfbot-v13";
|
import type { Client, Message } from "discord.js-selfbot-v13";
|
||||||
import { createChildLogger } from "../logger";
|
import { createChildLogger } from "../logger";
|
||||||
import type { SqliteDatabase } from "../muxer-queue";
|
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
|
import type { SqliteDatabase } from "../muxer-queue";
|
||||||
import { insertMessage, insertAttachment } from "./messageStore";
|
import { insertMessage, insertAttachment } from "./messageStore";
|
||||||
import { processAttachmentUpload } from "./attachmentUploader";
|
|
||||||
import { getDisplayContent, getMessageLocation, getMessageMetadata } from "./messageMetadata";
|
import { getDisplayContent, getMessageLocation, getMessageMetadata } from "./messageMetadata";
|
||||||
import { queueMessageAnalysis } from "./aiAnalyzer";
|
import { queueMessageAnalysis } from "./aiAnalyzer";
|
||||||
import type { MessageRecord, AttachmentRecord } from "./types";
|
import type { MessageRecord, AttachmentRecord } from "./types";
|
||||||
@@ -59,17 +58,15 @@ export async function captureMessage(
|
|||||||
size: attachment.size,
|
size: attachment.size,
|
||||||
type: attachment.contentType || "application/octet-stream",
|
type: attachment.contentType || "application/octet-stream",
|
||||||
discord_url: attachment.url,
|
discord_url: attachment.url,
|
||||||
uploaded_url: null,
|
uploaded_url: attachment.url,
|
||||||
upload_status: "pending",
|
upload_status: "uploaded",
|
||||||
upload_error: null,
|
upload_error: null,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
uploaded_at: null,
|
uploaded_at: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
insertAttachment(db, attachmentRecord);
|
insertAttachment(db, attachmentRecord);
|
||||||
|
|
||||||
processAttachmentUpload(db, attachment.id, attachment.url, attachment.name || "unknown")
|
|
||||||
.then(() => {
|
|
||||||
if (broadcaster.broadcastAttachmentUploaded) {
|
if (broadcaster.broadcastAttachmentUploaded) {
|
||||||
broadcaster.broadcastAttachmentUploaded({
|
broadcaster.broadcastAttachmentUploaded({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
@@ -79,13 +76,6 @@ export async function captureMessage(
|
|||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
{ attachmentId: attachment.id, error: error instanceof Error ? error.message : String(error) },
|
|
||||||
"Background attachment upload failed",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export function updateAttachmentAsFailedUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AIAnalysisUpdate {
|
interface AIAnalysisUpdate {
|
||||||
status: "pending" | "clean" | "flagged" | "error";
|
status: "pending" | "clean" | "warn" | "flagged" | "error";
|
||||||
flags?: string | null;
|
flags?: string | null;
|
||||||
score?: number | null;
|
score?: number | null;
|
||||||
raw?: string | null;
|
raw?: string | null;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface MessageRecord {
|
|||||||
deleted_at: number | null;
|
deleted_at: number | null;
|
||||||
type: "text" | "edited" | "deleted";
|
type: "text" | "edited" | "deleted";
|
||||||
metadata: string | null;
|
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_flags?: string | null;
|
||||||
ai_moderation_score?: number | null;
|
ai_moderation_score?: number | null;
|
||||||
ai_moderation_raw?: string | null;
|
ai_moderation_raw?: string | null;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { discordPlayer } from "./player";
|
|||||||
import type { VoiceController } from "./voiceController";
|
import type { VoiceController } from "./voiceController";
|
||||||
import { getDatabase, getPersistedValue, setPersistedValue } from "./muxer-queue";
|
import { getDatabase, getPersistedValue, setPersistedValue } from "./muxer-queue";
|
||||||
import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore";
|
import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore";
|
||||||
|
import { syncSelectedChannelBacklog } from "./moderation/backlogSync";
|
||||||
|
|
||||||
const wsLogger = createChildLogger("webserver");
|
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
|
// Inbound: Discord PCM → tagged chunks → browser
|
||||||
(global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => {
|
(global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user