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