Wire validated core bridge, migrate daemon to it, verify live
This commit is contained in:
+11
-7
@@ -16,19 +16,22 @@ pnpm install
|
||||
| --- | --- |
|
||||
| `pnpm poc:qr` | QR login. Writes the token file. |
|
||||
| `pnpm control:bridge` | Start the daemon. It listens on `http://127.0.0.1:8788/mcp`. |
|
||||
| `pnpm control:mock` | Start the mock core. Test the daemon without Electron. |
|
||||
| `pnpm test:trigger` | Inject a trigger event into the mock core. |
|
||||
| `pnpm poc:dm` | Send one test DM to zzib. |
|
||||
| `pnpm test:comm` | Run the unit tests for the pure logic. |
|
||||
| `pnpm cdp:probe` | Check CDP access and event delivery. |
|
||||
| `pnpm cdp:dispatch` | Run one CDP expression in the client. |
|
||||
| `pnpm check` | Type check the daemon code. |
|
||||
|
||||
## Transport
|
||||
|
||||
The daemon uses Chrome DevTools Protocol at this time. Start the client with
|
||||
`--remote-debugging-port=9222`. The daemon connects to the Discord page and runs the actions in the
|
||||
real client.
|
||||
The primary transport is the validated core bridge at `vesktop-puppet.sock`. The daemon sends
|
||||
validated actions and receives normalized events. See
|
||||
[../docs/core-bridge.md](../docs/core-bridge.md).
|
||||
|
||||
The target transport is the validated core bridge. See
|
||||
[../docs/core-bridge.md](../docs/core-bridge.md). The daemon logic is the same on both transports.
|
||||
The CDP transport is a development fallback. It needs no Vesktop patch, and it has no
|
||||
core-side guardrails. Use it for debugging only.
|
||||
|
||||
## Pacing
|
||||
|
||||
@@ -44,8 +47,9 @@ The only permitted live-send target is user zzib. See
|
||||
## Verified
|
||||
|
||||
- QR login works end-to-end. The token came from a phone scan.
|
||||
- The CDP transport works. `get_state` returned the real account and 7 guilds. `send_dm` delivered
|
||||
a real DM to zzib.
|
||||
- The core bridge works live. `get_state` returned the real account and 7 guilds. `send_dm` reached
|
||||
zzib. A synthetic owner message reached the daemon log with the command parsed.
|
||||
- The mock path works. The daemon handles state, sends, commands, and the two log buckets.
|
||||
- Pacing stayed inside the 500 to 2800 ms window.
|
||||
|
||||
## Findings
|
||||
|
||||
+4
-1
@@ -10,7 +10,10 @@
|
||||
"test:comm": "tsx src/test-communication.ts",
|
||||
"cdp:probe": "tsx src/cdp-probe.ts",
|
||||
"control:bridge": "tsx src/control-bridge.ts",
|
||||
"check": "tsc --noEmit"
|
||||
"check": "tsc --noEmit",
|
||||
"control:mock": "tsx src/mock-core.ts",
|
||||
"test:trigger": "tsx src/test-trigger.ts",
|
||||
"cdp:dispatch": "tsx src/cdp-dispatch.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Veskslop development tool — run one expression in the real client over CDP.
|
||||
*
|
||||
* Use it with the client started on --remote-debugging-port=9222. The main use
|
||||
* is to dispatch synthetic events for end-to-end tests.
|
||||
*
|
||||
* Usage:
|
||||
* CDP_EXPRESSION='Vencord.Webpack.Common.FluxDispatcher.dispatch({...})' pnpm cdp:dispatch
|
||||
*/
|
||||
|
||||
import WebSocket from "ws";
|
||||
|
||||
const expression = process.env.CDP_EXPRESSION;
|
||||
if (!expression) {
|
||||
console.error("CDP_EXPRESSION is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cdpUrl = process.env.CDP_URL ?? "http://127.0.0.1:9222";
|
||||
const targets = (await (await fetch(`${cdpUrl}/json`)).json()) as any[];
|
||||
const page = targets.find(target => target.type === "page" && target.url.includes("discord.com"));
|
||||
|
||||
if (!page) {
|
||||
console.error("No Discord page target");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", () => resolve());
|
||||
ws.once("error", reject);
|
||||
});
|
||||
|
||||
const response = await new Promise<any>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("CDP evaluate timed out")), 10_000);
|
||||
|
||||
ws.on("message", raw => {
|
||||
const message = JSON.parse(raw.toString());
|
||||
if (message.id !== 1) return;
|
||||
|
||||
clearTimeout(timer);
|
||||
resolve(message);
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
method: "Runtime.evaluate",
|
||||
params: { expression, returnByValue: true, awaitPromise: true }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(response.result ?? response.error));
|
||||
ws.close();
|
||||
+27
-27
@@ -1,13 +1,12 @@
|
||||
/*
|
||||
* Veskslop daemon — MCP control bridge over Chrome DevTools Protocol.
|
||||
* Veskslop daemon — MCP control bridge over the validated core bridge.
|
||||
*
|
||||
* Talks to an unmodified Vesktop through --remote-debugging-port, evaluates
|
||||
* actions against the real client, receives events through a CDP binding, and
|
||||
* runs the trigger/whitelist/log logic locally.
|
||||
* The daemon holds all logic. It sends validated actions to Vesktop and
|
||||
* receives normalized events. See docs/core-bridge.md.
|
||||
*
|
||||
* Usage:
|
||||
* VESKSLOP_TOKEN_FILE=... pnpm control:bridge
|
||||
* CDP_URL=http://127.0.0.1:9222 MCP_PORT=8788 pnpm control:bridge
|
||||
* pnpm control:bridge
|
||||
* VESKSLOP_SOCKET=/path/to/vesktop-puppet.sock MCP_PORT=8788 pnpm control:bridge
|
||||
*/
|
||||
|
||||
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
||||
@@ -15,45 +14,43 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { CdpClient } from "./cdp-client.js";
|
||||
import { Discord } from "./discord.js";
|
||||
import { CoreClient, coreSocketPath } from "./core-client.js";
|
||||
import { dedupe, getQueryCount, paced, sleep } from "./pacing.js";
|
||||
import { Puppet } from "./puppet.js";
|
||||
|
||||
const HOST = process.env.MCP_HOST ?? "127.0.0.1";
|
||||
const PORT = Number(process.env.MCP_PORT ?? 8788);
|
||||
|
||||
let discord: Discord | undefined;
|
||||
let core: CoreClient | undefined;
|
||||
let puppet: Puppet | undefined;
|
||||
let cdpConnected = false;
|
||||
let coreConnected = false;
|
||||
|
||||
async function connectLoop() {
|
||||
for (;;) {
|
||||
try {
|
||||
const cdp = await CdpClient.connect();
|
||||
const client = await CoreClient.connect();
|
||||
|
||||
discord = new Discord(cdp);
|
||||
puppet = new Puppet(discord);
|
||||
core = client;
|
||||
puppet = new Puppet(client);
|
||||
|
||||
await discord.installEventBridge();
|
||||
cdp.onEvent(event => {
|
||||
client.onEvent(event => {
|
||||
puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error));
|
||||
});
|
||||
|
||||
cdpConnected = true;
|
||||
console.log("[daemon] connected to Vesktop via CDP");
|
||||
coreConnected = true;
|
||||
console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(`[daemon] waiting for Vesktop CDP: ${(error as Error).message}`);
|
||||
console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`);
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireDiscord(): Discord {
|
||||
if (!cdpConnected || !discord) throw new Error("Not connected to Vesktop (run it with --remote-debugging-port)");
|
||||
return discord;
|
||||
function requireCore(): CoreClient {
|
||||
if (!coreConnected || !core) throw new Error("Not connected to Vesktop (is the core bridge running?)");
|
||||
return core;
|
||||
}
|
||||
|
||||
function requirePuppet(): Puppet {
|
||||
@@ -73,7 +70,7 @@ function buildServer(): McpServer {
|
||||
{
|
||||
description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries."
|
||||
},
|
||||
async () => jsonText(await requireDiscord().getState())
|
||||
async () => jsonText(await requireCore().getState())
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -86,7 +83,7 @@ function buildServer(): McpServer {
|
||||
}
|
||||
},
|
||||
async ({ channelId, content }) => {
|
||||
await paced(() => requireDiscord().sendMessage(channelId, content));
|
||||
await paced(() => requireCore().sendMessage(channelId, content));
|
||||
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
|
||||
}
|
||||
);
|
||||
@@ -101,7 +98,7 @@ function buildServer(): McpServer {
|
||||
}
|
||||
},
|
||||
async ({ userId, content }) => {
|
||||
const result = await paced(() => requireDiscord().sendDm(userId, content));
|
||||
const result = await paced(() => requireCore().sendDm(userId, content));
|
||||
return jsonText({ ...result, userId, queriesUsed: getQueryCount() });
|
||||
}
|
||||
);
|
||||
@@ -117,7 +114,7 @@ function buildServer(): McpServer {
|
||||
},
|
||||
async ({ channelId, limit }) => {
|
||||
const result = await dedupe<{ cached: number; messages: unknown[] }>(`read:${channelId}:${limit}`, () =>
|
||||
paced(() => requireDiscord().readMessages(channelId, limit))
|
||||
paced(() => requireCore().readMessages(channelId, limit))
|
||||
);
|
||||
return jsonText({ ...result, channelId, queriesUsed: getQueryCount() });
|
||||
}
|
||||
@@ -203,7 +200,7 @@ app.delete("/mcp", (_req, res) => {
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ ok: true, cdpConnected, ownerId: puppet?.owner, queries: getQueryCount() });
|
||||
res.json({ ok: true, coreConnected, socketPath: coreSocketPath, ownerId: puppet?.owner, queries: getQueryCount() });
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, (error?: Error) => {
|
||||
@@ -214,4 +211,7 @@ app.listen(PORT, HOST, (error?: Error) => {
|
||||
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => process.exit(0));
|
||||
process.on("SIGINT", () => {
|
||||
core?.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Veskslop daemon — client for the validated core bridge.
|
||||
*
|
||||
* Speaks newline-delimited JSON over the core socket. A request gets one
|
||||
* response line. Events arrive as { type: "event" } lines after subscribe.
|
||||
*/
|
||||
|
||||
import net from "node:net";
|
||||
|
||||
export const coreSocketPath =
|
||||
process.env.VESKSLOP_SOCKET ??
|
||||
(process.env.XDG_RUNTIME_DIR ? `${process.env.XDG_RUNTIME_DIR}/vesktop-puppet.sock` : "/tmp/vesktop-puppet.sock");
|
||||
|
||||
export class CoreError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export type CoreEventListener = (event: any) => void;
|
||||
|
||||
interface Pending {
|
||||
resolve: (value: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
export class CoreClient {
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
private readonly listeners = new Set<CoreEventListener>();
|
||||
|
||||
private constructor(private readonly socket: net.Socket) {
|
||||
socket.setEncoding("utf-8");
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
|
||||
let newline: number;
|
||||
while ((newline = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, newline);
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line) this.onLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
for (const request of this.pending.values()) {
|
||||
request.reject(new CoreError("DISCONNECTED", "core disconnected"));
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
|
||||
socket.on("error", () => socket.destroy());
|
||||
}
|
||||
|
||||
static connect(path = coreSocketPath): Promise<CoreClient> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(path);
|
||||
const client = new CoreClient(socket);
|
||||
|
||||
socket.once("connect", async () => {
|
||||
try {
|
||||
await client.request("events.subscribe");
|
||||
resolve(client);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
private onLine(line: string) {
|
||||
let message: any;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "event") {
|
||||
for (const listener of this.listeners) listener(message.event);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = this.pending.get(message.id);
|
||||
if (!request) return;
|
||||
this.pending.delete(message.id);
|
||||
|
||||
if (message.ok) request.resolve(message.result);
|
||||
else request.reject(new CoreError(message.error?.code ?? "UNKNOWN", message.error?.message ?? "core error"));
|
||||
}
|
||||
|
||||
request<T = any>(action: string, params?: Record<string, unknown>): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.socket.write(JSON.stringify({ id, action, params }) + "\n");
|
||||
});
|
||||
}
|
||||
|
||||
onEvent(listener: CoreEventListener) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/* Convenience actions */
|
||||
|
||||
async getState() {
|
||||
return this.request("state.get");
|
||||
}
|
||||
|
||||
async getSelfId(): Promise<string | undefined> {
|
||||
return (await this.getState())?.user?.id;
|
||||
}
|
||||
|
||||
async sendMessage(channelId: string, content: string) {
|
||||
await this.request("message.send", { channelId, content });
|
||||
}
|
||||
|
||||
async sendDm(userId: string, content: string) {
|
||||
return this.request("dm.send", { userId, content });
|
||||
}
|
||||
|
||||
async readMessages(channelId: string, limit: number) {
|
||||
return this.request("message.read", { channelId, limit });
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/*
|
||||
* Veskslop — Discord actions executed inside the real client via CDP.
|
||||
*
|
||||
* Every expression runs in the Discord page against Vencord's exposed globals,
|
||||
* so all traffic is performed by the official client.
|
||||
*/
|
||||
|
||||
import { CdpClient } from "./cdp-client.js";
|
||||
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface DiscordChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export interface DiscordGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
channels: DiscordChannel[];
|
||||
}
|
||||
|
||||
export interface DiscordState {
|
||||
user: DiscordUser;
|
||||
guilds: DiscordGuild[];
|
||||
}
|
||||
|
||||
const stringify = (value: unknown) => JSON.stringify(value);
|
||||
|
||||
export class Discord {
|
||||
constructor(private readonly cdp: CdpClient) {}
|
||||
|
||||
/** Install the page-side event pusher (idempotent across reconnects). */
|
||||
async installEventBridge(): Promise<void> {
|
||||
await this.cdp.evaluate(`
|
||||
(() => {
|
||||
if (globalThis.__veskslopBridgeInstalled) return "already";
|
||||
globalThis.__veskslopBridgeInstalled = true;
|
||||
|
||||
const { FluxDispatcher, ChannelStore } = Vencord.Webpack.Common;
|
||||
FluxDispatcher.subscribe("MESSAGE_CREATE", event => {
|
||||
try {
|
||||
const message = event.message;
|
||||
if (!message?.author) return;
|
||||
|
||||
veskSlopEmit(JSON.stringify({
|
||||
type: "MESSAGE_CREATE",
|
||||
id: message.id,
|
||||
channelId: event.channelId,
|
||||
guildId: event.guildId ?? null,
|
||||
channelType: ChannelStore.getChannel(event.channelId)?.type ?? null,
|
||||
author: {
|
||||
id: message.author.id,
|
||||
username: message.author.username,
|
||||
bot: !!message.author.bot
|
||||
},
|
||||
content: message.content ?? "",
|
||||
mentions: [
|
||||
...(message.mentions ?? []).map(user => user.id),
|
||||
...(message.mentionIds ?? [])
|
||||
],
|
||||
timestamp: message.timestamp ?? new Date().toISOString()
|
||||
}));
|
||||
} catch {
|
||||
// never break the client because of the bridge
|
||||
}
|
||||
});
|
||||
|
||||
return "installed";
|
||||
})()
|
||||
`);
|
||||
}
|
||||
|
||||
async getState(): Promise<DiscordState | null> {
|
||||
return this.cdp.evaluate<DiscordState | null>(`
|
||||
(() => {
|
||||
const { UserStore, GuildStore, ChannelStore } = Vencord.Webpack.Common;
|
||||
const user = UserStore.getCurrentUser();
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
user: { id: user.id, username: user.username },
|
||||
guilds: GuildStore.getGuildsArray().map(guild => ({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
channels: Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id)).map(channel => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type
|
||||
}))
|
||||
}))
|
||||
};
|
||||
})()
|
||||
`);
|
||||
}
|
||||
|
||||
async getSelfId(): Promise<string | undefined> {
|
||||
return (await this.getState())?.user.id;
|
||||
}
|
||||
|
||||
async sendMessage(channelId: string, content: string): Promise<void> {
|
||||
await this.cdp.evaluate(`
|
||||
Vencord.Util.sendMessage(${stringify(channelId)}, { content: ${stringify(content)} }, false)
|
||||
`);
|
||||
}
|
||||
|
||||
async sendDm(userId: string, content: string): Promise<{ sent: boolean; channelId?: string; error?: string }> {
|
||||
return this.cdp.evaluate(`
|
||||
(async () => {
|
||||
const { ChannelStore } = Vencord.Webpack.Common;
|
||||
const { openPrivateChannel, sendMessage } = Vencord.Util;
|
||||
|
||||
let channelId = ChannelStore.getDMFromUserId(${stringify(userId)});
|
||||
if (!channelId) {
|
||||
openPrivateChannel(${stringify(userId)});
|
||||
for (let i = 0; i < 20 && !channelId; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
channelId = ChannelStore.getDMFromUserId(${stringify(userId)});
|
||||
}
|
||||
}
|
||||
|
||||
if (!channelId) return { sent: false, error: "No DM channel yet" };
|
||||
|
||||
await sendMessage(channelId, { content: ${stringify(content)} }, false);
|
||||
return { sent: true, channelId };
|
||||
})()
|
||||
`);
|
||||
}
|
||||
|
||||
async readMessages(channelId: string, limit: number) {
|
||||
return this.cdp.evaluate(`
|
||||
(() => {
|
||||
const messages = Vencord.Webpack.Common.MessageStore.getMessages(${stringify(channelId)})?._array ?? [];
|
||||
return {
|
||||
cached: messages.length,
|
||||
messages: messages.slice(${-Math.abs(limit)}).map(message => ({
|
||||
id: message.id,
|
||||
author: { id: message.author.id, username: message.author.username },
|
||||
content: message.content,
|
||||
timestamp: message.timestamp
|
||||
}))
|
||||
};
|
||||
})()
|
||||
`);
|
||||
}
|
||||
|
||||
/** Seed the auth token and reload the client. Used for fresh profiles. */
|
||||
async seedToken(token: string): Promise<void> {
|
||||
await this.cdp.evaluate(`
|
||||
localStorage.setItem("token", ${stringify(JSON.stringify(token))});
|
||||
location.reload();
|
||||
"reloading"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Veskslop — mock core bridge for daemon tests.
|
||||
*
|
||||
* Implements the validated protocol with fake data. Use it to test the daemon
|
||||
* without Electron or Discord. The `debug.emit` action injects an event into
|
||||
* all subscribers and exists only in the mock.
|
||||
*
|
||||
* Usage:
|
||||
* VESKSLOP_SOCKET=/tmp/vesktop-puppet-mock.sock pnpm control:mock
|
||||
*/
|
||||
|
||||
import { rmSync } from "node:fs";
|
||||
import { createServer, Socket } from "node:net";
|
||||
|
||||
const socketPath = process.env.VESKSLOP_SOCKET ?? "/tmp/vesktop-puppet-mock.sock";
|
||||
|
||||
const subscribers = new Set<Socket>();
|
||||
|
||||
const FAKE_GUILDS = [
|
||||
{
|
||||
id: "100000000000000001",
|
||||
name: "Mock Guild",
|
||||
channels: [
|
||||
{ id: "200000000000000001", name: "general", type: 0 },
|
||||
{ id: "200000000000000002", name: "voice", type: 2 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const FAKE_MESSAGES = [
|
||||
{
|
||||
id: "300000000000000001",
|
||||
author: { id: "1", username: "mockuser" },
|
||||
content: "hello from the mock",
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
function handle(action: string | undefined, params: any) {
|
||||
switch (action) {
|
||||
case "ping":
|
||||
return "pong";
|
||||
case "state.get":
|
||||
return { user: { id: "999", username: "mockuser" }, guilds: FAKE_GUILDS };
|
||||
case "message.send":
|
||||
return { channelId: params?.channelId, sent: true };
|
||||
case "message.read":
|
||||
return { cached: FAKE_MESSAGES.length, messages: FAKE_MESSAGES };
|
||||
case "dm.send":
|
||||
return { userId: params?.userId, channelId: `dm-${params?.userId}`, sent: true };
|
||||
case "debug.emit": {
|
||||
const line = JSON.stringify({ type: "event", event: params?.event }) + "\n";
|
||||
for (const subscriber of subscribers) subscriber.write(line);
|
||||
return { emitted: true };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
rmSync(socketPath, { force: true });
|
||||
|
||||
const server = createServer(socket => {
|
||||
socket.setEncoding("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
|
||||
let newline: number;
|
||||
while ((newline = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (!line) continue;
|
||||
|
||||
try {
|
||||
const request = JSON.parse(line);
|
||||
|
||||
if (request.action === "events.subscribe") {
|
||||
subscribers.add(socket);
|
||||
socket.write(JSON.stringify({ id: request.id, ok: true, result: { subscribed: true } }) + "\n");
|
||||
console.log(`[mock] ${new Date().toISOString()} events.subscribe`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = handle(request.action, request.params);
|
||||
console.log(`[mock] ${new Date().toISOString()} ${request.action}`);
|
||||
socket.write(JSON.stringify({ id: request.id, ok: true, result }) + "\n");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(`[mock] ${new Date().toISOString()} ERROR ${message}`);
|
||||
socket.write(JSON.stringify({ ok: false, error: { code: "UNKNOWN_ACTION", message } }) + "\n");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", () => subscribers.delete(socket));
|
||||
socket.on("close", () => subscribers.delete(socket));
|
||||
});
|
||||
|
||||
server.listen(socketPath, () => {
|
||||
console.log(`[mock] listening on ${socketPath}`);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
server.close();
|
||||
rmSync(socketPath, { force: true });
|
||||
process.exit(0);
|
||||
});
|
||||
+7
-2
@@ -15,7 +15,12 @@ import {
|
||||
type ParsedCommand,
|
||||
type PuppetMessage
|
||||
} from "./communication.js";
|
||||
import type { Discord } from "./discord.js";
|
||||
|
||||
/** The subset of the transport that the puppet logic needs. */
|
||||
export interface PuppetTransport {
|
||||
getSelfId(): Promise<string | undefined>;
|
||||
sendMessage(channelId: string, content: string): Promise<void>;
|
||||
}
|
||||
|
||||
const DM_CHANNEL = 1;
|
||||
const GROUP_DM_CHANNEL = 3;
|
||||
@@ -36,7 +41,7 @@ export class Puppet {
|
||||
private whitelistIds: string[] = [];
|
||||
private selfId?: string;
|
||||
|
||||
constructor(private readonly discord: Discord) {
|
||||
constructor(private readonly discord: PuppetTransport) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
|
||||
if (state.ownerId) this.ownerId = state.ownerId;
|
||||
|
||||
+6
-9
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Veskslop — live DM send test through a running client.
|
||||
* Veskslop — live DM send test through the core bridge.
|
||||
*
|
||||
* Sanctioned test target: user zzib (defaults to their user ID).
|
||||
*
|
||||
@@ -8,8 +8,7 @@
|
||||
* TEST_DM_USER_ID=... TEST_DM_CONTENT="hello" pnpm poc:dm
|
||||
*/
|
||||
|
||||
import { CdpClient } from "./cdp-client.js";
|
||||
import { Discord } from "./discord.js";
|
||||
import { CoreClient } from "./core-client.js";
|
||||
|
||||
/** zzib — the only sanctioned live-send target. */
|
||||
const DEFAULT_TEST_DM_USER_ID = "177117296140091393";
|
||||
@@ -17,10 +16,8 @@ const DEFAULT_TEST_DM_USER_ID = "177117296140091393";
|
||||
const userId = process.env.TEST_DM_USER_ID ?? DEFAULT_TEST_DM_USER_ID;
|
||||
const content = process.env.TEST_DM_CONTENT ?? "veskslop test dm";
|
||||
|
||||
const cdp = await CdpClient.connect();
|
||||
const discord = new Discord(cdp);
|
||||
const core = await CoreClient.connect();
|
||||
console.log("[dm] connected to the core bridge");
|
||||
console.log("[dm]", await core.sendDm(userId, content));
|
||||
|
||||
console.log("[dm] connected via CDP");
|
||||
console.log("[dm]", await discord.sendDm(userId, content));
|
||||
|
||||
cdp.close();
|
||||
core.close();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Veskslop — inject a synthetic trigger event into the mock core.
|
||||
* Use it to test the daemon trigger path without Discord.
|
||||
*
|
||||
* Usage:
|
||||
* VESKSLOP_SOCKET=/tmp/vesktop-puppet-mock.sock pnpm test:trigger
|
||||
* TEST_TRIGGER_CONTENT="whitelist add @123" pnpm test:trigger
|
||||
*/
|
||||
|
||||
import net from "node:net";
|
||||
|
||||
import { coreSocketPath } from "./core-client.js";
|
||||
|
||||
const authorId = process.env.TEST_TRIGGER_AUTHOR ?? "177117296140091393";
|
||||
const content = process.env.TEST_TRIGGER_CONTENT ?? "whitelist list";
|
||||
|
||||
const event = {
|
||||
type: "MESSAGE_CREATE",
|
||||
id: `30000000000000${Date.now() % 10000}`,
|
||||
channelId: "805255536223715370",
|
||||
guildId: null,
|
||||
channelType: 1,
|
||||
author: { id: authorId, username: "zzib", bot: false },
|
||||
content,
|
||||
mentions: [],
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(coreSocketPath);
|
||||
socket.setEncoding("utf-8");
|
||||
|
||||
socket.once("connect", () =>
|
||||
socket.write(JSON.stringify({ id: 1, action: "debug.emit", params: { event } }) + "\n")
|
||||
);
|
||||
socket.once("data", data => {
|
||||
console.log("[trigger]", data.toString().trim());
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
Reference in New Issue
Block a user