Add headless puppet bridge: control socket, renderer handlers, MCP daemon

This commit is contained in:
agent
2026-09-11 12:59:29 +02:00
parent 24447f5d93
commit 7e4fb7aa34
18 changed files with 2180 additions and 38 deletions
+53 -36
View File
@@ -2,76 +2,93 @@
## What this is ## What this is
Veskslop is a **headless Discord client daemon** built on the Vesktop codebase. It turns one or Veskslop is a **headless Discord client daemon** built on the Vesktop codebase. It runs the **real
more **Discord user accounts** into a controllable service: Discord web client** (Vencord-patched Vesktop) without a visible window and puppets the client's own
interactions, so traffic and behavior stay as close to the official client as possible.
- an **MCP server** (Streamable HTTP) lets AI agents drive a "puppet" account; - an **MCP server** (Streamable HTTP) lets AI agents drive the account;
- a second, read-only account can collect stats/data without acting; - multiple user identities are planned (one read-only collector, one full puppet);
- **audio is piped in and out** over PipeWire; - **audio in/out** and **video streaming** come later, using the client's native voice stack
- **video streams** can be pushed into voice channels; plus PipeWire virtual devices on Linux;
- everything is **local-first**: clients reach it over LAN/Tailscale only. - everything is **local-first**: clients reach it over LAN/Tailscale only.
**Vesktop is a separate project with separate goals.** This repo shares history with it but not **Vesktop is a separate project with separate goals.** This repo shares history with it but not
direction. Do not carry Vesktop UI features here unless the daemon needs them. direction.
## Decisions (locked unless the user changes them) ## Decisions (locked unless the user changes them)
- **Core:** TypeScript on Node with pnpm. `discord.js` for gateway/REST, `@discordjs/voice` for voice. - **Core:** run the actual Vesktop/Discord client (Electron) headless. Do **not** build on
third-party selfbot protocol libraries — the popular ones are archived/deprecated and stale
protocol emulation is a ban risk.
- **Control:** puppet Vesktop's own interactions through its IPC command bus:
`vesktop.sock` (external) → `sendRendererCommand` (main) → renderer handlers that call
Vencord/Discord internals (`sendMessage`, stores, voice actions).
- **MCP:** an external local daemon speaks a JSON-lines protocol over `vesktop.sock` and exposes
Streamable HTTP MCP. It must run without Electron so it is testable standalone.
- **QR login:** our own terminal remote-auth tool (`poc/qr-login.ts`) acquires a user token.
Feeding that token into the headless client is deferred until it can be tested.
- **Accounts:** multiple user tokens. Expected setup is one read-only collector account plus one - **Accounts:** multiple user tokens. Expected setup is one read-only collector account plus one
full-control puppet account; design for N identities behind one control surface. full-control puppet account; design for N identities behind one control surface.
- **MCP transport:** Streamable HTTP only (no stdio/SSE unless asked).
- **Network:** bind localhost by default. Remote access is always over LAN/Tailscale — never expose - **Network:** bind localhost by default. Remote access is always over LAN/Tailscale — never expose
to the public internet. A client will later authenticate to the proxy for remote control. to the public internet. A client will later authenticate to the proxy for remote control.
- **Audio:** PipeWire only. - **Audio/video:** use the client's native stack first; PipeWire virtual devices for system audio
- **GUI:** optional minimal **web debug UI** if it is cheap to build; otherwise keep the existing piping. ffmpeg is required for non-Opus media and video encoding.
Vesktop Electron build runnable for debugging. The daemon must never require Electron. - **GUI:** optional minimal **web debug UI** later. The daemon must never require a visible window;
- **Pacing/safety:** actions emulate a human using the Vesktop client through a queue: on servers without a display, a virtual display (Xvfb) will be needed for Electron.
- first action is snappy (~30–80 ms delay); - **Pacing/safety:** all outbound actions are queued and paced.
- subsequent bulk actions get randomized delays (~500–2800 ms); - **Current policy:** every command sent to the client is spaced by a random **500–2800 ms**,
- a **risky fast mode** is opt-in and minimizes delays; serialized, with identical in-flight queries coalesced; prefer cached state over queries.
- default capabilities are clamped to free-account limits (upload size, stream resolution, etc.); - **Later policy:** human emulation — snappy first action (~30–80 ms), bulk actions randomized,
- individual abilities can be toggled off. opt-in risky fast mode, free-account limits by default, ability toggles.
## How to proceed ## How to proceed
**Phase 0 — PoC, do this first.** Find the smallest end-to-end thing that works, then stop and **Current milestone — puppeting bridge (testable without Electron).**
report back so the user can choose a direction. Candidate spikes, timeboxed:
1. `discord.js` login + list guilds + send a message from a throwaway test account. 1. Extend Vesktop's `vesktop.sock` server with a JSON-lines request/response protocol (keep the
2. Minimal MCP Streamable HTTP server exposing `send_message` / `read_messages`, wired to #1. legacy `run-shortcut` line working).
3. Join a voice channel and play a local file via `@discordjs/voice` (PipeWire output). 2. Add renderer handlers for `state:get`, `message:send`, `message:read` via Vencord internals.
3. Build the external MCP daemon (`poc/src/control-bridge.ts`) with a mock Vesktop socket
(`poc/src/mock-vesktop.ts`) so the whole path is testable in this container.
4. Then: voice join/leave handlers, audio pipes, video streaming, token injection, client
lightening, remote client auth.
Prefer whichever is quickest to prove; do not build production architecture before the user picks. Anything that needs a display, audio device, or a real client instance is **deferred** until it can
be tested; note it instead of guessing.
**After regrouping:** headless daemon skeleton → multi-account session manager → pacing queue →
MCP tool surface → audio pipelines → video pipeline → remote proxy/auth client → web debug UI.
## Architecture sketch ## Architecture sketch
``` ```
MCP clients (agents) ──Streamable HTTP──▶ Veskslop core ──▶ Discord (user tokens) MCP clients (agents) ──Streamable HTTP──▶ veskSlop control daemon
remote web client ─────auth over LAN─────▶ │ │ JSON lines over vesktop.sock
├─ action queue (human-emulation pacing) ▼
├─ capability policy + limits + audit log Vesktop main process
├─ audio pipeline (PipeWire) │ sendRendererCommand()
└─ video pipeline ▼
Discord renderer (Vencord patched)
│ official client actions
▼
Discord
``` ```
## Guardrails for agents working here ## Guardrails for agents working here
- **Never commit tokens, credentials, or `.env` files.** Use environment variables / a local secret store. - **Never commit tokens, credentials, or `.env` files.** Use environment variables / a local secret store.
- Do not adopt archived/deprecated selfbot libraries. If a library is needed, verify it was pushed
recently before proposing it.
- Do not hardcode Discord limits from memory — verify current free-tier numbers (upload size, stream - Do not hardcode Discord limits from memory — verify current free-tier numbers (upload size, stream
resolution, rate limits) at implementation time, and keep them configurable. resolution, rate limits) at implementation time, and keep them configurable.
- All outbound actions go through the pacing queue; no direct sends. - All outbound actions go through the pacing queue; no direct sends.
- Default to conservative limits and opt-in risky features. - Default to conservative limits and opt-in risky features.
- Test against throwaway accounts and private test guilds only. - Test against throwaway accounts and private test guilds only.
- **Live send tests:** DMs to user **zzib** are the only sanctioned real-send test target.
- Run `pnpm testTypes` and `pnpm lint` before calling work done; `pnpm build` to compile. - Run `pnpm testTypes` and `pnpm lint` before calling work done; `pnpm build` to compile.
## Open questions ## Open questions
- Which Phase 0 spike becomes the real foundation? (user decides after the spikes) - Headless Electron strategy: Xvfb vs offscreen rendering, and what to strip to lighten the client.
- Voice E2EE (DAVE) support status in the current voice stack — research before committing. - Token injection path for the official client (localStorage seeding vs client QR UI).
- Video pipeline feasibility: required codecs/containers and how to feed synthetic frames. - Voice/video: what the client's native stack can be driven to do without UI interaction.
- Scope and auth model of the future remote proxy client and web debug UI. - Scope and auth model of the future remote proxy client and web debug UI.
- How much of the Vesktop Electron tree gets deleted once the daemon is self-sufficient. - How much of the Vesktop Electron tree gets deleted once the daemon is self-sufficient.
+5
View File
@@ -0,0 +1,5 @@
node_modules/
*.log
.env
.token
.secrets/
+97
View File
@@ -0,0 +1,97 @@
# Veskslop Phase 0 spikes
Timeboxed proof-of-concept scripts, not production code. Project direction lives in
[../AGENTS.md](../AGENTS.md).
## Setup
```sh
pnpm install
```
## Current query policy
- 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.
## Spikes
### 0. QR login / token acquisition — `src/qr-login.ts`
```sh
pnpm poc:qr
TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr
```
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`). 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).
### 1. Vesktop control bridge — `src/control-bridge.ts`
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
TEST_DM_USER_ID=... pnpm poc:dm
```
Talks to a **real** running Vesktop's control socket and sends one paced DM. The only sanctioned
live-send target is user **zzib** (provide their user ID).
## 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.
- 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.
## 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`
(`allowBuilds`).
+29
View File
@@ -0,0 +1,29 @@
{
"name": "veskslop-poc",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Timeboxed Phase 0 spikes for Veskslop. Not production code.",
"scripts": {
"poc:qr": "tsx src/qr-login.ts",
"poc:dm": "tsx src/test-dm.ts",
"control:mock": "tsx src/mock-vesktop.ts",
"control:bridge": "tsx src/control-bridge.ts",
"check": "tsc --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"express": "^5.2.1",
"qrcode-terminal": "^0.12.0",
"ws": "^8.19.0",
"zod": "^4.6.2"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^22.19.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"tsx": "^4.23.12",
"typescript": "^5.9.3"
}
}
+1219
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
minimumReleaseAgeExclude:
- zod@4.6.2
allowBuilds:
esbuild: true
+4
View File
@@ -0,0 +1,4 @@
import { fileURLToPath } from "node:url";
/** Where QR login writes the token; overridable with TOKEN_FILE. */
export const tokenFile = process.env.TOKEN_FILE ?? fileURLToPath(new URL("../.token", import.meta.url));
+143
View File
@@ -0,0 +1,143 @@
/*
* Veskslop Phase 0 spike — MCP control bridge.
*
* 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.
*
* Usage:
* pnpm control:bridge
* VESKSLOP_SOCKET=/tmp/veskslop-mock.sock MCP_PORT=8788 pnpm control:bridge
*/
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import * as z from "zod/v4";
import { dedupe, getQueryCount, paced } from "./pacing.js";
import { sendVesktopCommand, socketPath } from "./vesktop-socket.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) }] };
}
function command<T>(name: string, data?: unknown): Promise<T> {
return paced(() => sendVesktopCommand<T>(name, data));
}
function buildServer(): McpServer {
const server = new McpServer({ name: "veskslop-control", version: "0.0.0" });
server.registerTool(
"get_state",
{
description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries."
},
async () => jsonText(await command("state:get"))
);
server.registerTool(
"send_message",
{
description: "Send a message as the logged-in user through the real client. Query-paced.",
inputSchema: {
channelId: z.string().describe("Target channel ID"),
content: z.string().min(1).describe("Message content")
}
},
async ({ channelId, content }) => {
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() });
}
);
server.registerTool(
"send_dm",
{
description: "Send a DM as the logged-in user. Sanctioned live-test target: user zzib.",
inputSchema: {
userId: z.string().describe("Recipient user ID"),
content: z.string().min(1).describe("Message content")
}
},
async ({ userId, content }) => {
const result = await command<Record<string, unknown>>("dm:send", { userId, content });
return jsonText({ ...result, queriesUsed: getQueryCount() });
}
);
return server;
}
const app = createMcpExpressApp({ host: HOST });
app.post("/mcp", async (req, res) => {
const server = buildServer();
try {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("Error handling MCP request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null
});
}
}
});
app.get("/mcp", (_req, res) => {
res.writeHead(405).end(
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null })
);
});
app.delete("/mcp", (_req, res) => {
res.writeHead(405).end(
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null })
);
});
app.get("/health", (_req, res) => {
res.json({ ok: true, socketPath, queries: getQueryCount() });
});
app.listen(PORT, HOST, (error?: Error) => {
if (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}`);
});
process.on("SIGINT", () => process.exit(0));
+88
View File
@@ -0,0 +1,88 @@
/*
* 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);
});
+55
View File
@@ -0,0 +1,55 @@
/*
* Veskslop Phase 0 spike — global query pacing.
*
* Current policy: every command sent to the client is spaced by a random
* 500-2800 ms. Commands are serialized through one queue, and identical
* concurrent queries are coalesced so the total query count stays minimal.
*
* The long-term human-emulation policy (snappy first action, slower bulk
* actions, risky fast mode) lives in AGENTS.md and is not active yet.
*/
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** Current per-query delay: 500-2800 ms. */
export function queryDelay(): number {
return 500 + Math.floor(Math.random() * 2301);
}
let queue: Promise<unknown> = Promise.resolve();
let queryCount = 0;
/** Serialize a REST query behind the queue with the configured delay. */
export function paced<T>(task: () => Promise<T>): Promise<T> {
const delay = queryDelay();
const run = queue.then(async () => {
await sleep(delay);
queryCount++;
if (process.env.DEBUG === "1") {
console.error(`[rest] query #${queryCount} (+${delay}ms)`);
}
return task();
});
queue = run.then(
() => undefined,
() => undefined
);
return run;
}
export function getQueryCount(): number {
return queryCount;
}
const inFlight = new Map<string, Promise<unknown>>();
/** Coalesce identical concurrent queries into a single request. */
export function dedupe<T>(key: string, task: () => Promise<T>): Promise<T> {
const existing = inFlight.get(key);
if (existing) return existing as Promise<T>;
const promise = task().finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}
+201
View File
@@ -0,0 +1,201 @@
/*
* Veskslop Phase 0 — Discord remote-auth QR login.
*
* Connects to Discord's remote-auth gateway, renders an approval QR in the
* terminal, waits for the phone to approve, and prints/saves the user token.
*
* Usage:
* pnpm poc:qr
* TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr
* HCAPTCHA_KEY=... pnpm poc:qr # only if Discord challenges the ticket exchange
*
* The token file is written with mode 600 and is gitignored.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import readline from "node:readline";
import qrcode from "qrcode-terminal";
import WebSocket from "ws";
import { tokenFile } from "./config.js";
const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2";
const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login";
const USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
const debug = process.env.DEBUG === "1";
const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const encodedPublicKey = keyPair.publicKey.export({ type: "spki", format: "der" }).toString("base64");
function log(...args: unknown[]) {
console.log("[qr]", ...args);
}
function debugLog(...args: unknown[]) {
if (debug) console.error("[qr:debug]", ...args);
}
function decryptPayload(payload: string): Buffer {
return crypto.privateDecrypt(
{
key: keyPair.privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256"
},
Buffer.from(payload, "base64")
);
}
function promptCaptchaKey(): Promise<string | undefined> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise(resolve => {
rl.question("hCaptcha key (blank to abort): ", answer => {
rl.close();
resolve(answer.trim() || undefined);
});
});
}
async function exchangeTicket(ticket: string, captchaKey?: string, captchaRqToken?: string): Promise<string | undefined> {
const body: Record<string, unknown> = { ticket };
if (captchaKey) {
body.captcha_key = captchaKey;
body.captcha_rqtoken = captchaRqToken;
}
const response = await fetch(LOGIN_URL, {
method: "POST",
headers: {
Accept: "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
Origin: "https://discord.com",
Referer: "https://discord.com/login",
"User-Agent": USER_AGENT
},
body: JSON.stringify(body)
});
const data: any = await response.json();
debugLog("ticket exchange", response.status, JSON.stringify(data));
if (data.encrypted_token) {
return decryptPayload(data.encrypted_token).toString();
}
if (data.captcha_sitekey || data.captcha_rqtoken) {
log("Discord wants a captcha to finish this login.");
log(` service: ${data.captcha_service}`);
log(` sitekey: ${data.captcha_sitekey}`);
log(` rqdata: ${data.captcha_rqdata}`);
const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey());
if (!key) {
log("No captcha key provided, giving up.");
return undefined;
}
return exchangeTicket(ticket, key, data.captcha_rqtoken);
}
log(`Ticket exchange failed (HTTP ${response.status}):`, JSON.stringify(data));
return undefined;
}
log("Connecting to Discord remote-auth gateway...");
const ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } });
let heartbeat: NodeJS.Timeout | undefined;
let finished = false;
ws.on("open", () => log("Connected. Requesting QR..."));
ws.on("message", async (raw: WebSocket.RawData) => {
const packet: any = JSON.parse(raw.toString());
debugLog("<-", packet.op ?? packet);
switch (packet.op) {
case "hello": {
heartbeat = setInterval(
() => ws.send(JSON.stringify({ op: "heartbeat" })),
packet.heartbeat_interval
);
ws.send(JSON.stringify({ op: "init", encoded_public_key: encodedPublicKey }));
break;
}
case "nonce_proof": {
const nonce = decryptPayload(packet.encrypted_nonce);
const proof = crypto.createHash("sha256").update(nonce).digest("base64url");
ws.send(JSON.stringify({ op: "nonce_proof", proof }));
break;
}
case "pending_remote_init": {
const url = `https://discord.com/ra/${packet.fingerprint}`;
log("Scan this QR code with the Discord mobile app (you may need the camera/QR scanner):\n");
qrcode.generate(url, { small: true });
log(`Or open this link on the phone: ${url}\n`);
break;
}
case "pending_ticket": {
const payload = decryptPayload(packet.encrypted_user_payload).toString();
const [id, discriminator, avatar, username] = payload.split(":");
debugLog("user payload:", payload);
const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username;
log(`Phone approved: ${tag} (${id})${avatar ? " with avatar " + avatar : ""}`);
break;
}
case "pending_login": {
const token = await exchangeTicket(packet.ticket);
if (!token) {
finished = true;
ws.close();
process.exitCode = 1;
break;
}
finished = true;
fs.writeFileSync(tokenFile, token + "\n", { mode: 0o600 });
log("\n=== token acquired ===");
console.log(token);
log(`Saved to ${tokenFile} (mode 600).`);
ws.close();
break;
}
case "cancel":
log("Login was cancelled on the phone.");
finished = true;
ws.close();
break;
case "heartbeat_ack":
break;
default:
debugLog("unhandled op", packet);
}
});
ws.on("close", () => {
if (heartbeat) clearInterval(heartbeat);
done();
});
ws.on("error", error => {
console.error("[qr] gateway error:", error.message);
process.exitCode = 1;
});
function done() {
if (!finished) log("Connection closed before login completed.");
process.exit(process.exitCode ?? 0);
}
process.on("SIGINT", () => {
log("Interrupted.");
ws.close();
});
+26
View File
@@ -0,0 +1,26 @@
/*
* Veskslop Phase 0 — live DM send test through a running Vesktop instance.
*
* Sanctioned test target: user zzib. Set TEST_DM_USER_ID 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.
*
* Usage:
* TEST_DM_USER_ID=... 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";
const userId = process.env.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);
}
console.log(`[dm] socket: ${socketPath}`);
const result = await paced(() => sendVesktopCommand("dm:send", { userId, content }));
console.log("[dm]", result);
+74
View File
@@ -0,0 +1,74 @@
/*
* 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)));
});
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src"]
}
+90
View File
@@ -0,0 +1,90 @@
/*
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
* Copyright (c) 2026 Vendicated and Vesktop contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { app } from "electron";
import { rmSync } from "fs";
import { rm } from "fs/promises";
import { createServer, Server, Socket } from "net";
import { join } from "path";
import { IpcCommands } from "shared/IpcEvents";
import { sendRendererCommand } from "./ipcCommands";
const { env, platform } = process;
let server: Server;
const SOCKET_PATH =
platform === "win32"
? "\\\\?\\pipe\\vesktop.sock"
: join(env.XDG_RUNTIME_DIR || env.TMPDIR || env.TMP || env.TEMP || "/tmp", "vesktop.sock");
/** JSON command name -> renderer IpcCommand. Extend here to expose more interactions. */
const COMMANDS: Record<string, IpcCommands> = {
"state:get": IpcCommands.STATE_GET,
"message:send": IpcCommands.MESSAGE_SEND,
"message:read": IpcCommands.MESSAGE_READ,
"dm:send": IpcCommands.DM_SEND
};
export async function initSocket() {
await rm(SOCKET_PATH, { force: true });
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) void handleLine(socket, line);
}
});
socket.on("error", () => socket.destroy());
});
server.listen(SOCKET_PATH);
server.on("error", err => {
console.error("Vesktop IPC Socket Error:", err);
});
}
async function handleLine(socket: Socket, line: string) {
let request: { id?: string | number; command?: string; data?: unknown };
try {
request = JSON.parse(line);
} catch {
socket.write(JSON.stringify({ ok: false, error: "Invalid JSON" }) + "\n");
return;
}
try {
const data = await dispatch(request.command, request.data);
socket.write(JSON.stringify({ id: request.id, ok: true, data }) + "\n");
} catch (e) {
const error = e instanceof Error ? e.message : String(e);
socket.write(JSON.stringify({ id: request.id, ok: false, error }) + "\n");
}
}
async function dispatch(command: string | undefined, data: unknown) {
if (command === "ping") return "pong";
const ipcCommand = command && COMMANDS[command];
if (!ipcCommand) throw new Error(`Unknown command: ${command}`);
return sendRendererCommand(ipcCommand, data);
}
app.on("will-quit", () => {
server?.close();
rmSync(SOCKET_PATH, { force: true });
});
+2
View File
@@ -14,6 +14,7 @@ import { app, BrowserWindow, nativeTheme } from "electron";
import { DATA_DIR } from "./constants"; import { DATA_DIR } from "./constants";
import { createFirstLaunchTour } from "./firstLaunch"; import { createFirstLaunchTour } from "./firstLaunch";
import { initSocket } from "./ipcSocket";
import { createWindows, mainWin } from "./mainWindow"; import { createWindows, mainWin } from "./mainWindow";
import { registerMediaPermissionsHandler } from "./mediaPermissions"; import { registerMediaPermissionsHandler } from "./mediaPermissions";
import { registerScreenShareHandler } from "./screenShare"; import { registerScreenShareHandler } from "./screenShare";
@@ -105,6 +106,7 @@ function init() {
registerScreenShareHandler(); registerScreenShareHandler();
registerMediaPermissionsHandler(); registerMediaPermissionsHandler();
initSocket();
bootstrap(); bootstrap();
+67 -1
View File
@@ -4,7 +4,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { SettingsRouter } from "@vencord/types/webpack/common"; import { openPrivateChannel, sendMessage } from "@vencord/types/utils";
import { ChannelStore, GuildStore, MessageStore, SettingsRouter, UserStore } from "@vencord/types/webpack/common";
import { IpcCommands } from "shared/IpcEvents"; import { IpcCommands } from "shared/IpcEvents";
import { openScreenSharePicker } from "./components/ScreenSharePicker"; import { openScreenSharePicker } from "./components/ScreenSharePicker";
@@ -52,3 +53,68 @@ onIpcCommand(IpcCommands.NAVIGATE_SETTINGS, () => {
onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages); onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages);
onIpcCommand(IpcCommands.SCREEN_SHARE_PICKER, data => openScreenSharePicker(data.screens, data.skipPicker)); onIpcCommand(IpcCommands.SCREEN_SHARE_PICKER, data => openScreenSharePicker(data.screens, data.skipPicker));
/* Veskslop puppet handlers */
onIpcCommand(IpcCommands.STATE_GET, () => {
const user = UserStore.getCurrentUser();
const guilds = GuildStore.getGuildsArray().map(guild => {
const channels = Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id)).map(channel => ({
id: channel.id,
name: channel.name,
type: channel.type
}));
return { id: guild.id, name: guild.name, channels };
});
return { user: { id: user.id, username: user.username }, guilds };
});
onIpcCommand(IpcCommands.MESSAGE_SEND, async (data: { channelId?: string; content?: string }) => {
if (!data?.channelId || typeof data.content !== "string") {
throw new Error("message:send requires { channelId, content }");
}
await sendMessage(data.channelId, { content: data.content });
return { channelId: data.channelId, sent: true };
});
onIpcCommand(IpcCommands.MESSAGE_READ, (data: { channelId?: string; limit?: number }) => {
if (!data?.channelId) throw new Error("message:read requires { channelId }");
const limit = Math.min(Math.max(data.limit ?? 20, 1), 100);
const messages = MessageStore.getMessages(data.channelId)?._array ?? [];
return {
channelId: data.channelId,
cached: messages.length,
messages: messages.slice(-limit).map(message => ({
id: message.id,
author: { id: message.author.id, username: message.author.username },
content: message.content,
timestamp: message.timestamp
}))
};
});
onIpcCommand(IpcCommands.DM_SEND, async (data: { userId?: string; content?: string }) => {
if (!data?.userId || typeof data.content !== "string") {
throw new Error("dm:send requires { userId, content }");
}
let channelId = ChannelStore.getDMFromUserId(data.userId);
if (!channelId) {
openPrivateChannel(data.userId);
for (let i = 0; i < 20 && !channelId; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
channelId = ChannelStore.getDMFromUserId(data.userId);
}
}
if (!channelId) throw new Error(`No DM channel for user ${data.userId} yet`);
await sendMessage(channelId, { content: data.content });
return { userId: data.userId, channelId, sent: true };
});
+7 -1
View File
@@ -83,5 +83,11 @@ export const enum IpcCommands {
GET_LANGUAGES = "navigator.languages", GET_LANGUAGES = "navigator.languages",
SCREEN_SHARE_PICKER = "screenshare:picker" SCREEN_SHARE_PICKER = "screenshare:picker",
/* Veskslop puppet commands */
STATE_GET = "state:get",
MESSAGE_SEND = "message:send",
MESSAGE_READ = "message:read",
DM_SEND = "dm:send"
} }