Add Nocturne debug web UI, core process control, and web QR login
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Veskslop daemon — auth state for the web UI.
|
||||
*
|
||||
* Owns at most one QR login session. The terminal tool and the web UI share
|
||||
* the session class. The token file stays the single credential store.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import { isTerminalQrState, QrLoginSession, type QrLoginState, type QrLoginUpdate } from "./qr-session.js";
|
||||
|
||||
export interface AuthSessionSnapshot {
|
||||
state: QrLoginState;
|
||||
url?: string;
|
||||
user?: object;
|
||||
error?: string;
|
||||
captcha?: object;
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
tokenFile: string;
|
||||
hasToken: boolean;
|
||||
session: AuthSessionSnapshot | null;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private session?: QrLoginSession;
|
||||
private snapshot: AuthSessionSnapshot | null = null;
|
||||
private freshLogin = false;
|
||||
|
||||
constructor(
|
||||
private readonly tokenFile: string,
|
||||
private readonly onUpdate?: (update: QrLoginUpdate) => void
|
||||
) {}
|
||||
|
||||
status(): AuthStatus {
|
||||
return {
|
||||
tokenFile: this.tokenFile,
|
||||
hasToken: existsSync(this.tokenFile),
|
||||
session: this.snapshot ? structuredClone(this.snapshot) : null
|
||||
};
|
||||
}
|
||||
|
||||
startQr(): AuthStatus {
|
||||
if (this.session && !isTerminalQrState(this.session.state)) return this.status();
|
||||
|
||||
this.snapshot = { state: "connecting" };
|
||||
this.session = new QrLoginSession({
|
||||
tokenFile: this.tokenFile,
|
||||
debug: process.env.DEBUG === "1",
|
||||
onUpdate: update => {
|
||||
this.snapshot = { ...update };
|
||||
if (update.state === "finished") this.freshLogin = true;
|
||||
this.onUpdate?.(update);
|
||||
}
|
||||
});
|
||||
this.session.start();
|
||||
|
||||
return this.status();
|
||||
}
|
||||
|
||||
cancelQr(): AuthStatus {
|
||||
this.session?.cancel();
|
||||
return this.status();
|
||||
}
|
||||
|
||||
submitCaptcha(key: string): boolean {
|
||||
return this.session?.submitCaptcha(key) ?? false;
|
||||
}
|
||||
|
||||
/** True once after a successful login. Used to force the new token on start. */
|
||||
consumeFreshLogin(): boolean {
|
||||
const value = this.freshLogin;
|
||||
this.freshLogin = false;
|
||||
return value;
|
||||
}
|
||||
|
||||
clearFreshLogin(): void {
|
||||
this.freshLogin = false;
|
||||
}
|
||||
}
|
||||
+115
-22
@@ -14,9 +14,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { AuthService } from "./auth-service.js";
|
||||
import { tokenFile } from "./config.js";
|
||||
import { CoreClient, coreSocketPath } from "./core-client.js";
|
||||
import { dedupe, getQueryCount, paced, sleep } from "./pacing.js";
|
||||
import { CoreProcess } from "./core-process.js";
|
||||
import { dedupe, getQueryCount, paced, pacedSend, sleep } from "./pacing.js";
|
||||
import { Puppet } from "./puppet.js";
|
||||
import { startWebUi } from "./webui.js";
|
||||
|
||||
const HOST = process.env.MCP_HOST ?? "127.0.0.1";
|
||||
const PORT = Number(process.env.MCP_PORT ?? 8788);
|
||||
@@ -24,27 +28,46 @@ const PORT = Number(process.env.MCP_PORT ?? 8788);
|
||||
let core: CoreClient | undefined;
|
||||
let puppet: Puppet | undefined;
|
||||
let coreConnected = false;
|
||||
let connecting = false;
|
||||
|
||||
async function connectLoop() {
|
||||
for (;;) {
|
||||
try {
|
||||
const client = await CoreClient.connect();
|
||||
async function connectLoop(): Promise<void> {
|
||||
if (connecting) return;
|
||||
connecting = true;
|
||||
|
||||
core = client;
|
||||
puppet = new Puppet(client);
|
||||
try {
|
||||
for (;;) {
|
||||
try {
|
||||
const client = await CoreClient.connect();
|
||||
|
||||
client.onEvent(event => {
|
||||
void puppet!.enqueueEvent(event);
|
||||
});
|
||||
core = client;
|
||||
puppet = new Puppet(client);
|
||||
|
||||
coreConnected = true;
|
||||
console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
|
||||
client.onEvent(event => {
|
||||
void puppet!.enqueueEvent(event);
|
||||
webui.broadcast(event);
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`);
|
||||
await sleep(2000);
|
||||
client.onClose(() => {
|
||||
console.warn("[daemon] core bridge disconnected");
|
||||
coreConnected = false;
|
||||
core = undefined;
|
||||
puppet = undefined;
|
||||
webui.broadcast({ type: "CORE_STATUS", coreConnected: false });
|
||||
void connectLoop();
|
||||
});
|
||||
|
||||
coreConnected = true;
|
||||
console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
|
||||
webui.broadcast({ type: "CORE_STATUS", coreConnected: true });
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`);
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
connecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +81,32 @@ function requirePuppet(): Puppet {
|
||||
return puppet;
|
||||
}
|
||||
|
||||
const coreProcess = new CoreProcess({ tokenFile });
|
||||
|
||||
const auth = new AuthService(tokenFile, update => {
|
||||
webui.broadcast({ type: "AUTH_QR", update });
|
||||
|
||||
if (update.state === "finished" && coreProcess.status().running) {
|
||||
setTimeout(() => {
|
||||
void coreProcess.restart(auth.consumeFreshLogin()).catch(error => {
|
||||
console.error("[core] restart after login failed:", error);
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
const webui = startWebUi({
|
||||
getCore: () => core,
|
||||
getPuppet: () => puppet,
|
||||
isCoreConnected: () => coreConnected,
|
||||
coreProcess,
|
||||
auth
|
||||
});
|
||||
|
||||
coreProcess.onExit(() => {
|
||||
webui.broadcast({ type: "CORE_STATUS", coreConnected, process: coreProcess.status() });
|
||||
});
|
||||
|
||||
function jsonText(value: unknown) {
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
||||
}
|
||||
@@ -66,6 +115,9 @@ function jsonText(value: unknown) {
|
||||
const READ_ONLY_TOOLS: Record<string, (args: any) => Promise<unknown> | unknown> = {
|
||||
get_state: () => requireCore().getState(),
|
||||
get_voice_state: args => requireCore().voiceGet(args?.userId, args?.guildId),
|
||||
get_voice_overview: args => requireCore().voiceOverview(args?.guildId),
|
||||
search_users: args => requireCore().userSearch(args?.query, args?.guildId, args?.limit),
|
||||
list_dms: args => requireCore().dmList(args?.limit),
|
||||
get_context: args =>
|
||||
requirePuppet().getContext(args?.channelId, {
|
||||
maxMessages: args?.maxMessages,
|
||||
@@ -100,7 +152,7 @@ function buildServer(): McpServer {
|
||||
}
|
||||
},
|
||||
async ({ channelId, content }) => {
|
||||
await paced(() => requireCore().sendMessage(channelId, content));
|
||||
await pacedSend(() => requireCore().sendMessage(channelId, content));
|
||||
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
|
||||
}
|
||||
);
|
||||
@@ -115,7 +167,7 @@ function buildServer(): McpServer {
|
||||
}
|
||||
},
|
||||
async ({ userId, content }) => {
|
||||
const result = await paced(() => requireCore().sendDm(userId, content));
|
||||
const result = await pacedSend(() => requireCore().sendDm(userId, content));
|
||||
return jsonText({ ...result, userId, queriesUsed: getQueryCount() });
|
||||
}
|
||||
);
|
||||
@@ -199,12 +251,47 @@ function buildServer(): McpServer {
|
||||
description: "Which voice channel a user is in and who else is there. Defaults to the logged-in user.",
|
||||
inputSchema: {
|
||||
userId: z.string().optional().describe("User ID. Defaults to the logged-in user."),
|
||||
guildId: z.string().optional().describe("Guild ID. Omit for a DM call.")
|
||||
guildId: z.string().optional().describe("Guild ID. Omit to search all guilds.")
|
||||
}
|
||||
},
|
||||
async ({ userId, guildId }) => jsonText(await requireCore().voiceGet(userId, guildId))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_voice_overview",
|
||||
{
|
||||
description: "List the occupied voice channels in a guild and their members.",
|
||||
inputSchema: {
|
||||
guildId: z.string().describe("Guild ID")
|
||||
}
|
||||
},
|
||||
async ({ guildId }) => jsonText(await requireCore().voiceOverview(guildId))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"search_users",
|
||||
{
|
||||
description: "Search users in the client cache by username, global name, guild nickname, or user ID.",
|
||||
inputSchema: {
|
||||
query: z.string().min(1).describe("Username, global name, nickname, or user ID"),
|
||||
guildId: z.string().optional().describe("Limit the search to one guild"),
|
||||
limit: z.number().int().min(1).max(25).default(10).describe("Max results")
|
||||
}
|
||||
},
|
||||
async ({ query, guildId, limit }) => jsonText(await requireCore().userSearch(query, guildId, limit))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_dms",
|
||||
{
|
||||
description: "List the cached DM and group DM channels with their recipients.",
|
||||
inputSchema: {
|
||||
limit: z.number().int().min(1).max(50).default(25).describe("Max channels")
|
||||
}
|
||||
},
|
||||
async ({ limit }) => jsonText(await requireCore().dmList(limit))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_action_log",
|
||||
{
|
||||
@@ -338,7 +425,13 @@ app.listen(PORT, HOST, (error?: Error) => {
|
||||
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
function shutdown() {
|
||||
core?.close();
|
||||
process.exit(0);
|
||||
});
|
||||
void coreProcess
|
||||
.dispose(4000)
|
||||
.catch(() => undefined)
|
||||
.finally(() => process.exit(0));
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Veskslop daemon — Vesktop core process control.
|
||||
*
|
||||
* The daemon can start, stop, and restart the Electron client that hosts the
|
||||
* core bridge. Only a process started by this daemon instance is managed.
|
||||
*
|
||||
* On Linux without a DISPLAY, the daemon starts Xvfb and points the client at
|
||||
* it. Set VESKSLOP_XVFB=0 to disable that.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
const DEFAULT_ARGS = "--no-sandbox --disable-gpu --disable-dev-shm-usage";
|
||||
const XVFB_ARGS = "-screen 0 1280x720x24 -nolisten tcp";
|
||||
const MAX_OUTPUT_LINES = 20;
|
||||
|
||||
export interface CoreExit {
|
||||
code: number | null;
|
||||
signal: string | null;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface CoreProcessStatus {
|
||||
managed: boolean;
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
startedAt: string | null;
|
||||
command: string | null;
|
||||
lastExit: CoreExit | null;
|
||||
output: string[];
|
||||
}
|
||||
|
||||
export interface CoreProcessOptions {
|
||||
tokenFile: string;
|
||||
}
|
||||
|
||||
function prefixLines(prefix: string, chunk: Buffer): string {
|
||||
return chunk
|
||||
.toString()
|
||||
.split("\n")
|
||||
.map(line => (line ? `${prefix} ${line}` : line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function findFreeDisplay(): string {
|
||||
for (let number = 99; number < 110; number++) {
|
||||
if (!existsSync(`/tmp/.X11-unix/X${number}`)) return `:${number}`;
|
||||
}
|
||||
return ":99";
|
||||
}
|
||||
|
||||
export class CoreProcess {
|
||||
private child?: ChildProcess;
|
||||
private startedAt?: number;
|
||||
private command?: string;
|
||||
private xvfb?: ChildProcess;
|
||||
private display?: string;
|
||||
private lastExit: CoreExit | null = null;
|
||||
private readonly output: string[] = [];
|
||||
private readonly exitListeners = new Set<() => void>();
|
||||
|
||||
constructor(private readonly options: CoreProcessOptions) {}
|
||||
|
||||
status(): CoreProcessStatus {
|
||||
const running = !!this.child && this.child.exitCode === null && !this.child.killed;
|
||||
return {
|
||||
managed: !!this.child,
|
||||
running,
|
||||
pid: this.child?.pid ?? null,
|
||||
startedAt: this.startedAt ? new Date(this.startedAt).toISOString() : null,
|
||||
command: this.command ?? null,
|
||||
lastExit: this.lastExit,
|
||||
output: [...this.output]
|
||||
};
|
||||
}
|
||||
|
||||
onExit(listener: () => void) {
|
||||
this.exitListeners.add(listener);
|
||||
return () => this.exitListeners.delete(listener);
|
||||
}
|
||||
|
||||
async start(forceToken = false): Promise<CoreProcessStatus> {
|
||||
if (this.child) return this.status();
|
||||
|
||||
const override = process.env.VESKSLOP_CORE_CMD;
|
||||
const args = (process.env.VESKSLOP_CORE_ARGS ?? DEFAULT_ARGS).split(/\s+/).filter(Boolean);
|
||||
const display = await this.ensureDisplay();
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
...(display ? { DISPLAY: display } : {}),
|
||||
VESKSLOP_HEADLESS: process.env.VESKSLOP_HEADLESS ?? "1",
|
||||
VESKSLOP_TOKEN_FILE: this.options.tokenFile,
|
||||
...(forceToken ? { VESKSLOP_FORCE_TOKEN: "1" } : {})
|
||||
};
|
||||
|
||||
let command: string;
|
||||
let child: ChildProcess;
|
||||
|
||||
if (override) {
|
||||
command = override;
|
||||
child = spawn(override, { cwd: ROOT, env, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
||||
} else {
|
||||
const electron = join(
|
||||
ROOT,
|
||||
"node_modules",
|
||||
".bin",
|
||||
process.platform === "win32" ? "electron.cmd" : "electron"
|
||||
);
|
||||
if (!existsSync(electron)) {
|
||||
throw new Error("electron is not installed; run pnpm install or set VESKSLOP_CORE_CMD");
|
||||
}
|
||||
command = `${electron} . ${args.join(" ")}`;
|
||||
child = spawn(electron, [".", ...args], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
this.child = child;
|
||||
this.startedAt = Date.now();
|
||||
this.command = command;
|
||||
this.lastExit = null;
|
||||
this.output.length = 0;
|
||||
|
||||
child.stdout?.on("data", chunk => {
|
||||
this.appendOutput(chunk);
|
||||
process.stdout.write(prefixLines("[core]", chunk));
|
||||
});
|
||||
child.stderr?.on("data", chunk => {
|
||||
this.appendOutput(chunk);
|
||||
process.stderr.write(prefixLines("[core]", chunk));
|
||||
});
|
||||
|
||||
child.on("error", error => {
|
||||
console.error("[core] failed to start:", error.message);
|
||||
this.appendOutput(Buffer.from(error.message));
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(`[core] exited (code ${code ?? "null"}${signal ? `, signal ${signal}` : ""})`);
|
||||
this.lastExit = { code, signal, at: new Date().toISOString() };
|
||||
this.child = undefined;
|
||||
this.startedAt = undefined;
|
||||
this.command = undefined;
|
||||
for (const listener of this.exitListeners) listener();
|
||||
});
|
||||
|
||||
console.log(`[core] starting: ${command}${display ? ` (DISPLAY=${display})` : ""}`);
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async stop(timeoutMs = 8000): Promise<CoreProcessStatus> {
|
||||
const child = this.child;
|
||||
if (!child) return this.status();
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async restart(forceToken = false): Promise<CoreProcessStatus> {
|
||||
await this.stop();
|
||||
return this.start(forceToken);
|
||||
}
|
||||
|
||||
/** Stop the core and any Xvfb that this daemon started. */
|
||||
async dispose(timeoutMs = 4000): Promise<void> {
|
||||
await this.stop(timeoutMs);
|
||||
this.stopXvfb();
|
||||
}
|
||||
|
||||
private appendOutput(chunk: Buffer) {
|
||||
for (const line of chunk.toString().split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
this.output.push(line);
|
||||
if (this.output.length > MAX_OUTPUT_LINES) this.output.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureDisplay(): Promise<string | undefined> {
|
||||
if (process.env.DISPLAY) return process.env.DISPLAY;
|
||||
if (process.platform !== "linux" || process.env.VESKSLOP_XVFB === "0") return undefined;
|
||||
if (this.xvfb && this.xvfb.exitCode === null) return this.display;
|
||||
|
||||
const display = findFreeDisplay();
|
||||
const child = spawn("Xvfb", [display, ...XVFB_ARGS.split(" ")], { stdio: ["ignore", "ignore", "pipe"] });
|
||||
|
||||
child.stderr?.on("data", chunk => process.stderr.write(prefixLines("[xvfb]", chunk)));
|
||||
child.on("error", error => {
|
||||
console.warn(`[core] Xvfb is not available: ${error.message}`);
|
||||
this.xvfb = undefined;
|
||||
this.display = undefined;
|
||||
});
|
||||
child.on("exit", () => {
|
||||
this.xvfb = undefined;
|
||||
this.display = undefined;
|
||||
});
|
||||
|
||||
this.xvfb = child;
|
||||
this.display = display;
|
||||
|
||||
// Wait for the X socket so Electron does not race the server startup.
|
||||
const socket = `/tmp/.X11-unix/X${display.slice(1)}`;
|
||||
for (let attempt = 0; attempt < 30 && !existsSync(socket); attempt++) await sleep(100);
|
||||
if (!existsSync(socket)) console.warn(`[core] Xvfb socket ${socket} did not appear; continuing anyway`);
|
||||
|
||||
console.log(`[core] Xvfb on ${display} (pid ${child.pid})`);
|
||||
return display;
|
||||
}
|
||||
|
||||
private stopXvfb() {
|
||||
const child = this.xvfb;
|
||||
this.xvfb = undefined;
|
||||
this.display = undefined;
|
||||
|
||||
if (child && child.exitCode === null) {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
-167
@@ -1,53 +1,20 @@
|
||||
/*
|
||||
* Veskslop Phase 0 — Discord remote-auth QR login.
|
||||
*
|
||||
* Connects to Discord's remote-auth gateway, renders an approval QR in the
|
||||
* terminal, waits for the phone to approve, and prints/saves the user token.
|
||||
* Veskslop Phase 0 — Discord remote-auth QR login (terminal front end).
|
||||
*
|
||||
* Usage:
|
||||
* pnpm poc:qr
|
||||
* TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr
|
||||
* HCAPTCHA_KEY=... pnpm poc:qr # only if Discord challenges the ticket exchange
|
||||
*
|
||||
* The token file is written with mode 600 and is gitignored.
|
||||
* The session logic lives in qr-session.ts and is shared with the web UI.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import readline from "node:readline";
|
||||
|
||||
import qrcode from "qrcode-terminal";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { tokenFile } from "./config.js";
|
||||
|
||||
const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2";
|
||||
const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login";
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
|
||||
const debug = process.env.DEBUG === "1";
|
||||
|
||||
const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const encodedPublicKey = keyPair.publicKey.export({ type: "spki", format: "der" }).toString("base64");
|
||||
|
||||
function log(...args: unknown[]) {
|
||||
console.log("[qr]", ...args);
|
||||
}
|
||||
|
||||
function debugLog(...args: unknown[]) {
|
||||
if (debug) console.error("[qr:debug]", ...args);
|
||||
}
|
||||
|
||||
function decryptPayload(payload: string): Buffer {
|
||||
return crypto.privateDecrypt(
|
||||
{
|
||||
key: keyPair.privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: "sha256"
|
||||
},
|
||||
Buffer.from(payload, "base64")
|
||||
);
|
||||
}
|
||||
import { QrLoginSession } from "./qr-session.js";
|
||||
|
||||
function promptCaptchaKey(): Promise<string | undefined> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
@@ -59,143 +26,58 @@ function promptCaptchaKey(): Promise<string | undefined> {
|
||||
});
|
||||
}
|
||||
|
||||
async function exchangeTicket(ticket: string, captchaKey?: string, captchaRqToken?: string): Promise<string | undefined> {
|
||||
const body: Record<string, unknown> = { ticket };
|
||||
if (captchaKey) {
|
||||
body.captcha_key = captchaKey;
|
||||
body.captcha_rqtoken = captchaRqToken;
|
||||
}
|
||||
|
||||
const response = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Content-Type": "application/json",
|
||||
Origin: "https://discord.com",
|
||||
Referer: "https://discord.com/login",
|
||||
"User-Agent": USER_AGENT
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data: any = await response.json();
|
||||
debugLog("ticket exchange", response.status, JSON.stringify(data));
|
||||
|
||||
if (data.encrypted_token) {
|
||||
return decryptPayload(data.encrypted_token).toString();
|
||||
}
|
||||
|
||||
if (data.captcha_sitekey || data.captcha_rqtoken) {
|
||||
log("Discord wants a captcha to finish this login.");
|
||||
log(` service: ${data.captcha_service}`);
|
||||
log(` sitekey: ${data.captcha_sitekey}`);
|
||||
log(` rqdata: ${data.captcha_rqdata}`);
|
||||
const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey());
|
||||
if (!key) {
|
||||
log("No captcha key provided, giving up.");
|
||||
return undefined;
|
||||
}
|
||||
return exchangeTicket(ticket, key, data.captcha_rqtoken);
|
||||
}
|
||||
|
||||
log(`Ticket exchange failed (HTTP ${response.status}):`, JSON.stringify(data));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
log("Connecting to Discord remote-auth gateway...");
|
||||
const ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } });
|
||||
|
||||
let heartbeat: NodeJS.Timeout | undefined;
|
||||
let finished = false;
|
||||
|
||||
ws.on("open", () => log("Connected. Requesting QR..."));
|
||||
|
||||
ws.on("message", async (raw: WebSocket.RawData) => {
|
||||
const packet: any = JSON.parse(raw.toString());
|
||||
debugLog("<-", packet.op ?? packet);
|
||||
|
||||
switch (packet.op) {
|
||||
case "hello": {
|
||||
heartbeat = setInterval(
|
||||
() => ws.send(JSON.stringify({ op: "heartbeat" })),
|
||||
packet.heartbeat_interval
|
||||
);
|
||||
ws.send(JSON.stringify({ op: "init", encoded_public_key: encodedPublicKey }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "nonce_proof": {
|
||||
const nonce = decryptPayload(packet.encrypted_nonce);
|
||||
const proof = crypto.createHash("sha256").update(nonce).digest("base64url");
|
||||
ws.send(JSON.stringify({ op: "nonce_proof", proof }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "pending_remote_init": {
|
||||
const url = `https://discord.com/ra/${packet.fingerprint}`;
|
||||
log("Scan this QR code with the Discord mobile app (you may need the camera/QR scanner):\n");
|
||||
qrcode.generate(url, { small: true });
|
||||
log(`Or open this link on the phone: ${url}\n`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "pending_ticket": {
|
||||
const payload = decryptPayload(packet.encrypted_user_payload).toString();
|
||||
const [id, discriminator, avatar, username] = payload.split(":");
|
||||
debugLog("user payload:", payload);
|
||||
const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username;
|
||||
log(`Phone approved: ${tag} (${id})${avatar ? " with avatar " + avatar : ""}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "pending_login": {
|
||||
const token = await exchangeTicket(packet.ticket);
|
||||
if (!token) {
|
||||
finished = true;
|
||||
ws.close();
|
||||
process.exitCode = 1;
|
||||
const session = new QrLoginSession({
|
||||
tokenFile,
|
||||
debug: process.env.DEBUG === "1",
|
||||
onUpdate: update => {
|
||||
switch (update.state) {
|
||||
case "pending":
|
||||
console.log("[qr] Scan this QR code with the Discord mobile app:\n");
|
||||
if (update.url) qrcode.generate(update.url, { small: true });
|
||||
console.log(`[qr] Or open this link on the phone: ${update.url}\n`);
|
||||
break;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
fs.writeFileSync(tokenFile, token + "\n", { mode: 0o600 });
|
||||
log("\n=== token acquired ===");
|
||||
console.log(token);
|
||||
log(`Saved to ${tokenFile} (mode 600).`);
|
||||
ws.close();
|
||||
break;
|
||||
case "scanned":
|
||||
console.log(`[qr] Phone approved: ${update.user?.tag} (${update.user?.id})`);
|
||||
break;
|
||||
|
||||
case "captcha":
|
||||
console.log("[qr] Discord wants a captcha to finish this login.");
|
||||
console.log(` service: ${update.captcha?.service}`);
|
||||
console.log(` sitekey: ${update.captcha?.sitekey}`);
|
||||
console.log(` rqdata: ${update.captcha?.rqdata}`);
|
||||
void (async () => {
|
||||
const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey());
|
||||
if (!key) session.cancel();
|
||||
else session.submitCaptcha(key);
|
||||
})();
|
||||
break;
|
||||
|
||||
case "finished":
|
||||
console.log("\n=== token acquired ===");
|
||||
console.log(session.token);
|
||||
console.log(`[qr] Saved to ${tokenFile} (mode 600).`);
|
||||
process.exit(0);
|
||||
break;
|
||||
|
||||
case "failed":
|
||||
console.error(`[qr] Login failed: ${update.error}`);
|
||||
process.exit(1);
|
||||
break;
|
||||
|
||||
case "cancelled":
|
||||
console.log("[qr] Login was cancelled.");
|
||||
process.exit(0);
|
||||
break;
|
||||
}
|
||||
|
||||
case "cancel":
|
||||
log("Login was cancelled on the phone.");
|
||||
finished = true;
|
||||
ws.close();
|
||||
break;
|
||||
|
||||
case "heartbeat_ack":
|
||||
break;
|
||||
|
||||
default:
|
||||
debugLog("unhandled op", packet);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
done();
|
||||
});
|
||||
|
||||
ws.on("error", error => {
|
||||
console.error("[qr] gateway error:", error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
function done() {
|
||||
if (!finished) log("Connection closed before login completed.");
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
console.log("[qr] Connecting to Discord remote-auth gateway...");
|
||||
session.start();
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
log("Interrupted.");
|
||||
ws.close();
|
||||
console.log("[qr] Interrupted.");
|
||||
session.cancel();
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* Veskslop daemon — Discord remote-auth QR login session.
|
||||
*
|
||||
* The terminal tool and the web UI share this class. It connects to Discord's
|
||||
* remote-auth gateway, produces the approval URL, waits for phone approval,
|
||||
* and writes the token file on success.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
|
||||
import WebSocket from "ws";
|
||||
|
||||
const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2";
|
||||
const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login";
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
|
||||
|
||||
export type QrLoginState = "connecting" | "pending" | "scanned" | "captcha" | "finished" | "failed" | "cancelled";
|
||||
|
||||
export interface QrLoginUser {
|
||||
id: string;
|
||||
tag: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface QrLoginCaptcha {
|
||||
service?: string;
|
||||
sitekey?: string;
|
||||
rqdata?: string;
|
||||
}
|
||||
|
||||
export interface QrLoginUpdate {
|
||||
state: QrLoginState;
|
||||
url?: string;
|
||||
user?: QrLoginUser;
|
||||
error?: string;
|
||||
captcha?: QrLoginCaptcha;
|
||||
}
|
||||
|
||||
export interface QrLoginOptions {
|
||||
tokenFile: string;
|
||||
onUpdate?: (update: QrLoginUpdate) => void;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export function isTerminalQrState(state: QrLoginState): boolean {
|
||||
return state === "finished" || state === "failed" || state === "cancelled";
|
||||
}
|
||||
|
||||
export class QrLoginSession {
|
||||
state: QrLoginState = "connecting";
|
||||
url?: string;
|
||||
user?: QrLoginUser;
|
||||
error?: string;
|
||||
captcha?: QrLoginCaptcha;
|
||||
token?: string;
|
||||
|
||||
private readonly keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
private readonly encodedPublicKey = this.keyPair.publicKey
|
||||
.export({ type: "spki", format: "der" })
|
||||
.toString("base64");
|
||||
|
||||
private ws?: WebSocket;
|
||||
private heartbeat?: NodeJS.Timeout;
|
||||
private ticket?: string;
|
||||
private captchaRqToken?: string;
|
||||
private closed = false;
|
||||
|
||||
constructor(private readonly options: QrLoginOptions) {}
|
||||
|
||||
start(): void {
|
||||
this.ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } });
|
||||
|
||||
this.ws.on("open", () => this.debug("connected, requesting QR"));
|
||||
|
||||
this.ws.on("message", raw => {
|
||||
void this.handle(raw).catch(error => this.fail(String(error?.message ?? error)));
|
||||
});
|
||||
|
||||
this.ws.on("close", () => {
|
||||
if (this.heartbeat) clearInterval(this.heartbeat);
|
||||
if (!this.closed) this.fail("gateway connection closed");
|
||||
});
|
||||
|
||||
this.ws.on("error", error => {
|
||||
this.debug(`gateway error: ${error.message}`);
|
||||
this.fail(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.ws?.close();
|
||||
this.update({ state: "cancelled" });
|
||||
}
|
||||
|
||||
submitCaptcha(key: string): boolean {
|
||||
if (this.state !== "captcha" || !this.ticket) return false;
|
||||
void this.finishWithTicket(key, this.captchaRqToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private update(update: QrLoginUpdate) {
|
||||
this.state = update.state;
|
||||
if (update.url) this.url = update.url;
|
||||
if (update.user) this.user = update.user;
|
||||
if (update.error) this.error = update.error;
|
||||
if (update.captcha) this.captcha = update.captcha;
|
||||
this.options.onUpdate?.(update);
|
||||
}
|
||||
|
||||
private fail(error: string) {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.ws?.close();
|
||||
this.update({ state: "failed", error });
|
||||
}
|
||||
|
||||
private async handle(raw: WebSocket.RawData) {
|
||||
const packet: any = JSON.parse(raw.toString());
|
||||
this.debug(`<- ${packet.op}`);
|
||||
|
||||
switch (packet.op) {
|
||||
case "hello":
|
||||
this.heartbeat = setInterval(
|
||||
() => this.ws?.send(JSON.stringify({ op: "heartbeat" })),
|
||||
packet.heartbeat_interval
|
||||
);
|
||||
this.ws?.send(JSON.stringify({ op: "init", encoded_public_key: this.encodedPublicKey }));
|
||||
break;
|
||||
|
||||
case "nonce_proof": {
|
||||
const nonce = this.decrypt(packet.encrypted_nonce);
|
||||
const proof = crypto.createHash("sha256").update(nonce).digest("base64url");
|
||||
this.ws?.send(JSON.stringify({ op: "nonce_proof", proof }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "pending_remote_init":
|
||||
this.update({ state: "pending", url: `https://discord.com/ra/${packet.fingerprint}` });
|
||||
break;
|
||||
|
||||
case "pending_ticket": {
|
||||
const payload = this.decrypt(packet.encrypted_user_payload).toString();
|
||||
const [id, discriminator, avatar, username] = payload.split(":");
|
||||
const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username;
|
||||
this.update({ state: "scanned", user: { id, tag, ...(avatar ? { avatar } : {}) } });
|
||||
break;
|
||||
}
|
||||
|
||||
case "pending_login":
|
||||
this.ticket = packet.ticket;
|
||||
await this.finishWithTicket();
|
||||
break;
|
||||
|
||||
case "cancel":
|
||||
this.closed = true;
|
||||
this.ws?.close();
|
||||
this.update({ state: "cancelled" });
|
||||
break;
|
||||
|
||||
case "heartbeat_ack":
|
||||
break;
|
||||
|
||||
default:
|
||||
this.debug(`unhandled op ${packet.op}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async finishWithTicket(captchaKey?: string, captchaRqToken?: string) {
|
||||
const token = await this.exchangeTicket(this.ticket!, captchaKey, captchaRqToken);
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(this.options.tokenFile, token + "\n", { mode: 0o600 });
|
||||
} catch (error) {
|
||||
this.fail(`could not write the token file: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.token = token;
|
||||
this.closed = true;
|
||||
this.ws?.close();
|
||||
this.update({ state: "finished", user: this.user });
|
||||
}
|
||||
|
||||
private async exchangeTicket(
|
||||
ticket: string,
|
||||
captchaKey?: string,
|
||||
captchaRqToken?: string
|
||||
): Promise<string | undefined> {
|
||||
const body: Record<string, unknown> = { ticket };
|
||||
if (captchaKey) {
|
||||
body.captcha_key = captchaKey;
|
||||
body.captcha_rqtoken = captchaRqToken;
|
||||
}
|
||||
|
||||
const response = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Content-Type": "application/json",
|
||||
Origin: "https://discord.com",
|
||||
Referer: "https://discord.com/login",
|
||||
"User-Agent": USER_AGENT
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data: any = await response.json();
|
||||
this.debug(`ticket exchange ${response.status}`);
|
||||
|
||||
if (data.encrypted_token) return this.decrypt(data.encrypted_token).toString();
|
||||
|
||||
if (data.captcha_sitekey || data.captcha_rqtoken) {
|
||||
this.captchaRqToken = data.captcha_rqtoken;
|
||||
this.update({
|
||||
state: "captcha",
|
||||
captcha: {
|
||||
service: data.captcha_service,
|
||||
sitekey: data.captcha_sitekey,
|
||||
rqdata: data.captcha_rqdata
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.fail(`ticket exchange failed (HTTP ${response.status})`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private decrypt(payload: string): Buffer {
|
||||
return crypto.privateDecrypt(
|
||||
{
|
||||
key: this.keyPair.privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: "sha256"
|
||||
},
|
||||
Buffer.from(payload, "base64")
|
||||
);
|
||||
}
|
||||
|
||||
private debug(...args: unknown[]) {
|
||||
if (this.options.debug) console.error("[qr:debug]", ...args);
|
||||
}
|
||||
}
|
||||
@@ -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