Add Nocturne debug web UI, core process control, and web QR login
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user