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
+115 -22
View File
@@ -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);