Rework core integration: validated capability layer, docs, and session state

This commit is contained in:
Fisto
2026-09-11 14:09:04 +02:00
parent 2a15f057df
commit e62d3007d8
33 changed files with 1897 additions and 612 deletions
+1
View File
@@ -3,3 +3,4 @@ node_modules/
.env
.token
.secrets/
.veskslop-state.json
+36 -83
View File
@@ -1,7 +1,8 @@
# Veskslop Phase 0 spikes
# Veskslop daemon
Timeboxed proof-of-concept scripts, not production code. Project direction lives in
[../AGENTS.md](../AGENTS.md).
The external daemon and the development spikes. The daemon holds all logic and exposes MCP.
Read the project docs first: [../docs/INDEX.md](../docs/INDEX.md).
## Setup
@@ -9,98 +10,50 @@ Timeboxed proof-of-concept scripts, not production code. Project direction lives
pnpm install
```
## Current query policy
## Run commands
- Every command sent to the client is spaced by a random **500–2800 ms** and serialized through one
queue (`src/pacing.ts`).
- Identical concurrent reads are coalesced (`dedupe`), and state reads are cache-only.
- `DEBUG=1` logs every command and its delay.
| Command | Purpose |
| --- | --- |
| `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 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 check` | Type check the daemon code. |
## Spikes
## Transport
### 0. QR login / token acquisition — `src/qr-login.ts`
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.
```sh
pnpm poc:qr
TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr
```
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.
Connects to `wss://remote-auth-gateway.discord.gg/?v=2`, renders the approval QR directly in the
terminal, waits for a phone scan, exchanges the login ticket and writes the token to
`poc/.token` (mode 600, gitignored; override with `TOKEN_FILE`). This is the **puppet account's**
token. No existing token required.
If Discord responds with an hCaptcha challenge, the script prints the challenge and prompts for a
`HCAPTCHA_KEY` (or takes it from the environment).
## Pacing
The headless client consumes this file on startup via `VESKSLOP_TOKEN_FILE` (default
`<DATA_DIR>/token`, see [../AGENTS.md](../AGENTS.md) → Auth persistence).
Every command gets a random delay of 500 to 2800 ms. One queue serializes the commands. The queue
coalesces identical concurrent reads. State reads use the cache. `DEBUG=1` logs each command and
its delay.
### 1. Vesktop control bridge — `src/control-bridge.ts`
## Live test target
The MCP daemon. Exposes Streamable HTTP and forwards tool calls over the JSON-lines protocol on
Vesktop's control socket (default `$XDG_RUNTIME_DIR/vesktop.sock`, override with `VESKSLOP_SOCKET`).
```sh
pnpm control:bridge # http://127.0.0.1:8788/mcp
VESKSLOP_SOCKET=... MCP_PORT=... pnpm control:bridge
```
Tools: `get_state` (cache-only), `send_message`, `read_messages` (coalesced), `send_dm`.
Vesktop side lives in this repo:
- `src/main/ipcSocket.ts` — parses JSON lines `{ id, command, data }` and forwards them through
`sendRendererCommand`.
- `src/renderer/ipcCommands.ts` — handlers that use official client interactions (`sendMessage`,
`openPrivateChannel`, guild/channel/message stores).
Protocol commands: `ping`, `state:get`, `message:send`, `message:read`, `dm:send`.
### 2. Mock Vesktop — `src/mock-vesktop.ts`
```sh
VESKSLOP_SOCKET=/tmp/veskslop-mock.sock pnpm control:mock
```
Stands in for the real Vesktop process so the bridge can be developed without Electron/Xvfb.
### 3. Live DM test — `src/test-dm.ts`
```sh
pnpm poc:dm
TEST_DM_CONTENT="hello" pnpm poc:dm
```
Talks to a **real** running Vesktop's control socket and sends one paced DM. Defaults to the only
sanctioned live-send target, user **zzib** (`TEST_DM_USER_ID` overrides).
The only permitted live-send target is user zzib. See
[../docs/testing.md](../docs/testing.md) for the full setup.
## Verified
- QR login works end-to-end; the token in `.token` came from a phone scan.
- MCP bridge path: `initialize`, `tools/list`, `get_state`, `send_message`, `read_messages`,
`send_dm` all round-trip correctly through the mock.
- **Live end-to-end (2026-09-11)**: a real Vesktop client was run headless under Xvfb with the
token seeded from `~/.config/veskslop/token`. `get_state` returned the real account and 7 guilds,
and `send_dm` delivered a real DM to zzib (`sent: true`). Cache-only `read_messages` on that
never-opened DM returned zero messages, as expected.
- Pacing observed live: consecutive commands reached the mock **592 ms**, **1862 ms** and
**1439 ms** apart — inside the 500–2800 ms window.
## Deferred (not testable in this container)
- Real renderer handlers against a live Discord session (needs Electron + display).
- Headless display strategy (Xvfb/offscreen) and client lightening.
- Token injection into the official client.
- Voice join/leave and audio/video pipelines (need a voice channel; ffmpeg/PipeWire absent).
- Remote client auth over LAN/Tailscale and the web debug UI.
- 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.
- Pacing stayed inside the 500 to 2800 ms window.
## Findings
- Official `discord.js` is bot-only (`/gateway/bot` → 401 with a user token), and
`discord.js-selfbot-v13` is archived — do not build on either. The core is the real Vesktop
client, puppeted through its own interactions.
- MCP SDK `1.30.0` stateless Streamable HTTP works as used here.
- Vesktop's existing `sendRendererCommand`/`onIpcCommand` bus is enough to puppet interactions; the
socket only needed a JSON-lines framing and a command map.
- pnpm 11: this folder is its own workspace root; build approvals live in `pnpm-workspace.yaml`
- Official `discord.js` is bot-only. It fails at login with a user token. The popular selfbot fork
is archived. Do not use either. The core is the real Vesktop client.
- MCP SDK `1.30.0` Streamable HTTP works as used here.
- CDP gives control and events with no Vesktop patch. Raw CDP has no guardrails. The validated core
bridge is the target for this reason.
- pnpm 11: this folder is its own workspace root. Build approvals live in `pnpm-workspace.yaml`
(`allowBuilds`).
+2 -1
View File
@@ -7,7 +7,8 @@
"scripts": {
"poc:qr": "tsx src/qr-login.ts",
"poc:dm": "tsx src/test-dm.ts",
"control:mock": "tsx src/mock-vesktop.ts",
"test:comm": "tsx src/test-communication.ts",
"cdp:probe": "tsx src/cdp-probe.ts",
"control:bridge": "tsx src/control-bridge.ts",
"check": "tsc --noEmit"
},
+102
View File
@@ -0,0 +1,102 @@
/*
* Veskslop — minimal Chrome DevTools Protocol client.
*
* Connects to the Discord page target exposed by Vesktop's
* --remote-debugging-port, evaluates expressions in the page, and receives
* pushed events through a CDP binding. This is the entire integration surface:
* Vesktop itself is unmodified.
*/
import WebSocket from "ws";
export type CdpListener = (event: any) => void;
interface PendingRequest {
resolve: (value: any) => void;
reject: (error: Error) => void;
}
export class CdpClient {
private ws!: WebSocket;
private nextId = 0;
private readonly pending = new Map<number, PendingRequest>();
private readonly listeners = new Set<CdpListener>();
static async connect(cdpUrl = process.env.CDP_URL ?? "http://127.0.0.1:9222"): Promise<CdpClient> {
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) throw new Error(`No Discord page target at ${cdpUrl}`);
const client = new CdpClient();
client.ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise<void>((resolve, reject) => {
client.ws.once("open", resolve);
client.ws.once("error", reject);
});
client.ws.on("message", (raw: WebSocket.RawData) => client.onMessage(raw));
await client.send("Runtime.enable");
await client.send("Runtime.addBinding", { name: "veskslopEmit" });
return client;
}
private onMessage(raw: WebSocket.RawData) {
const message = JSON.parse(raw.toString());
if (message.id && this.pending.has(message.id)) {
const request = this.pending.get(message.id)!;
this.pending.delete(message.id);
if (message.error) request.reject(new Error(message.error.message));
else request.resolve(message.result);
return;
}
if (message.method === "Runtime.bindingCalled" && message.params?.name === "veskslopEmit") {
try {
const event = JSON.parse(message.params.payload);
for (const listener of this.listeners) listener(event);
} catch {
// ignore malformed payloads
}
}
}
send(method: string, params?: unknown): Promise<any> {
const id = ++this.nextId;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
});
}
async evaluate<T = unknown>(expression: string): Promise<T> {
const result = await this.send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: true
});
if (result.exceptionDetails) {
const description =
result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "evaluation failed";
throw new Error(description);
}
return result.result?.value as T;
}
onEvent(listener: CdpListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
close() {
this.ws.close();
}
}
+84
View File
@@ -0,0 +1,84 @@
/*
* Veskslop — Chrome DevTools Protocol feasibility probe.
*
* Verifies that an external process can, with zero Vesktop patches:
* - reach the Discord page target
* - evaluate against Vencord/Discord internals
* - subscribe to Flux events and receive them via a CDP binding
*
* Usage: start Vesktop with --remote-debugging-port=9222, then: pnpm cdp:probe
*/
import WebSocket from "ws";
const CDP = process.env.CDP_URL ?? "http://127.0.0.1:9222";
const targets = (await (await fetch(`${CDP}/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. Targets:", targets.map(t => `${t.type} ${t.url}`).join("\n"));
process.exit(1);
}
console.log("target:", page.url);
const ws = new WebSocket(page.webSocketDebuggerUrl);
let nextId = 0;
const pending = new Map<number, (message: any) => void>();
ws.on("message", raw => {
const message = JSON.parse(raw.toString());
if (message.id && pending.has(message.id)) {
pending.get(message.id)!(message);
pending.delete(message.id);
return;
}
if (message.method === "Runtime.bindingCalled") {
console.log("BINDING EVENT:", message.params.name, "->", message.params.payload);
}
});
function send(method: string, params?: unknown): Promise<any> {
const id = ++nextId;
return new Promise(resolve => {
pending.set(id, resolve);
ws.send(JSON.stringify({ id, method, params }));
});
}
await new Promise<void>(resolve => ws.once("open", () => resolve()));
await send("Runtime.enable");
async function evaluate(expression: string): Promise<any> {
const response = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
if (response.result?.exceptionDetails) {
return { error: response.result.exceptionDetails.text };
}
return response.result?.result?.value;
}
console.log("Vencord global:", await evaluate("typeof Vencord"));
console.log("FluxDispatcher.subscribe:", await evaluate("typeof Vencord?.Webpack?.Common?.FluxDispatcher?.subscribe"));
console.log("sendMessage util:", await evaluate("typeof Vencord?.Util?.sendMessage"));
console.log("current user:", await evaluate("Vencord?.Webpack?.Common?.UserStore?.getCurrentUser?.()?.username ?? null"));
await send("Runtime.addBinding", { name: "veskslopEmit" });
await evaluate(`veskslopEmit("probe-ok")`);
await evaluate(`
Vencord.Webpack.Common.FluxDispatcher.subscribe("MESSAGE_CREATE", event => {
window.veskslopEmit(JSON.stringify({ type: "MESSAGE_CREATE", channelId: event.channelId, guildId: event.guildId ?? null }));
});
"subscribed"
`);
console.log("waiting 5s for binding events...");
await new Promise(resolve => setTimeout(resolve, 5000));
ws.close();
console.log("probe done");
+83
View File
@@ -0,0 +1,83 @@
/*
* Veskslop communication backend — pure logic (no Discord imports).
*
* Keeps a small ring buffer of trigger-qualifying messages: the last 100 from
* trusted users and the last 30 from untrusted ones. Also parses the
* `whitelist` commands and normalizes @mentions.
*/
export interface PuppetMessage {
id: string;
channelId: string;
guildId?: string;
authorId: string;
authorName: string;
/** Raw message content as received. */
content: string;
timestamp: string;
trusted: boolean;
/** Parsed command text when the message is one. */
command?: string;
}
export const TRUSTED_LOG_LIMIT = 100;
export const UNTRUSTED_LOG_LIMIT = 30;
export class MessageLog {
trusted: PuppetMessage[] = [];
untrusted: PuppetMessage[] = [];
add(message: PuppetMessage) {
const bucket = message.trusted ? this.trusted : this.untrusted;
const limit = message.trusted ? TRUSTED_LOG_LIMIT : UNTRUSTED_LOG_LIMIT;
bucket.push(message);
if (bucket.length > limit) bucket.splice(0, bucket.length - limit);
}
snapshot() {
return { trusted: [...this.trusted], untrusted: [...this.untrusted] };
}
}
const USER_MENTION = /<@!?(\d+)>/g;
export function extractMentionIds(content: string, mentions?: Array<{ id: string }>): Set<string> {
const ids = new Set<string>();
for (const mention of mentions ?? []) ids.add(mention.id);
for (const match of content.matchAll(USER_MENTION)) ids.add(match[1]);
return ids;
}
export function stripMentions(content: string, ids: ReadonlySet<string>): string {
return content
.replace(USER_MENTION, (full, id) => (ids.has(id) ? "" : full))
.replace(/\s+/g, " ")
.trim();
}
export interface ParsedCommand {
name: string;
args: string[];
}
export function parseCommand(text: string): ParsedCommand | undefined {
const trimmed = text.trim();
if (!trimmed) return undefined;
const [name, ...args] = trimmed.split(/\s+/);
return { name: name.toLowerCase(), args };
}
export function isTrusted(
userId: string,
ownerId: string | undefined,
whitelist: readonly string[] | undefined
): boolean {
return userId === ownerId || !!whitelist?.includes(userId);
}
/** Same 500-2800 ms pacing used by the control bridge, for autonomous replies. */
export function randomActionDelay(): number {
return 500 + Math.floor(Math.random() * 2301);
}
+112 -38
View File
@@ -1,14 +1,13 @@
/*
* Veskslop Phase 0 spike — MCP control bridge.
* Veskslop daemon — MCP control bridge over Chrome DevTools Protocol.
*
* Exposes MCP over Streamable HTTP and forwards tool calls to the Vesktop
* control socket, where the real client performs them. Every command is paced
* by the shared queue (300-800 ms), reads are coalesced, and state reads are
* cache-only.
* 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.
*
* Usage:
* pnpm control:bridge
* VESKSLOP_SOCKET=/tmp/veskslop-mock.sock MCP_PORT=8788 pnpm control:bridge
* VESKSLOP_TOKEN_FILE=... pnpm control:bridge
* CDP_URL=http://127.0.0.1:9222 MCP_PORT=8788 pnpm control:bridge
*/
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
@@ -16,18 +15,54 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import * as z from "zod/v4";
import { dedupe, getQueryCount, paced } from "./pacing.js";
import { sendVesktopCommand, socketPath } from "./vesktop-socket.js";
import { CdpClient } from "./cdp-client.js";
import { Discord } from "./discord.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);
function jsonText(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
let discord: Discord | undefined;
let puppet: Puppet | undefined;
let cdpConnected = false;
async function connectLoop() {
for (;;) {
try {
const cdp = await CdpClient.connect();
discord = new Discord(cdp);
puppet = new Puppet(discord);
await discord.installEventBridge();
cdp.onEvent(event => {
puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error));
});
cdpConnected = true;
console.log("[daemon] connected to Vesktop via CDP");
return;
} catch (error) {
console.warn(`[daemon] waiting for Vesktop CDP: ${(error as Error).message}`);
await sleep(2000);
}
}
}
function command<T>(name: string, data?: unknown): Promise<T> {
return paced(() => sendVesktopCommand<T>(name, data));
function requireDiscord(): Discord {
if (!cdpConnected || !discord) throw new Error("Not connected to Vesktop (run it with --remote-debugging-port)");
return discord;
}
function requirePuppet(): Puppet {
if (!puppet) throw new Error("Puppet not connected yet");
return puppet;
}
function jsonText(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
}
function buildServer(): McpServer {
@@ -38,7 +73,7 @@ function buildServer(): McpServer {
{
description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries."
},
async () => jsonText(await command("state:get"))
async () => jsonText(await requireDiscord().getState())
);
server.registerTool(
@@ -51,25 +86,8 @@ function buildServer(): McpServer {
}
},
async ({ channelId, content }) => {
const result = await command<Record<string, unknown>>("message:send", { channelId, content });
return jsonText({ ...result, 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(`read:${channelId}:${limit}`, () =>
command<{ messages: unknown[]; cached: number; channelId: string }>("message:read", { channelId, limit })
);
return jsonText({ ...result, queriesUsed: getQueryCount() });
await paced(() => requireDiscord().sendMessage(channelId, content));
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
}
);
@@ -83,14 +101,71 @@ function buildServer(): McpServer {
}
},
async ({ userId, content }) => {
const result = await command<Record<string, unknown>>("dm:send", { userId, content });
return jsonText({ ...result, queriesUsed: getQueryCount() });
const result = await paced(() => requireDiscord().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(() => requireDiscord().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(
"whitelist_list",
{
description: "List the puppet owner and trusted (whitelisted) user IDs."
},
async () => jsonText({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist })
);
server.registerTool(
"whitelist_add",
{
description: "Add users to the puppet whitelist.",
inputSchema: {
userIds: z.array(z.string()).min(1).describe("User IDs to trust")
}
},
async ({ userIds }) => jsonText({ whitelist: requirePuppet().addToWhitelist(userIds) })
);
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) })
);
return server;
}
void connectLoop();
const app = createMcpExpressApp({ host: HOST });
app.post("/mcp", async (req, res) => {
@@ -128,7 +203,7 @@ app.delete("/mcp", (_req, res) => {
});
app.get("/health", (_req, res) => {
res.json({ ok: true, socketPath, queries: getQueryCount() });
res.json({ ok: true, cdpConnected, ownerId: puppet?.owner, queries: getQueryCount() });
});
app.listen(PORT, HOST, (error?: Error) => {
@@ -136,8 +211,7 @@ app.listen(PORT, HOST, (error?: Error) => {
console.error("Failed to start control bridge:", error);
process.exit(1);
}
console.log(`[control] MCP listening on http://${HOST}:${PORT}/mcp`);
console.log(`[control] Vesktop socket: ${socketPath}`);
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
});
process.on("SIGINT", () => process.exit(0));
+159
View File
@@ -0,0 +1,159 @@
/*
* 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"
`);
}
}
-88
View File
@@ -1,88 +0,0 @@
/*
* Veskslop Phase 0 spike — mock Vesktop control socket.
*
* Stands in for the real Vesktop main process so the MCP control bridge can be
* developed and tested without Electron. Implements the JSON-lines protocol.
*
* Usage:
* VESKSLOP_SOCKET=/tmp/veskslop-mock.sock pnpm control:mock
*/
import { rmSync } from "node:fs";
import { createServer } from "node:net";
import { socketPath } from "./vesktop-socket.js";
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(command: string | undefined, data: any) {
switch (command) {
case "ping":
return "pong";
case "state:get":
return { user: { id: "1", username: "mockuser" }, guilds: FAKE_GUILDS };
case "message:send":
return { channelId: data?.channelId, content: data?.content, sent: true };
case "message:read":
return { channelId: data?.channelId, cached: FAKE_MESSAGES.length, messages: FAKE_MESSAGES };
case "dm:send":
return { userId: data?.userId, channelId: `dm-${data?.userId}`, content: data?.content, sent: true };
default:
throw new Error(`Unknown command: ${command}`);
}
}
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;
const start = Date.now();
try {
const request = JSON.parse(line);
const result = handle(request.command, request.data);
console.log(`[mock] ${new Date().toISOString()} ${request.command} (${Date.now() - start}ms)`);
socket.write(JSON.stringify({ id: request.id, ok: true, data: result }) + "\n");
} catch (e) {
const error = e instanceof Error ? e.message : String(e);
console.log(`[mock] ${new Date().toISOString()} ERROR ${error}`);
socket.write(JSON.stringify({ ok: false, error }) + "\n");
}
}
});
socket.on("error", () => socket.destroy());
});
server.listen(socketPath, () => {
console.log(`[mock] listening on ${socketPath}`);
});
process.on("SIGINT", () => {
server.close();
rmSync(socketPath, { force: true });
process.exit(0);
});
+185
View File
@@ -0,0 +1,185 @@
/*
* Veskslop daemon — puppet state: trust list, trigger log and Discord-facing
* command handling. All of this used to live inside the Vesktop renderer; it
* now lives here so Vesktop stays stock.
*/
import { readFileSync, writeFileSync } from "node:fs";
import {
isTrusted,
MessageLog,
parseCommand,
randomActionDelay,
stripMentions,
type ParsedCommand,
type PuppetMessage
} from "./communication.js";
import type { Discord } from "./discord.js";
const DM_CHANNEL = 1;
const GROUP_DM_CHANNEL = 3;
const DEFAULT_OWNER_ID = "177117296140091393";
const STATE_FILE =
process.env.VESKSLOP_STATE_FILE ?? new URL("../.veskslop-state.json", import.meta.url).pathname;
interface PersistedState {
ownerId?: string;
whitelist?: string[];
}
export class Puppet {
readonly log = new MessageLog();
private ownerId = DEFAULT_OWNER_ID;
private whitelistIds: string[] = [];
private selfId?: string;
constructor(private readonly discord: Discord) {
try {
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
if (state.ownerId) this.ownerId = state.ownerId;
if (Array.isArray(state.whitelist)) this.whitelistIds = state.whitelist;
} catch {
// first run: defaults apply
}
}
get whitelist(): string[] {
return [...this.whitelistIds];
}
get owner() {
return this.ownerId;
}
getLog() {
return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist };
}
isTrusted(userId: string): boolean {
return isTrusted(userId, this.ownerId, this.whitelistIds);
}
addToWhitelist(userIds: string[]): string[] {
const next = new Set(this.whitelistIds);
for (const id of userIds) if (id && id !== this.ownerId) next.add(id);
this.whitelistIds = [...next];
this.save();
return this.whitelist;
}
removeFromWhitelist(userIds: string[]): string[] {
const remove = new Set(userIds);
this.whitelistIds = this.whitelistIds.filter(id => !remove.has(id));
this.save();
return this.whitelist;
}
private save() {
try {
writeFileSync(STATE_FILE, JSON.stringify({ ownerId: this.ownerId, whitelist: this.whitelistIds }, null, 4));
} catch (error) {
console.error("[puppet] failed to save state:", error);
}
}
async handleEvent(event: any) {
if (event?.type !== "MESSAGE_CREATE") return;
const { author, channelId, guildId, channelType, content, mentions } = event;
if (!author || !channelId) return;
if (author.bot) return;
this.selfId ??= await this.discord.getSelfId();
if (!this.selfId || author.id === this.selfId) return;
const isGroupDm = channelType === GROUP_DM_CHANNEL;
const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null));
const mentionIds = new Set<string>(mentions ?? []);
if (!isDirectDm && !mentionIds.has(this.selfId)) return;
const trusted = this.isTrusted(author.id);
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
const command = parseCommand(text);
const entry: PuppetMessage = {
id: event.id,
channelId,
guildId: guildId ?? undefined,
authorId: author.id,
authorName: author.username,
content: content ?? "",
timestamp: event.timestamp ?? new Date().toISOString(),
trusted,
command: command ? `${command.name}${command.args.length ? " " + command.args.join(" ") : ""}` : undefined
};
this.log.add(entry);
if (command?.name === "whitelist" && trusted) {
await this.handleWhitelistCommand(command, author.id, channelId);
}
}
private extractUserIds(text: string): string[] {
const ids = new Set<string>();
for (const match of text.matchAll(/<@!?(\d+)>/g)) ids.add(match[1]);
for (const part of text.split(/\s+/)) if (/^\d{17,20}$/.test(part)) ids.add(part);
return [...ids];
}
private async handleWhitelistCommand(command: ParsedCommand, authorId: string, channelId: string) {
if (authorId !== this.ownerId) return;
const [sub, ...rest] = command.args;
switch (sub) {
case "list":
await this.reply(
channelId,
`Trusted users:\n${[this.ownerId, ...this.whitelistIds].join("\n")}`
);
return;
case "add": {
const ids = this.extractUserIds(rest.join(" "));
if (!ids.length) return this.reply(channelId, "Usage: whitelist add @user");
this.addToWhitelist(ids);
return this.reply(channelId, `Added to whitelist:\n${ids.join("\n")}`);
}
case "remove": {
const ids = this.extractUserIds(rest.join(" "));
if (!ids.length) return this.reply(channelId, "Usage: whitelist remove @user");
const removed = ids.filter(id => id !== this.ownerId);
this.removeFromWhitelist(removed);
return this.reply(
channelId,
[
removed.length ? `Removed from whitelist:\n${removed.join("\n")}` : "Nothing removed.",
ids.length !== removed.length ? "The owner cannot be removed." : ""
]
.filter(Boolean)
.join("\n")
);
}
default:
return this.reply(channelId, "Usage: whitelist add|remove|list");
}
}
private async reply(channelId: string, content: string) {
// Autonomous replies use the same 500-2800 ms pacing as everything else.
await new Promise(resolve => setTimeout(resolve, randomActionDelay()));
try {
await this.discord.sendMessage(channelId, content);
} catch (error) {
console.error("[puppet] reply failed:", error);
}
}
}
+75
View File
@@ -0,0 +1,75 @@
/*
* Veskslop — unit tests for the pure communication logic.
*
* Usage: pnpm test:comm
*/
import assert from "node:assert/strict";
import {
MessageLog,
TRUSTED_LOG_LIMIT,
UNTRUSTED_LOG_LIMIT,
extractMentionIds,
isTrusted,
parseCommand,
randomActionDelay,
stripMentions,
type PuppetMessage
} from "./communication.js";
function entry(id: number, trusted: boolean): PuppetMessage {
return {
id: String(id),
channelId: "c",
authorId: trusted ? "owner" : "stranger",
authorName: "tester",
content: `msg ${id}`,
timestamp: new Date().toISOString(),
trusted
};
}
// ring buffer limits
{
const log = new MessageLog();
for (let i = 0; i < 150; i++) log.add(entry(i, true));
assert.equal(log.trusted.length, TRUSTED_LOG_LIMIT);
assert.equal(log.trusted[0].id, "50");
for (let i = 0; i < 40; i++) log.add(entry(i, false));
assert.equal(log.untrusted.length, UNTRUSTED_LOG_LIMIT);
assert.equal(log.untrusted[0].id, "10");
}
// mentions
{
const ids = extractMentionIds("<@!42> hi <@7>", [{ id: "9" }]);
assert.deepEqual([...ids].sort(), ["42", "7", "9"]);
assert.equal(stripMentions("<@!42> whitelist list", new Set(["42"])), "whitelist list");
assert.equal(stripMentions("<@42> <@99> hey", new Set(["42"])), "<@99> hey");
}
// commands
{
assert.deepEqual(parseCommand(" WHITELIST add <@123> "), { name: "whitelist", args: ["add", "<@123>"] });
assert.deepEqual(parseCommand("whitelist list"), { name: "whitelist", args: ["list"] });
assert.equal(parseCommand(" "), undefined);
}
// trust
{
assert.equal(isTrusted("owner", "owner", []), true);
assert.equal(isTrusted("trusted", "owner", ["trusted"]), true);
assert.equal(isTrusted("stranger", "owner", ["trusted"]), false);
}
// pacing stays in the 500-2800 ms window
{
for (let i = 0; i < 200; i++) {
const delay = randomActionDelay();
assert.ok(delay >= 500 && delay <= 2800, `delay out of range: ${delay}`);
}
}
console.log("all communication tests passed");
+10 -13
View File
@@ -1,17 +1,15 @@
/*
* Veskslop Phase 0 — live DM send test through a running Vesktop instance.
* Veskslop — live DM send test through a running client.
*
* Sanctioned test target: user zzib. Defaults to their user ID.
* This talks to the control socket directly (which must be a real Vesktop,
* not the mock) and paces the command like everything else.
* Sanctioned test target: user zzib (defaults to their user ID).
*
* Usage:
* pnpm poc:dm
* TEST_DM_USER_ID=... TEST_DM_CONTENT="hello" pnpm poc:dm
*/
import { paced } from "./pacing.js";
import { sendVesktopCommand, socketPath } from "./vesktop-socket.js";
import { CdpClient } from "./cdp-client.js";
import { Discord } from "./discord.js";
/** zzib — the only sanctioned live-send target. */
const DEFAULT_TEST_DM_USER_ID = "177117296140091393";
@@ -19,11 +17,10 @@ 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";
if (!userId) {
console.error("TEST_DM_USER_ID is required (sanctioned test target: zzib)");
process.exit(1);
}
const cdp = await CdpClient.connect();
const discord = new Discord(cdp);
console.log(`[dm] socket: ${socketPath}`);
const result = await paced(() => sendVesktopCommand("dm:send", { userId, content }));
console.log("[dm]", result);
console.log("[dm] connected via CDP");
console.log("[dm]", await discord.sendDm(userId, content));
cdp.close();
-74
View File
@@ -1,74 +0,0 @@
/*
* Veskslop Phase 0 spike — JSON-lines client for Vesktop's control socket.
*
* The Vesktop main process listens on vesktop.sock and forwards commands to
* the renderer, which performs them using official client interactions.
*/
import net from "node:net";
export const socketPath =
process.env.VESKSLOP_SOCKET ??
(process.env.XDG_RUNTIME_DIR ? `${process.env.XDG_RUNTIME_DIR}/vesktop.sock` : "/tmp/vesktop.sock");
interface SocketResponse<T> {
id?: number;
ok: boolean;
data?: T;
error?: string;
}
let nextId = 1;
/** Send one command and resolve with its result. Throws on error/timeout. */
export function sendVesktopCommand<T = unknown>(
command: string,
data?: unknown,
timeoutMs = 30_000
): Promise<T> {
const id = nextId++;
return new Promise<T>((resolve, reject) => {
const socket = net.connect(socketPath);
socket.setEncoding("utf-8");
let buffer = "";
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
fn();
};
const timer = setTimeout(() => finish(() => reject(new Error(`Command timed out: ${command}`))), timeoutMs);
socket.on("connect", () => {
socket.write(JSON.stringify({ id, command, data }) + "\n");
});
socket.on("data", (chunk: string) => {
buffer += chunk;
const newline = buffer.indexOf("\n");
if (newline === -1) return;
const line = buffer.slice(0, newline);
let response: SocketResponse<T>;
try {
response = JSON.parse(line);
} catch {
return finish(() => reject(new Error(`Invalid response: ${line}`)));
}
if (response.ok) {
finish(() => resolve(response.data as T));
} else {
finish(() => reject(new Error(response.error ?? `Command failed: ${command}`)));
}
});
socket.on("error", error => finish(() => reject(error)));
});
}