Files
Veskslop-t/poc/src/webui.ts
T

1281 lines
46 KiB
TypeScript

/*
* 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<string, string> = {
en: "English",
sv: "Swedish",
de: "German",
da: "Danish",
nb: "Norwegian",
no: "Norwegian",
ru: "Russian",
chr: "Cherokee"
};
function groupVoices(voices: TtsVoice[]) {
const groups = new Map<string, { id: string; name: string; voices: TtsVoice[] }>();
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<string | undefined> {
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<string, unknown>) {
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<Response>();
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<void>) => (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<WebPreferences> = {};
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<string, string> = 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);
}
}
}
};
}