Compare commits

...

3 Commits

Author SHA1 Message Date
MythEclipse
0a5cedfed1 fix: resolve UI state reset and backlog sync hang
- Fix client startup order: fetch/apply server UI state BEFORE loadGuilds() to prevent overwriting persisted state with default guild
- Remove auto-post of first guild in loadGuilds() — let server state drive selection
- Refactor collectWatchableChannels() to collect text channels fast first, then discover threads in parallel with 5s per-channel timeout and 30s overall timeout to prevent blocking message sync
- Ignore /favicon.ico 404 in error logging to reduce noise

Fixes:
- UI state now persists across restart (was being overwritten by client startup race)
- Backlog sync no longer hangs on thread discovery (was blocking before message sync)
- Cleaner logs without favicon 404 errors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:45:51 +07:00
MythEclipse
d4a4f737a8 feat: enhance backlog sync logging and implement UI state persistence 2026-05-14 03:17:07 +07:00
MythEclipse
6e203604ec feat: remove OpenAI moderation configuration and update AI analysis logic 2026-05-14 02:44:26 +07:00
8 changed files with 167 additions and 83 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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,

View File

@@ -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");
}); });

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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")));