841 lines
29 KiB
TypeScript
841 lines
29 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 { readFileSync } from "node:fs";
|
|
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 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;
|
|
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);
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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/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 } = 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
|
|
};
|
|
}
|
|
|
|
if (favourites !== undefined) {
|
|
patch.favourites = {
|
|
users: Array.isArray(favourites?.users) ? favourites.users : [],
|
|
channels: Array.isArray(favourites?.channels) ? favourites.channels : []
|
|
};
|
|
}
|
|
|
|
if (audio !== undefined) {
|
|
patch.audio = { duck: audio?.duck !== false };
|
|
deps.audio.setDuckEnabled(patch.audio.duck);
|
|
}
|
|
|
|
res.json({ ok: true, result: deps.preferences.update(patch) });
|
|
})
|
|
);
|
|
|
|
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;
|
|
|
|
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 } : {})
|
|
});
|
|
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",
|
|
wrap(async (req, res) => {
|
|
const kind = ["image", "gif", "video", "audio"].includes(String(req.query.kind ?? ""))
|
|
? (String(req.query.kind) as "image" | "gif" | "video" | "audio")
|
|
: undefined;
|
|
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({ kind, limit, offset }) });
|
|
})
|
|
);
|
|
|
|
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 scrape = await deps.media.startScrape({ channelId, guildId, mode });
|
|
deps.getPuppet()?.appendAction({
|
|
type: "media",
|
|
action: "scrape",
|
|
channelId,
|
|
mode,
|
|
scrapeId: scrape.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);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|