Rework core integration: validated capability layer, docs, and session state
This commit is contained in:
@@ -3,3 +3,4 @@ node_modules/
|
||||
.env
|
||||
.token
|
||||
.secrets/
|
||||
.veskslop-state.json
|
||||
|
||||
+36
-83
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user