Add headless puppet bridge: control socket, renderer handlers, MCP daemon
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.env
|
||||
.token
|
||||
.secrets/
|
||||
@@ -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`).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1219
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- zod@4.6.2
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user