Add headless puppet bridge: control socket, renderer handlers, MCP daemon
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user