/* * Veskslop daemon — web control UI. * * A separate Express listener serves static HTML/JS and a small JSON API on * WEBUI_HOST:WEBUI_PORT. Remote clients can use the API directly, so the page * stays replaceable. There is no authentication: bind to LAN or Tailscale only. * See docs/webui.md for the API contract. */ import express, { type Request, type Response } from "express"; import { normalizeWakeword } from "./communication.js"; import { existsSync, readFileSync } from "node:fs"; import { Readable } from "node:stream"; import http from "node:http"; import https from "node:https"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import QRCode from "qrcode"; import { WebSocketServer } from "ws"; import type { AuthService, AuthStatus } from "./auth-service.js"; import type { AudioService } from "./audio-service.js"; import { CoreError, type CoreClient } from "./core-client.js"; import type { CoreProcess } from "./core-process.js"; import { MediaLimitError, type MediaService } from "./media.js"; import { dedupe, getQueryCount, paced, pacedSend, pacedVoice } from "./pacing.js"; import type { PreferencesStore, WebPreferences } from "./preferences.js"; import type { Puppet } from "./puppet.js"; import { ensureTls } from "./tls.js"; import type { TranscriptStore } from "./transcript.js"; import type { TtsProvider, TtsVoice } from "./tts.js"; const PINNED_LANGUAGES = ["en", "sv", "de", "da", "nb", "no", "ru", "chr"]; const LANGUAGE_NAMES: Record = { en: "English", sv: "Swedish", de: "German", da: "Danish", nb: "Norwegian", no: "Norwegian", ru: "Russian", chr: "Cherokee" }; function groupVoices(voices: TtsVoice[]) { const groups = new Map(); for (const voice of voices) { const base = voice.language.split("-")[0]; const group = groups.get(base) ?? { id: base, name: LANGUAGE_NAMES[base] ?? voice.language, voices: [] }; group.voices.push(voice); groups.set(base, group); } const all = [...groups.values()]; const pinned = PINNED_LANGUAGES.map(id => all.find(group => group.id === id)).filter( (group): group is { id: string; name: string; voices: TtsVoice[] } => !!group ); const rest = all .filter(group => !PINNED_LANGUAGES.includes(group.id)) .sort((a, b) => a.name.localeCompare(b.name)); return [...pinned, ...rest]; } const HOST = process.env.WEBUI_HOST ?? "0.0.0.0"; const PORT = Number(process.env.WEBUI_PORT ?? 8789); export interface WebUiDeps { getCore: () => CoreClient | undefined; getPuppet: () => Puppet | undefined; isCoreConnected: () => boolean; coreProcess: CoreProcess; auth: AuthService; audio: AudioService; transcripts: TranscriptStore; preferences: PreferencesStore; media: MediaService; /** Replace the daemon process with a fresh copy, keeping the client running. */ restartDaemon: () => void; connectAudioDevices: () => Promise<{ connected: boolean; message: string }>; } export interface WebUiHandle { broadcast(event: unknown): void; } class HttpError extends Error { constructor( public readonly status: number, message: string ) { super(message); } } function clampLimit(value: unknown, fallback: number, max: number): number { const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; return Math.min(Math.max(Math.trunc(parsed), 1), max); } /** Render the pending approval URL as a PNG data URL for the login page. */ async function qrDataUrl(status: AuthStatus): Promise { const session = status.session; if (session?.state !== "pending" || !session.url) return undefined; return QRCode.toDataURL(session.url, { margin: 1, width: 280 }); } function stateDir(): string { const stateFile = process.env.VESKSLOP_STATE_FILE ?? fileURLToPath(new URL("../.veskslop-state.json", import.meta.url)); return dirname(stateFile); } /** Live browser audio: the call output streamed as raw PCM over a WebSocket. */ function startAudioSocket(server: http.Server | https.Server, deps: WebUiDeps): void { const wss = new WebSocketServer({ server, path: "/audio" }); wss.on("connection", socket => { let stop: (() => void) | undefined; const send = (message: unknown) => { if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message)); }; socket.on("message", raw => { let message: any; try { message = JSON.parse(raw.toString()); } catch { return; } if (message.type !== "listen") return; if (!message.on) { stop?.(); stop = undefined; send({ type: "status", listening: false }); return; } if (stop) return; try { const status = deps.audio.status(); if (!status.available) { throw new Error(status.hint ?? status.error ?? "audio is not available"); } if (!status.ffmpeg) { throw new Error("ffmpeg is missing; install it to hear the call"); } stop = deps.audio.startMonitor( chunk => { if (socket.readyState === socket.OPEN) socket.send(chunk); }, error => { send({ type: "error", error: error.message }); stop?.(); stop = undefined; } ); send({ type: "status", listening: true }); } catch (error) { send({ type: "error", error: (error as Error).message }); } }); const cleanup = () => { stop?.(); stop = undefined; }; socket.on("close", cleanup); socket.on("error", cleanup); }); } /** Shared parsing for the media grid filters, used by /api/media and /api/media/stats. */ function parseMediaFilters(query: Record) { const list = (value: unknown) => value ? String(value) .split(",") .map(entry => entry.trim().toLowerCase()) .filter(Boolean) : undefined; const amount = (value: unknown) => { if (value === undefined || value === "") return undefined; const number = Number(value); return Number.isFinite(number) && number >= 0 ? number : undefined; }; return { kind: ["image", "gif", "video", "audio"].includes(String(query.kind ?? "")) ? (String(query.kind) as "image" | "gif" | "video" | "audio") : undefined, source: ["attachment", "sticker", "embed"].includes(String(query.source ?? "")) ? String(query.source) : undefined, channelId: query.channelId ? String(query.channelId) : undefined, authorIds: list(query.authorId), origin: query.origin === "dm" || query.origin === "guild" ? (String(query.origin) as "dm" | "guild") : undefined, cachedOnly: query.cachedOnly === "1" || query.cachedOnly === "true", exts: list(query.ext), excludeHosts: list(query.excludeHosts), excludeExts: list(query.excludeExt), excludeProviders: list(query.excludeProviders), excludeSources: list(query.excludeSources), includeProviders: list(query.includeProviders), includeSources: list(query.includeSources), titleQuery: query.title ? String(query.title).slice(0, 200) : undefined, titleExact: query.titleExact === "1" || query.titleExact === "true", sizeLessThan: amount(query.sizeLt), sizeGreaterThan: amount(query.sizeGt), lengthLessThan: amount(query.lenLt), lengthGreaterThan: amount(query.lenGt), aspect: query.aspect ? String(query.aspect) : undefined, resolution: query.resolution ? String(query.resolution) : undefined, after: query.after ? String(query.after) : undefined, before: query.before ? String(query.before) : undefined, sort: ["newest", "oldest", "user", "size", "length"].includes(String(query.sort ?? "")) ? (String(query.sort) as "newest" | "oldest" | "user" | "size" | "length") : undefined }; } export function startWebUi(deps: WebUiDeps): WebUiHandle { const app = express(); app.use(express.json({ limit: "64kb" })); app.use("/api", (_req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); }); app.use(express.static(fileURLToPath(new URL("../public", import.meta.url)))); const clients = new Set(); let eventCount = 0; const requireCore = (): CoreClient => { const core = deps.getCore(); if (!deps.isCoreConnected() || !core) throw new HttpError(503, "not connected to Vesktop"); return core; }; const requirePuppet = (): Puppet => { const puppet = deps.getPuppet(); if (!puppet) throw new HttpError(503, "puppet not connected yet"); return puppet; }; const wrap = (handler: (req: Request, res: Response) => Promise) => (req: Request, res: Response) => { handler(req, res).catch(error => { if (error instanceof CoreError) { res.status(400).json({ ok: false, code: error.code, error: error.message }); return; } if (error instanceof MediaLimitError) { res.status(409).json({ ok: false, code: error.code, error: error.message, result: { usedBytes: error.usedBytes, limitBytes: error.limitBytes } }); return; } const status = error instanceof HttpError ? error.status : 500; res.status(status).json({ ok: false, error: String(error?.message ?? error) }); }); }; const coreStatus = () => ({ bridgeConnected: deps.isCoreConnected(), process: deps.coreProcess.status() }); const authStatus = async () => { const status = deps.auth.status(); return { ...status, qrDataUrl: await qrDataUrl(status) }; }; app.get("/api/health", (_req, res) => { res.json({ ok: true, result: { coreConnected: deps.isCoreConnected(), ownerId: deps.getPuppet()?.owner ?? null, process: deps.coreProcess.status(), uptimeSeconds: Math.floor(process.uptime()), events: eventCount, queries: getQueryCount() } }); }); app.get("/api/core/status", (_req, res) => { res.json({ ok: true, result: coreStatus() }); }); app.post( "/api/core/start", wrap(async (req, res) => { const forceToken = !!req.body?.forceToken || deps.auth.consumeFreshLogin(); await deps.coreProcess.start(forceToken); res.json({ ok: true, result: coreStatus() }); }) ); app.post( "/api/core/stop", wrap(async (_req, res) => { await deps.coreProcess.stop(); res.json({ ok: true, result: coreStatus() }); }) ); app.post( "/api/daemon/restart", wrap(async (_req, res) => { res.json({ ok: true, result: { restarting: true } }); deps.restartDaemon(); }) ); app.get( "/api/media/preview-failures", wrap(async (req, res) => { const limit = clampLimit(req.query.limit, 50, 500); res.json({ ok: true, result: deps.media.previewFailures(limit) }); }) ); app.post( "/api/core/restart", wrap(async (req, res) => { const forceToken = !!req.body?.forceToken || deps.auth.consumeFreshLogin(); await deps.coreProcess.restart(forceToken); res.json({ ok: true, result: coreStatus() }); }) ); app.get( "/api/auth", wrap(async (_req, res) => { res.json({ ok: true, result: await authStatus() }); }) ); app.post( "/api/auth/qr", wrap(async (_req, res) => { deps.auth.startQr(); res.json({ ok: true, result: await authStatus() }); }) ); app.post( "/api/auth/qr/cancel", wrap(async (_req, res) => { deps.auth.cancelQr(); res.json({ ok: true, result: await authStatus() }); }) ); app.post( "/api/auth/qr/captcha", wrap(async (req, res) => { const key = String(req.body?.key ?? "").trim(); if (!key) throw new HttpError(400, "a captcha key is required"); res.json({ ok: true, result: { accepted: deps.auth.submitCaptcha(key) } }); }) ); app.get( "/api/state", wrap(async (_req, res) => { res.json({ ok: true, result: await requireCore().getState() }); }) ); app.get( "/api/messages", wrap(async (req, res) => { const channelId = String(req.query.channelId ?? ""); const limit = clampLimit(req.query.limit, 50, 100); const refresh = req.query.refresh === "1" || req.query.refresh === "true"; // refresh fetches from Discord and fills the client cache. Attachments // stay as CDN links; nothing is stored as a file. const result = await dedupe(`messages:${channelId}:${limit}:${refresh}`, () => paced(() => refresh ? requireCore().fetchMessages(channelId, limit) : requireCore().readMessages(channelId, limit) ) ); res.json({ ok: true, result }); }) ); app.post( "/api/messages", wrap(async (req, res) => { const { channelId, content } = req.body ?? {}; const result = await pacedSend(() => requireCore().sendMessage(String(channelId), String(content ?? ""))); deps.getPuppet()?.appendAction({ type: "manual", mode: "channel", channelId: String(channelId), contentLength: String(content ?? "").length }); res.json({ ok: true, result }); }) ); app.post( "/api/dm", wrap(async (req, res) => { const { content } = req.body ?? {}; let userId = req.body?.userId ? String(req.body.userId) : undefined; if (!userId) { const username = String(req.body?.username ?? "").trim(); if (!username) throw new HttpError(400, "userId or username is required"); const search = (await requireCore().userSearch(username, undefined, 10)) as { users?: Array<{ id: string; username: string }>; }; const needle = username.replace(/^@/, "").toLowerCase(); const match = search?.users?.find(user => user.id === username) ?? search?.users?.find(user => user.username?.toLowerCase() === needle) ?? search?.users?.[0]; if (!match) throw new HttpError(404, `no cached user matches ${username}`); userId = match.id; } const result = await pacedSend(() => requireCore().sendDm(String(userId), String(content ?? ""))); deps.getPuppet()?.appendAction({ type: "manual", mode: "dm", userId: String(userId), contentLength: String(content ?? "").length }); res.json({ ok: true, result }); }) ); app.get( "/api/users", wrap(async (req, res) => { const query = String(req.query.q ?? ""); const guildId = req.query.guildId ? String(req.query.guildId) : undefined; const limit = clampLimit(req.query.limit, 10, 25); res.json({ ok: true, result: await requireCore().userSearch(query, guildId, limit) }); }) ); app.get( "/api/dms", wrap(async (req, res) => { const limit = clampLimit(req.query.limit, 25, 50); res.json({ ok: true, result: await requireCore().dmList(limit) }); }) ); app.get( "/api/preferences", wrap(async (_req, res) => { res.json({ ok: true, result: deps.preferences.get() }); }) ); app.post( "/api/preferences", wrap(async (req, res) => { const { visibleGuilds, commands: commandPrefs, tts, audio, voice: voicePrefs, favourites, media: mediaPrefs } = req.body ?? {}; if (visibleGuilds !== undefined && visibleGuilds !== null && !Array.isArray(visibleGuilds)) { throw new HttpError(400, "visibleGuilds must be an array of guild IDs or null"); } const patch: Partial = {}; if (visibleGuilds !== undefined) patch.visibleGuilds = visibleGuilds; if (tts !== undefined) { patch.tts = { provider: tts?.provider ?? null, language: tts?.language ?? null, voice: tts?.voice ?? null, speed: tts?.speed ?? null, pitch: tts?.pitch ?? null, expression: tts?.expression ?? null, profile: tts?.profile ?? {}, profilePresets: Array.isArray(tts?.profilePresets) ? tts.profilePresets.slice(0, 5) : deps.preferences.get().tts.profilePresets, highpass: tts?.highpass ?? null, echo: tts?.echo ?? null, words: tts?.words === true, wordGapMs: tts?.wordGapMs ?? null, vox: tts?.vox === true, voxClean: tts?.voxClean === true, preset: tts?.preset === "vox" || tts?.preset === "hev" ? tts.preset : null, echoDelayMs: tts?.echoDelayMs ?? null, echoLevel: tts?.echoLevel ?? null, crunch: tts?.crunch ?? null, crunchBits: tts?.crunchBits ?? null, crunchRate: tts?.crunchRate ?? null, streamWords: tts?.streamWords !== false }; } if (favourites !== undefined) { patch.favourites = { users: Array.isArray(favourites?.users) ? favourites.users : [], channels: Array.isArray(favourites?.channels) ? favourites.channels : [] }; } if (mediaPrefs !== undefined) { const current = deps.preferences.get().media; patch.media = { limitBytes: Number.isFinite(Number(mediaPrefs?.limitBytes)) ? Number(mediaPrefs.limitBytes) : current.limitBytes, warnPercent: Number.isFinite(Number(mediaPrefs?.warnPercent)) ? Number(mediaPrefs.warnPercent) : current.warnPercent, overrunPercent: Number.isFinite(Number(mediaPrefs?.overrunPercent)) ? Number(mediaPrefs.overrunPercent) : current.overrunPercent, tunables: mediaPrefs?.tunables ?? current.tunables }; } if (audio !== undefined) { patch.audio = { duck: audio?.duck !== false }; deps.audio.setDuckEnabled(patch.audio.duck); } if (commandPrefs !== undefined) { patch.commands = { wakeword: normalizeWakeword(commandPrefs?.wakeword) }; } if (voicePrefs !== undefined) { patch.voice = { noiseSuppression: voicePrefs?.noiseSuppression === true, echoCancellation: voicePrefs?.echoCancellation === true, automaticGainControl: voicePrefs?.automaticGainControl === true, noiseCancellation: voicePrefs?.noiseCancellation === true, bypassWhileSpeaking: voicePrefs?.bypassWhileSpeaking !== false }; } const updated = deps.preferences.update(patch); if (mediaPrefs !== undefined) deps.media.applyTunables(); if (commandPrefs !== undefined) deps.getPuppet()?.updateConfig({ wakeword: updated.commands.wakeword }); if (voicePrefs !== undefined) { const core = deps.getCore(); if (core && deps.isCoreConnected()) { void core.voiceProcessing(patch.voice).catch(() => undefined); } } res.json({ ok: true, result: updated }); }) ); app.get( "/api/favourites", wrap(async (_req, res) => { const prefs = deps.preferences.get(); const userIds = prefs.favourites.users; if (!userIds.length) { res.json({ ok: true, result: { users: [], channels: prefs.favourites.channels } }); return; } const [statuses, voices] = await Promise.all([ requireCore().userStatus(userIds) as Promise<{ users?: Array<{ id: string; status: string }> }>, requireCore().voiceUsers(userIds) as Promise<{ users?: any[] }> ]); const statusMap = new Map((statuses.users ?? []).map(user => [user.id, user.status])); const voiceMap = new Map((voices.users ?? []).map(user => [user.id, user])); res.json({ ok: true, result: { users: userIds.map(id => { const voice = voiceMap.get(id); return { id, username: voice?.username ?? id, avatar: voice?.avatar ?? null, status: statusMap.get(id) ?? "offline", channelId: voice?.channelId ?? null, channelName: voice?.channelName ?? null, guildId: voice?.guildId ?? null }; }), channels: prefs.favourites.channels } }); }) ); app.get( "/api/voice", wrap(async (req, res) => { const userId = req.query.userId ? String(req.query.userId) : undefined; const guildId = req.query.guildId ? String(req.query.guildId) : undefined; res.json({ ok: true, result: await requireCore().voiceGet(userId, guildId) }); }) ); app.get( "/api/voice/overview", wrap(async (req, res) => { const guildId = String(req.query.guildId ?? ""); res.json({ ok: true, result: await requireCore().voiceOverview(guildId) }); }) ); app.get( "/api/voice/volume", wrap(async (req, res) => { let userIds = req.query.userIds ? String(req.query.userIds) .split(",") .map(id => id.trim()) .filter(Boolean) : []; if (!userIds.length) { const voice = (await requireCore().voiceGet()) as { members?: Array<{ id: string }> } | undefined; userIds = (voice?.members ?? []).map(member => member.id); } if (!userIds.length) { res.json({ ok: true, result: { outputVolume: null, users: [] } }); return; } res.json({ ok: true, result: await requireCore().userVolume(userIds) }); }) ); app.post( "/api/voice/volume", wrap(async (req, res) => { const userId = String(req.body?.userId ?? ""); const volume = Number(req.body?.volume); const result = await pacedVoice(() => requireCore().userSetVolume(userId, volume)); deps.getPuppet()?.appendAction({ type: "voice", action: "set-volume", userId, volume }); res.json({ ok: true, result }); }) ); app.post( "/api/voice/join", wrap(async (req, res) => { const channelId = String(req.body?.channelId ?? ""); const mute = req.body?.mute !== false; const result = await pacedVoice(() => requireCore().voiceJoin(channelId, mute)); deps.getPuppet()?.appendAction({ type: "voice", action: "join", channelId, mute }); // The core joins with processing off; put the user's resting state back. const resting = deps.preferences.get().voice; if (deps.isCoreConnected()) { void requireCore() .voiceProcessing({ noiseSuppression: resting.noiseSuppression, echoCancellation: resting.echoCancellation, automaticGainControl: resting.automaticGainControl, noiseCancellation: resting.noiseCancellation }) .catch(() => undefined); } res.json({ ok: true, result }); }) ); app.post( "/api/voice/leave", wrap(async (_req, res) => { const result = await pacedVoice(() => requireCore().voiceLeave()); deps.getPuppet()?.appendAction({ type: "voice", action: "leave" }); res.json({ ok: true, result }); }) ); app.post( "/api/voice/move", wrap(async (req, res) => { const channelId = String(req.body?.channelId ?? ""); const result = await pacedVoice(() => requireCore().voiceMove(channelId)); deps.getPuppet()?.appendAction({ type: "voice", action: "move", channelId }); res.json({ ok: true, result }); }) ); app.get( "/api/audio", wrap(async (_req, res) => { res.json({ ok: true, result: deps.audio.status() }); }) ); app.get( "/api/audio/tts/voices", wrap(async (req, res) => { const engine = req.query.engine ? String(req.query.engine) : undefined; res.json({ ok: true, result: groupVoices(await deps.audio.ttsVoices(engine as TtsProvider | undefined)) }); }) ); app.post( "/api/audio/tts/warm", wrap(async (req, res) => { const requested = req.body?.engine; const engine = ["local", "espeak", "command", "api"].includes(requested) ? (requested as TtsProvider) : undefined; const result = await deps.audio.warmTts(engine); deps.getPuppet()?.appendAction({ type: "audio", action: "warm-engine", engine: result.engine, ms: result.ms }); res.json({ ok: true, result }); }) ); app.get( "/api/audio/devices", wrap(async (_req, res) => { res.json({ ok: true, result: await requireCore().voiceDevices() }); }) ); app.post( "/api/audio/connect", wrap(async (_req, res) => { res.json({ ok: true, result: await deps.connectAudioDevices() }); }) ); app.post( "/api/audio/devices", wrap(async (req, res) => { const inputDeviceId = req.body?.inputDeviceId ? String(req.body.inputDeviceId) : undefined; const outputDeviceId = req.body?.outputDeviceId ? String(req.body.outputDeviceId) : undefined; const result = await requireCore().voiceSetDevices(inputDeviceId, outputDeviceId); deps.getPuppet()?.appendAction({ type: "audio", action: "devices", inputDeviceId: inputDeviceId ?? null, outputDeviceId: outputDeviceId ?? null }); res.json({ ok: true, result }); }) ); app.post( "/api/audio/speak", wrap(async (req, res) => { const text = String(req.body?.text ?? ""); const prefs = deps.preferences.get(); const requested = req.body?.provider; const provider = ["api", "local", "espeak", "command"].includes(requested) ? requested : prefs.tts.provider; const voice = req.body?.voice ? String(req.body.voice) : prefs.tts.voice ?? undefined; // Number(null) is 0: null, undefined, and "" all mean "unset". const optional = (value: unknown): number | undefined => { if (value === undefined || value === null || value === "") return undefined; const number = Number(value); return Number.isFinite(number) ? number : undefined; }; const speed = optional(req.body?.speed) ?? prefs.tts.speed ?? undefined; const pitch = optional(req.body?.pitch) ?? prefs.tts.pitch ?? undefined; const expression = optional(req.body?.expression) ?? prefs.tts.expression ?? undefined; const profile = req.body?.profile && typeof req.body.profile === "object" ? req.body.profile : prefs.tts.profile; const highpass = optional(req.body?.highpass) ?? prefs.tts.highpass ?? undefined; const echo = optional(req.body?.echo) ?? prefs.tts.echo ?? undefined; const words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words; const wordGapMs = optional(req.body?.wordGapMs) ?? prefs.tts.wordGapMs ?? undefined; // The Half-Life VOX voice carries its prosody with it. const vox = (req.body?.vox !== undefined ? req.body.vox === true : prefs.tts.vox) || voice === "vox"; const voxClean = req.body?.voxClean !== undefined ? req.body.voxClean === true : prefs.tts.voxClean; const preset = req.body?.preset === "vox" || req.body?.preset === "hev" ? req.body.preset : req.body?.preset === null || req.body?.preset === "" ? null : prefs.tts.preset; const echoDelayMs = optional(req.body?.echoDelayMs) ?? prefs.tts.echoDelayMs ?? undefined; const echoLevel = optional(req.body?.echoLevel) ?? prefs.tts.echoLevel ?? undefined; const crunch = ["hl90", "hl90hq", "telephone", "adpcm", "crush"].includes(String(req.body?.crunch ?? "")) ? (String(req.body.crunch) as "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush") : req.body?.crunch === null || req.body?.crunch === "" || req.body?.crunch === "off" ? undefined : prefs.tts.crunch ?? undefined; const crunchBits = optional(req.body?.crunchBits) ?? prefs.tts.crunchBits ?? undefined; const crunchRate = optional(req.body?.crunchRate) ?? prefs.tts.crunchRate ?? undefined; const streamWords = req.body?.streamWords !== undefined ? req.body.streamWords !== false : prefs.tts.streamWords; const result = await deps.audio.speak(text, { provider: provider ?? undefined, voice, ...(Number.isFinite(speed) ? { speed } : {}), ...(Number.isFinite(pitch) ? { pitch } : {}), ...(Number.isFinite(expression) ? { expression } : {}), ...(profile && Object.keys(profile).length ? { profile } : {}), ...(Number.isFinite(highpass) ? { highpass } : {}), ...(Number.isFinite(echo) ? { echo } : {}), ...(words ? { words: true } : {}), ...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}), ...(vox ? { vox: true } : {}), ...(voxClean ? { voxClean: true } : {}), ...(preset ? { preset } : {}), ...(Number.isFinite(echoDelayMs) ? { echoDelayMs } : {}), ...(Number.isFinite(echoLevel) ? { echoLevel } : {}), ...(crunch ? { crunch } : {}), ...(Number.isFinite(crunchBits) ? { crunchBits } : {}), ...(Number.isFinite(crunchRate) ? { crunchRate } : {}), stream: streamWords && words === true }); deps.getPuppet()?.appendAction({ type: "audio", action: "speak", provider: provider ?? deps.audio.status().tts.defaultProvider, chars: text.trim().length }); res.json({ ok: true, result }); }) ); app.post( "/api/audio/stop", wrap(async (_req, res) => { deps.audio.stop(); deps.getPuppet()?.appendAction({ type: "audio", action: "stop" }); res.json({ ok: true, result: deps.audio.status() }); }) ); app.post( "/api/audio/capture", wrap(async (req, res) => { const start = req.body?.start !== false; const result = start ? await deps.audio.startCapture() : (deps.audio.stopCapture(), { stopped: true }); deps.getPuppet()?.appendAction({ type: "audio", action: start ? "capture-start" : "capture-stop" }); res.json({ ok: true, result }); }) ); app.get( "/api/transcript", wrap(async (req, res) => { const limit = clampLimit(req.query.limit, 100, 500); const sessionId = req.query.sessionId ? String(req.query.sessionId) : undefined; res.json({ ok: true, result: { sessions: deps.transcripts.readSessions(20), segments: deps.transcripts.readSegments(limit, sessionId) } }); }) ); app.get( "/api/media/scrapes", wrap(async (_req, res) => { res.json({ ok: true, result: deps.media.listScrapes() }); }) ); app.get( "/api/media/status", wrap(async (_req, res) => { res.json({ ok: true, result: deps.media.status() }); }) ); app.get( "/api/media/backfill", wrap(async (_req, res) => { res.json({ ok: true, result: deps.media.backfillStatus() }); }) ); app.get( "/api/media/titles", wrap(async (_req, res) => { res.json({ ok: true, result: deps.media.titleStatus() }); }) ); app.get( "/api/media/recover", wrap(async (_req, res) => { res.json({ ok: true, result: deps.media.recoverStatus() }); }) ); app.post( "/api/media/recover/start", wrap(async (_req, res) => { deps.media.startRecover(); res.json({ ok: true, result: deps.media.recoverStatus() }); }) ); app.post( "/api/media/recover/pause", wrap(async (_req, res) => { deps.media.pauseRecover(); res.json({ ok: true, result: deps.media.recoverStatus() }); }) ); app.post( "/api/media/titles/start", wrap(async (_req, res) => { res.json({ ok: true, result: deps.media.startTitles() }); }) ); app.post( "/api/media/titles/pause", wrap(async (_req, res) => { res.json({ ok: true, result: deps.media.pauseTitles() }); }) ); app.post( "/api/media/backfill/start", wrap(async (req, res) => { const chatId = req.body?.chatId ? String(req.body.chatId) : undefined; res.json({ ok: true, result: deps.media.startBackfill(chatId) }); }) ); app.post( "/api/media/backfill/pause", wrap(async (req, res) => { const chatId = req.body?.chatId ? String(req.body.chatId) : undefined; res.json({ ok: true, result: deps.media.pauseBackfill(chatId) }); }) ); app.get( "/api/media/:scrapeId/:itemId/thumb", wrap(async (req, res) => { const file = deps.media.getMediaThumb(String(req.params.scrapeId), Number(req.params.itemId)); if (!file || !existsSync(file.path)) throw new HttpError(404, "thumb not found"); res.type(file.contentType).sendFile(file.path); }) ); app.get( "/api/media/:scrapeId/:itemId/anim", wrap(async (req, res) => { const file = deps.media.getMediaAnim(String(req.params.scrapeId), Number(req.params.itemId)); if (!file || !existsSync(file.path)) throw new HttpError(404, "anim not found"); res.type(file.contentType).sendFile(file.path); }) ); app.get( "/api/media/:scrapeId/:itemId/file", wrap(async (req, res) => { const file = deps.media.getMediaFile(String(req.params.scrapeId), Number(req.params.itemId)); if (!file || !existsSync(file.path)) throw new HttpError(404, "file not found"); res.type(file.contentType).sendFile(file.path); }) ); // Playback and full-view source: local file when downloaded, otherwise a refreshed remote URL. app.get( "/api/media/:scrapeId/:itemId/stream", wrap(async (req, res) => { const source = await deps.media.resolveMediaSource( String(req.params.scrapeId), Number(req.params.itemId) ); if (!source) throw new HttpError(404, "media source not found"); if (source.kind === "file") { if (!existsSync(source.path)) throw new HttpError(404, "file not found"); res.type(source.contentType).sendFile(source.path); return; } const rangeHeaders: Record = req.headers.range ? { range: String(req.headers.range) } : {}; let upstream = await fetch(source.url, { headers: rangeHeaders }); // The signed CDN URL may have expired; re-fetch the message once and retry. if (!upstream.ok && [403, 404, 410].includes(upstream.status)) { const refreshed = await deps.media.refreshMediaUrl( String(req.params.scrapeId), Number(req.params.itemId) ); if (refreshed) upstream = await fetch(refreshed, { headers: rangeHeaders }); } if (!upstream.ok && upstream.status !== 206) { throw new HttpError(502, `upstream ${upstream.status}`); } // Never pipe a page into a media element: embed URLs that are not direct files // (YouTube, provider redirectors) answer with HTML. const upstreamType = upstream.headers.get("content-type") ?? ""; if (!/^(image|video|audio|application\/octet-stream)/i.test(upstreamType)) { throw new HttpError(415, `not a media resource (${upstreamType || "unknown"})`); } res.status(upstream.status); for (const header of ["content-type", "content-length", "accept-ranges", "content-range"]) { const value = upstream.headers.get(header); if (value) res.setHeader(header, value); } if (!upstream.body) { res.end(); return; } Readable.fromWeb(upstream.body as any).pipe(res); }) ); app.get( "/api/media/storage", wrap(async (_req, res) => { res.json({ ok: true, result: deps.media.refreshRetention() }); }) ); app.post( "/api/media/clean", wrap(async (req, res) => { const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String) : undefined; const deleted = deps.media.cleanMedia(ids); deps.getPuppet()?.appendAction({ type: "media", action: "clean", deleted }); res.json({ ok: true, result: { deleted } }); }) ); app.get( "/api/media", wrap(async (req, res) => { const filters = parseMediaFilters(req.query); const limit = clampLimit(req.query.limit, 60, 500); const offset = Math.max(0, Number(req.query.offset ?? 0) || 0); res.json({ ok: true, result: deps.media.queryMedia({ ...filters, limit, offset }) }); }) ); app.get( "/api/media/authors", wrap(async (_req, res) => { res.json({ ok: true, result: deps.media.listAuthors() }); }) ); app.get( "/api/media/stats", wrap(async (req, res) => { res.json({ ok: true, result: deps.media.stats({ ...parseMediaFilters(req.query), after: req.query.after ? String(req.query.after) : undefined, before: req.query.before ? String(req.query.before) : undefined }) }); }) ); app.post( "/api/media/count", wrap(async (req, res) => { const channelId = String(req.body?.channelId ?? ""); if (!channelId) throw new HttpError(400, "channelId is required"); res.json({ ok: true, result: await deps.media.countMessages({ channelId, guildId: req.body?.guildId ? String(req.body.guildId) : null, after: req.body?.after ? String(req.body.after) : null, before: req.body?.before ? String(req.body.before) : null }) }); }) ); app.get( "/api/media/errors", wrap(async (_req, res) => { res.json({ ok: true, result: deps.media.listErrors() }); }) ); app.post( "/api/media/errors/clear", wrap(async (_req, res) => { res.json({ ok: true, result: { cleared: deps.media.clearErrors() } }); }) ); app.post( "/api/media/scrape", wrap(async (req, res) => { const channelId = String(req.body?.channelId ?? ""); if (!channelId) throw new HttpError(400, "channelId is required"); const mode = req.body?.mode === "download" ? "download" : "links"; const guildId = req.body?.guildId ? String(req.body.guildId) : null; const confirm = req.body?.confirm === true; const requested = String(req.body?.backend ?? "auto"); const backend = requested === "slopbot" || requested === "puppet" ? requested : "auto"; const downloadDir = req.body?.downloadDir ? String(req.body.downloadDir) : null; const after = req.body?.after ? String(req.body.after) : null; const before = req.body?.before ? String(req.body.before) : null; const scrape = await deps.media.startScrape({ channelId, guildId, mode, confirm, backend, downloadDir, after, before }); deps.getPuppet()?.appendAction({ type: "media", action: "scrape", channelId, mode, scrapeId: scrape.id }); res.json({ ok: true, result: scrape }); }) ); app.post( "/api/media/scrape/resume", wrap(async (req, res) => { const id = String(req.body?.id ?? ""); if (!id) throw new HttpError(400, "id is required"); const scrape = await deps.media.resumeScrape(id); deps.getPuppet()?.appendAction({ type: "media", action: "resume", scrapeId: id }); res.json({ ok: true, result: scrape }); }) ); app.post( "/api/media/scrape/stop", wrap(async (req, res) => { const id = String(req.body?.id ?? ""); if (!id) throw new HttpError(400, "id is required"); deps.media.stopScrape(id); deps.getPuppet()?.appendAction({ type: "media", action: "stop", scrapeId: id }); res.json({ ok: true, result: { stopped: id } }); }) ); app.get("/api/logs", (req, res) => { const puppet = requirePuppet(); const limit = clampLimit(req.query.limit, 100, 500); res.json({ ok: true, result: puppet.readActionLog(limit) }); }); app.get("/api/events", (req, res) => { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", "X-Accel-Buffering": "no" }); res.write( `data: ${JSON.stringify({ type: "hello", coreConnected: deps.isCoreConnected(), ownerId: deps.getPuppet()?.owner ?? null })}\n\n` ); clients.add(res); const keepAlive = setInterval(() => res.write(": ping\n\n"), 15000); req.on("close", () => { clearInterval(keepAlive); clients.delete(res); }); }); const tls = ensureTls(stateDir()); const server = tls ? https.createServer({ key: readFileSync(tls.key), cert: readFileSync(tls.cert) }, app) : http.createServer(app); startAudioSocket(server, deps); server.listen(PORT, HOST, () => { console.log(`[webui] listening on ${tls ? "https" : "http"}://${HOST}:${PORT} (no auth; LAN or Tailscale only)`); if (!tls && process.env.WEBUI_TLS !== "0") { console.warn("[webui] TLS is unavailable; browser media APIs need HTTPS or localhost"); } }); server.on("error", error => { console.error("[webui] failed to start:", error); }); return { broadcast(event: unknown) { eventCount++; const line = `data: ${JSON.stringify(event)}\n\n`; for (const client of clients) { try { client.write(line); } catch { clients.delete(client); } } } }; }