1236 lines
45 KiB
TypeScript
1236 lines
45 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 { 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, tts, audio, 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);
|
|
}
|
|
|
|
const updated = deps.preferences.update(patch);
|
|
if (mediaPrefs !== undefined) deps.media.applyTunables();
|
|
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 });
|
|
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;
|
|
const speed = req.body?.speed !== undefined ? Number(req.body.speed) : prefs.tts.speed ?? undefined;
|
|
const pitch = req.body?.pitch !== undefined ? Number(req.body.pitch) : prefs.tts.pitch ?? undefined;
|
|
const expression =
|
|
req.body?.expression !== undefined ? Number(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 =
|
|
req.body?.highpass !== undefined ? Number(req.body.highpass) : prefs.tts.highpass ?? undefined;
|
|
const echo = req.body?.echo !== undefined ? Number(req.body.echo) : prefs.tts.echo ?? undefined;
|
|
const words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words;
|
|
const wordGapMs =
|
|
req.body?.wordGapMs !== undefined ? Number(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 =
|
|
req.body?.echoDelayMs !== undefined ? Number(req.body.echoDelayMs) : prefs.tts.echoDelayMs ?? undefined;
|
|
const echoLevel =
|
|
req.body?.echoLevel !== undefined ? Number(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 =
|
|
req.body?.crunchBits !== undefined ? Number(req.body.crunchBits) : prefs.tts.crunchBits ?? undefined;
|
|
const crunchRate =
|
|
req.body?.crunchRate !== undefined ? Number(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);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|