feat: add AI analysis integration with moderation and LLM processing
This commit is contained in:
10
.env.example
10
.env.example
@@ -38,3 +38,13 @@ ATTACHMENT_RETRY_ATTEMPTS=3
|
|||||||
BACKLOG_SYNC_HOURS=24
|
BACKLOG_SYNC_HOURS=24
|
||||||
BACKLOG_SYNC_BATCH_SIZE=100
|
BACKLOG_SYNC_BATCH_SIZE=100
|
||||||
|
|
||||||
|
# AI Analysis Configuration
|
||||||
|
AI_ANALYSIS_ENABLED=false
|
||||||
|
OPENAI_MODERATION_API_KEY=your_openai_moderation_key_here
|
||||||
|
OPENAI_MODERATION_BASE_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_MODERATION_MODEL=omni-moderation-latest
|
||||||
|
AI_LLM_API_KEY=your_9router_key_here
|
||||||
|
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
|
||||||
|
AI_LLM_MODEL=free
|
||||||
|
AI_ANALYSIS_TIMEOUT_MS=30000
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
async function disconnectVoice() { await apiRequest('/api/disconnect', { method: 'POST' }); await refreshStatus(); }
|
async function disconnectVoice() { await apiRequest('/api/disconnect', { method: 'POST' }); await refreshStatus(); }
|
||||||
|
|
||||||
function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${location.host}/ws`); state.socket.binaryType = 'arraybuffer'; state.socket.onopen = () => { el.wsDot.classList.add('on'); el.wsStatusText.textContent = 'Connected'; }; state.socket.onclose = () => { el.wsDot.classList.remove('on'); el.wsStatusText.textContent = 'Reconnecting'; setTimeout(connectWebSocket, 2500); }; state.socket.onerror = () => { el.wsDot.classList.remove('on'); el.wsDot.classList.add('warn'); el.wsStatusText.textContent = 'Socket error'; }; state.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
|
function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${location.host}/ws`); state.socket.binaryType = 'arraybuffer'; state.socket.onopen = () => { el.wsDot.classList.add('on'); el.wsStatusText.textContent = 'Connected'; }; state.socket.onclose = () => { el.wsDot.classList.remove('on'); el.wsStatusText.textContent = 'Reconnecting'; setTimeout(connectWebSocket, 2500); }; state.socket.onerror = () => { el.wsDot.classList.remove('on'); el.wsDot.classList.add('warn'); el.wsStatusText.textContent = 'Socket error'; }; state.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
|
||||||
function handleJsonEvent(raw) { const message = JSON.parse(raw); if (message.type === 'ui_state') return applyServerState(message.state); if (message.type === 'user_state') return renderUsers(message.users || []); if (message.type === 'message_created') { state.text.unshift(message.data); renderText(); } if (message.type === 'message_updated') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' }); renderText(); } if (message.type === 'message_deleted') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' }); renderText(); } if (message.type === 'attachment_uploaded') fetchText(); }
|
function handleJsonEvent(raw) { const message = JSON.parse(raw); if (message.type === 'ui_state') return applyServerState(message.state); if (message.type === 'user_state') return renderUsers(message.users || []); if (message.type === 'message_created') { state.text.unshift(message.data); renderText(); } if (message.type === 'message_updated') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' }); renderText(); } if (message.type === 'message_deleted') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' }); renderText(); } if (message.type === 'attachment_uploaded') fetchText(); if (message.type === 'message_analyzed') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, message.data); renderText(); } }
|
||||||
|
|
||||||
async function applyServerState(next) {
|
async function applyServerState(next) {
|
||||||
if (!next || state.applyingServerState) return;
|
if (!next || state.applyingServerState) return;
|
||||||
@@ -122,7 +122,8 @@
|
|||||||
|
|
||||||
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); 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(); 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 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); }
|
||||||
|
|
||||||
function handleIncomingPCM(data) { if (!state.localListening || !state.audioContextListen) return; const headerView = new DataView(data, 0, 4); const userIdHash = headerView.getInt32(0, true); const audioData = data.slice(4); const int16Array = new Int16Array(audioData); const float32Array = new Float32Array(int16Array.length); for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768; const audioBuffer = state.audioContextListen.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE); const nowBuffering = audioBuffer.getChannelData(0); for (let i = 0; i < audioBuffer.length; i++) nowBuffering[i] = float32Array[i]; const source = state.audioContextListen.createBufferSource(); source.buffer = audioBuffer; source.connect(state.audioContextListen.destination); const currentTime = state.audioContextListen.currentTime; let userNextStartTime = state.userTimelines.get(userIdHash) || 0; if (userNextStartTime < currentTime) userNextStartTime = currentTime + 0.05; source.start(userNextStartTime); userNextStartTime += audioBuffer.duration; state.userTimelines.set(userIdHash, userNextStartTime); }
|
function handleIncomingPCM(data) { if (!state.localListening || !state.audioContextListen) return; const headerView = new DataView(data, 0, 4); const userIdHash = headerView.getInt32(0, true); const audioData = data.slice(4); const int16Array = new Int16Array(audioData); const float32Array = new Float32Array(int16Array.length); for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768; const audioBuffer = state.audioContextListen.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE); const nowBuffering = audioBuffer.getChannelData(0); for (let i = 0; i < audioBuffer.length; i++) nowBuffering[i] = float32Array[i]; const source = state.audioContextListen.createBufferSource(); source.buffer = audioBuffer; source.connect(state.audioContextListen.destination); const currentTime = state.audioContextListen.currentTime; let userNextStartTime = state.userTimelines.get(userIdHash) || 0; if (userNextStartTime < currentTime) userNextStartTime = currentTime + 0.05; source.start(userNextStartTime); userNextStartTime += audioBuffer.duration; state.userTimelines.set(userIdHash, userNextStartTime); }
|
||||||
|
|||||||
@@ -34,6 +34,34 @@ const configSchema = z.object({
|
|||||||
ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3),
|
ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3),
|
||||||
BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24),
|
BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24),
|
||||||
BACKLOG_SYNC_BATCH_SIZE: z.coerce.number().int().positive().max(100).default(100),
|
BACKLOG_SYNC_BATCH_SIZE: z.coerce.number().int().positive().max(100).default(100),
|
||||||
|
AI_ANALYSIS_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.default(false),
|
||||||
|
OPENAI_MODERATION_API_KEY: z.string().optional(),
|
||||||
|
OPENAI_MODERATION_BASE_URL: z.string().url().default("https://api.openai.com/v1"),
|
||||||
|
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
||||||
|
AI_LLM_API_KEY: z.string().optional(),
|
||||||
|
AI_LLM_BASE_URL: z.string().url().default("https://9router.asepharyana.tech/v1"),
|
||||||
|
AI_LLM_MODEL: z.string().default("free"),
|
||||||
|
AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (!value.AI_ANALYSIS_ENABLED) return;
|
||||||
|
if (!value.OPENAI_MODERATION_API_KEY) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["OPENAI_MODERATION_API_KEY"],
|
||||||
|
message: "OPENAI_MODERATION_API_KEY is required when AI_ANALYSIS_ENABLED=true",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!value.AI_LLM_API_KEY) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["AI_LLM_API_KEY"],
|
||||||
|
message: "AI_LLM_API_KEY is required when AI_ANALYSIS_ENABLED=true",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>;
|
export type AppConfig = z.infer<typeof configSchema>;
|
||||||
|
|||||||
169
src/moderation/aiAnalyzer.ts
Normal file
169
src/moderation/aiAnalyzer.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { config } from "../config";
|
||||||
|
import { createChildLogger } from "../logger";
|
||||||
|
import type { SqliteDatabase } from "../muxer-queue";
|
||||||
|
import { retryWithBackoff } from "../retry";
|
||||||
|
import { getMessageById, updateMessageAIAnalysis } from "./messageStore";
|
||||||
|
import type { MessageRecord } from "./types";
|
||||||
|
|
||||||
|
const logger = createChildLogger("ai-analyzer");
|
||||||
|
const queuedMessageIds = new Set<string>();
|
||||||
|
let isProcessing = false;
|
||||||
|
|
||||||
|
interface ModerationResult {
|
||||||
|
flagged: boolean;
|
||||||
|
flags: string[];
|
||||||
|
score: number;
|
||||||
|
raw: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatCompletionResponse {
|
||||||
|
choices?: Array<{
|
||||||
|
message?: {
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnalysisText(message: MessageRecord): string {
|
||||||
|
return (message.edited_content || message.content || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = typeof body === "object" && body && "error" in body
|
||||||
|
? JSON.stringify(body)
|
||||||
|
: response.statusText;
|
||||||
|
throw new Error(`AI request failed (${response.status}): ${message}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runModeration(text: string): Promise<ModerationResult> {
|
||||||
|
const response = await retryWithBackoff(
|
||||||
|
() => fetchJson(`${config.OPENAI_MODERATION_BASE_URL}/moderations`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${config.OPENAI_MODERATION_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.OPENAI_MODERATION_MODEL,
|
||||||
|
input: text,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ retries: 2, logger },
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
const result = response.results?.[0] || {};
|
||||||
|
const categories = result.categories || {};
|
||||||
|
const categoryScores = result.category_scores || {};
|
||||||
|
const flags = Object.entries(categories)
|
||||||
|
.filter(([, flagged]) => Boolean(flagged))
|
||||||
|
.map(([name]) => name);
|
||||||
|
const score = Math.max(0, ...Object.values(categoryScores).map((value) => Number(value) || 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
flagged: Boolean(result.flagged) || flags.length > 0,
|
||||||
|
flags,
|
||||||
|
score,
|
||||||
|
raw: response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLLMAnalysis(text: string, moderation: ModerationResult): Promise<string> {
|
||||||
|
const response = await retryWithBackoff(
|
||||||
|
() => fetchJson(`${config.AI_LLM_BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${config.AI_LLM_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.AI_LLM_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "Kamu analis moderation Discord. Jawab singkat dalam Bahasa Indonesia: ringkasan risiko, alasan, dan aksi yang disarankan. Jangan mengulang pesan mentah secara panjang.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: JSON.stringify({
|
||||||
|
message: text,
|
||||||
|
moderationFlagged: moderation.flagged,
|
||||||
|
moderationFlags: moderation.flags,
|
||||||
|
moderationScore: moderation.score,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.2,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ retries: 2, logger },
|
||||||
|
) as ChatCompletionResponse;
|
||||||
|
|
||||||
|
return response.choices?.[0]?.message?.content?.trim() || "Tidak ada analisis dari LLM.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Promise<void> {
|
||||||
|
const text = getAnalysisText(message);
|
||||||
|
if (!config.AI_ANALYSIS_ENABLED || text.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moderation = await runModeration(text);
|
||||||
|
const analysis = await runLLMAnalysis(text, moderation);
|
||||||
|
const row = updateMessageAIAnalysis(db, message.id, {
|
||||||
|
status: moderation.flagged ? "flagged" : "clean",
|
||||||
|
flags: JSON.stringify(moderation.flags),
|
||||||
|
score: moderation.score,
|
||||||
|
raw: JSON.stringify(moderation.raw),
|
||||||
|
analysis,
|
||||||
|
analyzedAt: Date.now(),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
if (row) (globalThis as any).broadcastMessageAnalyzed?.(row);
|
||||||
|
} catch (error) {
|
||||||
|
const row = updateMessageAIAnalysis(db, message.id, {
|
||||||
|
status: "error",
|
||||||
|
flags: null,
|
||||||
|
score: null,
|
||||||
|
raw: null,
|
||||||
|
analysis: null,
|
||||||
|
analyzedAt: Date.now(),
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
if (row) (globalThis as any).broadcastMessageAnalyzed?.(row);
|
||||||
|
logger.warn({ messageId: message.id, error }, "AI analysis failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drainQueue(db: SqliteDatabase): Promise<void> {
|
||||||
|
if (isProcessing) return;
|
||||||
|
isProcessing = true;
|
||||||
|
try {
|
||||||
|
while (queuedMessageIds.size > 0) {
|
||||||
|
const [messageId] = queuedMessageIds;
|
||||||
|
queuedMessageIds.delete(messageId);
|
||||||
|
const message = getMessageById(db, messageId);
|
||||||
|
if (message) await analyzeAndStore(db, message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueMessageAnalysis(db: SqliteDatabase, messageId: string): void {
|
||||||
|
if (!config.AI_ANALYSIS_ENABLED) return;
|
||||||
|
queuedMessageIds.add(messageId);
|
||||||
|
setImmediate(() => {
|
||||||
|
drainQueue(db).catch((error) => logger.error({ error }, "AI analysis queue failed"));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { config } from "../config";
|
|||||||
import { insertMessage, insertAttachment } from "./messageStore";
|
import { insertMessage, insertAttachment } from "./messageStore";
|
||||||
import { processAttachmentUpload } from "./attachmentUploader";
|
import { processAttachmentUpload } from "./attachmentUploader";
|
||||||
import { getDisplayContent, getMessageLocation, getMessageMetadata } from "./messageMetadata";
|
import { getDisplayContent, getMessageLocation, getMessageMetadata } from "./messageMetadata";
|
||||||
|
import { queueMessageAnalysis } from "./aiAnalyzer";
|
||||||
import type { MessageRecord, AttachmentRecord } from "./types";
|
import type { MessageRecord, AttachmentRecord } from "./types";
|
||||||
|
|
||||||
const logger = createChildLogger("message-capture");
|
const logger = createChildLogger("message-capture");
|
||||||
@@ -35,6 +36,7 @@ export async function captureMessage(
|
|||||||
};
|
};
|
||||||
|
|
||||||
insertMessage(db, messageRecord);
|
insertMessage(db, messageRecord);
|
||||||
|
queueMessageAnalysis(db, message.id);
|
||||||
|
|
||||||
const broadcaster = globalThis as any;
|
const broadcaster = globalThis as any;
|
||||||
if (broadcaster.broadcastMessageCreated) {
|
if (broadcaster.broadcastMessageCreated) {
|
||||||
@@ -126,6 +128,7 @@ export function registerMessageCapture(client: Client, db: SqliteDatabase): void
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
const editedAt = Date.now();
|
const editedAt = Date.now();
|
||||||
updateMessageAsEdited(db, newMessage.id, getDisplayContent(newMessage as Message), editedAt);
|
updateMessageAsEdited(db, newMessage.id, getDisplayContent(newMessage as Message), editedAt);
|
||||||
|
queueMessageAnalysis(db, newMessage.id);
|
||||||
|
|
||||||
const broadcaster = globalThis as any;
|
const broadcaster = globalThis as any;
|
||||||
if (broadcaster.broadcastMessageUpdated) {
|
if (broadcaster.broadcastMessageUpdated) {
|
||||||
|
|||||||
@@ -220,3 +220,76 @@ export function updateAttachmentAsFailedUpload(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AIAnalysisUpdate {
|
||||||
|
status: "pending" | "clean" | "flagged" | "error";
|
||||||
|
flags?: string | null;
|
||||||
|
score?: number | null;
|
||||||
|
raw?: string | null;
|
||||||
|
analysis?: string | null;
|
||||||
|
analyzedAt?: number | null;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMessageAIAnalysis(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
messageId: string,
|
||||||
|
result: AIAnalysisUpdate,
|
||||||
|
): MessageRecord | null {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE messages
|
||||||
|
SET ai_status = ?, ai_moderation_flags = ?, ai_moderation_score = ?,
|
||||||
|
ai_moderation_raw = ?, ai_analysis = ?, ai_analyzed_at = ?, ai_error = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
result.status,
|
||||||
|
result.flags ?? null,
|
||||||
|
result.score ?? null,
|
||||||
|
result.raw ?? null,
|
||||||
|
result.analysis ?? null,
|
||||||
|
result.analyzedAt ?? Date.now(),
|
||||||
|
result.error ?? null,
|
||||||
|
messageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = db.prepare("SELECT * FROM messages WHERE id = ?").get(messageId) as MessageRecord | undefined;
|
||||||
|
return row ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ messageId, error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"Failed to update message AI analysis",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingAIAnalysisMessages(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
limit: number = 25,
|
||||||
|
): MessageRecord[] {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE ai_status = 'pending'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND COALESCE(edited_content, content) != ''
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
return stmt.all(limit) as MessageRecord[];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"Failed to get pending AI analysis messages",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageById(db: SqliteDatabase, messageId: string): MessageRecord | null {
|
||||||
|
const row = db.prepare("SELECT * FROM messages WHERE id = ?").get(messageId) as MessageRecord | undefined;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ 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_moderation_flags?: string | null;
|
||||||
|
ai_moderation_score?: number | null;
|
||||||
|
ai_moderation_raw?: string | null;
|
||||||
|
ai_analysis?: string | null;
|
||||||
|
ai_analyzed_at?: number | null;
|
||||||
|
ai_error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttachmentRecord {
|
export interface AttachmentRecord {
|
||||||
|
|||||||
@@ -71,7 +71,14 @@ function initializeDatabase(): SqliteDatabase {
|
|||||||
edited_at INTEGER,
|
edited_at INTEGER,
|
||||||
deleted_at INTEGER,
|
deleted_at INTEGER,
|
||||||
type TEXT NOT NULL DEFAULT 'text',
|
type TEXT NOT NULL DEFAULT 'text',
|
||||||
metadata TEXT
|
metadata TEXT,
|
||||||
|
ai_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
ai_moderation_flags TEXT,
|
||||||
|
ai_moderation_score REAL,
|
||||||
|
ai_moderation_raw TEXT,
|
||||||
|
ai_analysis TEXT,
|
||||||
|
ai_analyzed_at INTEGER,
|
||||||
|
ai_error TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id);
|
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id);
|
||||||
@@ -103,10 +110,23 @@ function initializeDatabase(): SqliteDatabase {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_attachments_status ON attachments(upload_status);
|
CREATE INDEX IF NOT EXISTS idx_attachments_status ON attachments(upload_status);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const migrations = [
|
||||||
|
"ALTER TABLE attachments ADD COLUMN thread_id TEXT",
|
||||||
|
"ALTER TABLE messages ADD COLUMN ai_status TEXT NOT NULL DEFAULT 'pending'",
|
||||||
|
"ALTER TABLE messages ADD COLUMN ai_moderation_flags TEXT",
|
||||||
|
"ALTER TABLE messages ADD COLUMN ai_moderation_score REAL",
|
||||||
|
"ALTER TABLE messages ADD COLUMN ai_moderation_raw TEXT",
|
||||||
|
"ALTER TABLE messages ADD COLUMN ai_analysis TEXT",
|
||||||
|
"ALTER TABLE messages ADD COLUMN ai_analyzed_at INTEGER",
|
||||||
|
"ALTER TABLE messages ADD COLUMN ai_error TEXT",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
try {
|
try {
|
||||||
database.exec("ALTER TABLE attachments ADD COLUMN thread_id TEXT");
|
database.exec(migration);
|
||||||
} catch {
|
} catch {
|
||||||
// Column already exists on databases initialized after the moderation schema was added.
|
// Column already exists on databases initialized after schema updates.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return database;
|
return database;
|
||||||
|
|||||||
@@ -324,6 +324,10 @@ export function startWebserver(
|
|||||||
broadcastMessageEvent("attachment_uploaded", data);
|
broadcastMessageEvent("attachment_uploaded", data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(global as any).broadcastMessageAnalyzed = (data: any) => {
|
||||||
|
broadcastMessageEvent("message_analyzed", data);
|
||||||
|
};
|
||||||
|
|
||||||
// --- Outbound: browser PCM (24kHz mono) → Opus → Discord ---
|
// --- Outbound: browser PCM (24kHz mono) → Opus → Discord ---
|
||||||
const RATE = 48000;
|
const RATE = 48000;
|
||||||
const CHANNELS = 2;
|
const CHANNELS = 2;
|
||||||
|
|||||||
Reference in New Issue
Block a user