Add Nocturne debug web UI, core process control, and web QR login

This commit is contained in:
Fisto
2026-09-13 15:47:47 +02:00
parent 4e1046fae7
commit 2bcd8c6d7a
16 changed files with 4806 additions and 198 deletions
+349
View File
@@ -0,0 +1,349 @@
/*
* 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 { fileURLToPath } from "node:url";
import QRCode from "qrcode";
import type { AuthService, AuthStatus } from "./auth-service.js";
import { CoreError, type CoreClient } from "./core-client.js";
import type { CoreProcess } from "./core-process.js";
import { dedupe, getQueryCount, paced, pacedSend } from "./pacing.js";
import type { Puppet } from "./puppet.js";
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;
}
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 });
}
export function startWebUi(deps: WebUiDeps): WebUiHandle {
const app = express();
app.use(express.json({ limit: "64kb" }));
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/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.post("/api/voice/join", (_req, res) => {
res.status(501).json({ ok: false, error: "voice.join is not implemented yet" });
});
app.post("/api/voice/leave", (_req, res) => {
res.status(501).json({ ok: false, error: "voice.leave is not implemented yet" });
});
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 server = app.listen(PORT, HOST, () => {
console.log(`[webui] listening on http://${HOST}:${PORT} (no auth; LAN or Tailscale only)`);
});
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);
}
}
}
};
}