966 lines
35 KiB
TypeScript
966 lines
35 KiB
TypeScript
/*
|
|
* Veskslop daemon — MCP control bridge over the validated core bridge.
|
|
*
|
|
* The daemon holds all logic. It sends validated actions to Vesktop and
|
|
* receives normalized events. See docs/core-bridge.md.
|
|
*
|
|
* Usage:
|
|
* pnpm control:bridge
|
|
* VESKSLOP_SOCKET=/path/to/vesktop-puppet.sock MCP_PORT=8788 pnpm control:bridge
|
|
*/
|
|
|
|
import { config as loadEnv } from "dotenv";
|
|
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
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 { AudioService } from "./audio-service.js";
|
|
import { tokenFile } from "./config.js";
|
|
import { dirname, join } from "node:path";
|
|
import { Stt } from "./stt.js";
|
|
import { TranscriptStore } from "./transcript.js";
|
|
import { DEFAULT_VAD } from "./vad.js";
|
|
import { CoreClient, coreSocketPath } from "./core-client.js";
|
|
import { CoreProcess, staleBundleWarning } from "./core-process.js";
|
|
import { dedupe, getQueryCount, paced, pacedSend, pacedVoice, sleep } from "./pacing.js";
|
|
import { MediaService } from "./media.js";
|
|
import { PreferencesStore } from "./preferences.js";
|
|
import { Puppet } from "./puppet.js";
|
|
import { startWebUi } from "./webui.js";
|
|
|
|
loadEnv({ override: true });
|
|
|
|
const HOST = process.env.MCP_HOST ?? "127.0.0.1";
|
|
const PORT = Number(process.env.MCP_PORT ?? 8788);
|
|
|
|
let core: CoreClient | undefined;
|
|
let puppet: Puppet | undefined;
|
|
let coreConnected = false;
|
|
let connecting = false;
|
|
|
|
const media = new MediaService({
|
|
getCore: () => core,
|
|
isCoreConnected: () => coreConnected,
|
|
getMediaPrefs: () => preferences.get().media,
|
|
log: line => console.log(`[media] ${line}`)
|
|
});
|
|
|
|
async function connectLoop(): Promise<void> {
|
|
if (connecting) return;
|
|
connecting = true;
|
|
|
|
try {
|
|
for (;;) {
|
|
try {
|
|
const client = await CoreClient.connect();
|
|
|
|
core = client;
|
|
puppet = new Puppet(client, audio, media);
|
|
|
|
client.onEvent(event => {
|
|
// Speaking hints add to the reconciled set so the poller can
|
|
// clear users the store no longer reports as speaking.
|
|
if (event?.type === "VOICE_SPEAKING" && event.userId) {
|
|
if (event.speaking) speakingUsers.add(String(event.userId));
|
|
else speakingUsers.delete(String(event.userId));
|
|
}
|
|
|
|
void puppet!.enqueueEvent(event);
|
|
void audio.onEvent(event);
|
|
webui.broadcast(event);
|
|
});
|
|
|
|
client.onClose(() => {
|
|
console.warn("[daemon] core bridge disconnected");
|
|
stopSpeakingPoll();
|
|
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 });
|
|
startSpeakingPoll();
|
|
void autoConnectAudioWithRetry(client);
|
|
|
|
return;
|
|
} catch (error) {
|
|
console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`);
|
|
await sleep(2000);
|
|
}
|
|
}
|
|
} finally {
|
|
connecting = false;
|
|
}
|
|
}
|
|
|
|
function requireCore(): CoreClient {
|
|
if (!coreConnected || !core) throw new Error("Not connected to Vesktop (is the core bridge running?)");
|
|
return core;
|
|
}
|
|
|
|
function requirePuppet(): Puppet {
|
|
if (!puppet) throw new Error("Puppet not connected yet");
|
|
return puppet;
|
|
}
|
|
|
|
/*
|
|
* Discord's dispatcher only sends speaking-start events, so the UI and the
|
|
* transcript attribution need the store's authoritative set. Poll it once a
|
|
* second and synthesize the missing stop/start events. Dispatcher events feed
|
|
* the same set so a user the store no longer reports is cleared.
|
|
*/
|
|
|
|
let speakingTimer: ReturnType<typeof setInterval> | undefined;
|
|
const speakingUsers = new Set<string>();
|
|
|
|
function emitCoreEvent(event: any): void {
|
|
void puppet?.enqueueEvent(event);
|
|
void audio.onEvent(event);
|
|
webui.broadcast(event);
|
|
}
|
|
|
|
async function pollSpeaking(): Promise<void> {
|
|
if (!core) return;
|
|
|
|
try {
|
|
const result = (await core.voiceSpeaking()) as { channelId?: string | null; userIds?: string[] };
|
|
const next = new Set<string>(result?.channelId ? (result.userIds ?? []) : []);
|
|
|
|
for (const userId of next) {
|
|
if (!speakingUsers.has(userId)) {
|
|
emitCoreEvent({
|
|
type: "VOICE_SPEAKING",
|
|
userId,
|
|
speaking: true,
|
|
channelId: result.channelId ?? null
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const userId of speakingUsers) {
|
|
if (!next.has(userId)) {
|
|
emitCoreEvent({
|
|
type: "VOICE_SPEAKING",
|
|
userId,
|
|
speaking: false,
|
|
channelId: result?.channelId ?? null
|
|
});
|
|
}
|
|
}
|
|
|
|
speakingUsers.clear();
|
|
for (const userId of next) speakingUsers.add(userId);
|
|
} catch {
|
|
// The close handler stops the poller.
|
|
}
|
|
}
|
|
|
|
function startSpeakingPoll(): void {
|
|
stopSpeakingPoll();
|
|
speakingTimer = setInterval(() => void pollSpeaking(), 1000);
|
|
}
|
|
|
|
function stopSpeakingPoll(): void {
|
|
if (speakingTimer) clearInterval(speakingTimer);
|
|
speakingTimer = undefined;
|
|
speakingUsers.clear();
|
|
}
|
|
|
|
const coreProcess = new CoreProcess({ tokenFile });
|
|
|
|
const transcripts = new TranscriptStore(
|
|
process.env.VESKSLOP_TRANSCRIPT ?? join(dirname(tokenFile), "transcripts.jsonl")
|
|
);
|
|
const preferences = new PreferencesStore(
|
|
process.env.VESKSLOP_PREFERENCES ?? join(dirname(tokenFile), "preferences.json"),
|
|
{ legacyFile: process.env.VESKSLOP_STATE_FILE ?? join(dirname(tokenFile), ".veskslop-state.json") }
|
|
);
|
|
const stt = new Stt();
|
|
|
|
const audio = new AudioService({
|
|
cacheDir: process.env.VESKSLOP_CACHE_DIR,
|
|
transcripts,
|
|
stt,
|
|
autoCapture: process.env.VESKSLOP_CAPTURE_AUTO === "1",
|
|
vad: {
|
|
...DEFAULT_VAD,
|
|
thresholdDb: Number(process.env.VESKSLOP_VAD_THRESHOLD_DB ?? DEFAULT_VAD.thresholdDb),
|
|
minSpeechMs: Number(process.env.VESKSLOP_VAD_MIN_SPEECH_MS ?? DEFAULT_VAD.minSpeechMs),
|
|
silenceMs: Number(process.env.VESKSLOP_VAD_SILENCE_MS ?? DEFAULT_VAD.silenceMs),
|
|
preRollMs: Number(process.env.VESKSLOP_VAD_PRE_ROLL_MS ?? DEFAULT_VAD.preRollMs),
|
|
maxSegmentMs: Number(process.env.VESKSLOP_VAD_MAX_SEGMENT_MS ?? DEFAULT_VAD.maxSegmentMs)
|
|
},
|
|
getSelfId: async () => (core ? await core.getSelfId() : undefined),
|
|
getVoiceContext: async () => {
|
|
if (!core) return {};
|
|
const state = (await core.voiceGet()) as { guildId?: string | null; channelId?: string | null };
|
|
return { guildId: state?.guildId ?? null, channelId: state?.channelId ?? null };
|
|
},
|
|
getSpeakerName: async userId => {
|
|
if (!core) return undefined;
|
|
const state = (await core.voiceGet(userId)) as {
|
|
members?: Array<{ id: string; username?: string }>;
|
|
};
|
|
return state?.members?.find(member => member.id === userId)?.username;
|
|
},
|
|
onTranscript: event => webui.broadcast(event)
|
|
});
|
|
|
|
audio.setDuckEnabled(preferences.get().audio?.duck !== false);
|
|
void audio.init();
|
|
|
|
/** Point the client at the virtual microphone and capture sink when present. */
|
|
async function autoConnectAudio(client: CoreClient): Promise<{ connected: boolean; message: string }> {
|
|
if (process.env.VESKSLOP_AUDIO_AUTOCONNECT === "0") {
|
|
return { connected: false, message: "auto-connect is disabled" };
|
|
}
|
|
|
|
const status = audio.status();
|
|
if (!status.available) {
|
|
return { connected: false, message: status.hint ?? status.error ?? "audio is not available" };
|
|
}
|
|
|
|
try {
|
|
await audio.manager.ensureDevices();
|
|
|
|
const devices = (await client.voiceDevices()) as {
|
|
input?: Array<{ id: string; label: string }>;
|
|
output?: Array<{ id: string; label: string }>;
|
|
};
|
|
|
|
const input = devices.input?.find(device => device.label.includes("VeskslopMic"));
|
|
const output = devices.output?.find(device => device.label.includes("VeskslopCapture"));
|
|
|
|
if (!input && !output) {
|
|
return { connected: false, message: "the client does not see the virtual devices yet" };
|
|
}
|
|
|
|
const result = await client.voiceSetDevices(input?.id, output?.id);
|
|
console.log("[audio] client devices set:", JSON.stringify(result));
|
|
return { connected: true, message: "the client now uses the virtual devices" };
|
|
} catch (error) {
|
|
return { connected: false, message: (error as Error).message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The bridge socket is up before the renderer finishes loading, so the first
|
|
* device calls can fail with "core is not ready" style errors. Retry.
|
|
*/
|
|
async function autoConnectAudioWithRetry(client: CoreClient): Promise<void> {
|
|
for (let attempt = 1; attempt <= 10; attempt++) {
|
|
if (attempt > 1) await sleep(2500);
|
|
|
|
const result = await autoConnectAudio(client);
|
|
if (result.connected) return;
|
|
|
|
console.warn(`[audio] device auto-connect attempt ${attempt}: ${result.message}`);
|
|
}
|
|
}
|
|
|
|
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,
|
|
audio,
|
|
transcripts,
|
|
preferences,
|
|
media,
|
|
connectAudioDevices: () => autoConnectAudio(requireCore())
|
|
});
|
|
|
|
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) }] };
|
|
}
|
|
|
|
/** Read-only tools that the batch tool can run. Mutating tools stay out. */
|
|
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_audio_status: () => audio.status(),
|
|
list_audio_devices: () => requireCore().voiceDevices(),
|
|
get_transcript: args => transcripts.readSegments(args?.limit ?? 100, args?.sessionId),
|
|
get_context: args =>
|
|
requirePuppet().getContext(args?.channelId, {
|
|
maxMessages: args?.maxMessages,
|
|
maxChars: args?.maxChars,
|
|
refresh: args?.refresh
|
|
}),
|
|
get_message_log: () => requirePuppet().getLog(),
|
|
get_action_log: args => requirePuppet().readActionLog(args?.limit ?? 100),
|
|
get_puppet_config: () => requirePuppet().getConfig(),
|
|
whitelist_list: () => ({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }),
|
|
read_messages: args => paced(() => requireCore().readMessages(args?.channelId, args?.limit ?? 20)),
|
|
list_media_scrapes: () => media.listScrapes(),
|
|
get_media_storage: () => media.refreshRetention(),
|
|
query_media: args =>
|
|
media.queryMedia({
|
|
kind: args?.kind,
|
|
scrapeId: args?.scrapeId,
|
|
limit: args?.limit,
|
|
offset: args?.offset
|
|
})
|
|
};
|
|
|
|
function buildServer(): McpServer {
|
|
const server = new McpServer({ name: "veskslop-control", version: "0.0.0" });
|
|
|
|
server.registerTool(
|
|
"get_state",
|
|
{
|
|
description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries."
|
|
},
|
|
async () => jsonText(await requireCore().getState())
|
|
);
|
|
|
|
server.registerTool(
|
|
"send_message",
|
|
{
|
|
description: "Send a message as the logged-in user through the real client. Query-paced.",
|
|
inputSchema: {
|
|
channelId: z.string().describe("Target channel ID"),
|
|
content: z.string().min(1).describe("Message content")
|
|
}
|
|
},
|
|
async ({ channelId, content }) => {
|
|
await pacedSend(() => requireCore().sendMessage(channelId, content));
|
|
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
"send_dm",
|
|
{
|
|
description: "Send a DM as the logged-in user. Sanctioned live-test target: user zzib.",
|
|
inputSchema: {
|
|
userId: z.string().describe("Recipient user ID"),
|
|
content: z.string().min(1).describe("Message content")
|
|
}
|
|
},
|
|
async ({ userId, content }) => {
|
|
const result = await pacedSend(() => requireCore().sendDm(userId, content));
|
|
return jsonText({ ...result, userId, queriesUsed: getQueryCount() });
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
"read_messages",
|
|
{
|
|
description: "Read messages from the client's cache. Identical concurrent reads are coalesced.",
|
|
inputSchema: {
|
|
channelId: z.string().describe("Target channel ID"),
|
|
limit: z.number().int().min(1).max(100).default(20).describe("How many messages")
|
|
}
|
|
},
|
|
async ({ channelId, limit }) => {
|
|
const result = await dedupe<{ cached: number; messages: unknown[] }>(`read:${channelId}:${limit}`, () =>
|
|
paced(() => requireCore().readMessages(channelId, limit))
|
|
);
|
|
return jsonText({ ...result, channelId, queriesUsed: getQueryCount() });
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_message_log",
|
|
{
|
|
description: "Read the trigger log: last 100 messages from trusted users, last 30 from untrusted."
|
|
},
|
|
async () => jsonText(requirePuppet().getLog())
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_context",
|
|
{
|
|
description:
|
|
"Assemble recent conversation context for a channel. Fetches new messages incrementally by default.",
|
|
inputSchema: {
|
|
channelId: z.string().describe("Channel ID"),
|
|
maxMessages: z.number().int().min(1).max(50).optional().describe("Message limit"),
|
|
maxChars: z.number().int().min(200).max(20000).optional().describe("Character limit"),
|
|
refresh: z.boolean().optional().describe("Fetch new messages first. Defaults to true.")
|
|
}
|
|
},
|
|
async ({ channelId, maxMessages, maxChars, refresh }) =>
|
|
jsonText(await requirePuppet().getContext(channelId, { maxMessages, maxChars, refresh }))
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_puppet_config",
|
|
{
|
|
description: "Read the response policy and context settings."
|
|
},
|
|
async () => jsonText(requirePuppet().getConfig())
|
|
);
|
|
|
|
server.registerTool(
|
|
"set_puppet_config",
|
|
{
|
|
description: "Update the response policy and context settings. Sensitive commands reply by DM.",
|
|
inputSchema: {
|
|
config: z.object({
|
|
contextMessages: z.number().int().min(1).max(50).optional(),
|
|
contextChars: z.number().int().min(200).max(20000).optional(),
|
|
fetchCooldownSeconds: z.number().int().min(0).max(300).optional(),
|
|
continuationSeconds: z.number().int().min(0).max(300).optional(),
|
|
pollSeconds: z.number().int().min(10).max(3600).optional(),
|
|
voiceLeaveDelaySeconds: z.number().int().min(0).max(600).optional(),
|
|
responsePolicy: z
|
|
.object({
|
|
defaultMode: z.enum(["channel", "dm"]).optional(),
|
|
sensitiveCommands: z.array(z.string()).optional(),
|
|
overrides: z.record(z.string(), z.enum(["channel", "dm"])).optional()
|
|
})
|
|
.optional()
|
|
})
|
|
}
|
|
},
|
|
async ({ config }) => jsonText(requirePuppet().updateConfig(config))
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_voice_state",
|
|
{
|
|
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 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(
|
|
"voice_join",
|
|
{
|
|
description: "Join a voice channel as the logged-in user. Joins muted by default.",
|
|
inputSchema: {
|
|
channelId: z.string().describe("Voice channel ID"),
|
|
mute: z.boolean().optional().describe("Join muted. Defaults to true.")
|
|
}
|
|
},
|
|
async ({ channelId, mute }) =>
|
|
jsonText(await pacedVoice(() => requireCore().voiceJoin(channelId, mute ?? true)))
|
|
);
|
|
|
|
server.registerTool(
|
|
"voice_leave",
|
|
{
|
|
description: "Leave the current voice channel."
|
|
},
|
|
async () => jsonText(await pacedVoice(() => requireCore().voiceLeave()))
|
|
);
|
|
|
|
server.registerTool(
|
|
"voice_move",
|
|
{
|
|
description: "Move the client to another voice channel.",
|
|
inputSchema: {
|
|
channelId: z.string().describe("Channel ID")
|
|
}
|
|
},
|
|
async ({ channelId }) => jsonText(await pacedVoice(() => requireCore().voiceMove(channelId)))
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_user_volume",
|
|
{
|
|
description:
|
|
"Per-user playback volume (0 to 200, 100 is default) and the client's own output volume. A volume of 0 silences that user.",
|
|
inputSchema: {
|
|
userIds: z.array(z.string()).min(1).max(50).describe("User IDs to look up")
|
|
}
|
|
},
|
|
async ({ userIds }) => jsonText(await requireCore().userVolume(userIds))
|
|
);
|
|
|
|
server.registerTool(
|
|
"set_user_volume",
|
|
{
|
|
description: "Set a user's playback volume. 0 silences the user; 100 is default; 200 is the maximum.",
|
|
inputSchema: {
|
|
userId: z.string().describe("User ID"),
|
|
volume: z.number().int().min(0).max(200).describe("Volume from 0 to 200")
|
|
}
|
|
},
|
|
async ({ userId, volume }) => jsonText(await pacedVoice(() => requireCore().userSetVolume(userId, volume)))
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_audio_status",
|
|
{
|
|
description: "Audio stack, virtual devices, TTS providers, queue, ducking, and capture state."
|
|
},
|
|
async () => jsonText(audio.status())
|
|
);
|
|
|
|
server.registerTool(
|
|
"speak",
|
|
{
|
|
description: "Queue text to speech into the bot's virtual microphone.",
|
|
inputSchema: {
|
|
text: z.string().min(1).max(1000).describe("Text to speak"),
|
|
provider: z
|
|
.enum(["local", "espeak", "command", "api"])
|
|
.optional()
|
|
.describe("TTS engine: command is the configured VESKSLOP_TTS_CMD engine"),
|
|
voice: z.string().optional().describe("Voice name for the provider"),
|
|
speed: z.number().min(0.5).max(2).optional().describe("Rate multiplier, 1.0 is natural"),
|
|
pitch: z.number().min(-12).max(12).optional().describe("Pitch offset in semitones"),
|
|
expression: z
|
|
.number()
|
|
.min(0)
|
|
.max(100)
|
|
.optional()
|
|
.describe("Flextalk Expression profile value (Keith Bell, Kerry Watson)"),
|
|
profile: z
|
|
.record(z.string(), z.number())
|
|
.optional()
|
|
.describe("Flextalk profile overrides (pitch, rate, prominences, head_size, lowering)"),
|
|
highpass: z.number().min(40).max(400).optional().describe("High-pass filter Hz"),
|
|
echo: z.number().min(0).max(100).optional().describe("Mono echo amount"),
|
|
words: z.boolean().optional().describe("Synthesize each word and merge with gaps"),
|
|
wordGapMs: z.number().min(0).max(500).optional().describe("Silence between words in ms")
|
|
}
|
|
},
|
|
async ({ text, provider, voice, speed, pitch, expression, profile, highpass, echo, words, wordGapMs }) =>
|
|
jsonText(
|
|
await audio.speak(text, {
|
|
provider,
|
|
voice,
|
|
speed,
|
|
pitch,
|
|
expression,
|
|
profile,
|
|
highpass,
|
|
echo,
|
|
words,
|
|
wordGapMs
|
|
})
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"start_tts_engine",
|
|
{
|
|
description: "Start (warm up) the TTS engine so the first spoken line is fast.",
|
|
inputSchema: {
|
|
engine: z
|
|
.enum(["local", "espeak", "command", "api"])
|
|
.optional()
|
|
.describe("Engine to start. Defaults to the configured one.")
|
|
}
|
|
},
|
|
async ({ engine }) => jsonText(await audio.warmTts(engine))
|
|
);
|
|
|
|
server.registerTool(
|
|
"stop_audio",
|
|
{
|
|
description: "Stop speech and clear the queue."
|
|
},
|
|
async () => {
|
|
audio.stop();
|
|
return jsonText(audio.status());
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
"start_capture",
|
|
{
|
|
description: "Record the client output sink to a WAV file on the host."
|
|
},
|
|
async () => jsonText(await audio.startCapture())
|
|
);
|
|
|
|
server.registerTool(
|
|
"stop_capture",
|
|
{
|
|
description: "Stop the current capture."
|
|
},
|
|
async () => {
|
|
audio.stopCapture();
|
|
return jsonText(audio.status());
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
"list_audio_devices",
|
|
{
|
|
description: "List the client's input and output audio devices."
|
|
},
|
|
async () => jsonText(await requireCore().voiceDevices())
|
|
);
|
|
|
|
server.registerTool(
|
|
"set_audio_devices",
|
|
{
|
|
description: "Set the client's input and output audio devices.",
|
|
inputSchema: {
|
|
inputDeviceId: z.string().optional().describe("Input device ID"),
|
|
outputDeviceId: z.string().optional().describe("Output device ID")
|
|
}
|
|
},
|
|
async ({ inputDeviceId, outputDeviceId }) =>
|
|
jsonText(await requireCore().voiceSetDevices(inputDeviceId, outputDeviceId))
|
|
);
|
|
|
|
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",
|
|
{
|
|
description: "Read the persistent action log: triggers, commands, replies, and errors.",
|
|
inputSchema: {
|
|
limit: z.number().int().min(1).max(500).default(100).describe("How many entries")
|
|
}
|
|
},
|
|
async ({ limit }) => jsonText(requirePuppet().readActionLog(limit))
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_transcript",
|
|
{
|
|
description: "Read captured call segments: time, speaker, WAV path, and text when STT is configured.",
|
|
inputSchema: {
|
|
limit: z.number().int().min(1).max(500).default(100).describe("How many segments"),
|
|
sessionId: z.string().optional().describe("Only this capture session")
|
|
}
|
|
},
|
|
async ({ limit, sessionId }) => jsonText(transcripts.readSegments(limit, sessionId))
|
|
);
|
|
|
|
server.registerTool(
|
|
"run_commands",
|
|
{
|
|
description: "Run several read-only commands in one call, in order.",
|
|
inputSchema: {
|
|
commands: z
|
|
.array(
|
|
z.object({
|
|
tool: z.string().describe("Read-only tool name"),
|
|
arguments: z.record(z.string(), z.any()).optional()
|
|
})
|
|
)
|
|
.min(1)
|
|
.max(10)
|
|
}
|
|
},
|
|
async ({ commands }) => {
|
|
const results: Array<Record<string, unknown>> = [];
|
|
|
|
for (const item of commands) {
|
|
const handler = READ_ONLY_TOOLS[item.tool];
|
|
if (!handler) {
|
|
results.push({ tool: item.tool, ok: false, error: "unknown or non-read-only tool" });
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
results.push({ tool: item.tool, ok: true, result: await handler((item.arguments ?? {}) as any) });
|
|
} catch (error) {
|
|
results.push({ tool: item.tool, ok: false, error: String((error as Error).message) });
|
|
}
|
|
}
|
|
|
|
return jsonText({ results });
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
"whitelist_list",
|
|
{
|
|
description: "List the puppet owner and trusted users with their tiers."
|
|
},
|
|
async () => jsonText({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist })
|
|
);
|
|
|
|
server.registerTool(
|
|
"whitelist_add",
|
|
{
|
|
description: "Add users to the puppet whitelist with an optional tier. Defaults to 'user'.",
|
|
inputSchema: {
|
|
userIds: z.array(z.string()).min(1).describe("User IDs to trust"),
|
|
tier: z.enum(["user", "trusted", "admin"]).optional().describe("Trust tier")
|
|
}
|
|
},
|
|
async ({ userIds, tier }) =>
|
|
jsonText({ whitelist: requirePuppet().addToWhitelist(userIds, tier ?? "user") })
|
|
);
|
|
|
|
server.registerTool(
|
|
"whitelist_remove",
|
|
{
|
|
description: "Remove users from the puppet whitelist. The owner cannot be removed.",
|
|
inputSchema: {
|
|
userIds: z.array(z.string()).min(1).describe("User IDs to untrust")
|
|
}
|
|
},
|
|
async ({ userIds }) => jsonText({ whitelist: requirePuppet().removeFromWhitelist(userIds) })
|
|
);
|
|
|
|
server.registerTool(
|
|
"scrape_media",
|
|
{
|
|
description:
|
|
"Start a media scrape of a channel. Uses slopbot for guild channels it is in, otherwise the client. Near the storage limit it fails with MEDIA_LIMIT unless confirm is true.",
|
|
inputSchema: {
|
|
channelId: z.string().describe("Channel ID to scrape"),
|
|
guildId: z.string().optional().describe("Guild ID; slopbot needs it to match"),
|
|
mode: z
|
|
.enum(["links", "download"])
|
|
.optional()
|
|
.describe("links indexes URLs (default); download fetches attachment bytes"),
|
|
backend: z
|
|
.enum(["auto", "slopbot", "puppet"])
|
|
.optional()
|
|
.describe("auto (default) picks slopbot for its guilds, the client otherwise"),
|
|
confirm: z.boolean().optional().describe("Accept the near-limit warning")
|
|
}
|
|
},
|
|
async ({ channelId, guildId, mode, backend, confirm }) =>
|
|
jsonText(await media.startScrape({ channelId, guildId: guildId ?? null, mode, backend, confirm }))
|
|
);
|
|
|
|
server.registerTool(
|
|
"list_media_scrapes",
|
|
{ description: "List media scrapes with state, pages, messages, and media counts." },
|
|
async () => jsonText(media.listScrapes())
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_media_scrape",
|
|
{
|
|
description: "Get one media scrape by ID.",
|
|
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
|
|
},
|
|
async ({ scrapeId }) => jsonText(media.getScrape(scrapeId))
|
|
);
|
|
|
|
server.registerTool(
|
|
"stop_media_scrape",
|
|
{
|
|
description: "Stop a media scrape. It pauses after the current page.",
|
|
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
|
|
},
|
|
async ({ scrapeId }) => {
|
|
media.stopScrape(scrapeId);
|
|
return jsonText({ stopped: scrapeId });
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_media_storage",
|
|
{
|
|
description:
|
|
"Media storage usage against the limit, per-kind sizes, and the scrape list. Marks old scrapes when over the limit."
|
|
},
|
|
async () => jsonText(media.refreshRetention())
|
|
);
|
|
|
|
server.registerTool(
|
|
"query_media",
|
|
{
|
|
description: "Query media items across scrapes, newest scrape first.",
|
|
inputSchema: {
|
|
kind: z.enum(["image", "gif", "video", "audio"]).optional(),
|
|
source: z
|
|
.enum(["attachment", "sticker", "embed"])
|
|
.optional()
|
|
.describe("Where the item came from"),
|
|
scrapeId: z.string().optional().describe("Restrict to one scrape"),
|
|
channelId: z.string().optional().describe("Restrict to one source channel"),
|
|
authorId: z.string().optional().describe("Restrict to one poster"),
|
|
aspect: z
|
|
.enum(["vertical", "square", "wide43", "wide169", "ultrawide", "other"])
|
|
.optional()
|
|
.describe("Aspect-ratio bucket"),
|
|
resolution: z
|
|
.enum(["sd", "hd", "fhd", "qhd", "uhd", "unknown"])
|
|
.optional()
|
|
.describe("Resolution bucket by shortest side"),
|
|
after: z.string().optional().describe("Only items posted at or after this ISO time"),
|
|
before: z.string().optional().describe("Only items posted at or before this ISO time"),
|
|
limit: z.number().int().min(1).max(500).optional(),
|
|
offset: z.number().int().min(0).optional()
|
|
}
|
|
},
|
|
async ({ kind, source, scrapeId, channelId, authorId, aspect, resolution, after, before, limit, offset }) =>
|
|
jsonText(
|
|
media.queryMedia({
|
|
kind,
|
|
source,
|
|
scrapeId,
|
|
channelId,
|
|
authorId,
|
|
aspect,
|
|
resolution,
|
|
after,
|
|
before,
|
|
limit,
|
|
offset
|
|
})
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_media_item",
|
|
{
|
|
description: "Get one media item by scrape ID and item ID.",
|
|
inputSchema: {
|
|
scrapeId: z.string().describe("Scrape ID"),
|
|
itemId: z.number().int().min(1).describe("Media row ID inside the scrape")
|
|
}
|
|
},
|
|
async ({ scrapeId, itemId }) => jsonText(media.getMediaItem(scrapeId, itemId))
|
|
);
|
|
|
|
server.registerTool(
|
|
"delete_media_scrape",
|
|
{
|
|
description: "Delete a scrape and its files. Irreversible.",
|
|
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
|
|
},
|
|
async ({ scrapeId }) => jsonText({ deleted: media.cleanMedia([scrapeId]) === 1 })
|
|
);
|
|
|
|
return server;
|
|
}
|
|
|
|
void connectLoop();
|
|
|
|
const app = createMcpExpressApp({ host: HOST });
|
|
|
|
app.post("/mcp", async (req, res) => {
|
|
const server = buildServer();
|
|
try {
|
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
res.on("close", () => {
|
|
transport.close();
|
|
server.close();
|
|
});
|
|
await server.connect(transport);
|
|
await transport.handleRequest(req, res, req.body);
|
|
} catch (error) {
|
|
console.error("Error handling MCP request:", error);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32603, message: "Internal server error" },
|
|
id: null
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
app.get("/mcp", (_req, res) => {
|
|
res.writeHead(405).end(
|
|
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null })
|
|
);
|
|
});
|
|
|
|
app.delete("/mcp", (_req, res) => {
|
|
res.writeHead(405).end(
|
|
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null })
|
|
);
|
|
});
|
|
|
|
app.get("/health", async (_req, res) => {
|
|
let selfId = puppet?.self;
|
|
if (!selfId && core) {
|
|
try {
|
|
selfId = await core.getSelfId();
|
|
} catch {
|
|
// The core may not be ready yet.
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
ok: true,
|
|
coreConnected,
|
|
socketPath: coreSocketPath,
|
|
ownerId: puppet?.owner,
|
|
selfId,
|
|
queries: getQueryCount()
|
|
});
|
|
});
|
|
|
|
app.listen(PORT, HOST, (error?: Error) => {
|
|
if (error) {
|
|
console.error("Failed to start control bridge:", error);
|
|
process.exit(1);
|
|
}
|
|
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
|
|
|
|
const stale = staleBundleWarning();
|
|
if (stale) console.warn(`[daemon] ${stale}`);
|
|
});
|
|
|
|
function shutdown() {
|
|
stopSpeakingPoll();
|
|
core?.close();
|
|
void Promise.allSettled([coreProcess.dispose(4000), audio.dispose()]).finally(() => process.exit(0));
|
|
}
|
|
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|