Compare commits
3 Commits
be6c9f8132
...
0a5cedfed1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5cedfed1 | ||
|
|
d4a4f737a8 | ||
|
|
6e203604ec |
@@ -40,9 +40,6 @@ BACKLOG_SYNC_BATCH_SIZE=100
|
|||||||
|
|
||||||
# AI Analysis Configuration
|
# AI Analysis Configuration
|
||||||
AI_ANALYSIS_ENABLED=false
|
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_API_KEY=your_9router_key_here
|
||||||
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
|
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
|
||||||
AI_LLM_MODEL=free
|
AI_LLM_MODEL=free
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
||||||
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
||||||
|
|
||||||
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } else if (guilds[0]?.id) { await postUIState({ selectedGuild: guilds[0].id }); } }
|
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } }
|
||||||
async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
|
async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
|
||||||
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
||||||
async function connectVoice() { const guildId = el.guildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
async function connectVoice() { const guildId = el.guildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
||||||
|
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
loadGuilds().then(refreshStatus).then(() => apiRequest('/api/ui-state')).then(applyServerState).catch((error) => showError(error.message));
|
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).catch((error) => showError(error.message));
|
||||||
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
|
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -48,13 +48,6 @@ const configSchema = z.object({
|
|||||||
AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
||||||
}).superRefine((value, ctx) => {
|
}).superRefine((value, ctx) => {
|
||||||
if (!value.AI_ANALYSIS_ENABLED) return;
|
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) {
|
if (!value.AI_LLM_API_KEY) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { startWebserver } from "./webserver";
|
|||||||
import { registerMessageCapture } from "./moderation/messageCapture";
|
import { registerMessageCapture } from "./moderation/messageCapture";
|
||||||
import { syncBacklogMessages } from "./moderation/backlogSync";
|
import { syncBacklogMessages } from "./moderation/backlogSync";
|
||||||
import { getDatabase } from "./muxer-queue";
|
import { getDatabase } from "./muxer-queue";
|
||||||
|
import { startPendingAIAnalysisWorker } from "./moderation/aiAnalyzer";
|
||||||
|
|
||||||
const logger = createChildLogger("bot");
|
const logger = createChildLogger("bot");
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ async function gracefulShutdown(signal: string) {
|
|||||||
client.on("ready", async () => {
|
client.on("ready", async () => {
|
||||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||||
registerMessageCapture(client, db);
|
registerMessageCapture(client, db);
|
||||||
|
startPendingAIAnalysisWorker(db);
|
||||||
syncBacklogMessages(client, db).catch((error) => {
|
syncBacklogMessages(client, db).catch((error) => {
|
||||||
logger.warn({ error }, "Backlog sync failed");
|
logger.warn({ error }, "Backlog sync failed");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,20 +2,13 @@ import { config } from "../config";
|
|||||||
import { createChildLogger } from "../logger";
|
import { createChildLogger } from "../logger";
|
||||||
import type { SqliteDatabase } from "../muxer-queue";
|
import type { SqliteDatabase } from "../muxer-queue";
|
||||||
import { retryWithBackoff } from "../retry";
|
import { retryWithBackoff } from "../retry";
|
||||||
import { getMessageById, updateMessageAIAnalysis } from "./messageStore";
|
import { getMessageById, getPendingAIAnalysisMessages, updateMessageAIAnalysis } from "./messageStore";
|
||||||
import type { MessageRecord } from "./types";
|
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;
|
||||||
|
|
||||||
interface ModerationResult {
|
|
||||||
flagged: boolean;
|
|
||||||
flags: string[];
|
|
||||||
score: number;
|
|
||||||
raw: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatCompletionResponse {
|
interface ChatCompletionResponse {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
message?: {
|
message?: {
|
||||||
@@ -24,6 +17,13 @@ interface ChatCompletionResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LLMAnalysis {
|
||||||
|
status: "clean" | "flagged";
|
||||||
|
flags: string[];
|
||||||
|
score: number;
|
||||||
|
analysis: string;
|
||||||
|
}
|
||||||
|
|
||||||
function getAnalysisText(message: MessageRecord): string {
|
function getAnalysisText(message: MessageRecord): string {
|
||||||
return (message.edited_content || message.content || "").trim();
|
return (message.edited_content || message.content || "").trim();
|
||||||
}
|
}
|
||||||
@@ -47,39 +47,31 @@ async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runModeration(text: string): Promise<ModerationResult> {
|
function parseLLMAnalysis(content: string): LLMAnalysis {
|
||||||
const response = await retryWithBackoff(
|
const jsonStart = content.indexOf("{");
|
||||||
() => fetchJson(`${config.OPENAI_MODERATION_BASE_URL}/moderations`, {
|
const jsonEnd = content.lastIndexOf("}");
|
||||||
method: "POST",
|
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
||||||
headers: {
|
try {
|
||||||
"Authorization": `Bearer ${config.OPENAI_MODERATION_API_KEY}`,
|
const parsed = JSON.parse(content.slice(jsonStart, jsonEnd + 1));
|
||||||
"Content-Type": "application/json",
|
const status = parsed.status === "flagged" ? "flagged" : "clean";
|
||||||
},
|
const flags = Array.isArray(parsed.flags) ? parsed.flags.map(String) : [];
|
||||||
body: JSON.stringify({
|
const score = Math.max(0, Math.min(1, Number(parsed.score) || 0));
|
||||||
model: config.OPENAI_MODERATION_MODEL,
|
const analysis = typeof parsed.analysis === "string" ? parsed.analysis : content;
|
||||||
input: text,
|
return { status, flags, score, analysis };
|
||||||
}),
|
} catch {
|
||||||
}),
|
// Fall through to text-only parsing.
|
||||||
{ 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 {
|
return {
|
||||||
flagged: Boolean(result.flagged) || flags.length > 0,
|
status: /flagged|bahaya|berisiko|toxic|hate|harassment|violence|sexual|self-harm/i.test(content) ? "flagged" : "clean",
|
||||||
flags,
|
flags: [],
|
||||||
score,
|
score: 0,
|
||||||
raw: response,
|
analysis: content.trim() || "Tidak ada analisis dari LLM.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLLMAnalysis(text: string, moderation: ModerationResult): Promise<string> {
|
async function runLLMAnalysis(text: string): Promise<{ result: 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",
|
||||||
@@ -92,16 +84,11 @@ async function runLLMAnalysis(text: string, moderation: ModerationResult): Promi
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "system",
|
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.",
|
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\"}.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: JSON.stringify({
|
content: text,
|
||||||
message: text,
|
|
||||||
moderationFlagged: moderation.flagged,
|
|
||||||
moderationFlags: moderation.flags,
|
|
||||||
moderationScore: moderation.score,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
@@ -110,7 +97,8 @@ async function runLLMAnalysis(text: string, moderation: ModerationResult): Promi
|
|||||||
{ retries: 2, logger },
|
{ retries: 2, logger },
|
||||||
) as ChatCompletionResponse;
|
) as ChatCompletionResponse;
|
||||||
|
|
||||||
return response.choices?.[0]?.message?.content?.trim() || "Tidak ada analisis dari LLM.";
|
const content = response.choices?.[0]?.message?.content?.trim() || "";
|
||||||
|
return { result: parseLLMAnalysis(content), raw: response };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Promise<void> {
|
async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Promise<void> {
|
||||||
@@ -118,14 +106,13 @@ async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Prom
|
|||||||
if (!config.AI_ANALYSIS_ENABLED || text.length === 0) return;
|
if (!config.AI_ANALYSIS_ENABLED || text.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moderation = await runModeration(text);
|
const { result, raw } = await runLLMAnalysis(text);
|
||||||
const analysis = await runLLMAnalysis(text, moderation);
|
|
||||||
const row = updateMessageAIAnalysis(db, message.id, {
|
const row = updateMessageAIAnalysis(db, message.id, {
|
||||||
status: moderation.flagged ? "flagged" : "clean",
|
status: result.status,
|
||||||
flags: JSON.stringify(moderation.flags),
|
flags: JSON.stringify(result.flags),
|
||||||
score: moderation.score,
|
score: result.score,
|
||||||
raw: JSON.stringify(moderation.raw),
|
raw: JSON.stringify(raw),
|
||||||
analysis,
|
analysis: result.analysis,
|
||||||
analyzedAt: Date.now(),
|
analyzedAt: Date.now(),
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -150,7 +137,8 @@ async function drainQueue(db: SqliteDatabase): Promise<void> {
|
|||||||
isProcessing = true;
|
isProcessing = true;
|
||||||
try {
|
try {
|
||||||
while (queuedMessageIds.size > 0) {
|
while (queuedMessageIds.size > 0) {
|
||||||
const [messageId] = queuedMessageIds;
|
const messageId = queuedMessageIds.values().next().value as string | undefined;
|
||||||
|
if (!messageId) break;
|
||||||
queuedMessageIds.delete(messageId);
|
queuedMessageIds.delete(messageId);
|
||||||
const message = getMessageById(db, messageId);
|
const message = getMessageById(db, messageId);
|
||||||
if (message) await analyzeAndStore(db, message);
|
if (message) await analyzeAndStore(db, message);
|
||||||
@@ -162,8 +150,28 @@ async function drainQueue(db: SqliteDatabase): Promise<void> {
|
|||||||
|
|
||||||
export function queueMessageAnalysis(db: SqliteDatabase, messageId: string): void {
|
export function queueMessageAnalysis(db: SqliteDatabase, messageId: string): void {
|
||||||
if (!config.AI_ANALYSIS_ENABLED) return;
|
if (!config.AI_ANALYSIS_ENABLED) return;
|
||||||
|
logger.debug({ messageId }, "Queueing AI analysis");
|
||||||
queuedMessageIds.add(messageId);
|
queuedMessageIds.add(messageId);
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
drainQueue(db).catch((error) => logger.error({ error }, "AI analysis queue failed"));
|
drainQueue(db).catch((error) => logger.error({ error }, "AI analysis queue failed"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startPendingAIAnalysisWorker(db: SqliteDatabase): void {
|
||||||
|
if (!config.AI_ANALYSIS_ENABLED) {
|
||||||
|
logger.info("AI analysis disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("AI analysis worker started");
|
||||||
|
setInterval(() => {
|
||||||
|
if (isProcessing) return;
|
||||||
|
const pendingMessages = getPendingAIAnalysisMessages(db, 3);
|
||||||
|
if (pendingMessages.length === 0) return;
|
||||||
|
logger.info({ count: pendingMessages.length }, "Queueing pending AI analysis messages");
|
||||||
|
for (const message of pendingMessages) {
|
||||||
|
queuedMessageIds.add(message.id);
|
||||||
|
}
|
||||||
|
drainQueue(db).catch((error) => logger.error({ error }, "Pending AI analysis worker failed"));
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,24 +17,51 @@ function isWatchableChannel(channel: { type?: string; messages?: unknown }): boo
|
|||||||
|
|
||||||
async function collectWatchableChannels(guild: any): Promise<any[]> {
|
async function collectWatchableChannels(guild: any): Promise<any[]> {
|
||||||
const channels: any[] = [];
|
const channels: any[] = [];
|
||||||
|
|
||||||
|
// Fast pass: collect text channels from cache only
|
||||||
for (const channel of guild.channels.cache.values()) {
|
for (const channel of guild.channels.cache.values()) {
|
||||||
if (isWatchableChannel(channel)) {
|
if (isWatchableChannel(channel)) {
|
||||||
channels.push(channel);
|
channels.push(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel.threads?.fetch) {
|
|
||||||
for (const archived of [false, true]) {
|
|
||||||
const fetched = await channel.threads
|
|
||||||
.fetch({ archived, limit: 100 })
|
|
||||||
.catch(() => null);
|
|
||||||
if (!fetched?.threads) continue;
|
|
||||||
for (const thread of fetched.threads.values()) {
|
|
||||||
if (isWatchableChannel(thread)) channels.push(thread);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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());
|
return Array.from(new Map(channels.map((channel) => [channel.id, channel])).values());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +117,27 @@ export async function syncBacklogMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cutoffTime = Date.now() - config.BACKLOG_SYNC_HOURS * 60 * 60 * 1000;
|
const cutoffTime = Date.now() - config.BACKLOG_SYNC_HOURS * 60 * 60 * 1000;
|
||||||
await guild.channels.fetch().catch(() => null);
|
logger.info(
|
||||||
|
{ guildId: guild.id, hours: config.BACKLOG_SYNC_HOURS },
|
||||||
|
"Starting message backlog sync",
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
const channels = await collectWatchableChannels(guild);
|
||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
logger.info(
|
logger.info(
|
||||||
{ guildId: guild.id, channels: channels.length, hours: config.BACKLOG_SYNC_HOURS },
|
{ guildId: guild.id, channels: channels.length, hours: config.BACKLOG_SYNC_HOURS },
|
||||||
"Starting message backlog sync",
|
"Watchable channels collected for backlog sync",
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const channel of channels) {
|
for (const channel of channels) {
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ function initializeDatabase(): SqliteDatabase {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id);
|
CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
|
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachments_status ON attachments(upload_status);
|
CREATE INDEX IF NOT EXISTS idx_attachments_status ON attachments(upload_status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ui_state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const migrations = [
|
const migrations = [
|
||||||
@@ -141,6 +147,28 @@ function getDatabase(): SqliteDatabase {
|
|||||||
|
|
||||||
export { getDatabase };
|
export { getDatabase };
|
||||||
|
|
||||||
|
export function getPersistedValue<T>(key: string, fallback: T): T {
|
||||||
|
const row = getDatabase()
|
||||||
|
.prepare("SELECT value FROM ui_state WHERE key = ?")
|
||||||
|
.get(key) as { value: string } | undefined;
|
||||||
|
if (!row) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.value) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPersistedValue(key: string, value: unknown): void {
|
||||||
|
getDatabase()
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO ui_state (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||||
|
`)
|
||||||
|
.run(key, JSON.stringify(value), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
|
export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const database = getDatabase();
|
const database = getDatabase();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import express from "express";
|
|||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import http from "http";
|
import http from "http";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import pinoHttp from "pino-http";
|
|
||||||
import * as prism from "prism-media";
|
import * as prism from "prism-media";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { AppError } from "./errors";
|
import { AppError } from "./errors";
|
||||||
@@ -11,7 +10,7 @@ import { createChildLogger, logger } from "./logger";
|
|||||||
import { getMetrics, uptimeGauge } from "./metrics";
|
import { getMetrics, uptimeGauge } from "./metrics";
|
||||||
import { discordPlayer } from "./player";
|
import { discordPlayer } from "./player";
|
||||||
import type { VoiceController } from "./voiceController";
|
import type { VoiceController } from "./voiceController";
|
||||||
import { getDatabase } from "./muxer-queue";
|
import { getDatabase, getPersistedValue, setPersistedValue } from "./muxer-queue";
|
||||||
import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore";
|
import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore";
|
||||||
|
|
||||||
const wsLogger = createChildLogger("webserver");
|
const wsLogger = createChildLogger("webserver");
|
||||||
@@ -31,7 +30,7 @@ interface SharedUIState {
|
|||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedUIState: SharedUIState = {
|
const defaultSharedUIState: SharedUIState = {
|
||||||
selectedGuild: "",
|
selectedGuild: "",
|
||||||
selectedVoiceChannel: "",
|
selectedVoiceChannel: "",
|
||||||
selectedTextChannel: "",
|
selectedTextChannel: "",
|
||||||
@@ -40,6 +39,8 @@ const sharedUIState: SharedUIState = {
|
|||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sharedUIState: SharedUIState = getPersistedValue("web-ui-state", defaultSharedUIState);
|
||||||
|
|
||||||
function getSharedUIState(): SharedUIState {
|
function getSharedUIState(): SharedUIState {
|
||||||
return { ...sharedUIState };
|
return { ...sharedUIState };
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,7 @@ function patchSharedUIState(patch: Partial<SharedUIState>) {
|
|||||||
if (typeof patch.isStreaming === "boolean") {
|
if (typeof patch.isStreaming === "boolean") {
|
||||||
sharedUIState.isStreaming = patch.isStreaming;
|
sharedUIState.isStreaming = patch.isStreaming;
|
||||||
}
|
}
|
||||||
|
setPersistedValue("web-ui-state", sharedUIState);
|
||||||
broadcastUIState();
|
broadcastUIState();
|
||||||
return getSharedUIState();
|
return getSharedUIState();
|
||||||
}
|
}
|
||||||
@@ -121,8 +123,22 @@ export function startWebserver(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// HTTP request logging
|
app.use((req, res, next) => {
|
||||||
app.use(pinoHttp({ logger }));
|
if (req.path.startsWith("/api/")) {
|
||||||
|
res.set("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
|
res.on("finish", () => {
|
||||||
|
if (req.originalUrl.startsWith("/.well-known/appspecific/")) return;
|
||||||
|
if (req.originalUrl === "/favicon.ico") return;
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
logger.error(
|
||||||
|
{ method: req.method, url: req.originalUrl, statusCode: res.statusCode },
|
||||||
|
"HTTP request failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "../public")));
|
app.use(express.static(path.join(__dirname, "../public")));
|
||||||
|
|||||||
Reference in New Issue
Block a user