From e62d3007d88708775e2cba9be8ab1688ae08e597 Mon Sep 17 00:00:00 2001 From: Fisto Date: Fri, 11 Sep 2026 14:09:04 +0200 Subject: [PATCH] Rework core integration: validated capability layer, docs, and session state --- AGENTS.md | 163 +++++++++------------------ CURRENT.md | 46 ++++++++ README.md | 13 +++ docs/INDEX.md | 23 ++++ docs/architecture.md | 74 ++++++++++++ docs/auth.md | 63 +++++++++++ docs/communication.md | 86 ++++++++++++++ docs/core-bridge.md | 129 +++++++++++++++++++++ docs/daemon.md | 93 +++++++++++++++ docs/decisions.md | 81 ++++++++++++++ docs/testing.md | 101 +++++++++++++++++ poc/.gitignore | 1 + poc/README.md | 119 ++++++-------------- poc/package.json | 3 +- poc/src/cdp-client.ts | 102 +++++++++++++++++ poc/src/cdp-probe.ts | 84 ++++++++++++++ poc/src/communication.ts | 83 ++++++++++++++ poc/src/control-bridge.ts | 150 ++++++++++++++++++------- poc/src/discord.ts | 159 ++++++++++++++++++++++++++ poc/src/mock-vesktop.ts | 88 --------------- poc/src/puppet.ts | 185 ++++++++++++++++++++++++++++++ poc/src/test-communication.ts | 75 +++++++++++++ poc/src/test-dm.ts | 23 ++-- poc/src/vesktop-socket.ts | 74 ------------ src/main/constants.ts | 7 +- src/main/ipc.ts | 12 -- src/main/ipcSocket.ts | 90 --------------- src/main/main.ts | 8 +- src/preload/index.ts | 14 --- src/renderer/ipcCommands.ts | 68 +---------- src/renderer/puppet/actions.ts | 199 +++++++++++++++++++++++++++++++++ src/shared/IpcEvents.ts | 10 +- src/shared/puppetProtocol.ts | 83 ++++++++++++++ 33 files changed, 1897 insertions(+), 612 deletions(-) create mode 100644 CURRENT.md create mode 100644 docs/INDEX.md create mode 100644 docs/architecture.md create mode 100644 docs/auth.md create mode 100644 docs/communication.md create mode 100644 docs/core-bridge.md create mode 100644 docs/daemon.md create mode 100644 docs/decisions.md create mode 100644 docs/testing.md create mode 100644 poc/src/cdp-client.ts create mode 100644 poc/src/cdp-probe.ts create mode 100644 poc/src/communication.ts create mode 100644 poc/src/discord.ts delete mode 100644 poc/src/mock-vesktop.ts create mode 100644 poc/src/puppet.ts create mode 100644 poc/src/test-communication.ts delete mode 100644 poc/src/vesktop-socket.ts delete mode 100644 src/main/ipcSocket.ts create mode 100644 src/renderer/puppet/actions.ts create mode 100644 src/shared/puppetProtocol.ts diff --git a/AGENTS.md b/AGENTS.md index 292c8fd..abb0559 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,130 +1,65 @@ # AGENTS.md — Veskslop -## What this is +Veskslop controls Discord user accounts without a visible client window. It runs the real Vesktop +client and drives it through a small, validated capability layer. All logic lives in the external +daemon. -Veskslop is a **headless Discord client daemon** built on the Vesktop codebase. It runs the **real -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. +**Read the docs first:** [docs/INDEX.md](docs/INDEX.md) and [CURRENT.md](CURRENT.md). -- an **MCP server** (Streamable HTTP) lets AI agents drive the account; -- multiple user identities are planned (one read-only collector, one full puppet); -- **audio in/out** and **video streaming** come later, using the client's native voice stack - plus PipeWire virtual devices on Linux; -- everything is **local-first**: clients reach it over LAN/Tailscale only. +`CURRENT.md` tracks the work state: the current focus, the completed work, the current task, and the +next steps. Read it before you start. Update it before you stop. -**Vesktop is a separate project with separate goals.** This repo shares history with it but not -direction. +- [architecture.md](docs/architecture.md) — system parts and boundaries +- [core-bridge.md](docs/core-bridge.md) — the Vesktop-side capability layer +- [daemon.md](docs/daemon.md) — the external MCP daemon +- [communication.md](docs/communication.md) — trust model, commands, trigger log +- [auth.md](docs/auth.md) — QR login and token persistence +- [testing.md](docs/testing.md) — tests and live test setup +- [decisions.md](docs/decisions.md) — design decisions -## Decisions (locked unless the user changes them) +## Locked decisions -- **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 +- Use the real Vesktop client. Do not use selfbot protocol libraries. Most are archived, 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 - full-control puppet account; design for N identities behind one control surface. -- **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. -- **Audio/video:** use the client's native stack first; PipeWire virtual devices for system audio - piping. ffmpeg is required for non-Opus media and video encoding. -- **GUI:** optional minimal **web debug UI** later. The daemon must never require a visible window; - on servers without a display, a virtual display (Xvfb) will be needed for Electron. -- **Pacing/safety:** all outbound actions are queued and paced. - - **Current policy:** every command sent to the client is spaced by a random **500–2800 ms**, - serialized, with identical in-flight queries coalesced; prefer cached state over queries. - - **Later policy:** human emulation — snappy first action (~30–80 ms), bulk actions randomized, - opt-in risky fast mode, free-account limits by default, ability toggles. +- The core validates. The daemon requests. The core rejects bad input with a structured error. + The client must not crash, and the daemon must not send malformed data to Discord. +- Keep core patches isolated. Add actions in `src/renderer/puppet/` and the bridge in + `src/main/puppetBridge.ts`. Do not spread logic through upstream files. +- The daemon holds the logic: triggers, trust list, command parsing, trigger log, and pacing. +- The daemon binds to localhost. Remote access goes over LAN or Tailscale only. +- Pacing: the daemon delays every command by 500 to 2800 ms. The core adds a hard limit of 500 ms + between sends and 30 sends per minute. -## Auth persistence +## Guardrails -- Durable credential: the token file, `VESKSLOP_TOKEN_FILE` (default `/token`, mode 600, - gitignored). It is the **puppet account's** token; zzib is only the DM test target. -- `DATA_DIR` is pinned: `$VENCORD_USER_DATA_DIR`, else `$VESKSLOP_DATA_DIR`, else - `/veskslop` (`src/main/constants.ts`). It does not depend on the app/product name, so - renames and version bumps do not move the Electron profile where Discord stores the session. -- On startup the preload asks main for the token (sync IPC) and seeds `localStorage.token` before - the client boots, but only when no session exists. `VESKSLOP_FORCE_TOKEN=1` overrides. -- Recovery: if Discord invalidates the session, run `pnpm poc:qr` for a fresh token and update the - token file. -- Discord deletes the stored token when it thinks devtools opened; keep Vesktop's devtools detector - patch (`src/renderer/patches/devtoolsFixes.ts`) so this cannot cause random logouts. +- Never commit tokens, credentials, or `.env` files. +- Do not hardcode Discord limits from memory. Make the limits configurable. +- All outbound actions go through the paced queue. +- Test with throwaway accounts and private test guilds only. +- Live send tests: only DMs to user **zzib** (`177117296140091393`). +- Run `pnpm testTypes` and `pnpm lint` before you report done. Run `pnpm build` to compile. -## Headless run (verified 2026-09-11) +## Status -The real client has been run with no visible window and successfully driven end-to-end: +| Area | Status | +| --- | --- | +| Action protocol and renderer actions | Implemented | +| Main bridge, event push, hooks | Pending | +| Daemon on the CDP transport | Implemented and verified live | +| Daemon on the core-bridge transport | Pending | +| QR login, pacing, trigger log | Implemented | +| Auth persistence in the core | Verified, then removed during the CDP pivot. To re-add. | +| Voice, audio, video | Not started | -```sh -Xvfb :99 -screen 0 1280x720x24 & -DISPLAY=:99 VESKSLOP_HEADLESS=1 \ -VESKSLOP_TOKEN_FILE=~/.config/veskslop/token \ -electron . --no-sandbox --disable-gpu --disable-dev-shm-usage -``` +## Work items -- A display server is still required; Xvfb works. -- `VESKSLOP_HEADLESS=1` skips the first-launch tour so the main window is created directly. -- The token file is seeded before the client boots (verified: logged in with the puppet account). -- Outbound sends must pass `waitForChannelReady = false` (`sendMessage(channelId, data, false)` in - `src/renderer/ipcCommands.ts`). Otherwise the client's MessageQueue waits forever for channels - that are never opened in a headless session. -- `message:read` is cache-only; channels the client never opened return zero messages. A - fetch-based read (through the client) is still to be added. +See the todo list in the current session. The main items are: -## How to proceed - -**Current milestone — puppeting bridge (testable without Electron).** - -1. Extend Vesktop's `vesktop.sock` server with a JSON-lines request/response protocol (keep the - legacy `run-shortcut` line working). -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. - -Anything that needs a display, audio device, or a real client instance is **deferred** until it can -be tested; note it instead of guessing. - -## Architecture sketch - -``` -MCP clients (agents) ──Streamable HTTP──▶ veskSlop control daemon - │ JSON lines over vesktop.sock - ▼ - Vesktop main process - │ sendRendererCommand() - ▼ - Discord renderer (Vencord patched) - │ official client actions - ▼ - Discord -``` - -## Guardrails for agents working here - -- **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 - resolution, rate limits) at implementation time, and keep them configurable. -- All outbound actions go through the pacing queue; no direct sends. -- Default to conservative limits and opt-in risky features. -- Test against throwaway accounts and private test guilds only. -- **Live send tests:** DMs to user **zzib** (`177117296140091393`) are the only sanctioned real-send - test target. -- Run `pnpm testTypes` and `pnpm lint` before calling work done; `pnpm build` to compile. - -## Open questions - -- What to strip to lighten the client once the daemon path is stable. -- 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. -- How much of the Vesktop Electron tree gets deleted once the daemon is self-sufficient. +1. Wire the core bridge: `events.ts`, `index.ts`, `puppetBridge.ts`, and the upstream hooks. +2. Re-add auth persistence: pinned `DATA_DIR`, token file, preload seeding, `VESKSLOP_HEADLESS`. +3. Move the daemon to the core-bridge transport. +4. Add a mock core for tests. +5. Later: voice actions, audio, video, fetch-based read, client lightening, web debug UI. ## Dev commands @@ -133,4 +68,8 @@ pnpm install pnpm build # esbuild bundle into dist/ pnpm testTypes # tsc --noEmit pnpm lint + +cd poc +pnpm check # type check the daemon +pnpm test:comm # unit tests ``` diff --git a/CURRENT.md b/CURRENT.md new file mode 100644 index 0000000..5cd2099 --- /dev/null +++ b/CURRENT.md @@ -0,0 +1,46 @@ +# CURRENT.md — session state + +Last updated: 2026-09-11 + +This file tracks the work state. Read it first. Update it before you stop. + +## Current focus (broad) + +Rework the Vesktop integration into a validated capability layer ("core bridge"). Move all logic +into the external daemon. Keep upstream divergence small. + +## In progress + +- Core bridge wiring. The protocol and the renderer actions exist. The events, the main bridge, and + the upstream hooks are not wired yet. + +## Done + +- Audit of the earlier Vesktop patches. `src/` was reverted to upstream. +- Action protocol and validated renderer actions: `src/shared/puppetProtocol.ts`, + `src/renderer/puppet/actions.ts`. +- CDP feasibility proven. The daemon works on CDP. `get_state` and `send_dm` were verified live. +- QR login verified end-to-end. The token file works. +- Pacing policy (500 to 2800 ms), trigger log, and trust model logic live in the daemon. +- Module docs in `docs/` and the agent entry point in `AGENTS.md`. + +## Next (in order) + +1. Wire the core bridge: `events.ts`, `index.ts`, `puppetBridge.ts`, and the upstream hooks. +2. Re-add auth persistence to the core: pinned `DATA_DIR`, token file, preload seeding, + `VESKSLOP_HEADLESS`. +3. Move the daemon to the core-bridge transport. Keep CDP as a development fallback. +4. Add a mock core. Run the daemon tests without Electron. +5. Type check, build, and run a live test. The live send target is zzib only. +6. Later: voice join and leave, audio, video, fetch-based read, client lightening, web debug UI. + +## Blocked / needs input + +- None at this time. + +## Where to look + +- Design documents: `docs/INDEX.md` +- Agent rules: `AGENTS.md` +- Daemon code: `poc/src/` +- Core bridge code: `src/shared/puppetProtocol.ts`, `src/renderer/puppet/` diff --git a/README.md b/README.md index a21f1bf..8a9adde 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,19 @@ Veskslop stands on the shoulders of legends: GPL-3.0-or-later. See [LICENSE](LICENSE). Fork responsibly, ship slop ethically. +## 🛠️ Development + +This project runs the real Vesktop client and controls it through a small, validated capability +layer. All logic lives in an external daemon. + +Start here: + +- [docs/INDEX.md](docs/INDEX.md) — all design documents +- [AGENTS.md](AGENTS.md) — rules for agents and contributors +- [poc/README.md](poc/README.md) — daemon run commands +- [docs/core-bridge.md](docs/core-bridge.md) — the capability layer +- [docs/daemon.md](docs/daemon.md) — the MCP daemon +

diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..ff1df2b --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,23 @@ +# Veskslop documentation index + +This file lists the project documents. + +## Design documents + +| Document | Scope | +| --- | --- | +| [architecture.md](architecture.md) | System parts, boundaries, and data flow | +| [core-bridge.md](core-bridge.md) | The Vesktop-side capability layer | +| [daemon.md](daemon.md) | The external MCP daemon | +| [communication.md](communication.md) | Trust model, commands, and the trigger log | +| [auth.md](auth.md) | QR login and token persistence | +| [testing.md](testing.md) | Tests and the live test setup | +| [decisions.md](decisions.md) | Design decisions and their reasons | + +## Documents outside `docs/` + +| File | Scope | +| --- | --- | +| [../AGENTS.md](../AGENTS.md) | Agent entry point and guardrails | +| [../poc/README.md](../poc/README.md) | Spike and daemon run commands | +| [../README.md](../README.md) | Project introduction | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..69427ea --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,74 @@ +# Architecture + +## Purpose + +Veskslop controls one or more Discord user accounts without a visible client window. It uses the +real Vesktop client, so the traffic comes from the official client. + +## Parts + +The system has four parts: + +1. **Vesktop core** — the real Vesktop client. It runs headless. It exposes a small capability layer. +2. **Core bridge** — the capability layer. It accepts validated actions and pushes events. +3. **Veskslop daemon** — the external process. It holds all logic. It exposes MCP. +4. **MCP clients** — agents and tools. They call the daemon. + +``` +MCP clients (agents) + │ Streamable HTTP (MCP) + ▼ +Veskslop daemon ──── JSON lines over a Unix socket ────▶ Vesktop core + │ │ + │ QR login, pacing, trigger log, │ validated actions + events + │ trust list, MCP tools ▼ + │ Discord (official client traffic) + ▼ +Local state files (.token, .veskslop-state.json) +``` + +## Boundary rules + +The boundary is narrow on purpose. + +- The core exposes a fixed action list. The list has no general "run this code" action. +- The core validates every input. Bad input returns a structured error. The client does not crash. +- The core rate limits all outbound actions. A bug in the daemon cannot cause a send flood. +- The daemon holds the logic: triggers, trust list, command parsing, the trigger log, and pacing. +- The daemon holds no Discord protocol code. + +These rules keep upstream rebases small. Logic changes do not touch the core. The core changes only +when the action list changes. + +## Components and status + +| Component | Location | Status | +| --- | --- | --- | +| Action protocol | `src/shared/puppetProtocol.ts` | Implemented | +| Validated actions | `src/renderer/puppet/actions.ts` | Implemented | +| Event push | `src/renderer/puppet/events.ts` | Pending | +| Main bridge | `src/main/puppetBridge.ts` | Pending | +| Core hooks | `src/main/main.ts`, `src/renderer/ipcCommands.ts`, `src/shared/IpcEvents.ts`, `src/preload/VesktopNative.ts` | Pending | +| Daemon (CDP transport) | `poc/src/` | Implemented and verified live | +| Daemon (core-bridge transport) | `poc/src/core-client.ts` | Pending | +| QR login | `poc/src/qr-login.ts` | Implemented and verified live | +| Pacing | `poc/src/pacing.ts` | Implemented | +| Trigger log and trust list | `poc/src/communication.ts`, `poc/src/puppet.ts` | Implemented on the CDP transport | +| Voice, audio, video | Not started | Not started | + +## Data flow + +1. An agent calls an MCP tool on the daemon. +2. The daemon applies pacing and policy. +3. The daemon sends one JSON-line action to the core bridge. +4. The bridge forwards the action to the renderer. +5. The renderer validates the action and calls the official client. +6. The renderer returns a result or a structured error. +7. The bridge returns the response to the daemon. + +For inbound messages, the flow goes the other way: + +1. The client receives a gateway event. +2. The renderer normalizes the event. +3. The bridge pushes the event to subscribed daemon connections. +4. The daemon applies the trigger rules and updates the log. diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..4aea412 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,63 @@ +# Authentication + +This document describes account login and token persistence. + +## QR login + +`poc/src/qr-login.ts` logs in to an account with a QR code. The tool connects to the Discord +remote-auth gateway. It shows the QR code in the terminal. The user scans the code with the Discord +mobile app. The tool then writes the account token to a file. + +```sh +pnpm poc:qr +TOKEN_FILE=/path/to/token pnpm poc:qr +``` + +The tool prints the QR code, the approval result, and the token file path. If Discord asks for a +captcha, the tool shows the challenge and reads a key from `HCAPTCHA_KEY` or from the terminal. + +The flow was verified end-to-end on 2026-09-11. + +## Token file + +- Default path: `poc/.token`, relative to the `poc/` directory. +- Override: `TOKEN_FILE`. +- File mode: `600`. +- The file is gitignored. +- The token belongs to the puppet account. zzib is only the DM test target. + +## Client session persistence + +The client session is separate from the token file. The session lives in the Electron profile. + +The design has three parts: + +1. **Pinned profile directory.** `DATA_DIR` is `$VENCORD_USER_DATA_DIR`, else `$VESKSLOP_DATA_DIR`, + else `/veskslop`. The path does not depend on the app name. A rename or a version update + does not move the profile. +2. **Token seeding.** On startup, the preload reads the token file through a synchronous IPC call. + The preload writes `localStorage.token` before the client boots. The seed happens only when no + session exists. `VESKSLOP_FORCE_TOKEN=1` overrides an existing session. +3. **Recovery.** If Discord invalidates the session, run `pnpm poc:qr` again. Then update the token + file. + +The client stores the session in `/sessionData`. A version update keeps the directory. + +## Devtools caution + +Discord deletes the stored token when it thinks that devtools opened. Vesktop already patches this +detector in `src/renderer/patches/devtoolsFixes.ts`. Keep this patch. Without it, random logouts +can occur. + +## Status + +| Part | Status | +| --- | --- | +| QR login and token file | Implemented and verified | +| Pinned profile directory | Implemented and verified. Currently out of the tree after the CDP pivot. | +| Token seeding | Implemented and verified. Currently out of the tree after the CDP pivot. | +| Headless first-launch skip | Implemented and verified. Currently out of the tree after the CDP pivot. | +| Devtools patch | Upstream Vesktop code. Keep it. | + +The three out-of-tree parts are small. Add them again with the core bridge, at the same time as the +bridge hooks. diff --git a/docs/communication.md b/docs/communication.md new file mode 100644 index 0000000..1909e16 --- /dev/null +++ b/docs/communication.md @@ -0,0 +1,86 @@ +# Communication + +This document describes the trigger rules, the trust model, the commands, and the trigger log. + +## Trigger rules + +A message is a trigger when one of these conditions is true: + +- The message is a direct message to the puppet. A direct message is a one-to-one DM, not a group. +- The message mentions the puppet. This applies in guild channels and group DMs. + +All other messages are ignored. + +## Trust model + +The puppet has one owner and a trust list. + +- The owner is always trusted. The default owner is `177117296140091393` (zzib). +- The trust list holds additional user IDs. +- A trusted user gets the 100-entry log. An untrusted user gets the 30-entry log. + +The owner controls the trust list. Commands from other users do not change the list. + +## Commands + +The owner sends commands from Discord. In a direct message, the text is the command. In a guild or +a group DM, mention the puppet first. + +| Command | Effect | +| --- | --- | +| `whitelist list` | Show the owner and the trusted users. | +| `whitelist add @user` | Add one or more users to the trust list. | +| `whitelist remove @user` | Remove one or more users from the trust list. | + +Notes: + +- The owner cannot be removed. +- The command accepts mentions and raw user IDs. +- The daemon replies with a confirmation. The reply uses the same pacing as all other sends. + +## Trigger log + +The log has two ring buffers. + +| Buffer | Limit | Content | +| --- | --- | --- | +| Trusted | 100 entries | Triggers from the owner and trusted users | +| Untrusted | 30 entries | Triggers from all other users | + +One entry holds these fields: + +| Field | Meaning | +| --- | --- | +| `id` | The message ID | +| `channelId` | The channel ID | +| `guildId` | The guild ID, or absent for a DM | +| `authorId` | The author user ID | +| `authorName` | The author username | +| `content` | The raw message content | +| `timestamp` | The message timestamp | +| `trusted` | True for a trusted author | +| `command` | The parsed command text, when the message is a command | + +## Event flow + +1. The client receives a `MESSAGE_CREATE` gateway event. +2. The core renderer normalizes the event. +3. The core bridge pushes the event to the daemon. +4. The daemon applies the trigger rules. +5. The daemon adds a qualifying message to the log. +6. The daemon runs a trusted command, when the message is a command. + +## State file + +The daemon stores the owner ID and the trust list in JSON. + +- Default path: `poc/.veskslop-state.json`. +- Override: `VESKSLOP_STATE_FILE`. +- The trigger log is in memory only. The log resets at restart. + +## How to add a trigger rule + +1. Add the rule in `poc/src/puppet.ts`, in `handleEvent`. +2. Keep the rule small and testable. +3. Add a unit test in `poc/src/test-communication.ts` when the rule is pure logic. +4. Run `pnpm test:comm`. diff --git a/docs/core-bridge.md b/docs/core-bridge.md new file mode 100644 index 0000000..d116d2c --- /dev/null +++ b/docs/core-bridge.md @@ -0,0 +1,129 @@ +# Core bridge + +The core bridge is the only surface between the Vesktop core and the external daemon. This document +describes the protocol, the validation rules, and the hooks into upstream files. + +## Files + +| File | Role | Status | +| --- | --- | --- | +| `src/shared/puppetProtocol.ts` | Action names, error codes, limits, and shared types | Implemented | +| `src/renderer/puppet/actions.ts` | Validated action handlers and the core rate limit | Implemented | +| `src/renderer/puppet/events.ts` | Event normalization and push to main | Pending | +| `src/renderer/puppet/index.ts` | Renderer initialization | Pending | +| `src/main/puppetBridge.ts` | Unix socket server, request forwarding, event fan-out | Pending | + +## Transport + +The core bridge uses a Unix socket. On Linux the path is `$XDG_RUNTIME_DIR/vesktop-puppet.sock`. +On Linux without `XDG_RUNTIME_DIR`, the path is `/tmp/vesktop-puppet.sock`. On Windows the path is +the named pipe `\\.\pipe\vesktop-puppet.sock`. + +The protocol is newline-delimited JSON. Each request gets one response line. An event subscriber +gets event lines until the connection closes. + +Request: + +```json +{ "id": 1, "action": "message.send", "params": { "channelId": "123", "content": "hello" } } +``` + +Success response: + +```json +{ "id": 1, "ok": true, "result": { "channelId": "123", "sent": true } } +``` + +Error response: + +```json +{ "id": 1, "ok": false, "error": { "code": "CONTENT_EMPTY", "message": "content is empty" } } +``` + +Event line: + +```json +{ "type": "event", "event": { "type": "MESSAGE_CREATE", "channelId": "123" } } +``` + +## Actions + +| Action | Parameters | Result | +| --- | --- | --- | +| `ping` | none | `"pong"` | +| `events.subscribe` | none | `{ "subscribed": true }`. The socket becomes an event subscriber. | +| `state.get` | none | The current user, the guilds, and the channels. Cache-only. | +| `message.send` | `channelId`, `content` | `{ channelId, sent: true }` | +| `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. | +| `dm.send` | `userId`, `content` | `{ userId, channelId, sent: true }` | + +## Validation rules + +The core validates all input. The daemon cannot bypass these rules. + +- A channel ID and a user ID must match the snowflake format. The format is 17 to 20 digits. +- `content` must be a string. The content must not be empty after a trim. +- `content` must not contain control characters. +- `content` must not be longer than 2000 characters. +- The target channel must exist. The channel type must accept text. The allowed types are + `GUILD_TEXT`, `DM`, `GROUP_DM`, `GUILD_ANNOUNCEMENT`, `ANNOUNCEMENT_THREAD`, `PUBLIC_THREAD`, + and `PRIVATE_THREAD`. +- `limit` must be an integer from 1 to 100. +- A request line must not be larger than 64 KiB. + +## Core rate limit + +The core rate limit is a safety net. It does not replace the daemon pacing. + +- The minimum interval between two outbound actions is 500 ms. +- The maximum is 30 outbound actions per minute. +- The limit applies to `message.send` and `dm.send`. + +## Error codes + +| Code | Meaning | +| --- | --- | +| `INVALID_REQUEST` | The line is not valid JSON. | +| `PAYLOAD_TOO_LARGE` | The request line is larger than 64 KiB. | +| `UNKNOWN_ACTION` | The action name is not in the action list. | +| `CORE_UNAVAILABLE` | The bridge cannot reach the renderer. | +| `NOT_LOGGED_IN` | No user is logged in. | +| `INVALID_SNOWFLAKE` | The ID is not a snowflake. | +| `INVALID_PARAM` | A parameter has the wrong type or value. | +| `CONTENT_EMPTY` | The content is empty. | +| `CONTENT_TOO_LONG` | The content is longer than 2000 characters. | +| `CHANNEL_NOT_FOUND` | The channel is not in the client cache. | +| `NOT_SENDABLE` | The channel type cannot receive messages. | +| `SEND_FAILED` | Discord rejected the message. | +| `DM_UNAVAILABLE` | The client cannot create a DM channel. | +| `RATE_LIMITED` | The core rate limit rejected the action. | +| `INTERNAL` | An unexpected error occurred. The details go to the local log only. | + +## Hooks into upstream files + +The bridge uses a small number of hooks. Keep these changes minimal. + +| File | Change | +| --- | --- | +| `src/main/main.ts` | Import the bridge. Call `initPuppetBridge()` after `app.whenReady()`. | +| `src/shared/IpcEvents.ts` | Add `PUPPET_EVENT` and `IpcCommands.PUPPET_COMMAND`. | +| `src/renderer/index.ts` | Import `./puppet`. | +| `src/renderer/ipcCommands.ts` | Register `PUPPET_COMMAND` and call `handlePuppetAction`. | +| `src/preload/VesktopNative.ts` | Add `puppet.emit(event)` for event push. | + +## How to add an action + +1. Add the action name to `PuppetAction` in `src/shared/puppetProtocol.ts`. +2. Add a validation case in `src/renderer/puppet/actions.ts`. +3. Add the action to the known-action set in `src/main/puppetBridge.ts`. +4. Add a tool in the daemon (`poc/src/control-bridge.ts`). +5. Add a unit test for the validation path. +6. Run `pnpm testTypes`, `pnpm lint`, and `pnpm build`. + +## Why this shape + +The shape gives three properties: + +- The daemon cannot run arbitrary code in the client. +- A daemon bug cannot crash the client or send malformed data to Discord. +- The core patch stays small, so an upstream rebase is easy. diff --git a/docs/daemon.md b/docs/daemon.md new file mode 100644 index 0000000..897f7ae --- /dev/null +++ b/docs/daemon.md @@ -0,0 +1,93 @@ +# Daemon + +The daemon runs outside Vesktop. It holds all logic. It talks to the core bridge and exposes MCP. + +## Files + +| File | Role | Status | +| --- | --- | --- | +| `poc/src/control-bridge.ts` | MCP server, tool list, health endpoint | Implemented on CDP | +| `poc/src/puppet.ts` | Trust list, command handling, trigger log | Implemented | +| `poc/src/communication.ts` | Pure log and parser logic | Implemented | +| `poc/src/pacing.ts` | Paced queue and query coalescing | Implemented | +| `poc/src/cdp-client.ts` | Chrome DevTools Protocol client | Implemented (current transport) | +| `poc/src/discord.ts` | Client actions over CDP | Implemented (current transport) | +| `poc/src/core-client.ts` | Client actions over the core bridge | Pending (target transport) | +| `poc/src/qr-login.ts` | Terminal QR login | Implemented and verified | +| `poc/src/test-communication.ts` | Unit tests for the pure logic | Implemented | +| `poc/src/cdp-probe.ts` | CDP feasibility probe | Implemented (dev tool) | + +## Transport + +The daemon supports two transports. + +1. **CDP (current)** — the daemon starts Vesktop with `--remote-debugging-port=9222`. The daemon + connects to the Discord page target. It evaluates the action code in the page and receives + events through a CDP binding. This transport needs no Vesktop patch. The project used it to + prove the concept and to test the client. +2. **Core bridge (target)** — the daemon connects to `vesktop-puppet.sock`. All actions go through + the validated capability layer. This transport gives the core-side guardrails. + +The daemon logic is the same on both transports. Only the client class changes. + +## Pacing + +The daemon paces all commands. The policy is a random delay of 500 to 2800 ms. The queue +serializes the commands. The queue coalesces identical concurrent reads. The daemon also prefers +cached state over queries. + +The core adds a second, hard rate limit. See [core-bridge.md](core-bridge.md). + +## MCP tools + +The daemon exposes MCP over Streamable HTTP. The default address is `http://127.0.0.1:8788/mcp`. + +| Tool | Purpose | +| --- | --- | +| `get_state` | List the user, the guilds, and the channels. | +| `send_message` | Send a message to a channel. | +| `read_messages` | Read cached messages from a channel. | +| `send_dm` | Send a DM to a user. | +| `get_message_log` | Read the trigger log. | +| `whitelist_list` | List the owner and the trusted users. | +| `whitelist_add` | Add users to the trust list. | +| `whitelist_remove` | Remove users from the trust list. | + +## Run commands + +```sh +# Start the daemon. It connects to the client on CDP port 9222. +pnpm control:bridge + +# Send one test DM. The only permitted live target is zzib. +pnpm poc:dm + +# Run the pure-logic unit tests. +pnpm test:comm + +# Probe CDP access and events. +pnpm cdp:probe +``` + +## Health endpoint + +`GET /health` returns the CDP state, the owner ID, and the query count. + +```json +{ "ok": true, "cdpConnected": true, "ownerId": "177117296140091393", "queries": 4 } +``` + +## How to add an MCP tool + +1. Add the action to the core bridge. See [core-bridge.md](core-bridge.md). +2. Add a `server.registerTool` call in `poc/src/control-bridge.ts`. +3. Wrap outbound calls in `paced`. +4. Add a unit test if the tool has local logic. +5. Run `pnpm check` in `poc/`. + +## Security notes + +- The MCP server binds to localhost by default. +- Remote access must go over LAN or Tailscale. Do not expose the daemon to the public internet. +- The daemon holds no Discord credentials. The client holds the session. +- The daemon token file is separate from the client session. See [auth.md](auth.md). diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..c176d7d --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,81 @@ +# Decisions + +This document records the main design decisions and their reasons. + +## ADR-1: Use the real client + +**Decision.** Run the real Vesktop client. Do not use a third-party selfbot protocol library. + +**Reason.** The popular selfbot libraries are archived or deprecated. Stale protocol emulation is a +ban risk. The real client sends official traffic. + +## ADR-2: Validated capability layer + +**Decision.** The core exposes a small, fixed action list. The core validates all input. The core +returns structured errors. All logic lives in the external daemon. + +**Reason.** The split gives three properties: + +- The daemon cannot run arbitrary code in the client. +- A daemon bug cannot crash the client or send malformed data to Discord. +- Core patches stay small, so an upstream rebase is easy. + +**Consequence.** A new capability needs a small core change. The action list is the contract. + +## ADR-3: CDP is a fallback, not the primary transport + +**Decision.** The project validated control over Chrome DevTools Protocol. The primary transport is +the validated core bridge. + +**Reason.** CDP needs no Vesktop patch. However, CDP evaluates raw code in the page. It bypasses the +core-side validation and rate limits. The project keeps the probe as a development tool. + +**Status.** The daemon uses CDP at this time. The core-bridge transport is pending. + +## ADR-4: No bespoke client + +**Decision.** Do not write a new Electron shell for Discord. + +**Reason.** Vesktop maintains the desktop integration for Vencord, for example settings, themes, +quick CSS, and the native plugin bridge. A bespoke shell must reimplement that surface. Vesktop +already does this work. + +**Consequence.** Keep the patch small and isolated. This approach is cheaper than a new shell. + +## ADR-5: Two pacing layers + +**Decision.** The daemon paces all commands with a random delay of 500 to 2800 ms. The core adds a +hard limit of 500 ms between sends and 30 sends per minute. + +**Reason.** The daemon policy looks human. The core limit is a safety net. A daemon bug cannot cause +a send flood. + +## ADR-6: Pin the profile directory + +**Decision.** `DATA_DIR` is `$VENCORD_USER_DATA_DIR`, else `$VESKSLOP_DATA_DIR`, else +`/veskslop`. + +**Reason.** The Discord session lives in the Electron profile. An app rename or a version update +must not move the profile. A stable path keeps the login. + +## ADR-7: Separate token file + +**Decision.** The QR tool writes the account token to a file. The client seeds the token at startup +when no session exists. + +**Reason.** The token file survives a profile reset. It also survives a change in the Discord +storage format. The recovery path is one QR scan. + +## ADR-8: Trigger log limits + +**Decision.** Keep the last 100 triggers from trusted users and the last 30 triggers from untrusted +users. + +**Reason.** The limits keep memory small. The trusted limit is larger because those messages matter +more. + +## ADR-9: One live test target + +**Decision.** The only permitted live-send target is user zzib (`177117296140091393`). + +**Reason.** Tests must not send messages to other users. DMs to zzib are safe and permitted. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..c785112 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,101 @@ +# Testing + +This document describes the test setup, the run commands, and the known gaps. + +## Unit tests + +The pure daemon logic has unit tests. The tests cover the ring buffers, the mention helpers, the +command parser, the trust check, and the pacing range. + +```sh +cd poc +pnpm test:comm +``` + +The file is `poc/src/test-communication.ts`. The tests do not need Discord or Vesktop. + +## Type checks + +```sh +cd poc && pnpm check # daemon and spike code +cd .. && pnpm testTypes # Vesktop core +``` + +Both commands must pass before a change is complete. + +## Build + +```sh +pnpm build +``` + +The build writes the application to `dist/`. + +## CDP probe + +The probe checks the current CDP transport. Start the client with +`--remote-debugging-port=9222` first. + +```sh +cd poc +pnpm cdp:probe +``` + +A good result shows the Vencord global, the Flux dispatcher, the current user, and one binding +event. + +## Live test setup + +The container has no display and no PipeWire. Use a virtual display. + +```sh +# 1. Start a virtual display. On Linux, install Xvfb or extract it locally. +Xvfb :99 -screen 0 1280x720x24 & + +# 2. Start the stock client with a pinned profile and remote debugging. +DISPLAY=:99 \ +VENCORD_USER_DATA_DIR=~/.config/veskslop \ +electron . --no-sandbox --disable-gpu --disable-dev-shm-usage --remote-debugging-port=9222 + +# 3. Start the daemon. +cd poc && pnpm control:bridge + +# 4. Check the health endpoint. +curl http://127.0.0.1:8788/health +``` + +Notes: + +- `VENCORD_USER_DATA_DIR` pins the Electron profile. The profile holds the client session. +- Write `{"firstLaunch": false}` to `/state.json` before the first start. This step skips + the first-launch window. The `VESKSLOP_HEADLESS=1` flag does the same job when it is in the tree. +- The container needs `libasound.so.2` for Electron. Extract the library locally when the system + package is not available. + +## Sanctioned live target + +The only permitted live-send target is user **zzib** (`177117296140091393`). Send a test message +with this command: + +```sh +cd poc +pnpm poc:dm +``` + +## CDP live results + +The CDP transport was verified on 2026-09-11: + +- `get_state` returned the real account and 7 guilds. +- `send_dm` delivered a real DM to zzib. +- The client ran headless under Xvfb. +- The token was seeded from the token file. + +## Known gaps + +| Gap | Effect | +| --- | --- | +| Mock core is not in the tree. | The daemon tests need the real client. | +| The trigger event path needs an incoming message. | Use zzib's account for a live test. | +| `message.read` is cache-only. | A channel that the client never opened returns zero messages. | +| Voice, audio, and video are not started. | No test coverage. | diff --git a/poc/.gitignore b/poc/.gitignore index 97c6885..9f6206d 100644 --- a/poc/.gitignore +++ b/poc/.gitignore @@ -3,3 +3,4 @@ node_modules/ .env .token .secrets/ +.veskslop-state.json diff --git a/poc/README.md b/poc/README.md index ed931de..f9dc2a8 100644 --- a/poc/README.md +++ b/poc/README.md @@ -1,7 +1,8 @@ -# Veskslop Phase 0 spikes +# Veskslop daemon -Timeboxed proof-of-concept scripts, not production code. Project direction lives in -[../AGENTS.md](../AGENTS.md). +The external daemon and the development spikes. The daemon holds all logic and exposes MCP. + +Read the project docs first: [../docs/INDEX.md](../docs/INDEX.md). ## Setup @@ -9,98 +10,50 @@ Timeboxed proof-of-concept scripts, not production code. Project direction lives pnpm install ``` -## Current query policy +## Run commands -- Every command sent to the client is spaced by a random **500–2800 ms** and serialized through one - queue (`src/pacing.ts`). -- Identical concurrent reads are coalesced (`dedupe`), and state reads are cache-only. -- `DEBUG=1` logs every command and its delay. +| Command | Purpose | +| --- | --- | +| `pnpm poc:qr` | QR login. Writes the token file. | +| `pnpm control:bridge` | Start the daemon. It listens on `http://127.0.0.1:8788/mcp`. | +| `pnpm poc:dm` | Send one test DM to zzib. | +| `pnpm test:comm` | Run the unit tests for the pure logic. | +| `pnpm cdp:probe` | Check CDP access and event delivery. | +| `pnpm check` | Type check the daemon code. | -## Spikes +## Transport -### 0. QR login / token acquisition — `src/qr-login.ts` +The daemon uses Chrome DevTools Protocol at this time. Start the client with +`--remote-debugging-port=9222`. The daemon connects to the Discord page and runs the actions in the +real client. -```sh -pnpm poc:qr -TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr -``` +The target transport is the validated core bridge. See +[../docs/core-bridge.md](../docs/core-bridge.md). The daemon logic is the same on both transports. -Connects to `wss://remote-auth-gateway.discord.gg/?v=2`, renders the approval QR directly in the -terminal, waits for a phone scan, exchanges the login ticket and writes the token to -`poc/.token` (mode 600, gitignored; override with `TOKEN_FILE`). This is the **puppet account's** -token. No existing token required. -If Discord responds with an hCaptcha challenge, the script prints the challenge and prompts for a -`HCAPTCHA_KEY` (or takes it from the environment). +## Pacing -The headless client consumes this file on startup via `VESKSLOP_TOKEN_FILE` (default -`/token`, see [../AGENTS.md](../AGENTS.md) → Auth persistence). +Every command gets a random delay of 500 to 2800 ms. One queue serializes the commands. The queue +coalesces identical concurrent reads. State reads use the cache. `DEBUG=1` logs each command and +its delay. -### 1. Vesktop control bridge — `src/control-bridge.ts` +## Live test target -The MCP daemon. Exposes Streamable HTTP and forwards tool calls over the JSON-lines protocol on -Vesktop's control socket (default `$XDG_RUNTIME_DIR/vesktop.sock`, override with `VESKSLOP_SOCKET`). - -```sh -pnpm control:bridge # http://127.0.0.1:8788/mcp -VESKSLOP_SOCKET=... MCP_PORT=... pnpm control:bridge -``` - -Tools: `get_state` (cache-only), `send_message`, `read_messages` (coalesced), `send_dm`. - -Vesktop side lives in this repo: - -- `src/main/ipcSocket.ts` — parses JSON lines `{ id, command, data }` and forwards them through - `sendRendererCommand`. -- `src/renderer/ipcCommands.ts` — handlers that use official client interactions (`sendMessage`, - `openPrivateChannel`, guild/channel/message stores). - -Protocol commands: `ping`, `state:get`, `message:send`, `message:read`, `dm:send`. - -### 2. Mock Vesktop — `src/mock-vesktop.ts` - -```sh -VESKSLOP_SOCKET=/tmp/veskslop-mock.sock pnpm control:mock -``` - -Stands in for the real Vesktop process so the bridge can be developed without Electron/Xvfb. - -### 3. Live DM test — `src/test-dm.ts` - -```sh -pnpm poc:dm -TEST_DM_CONTENT="hello" pnpm poc:dm -``` - -Talks to a **real** running Vesktop's control socket and sends one paced DM. Defaults to the only -sanctioned live-send target, user **zzib** (`TEST_DM_USER_ID` overrides). +The only permitted live-send target is user zzib. See +[../docs/testing.md](../docs/testing.md) for the full setup. ## Verified -- QR login works end-to-end; the token in `.token` came from a phone scan. -- MCP bridge path: `initialize`, `tools/list`, `get_state`, `send_message`, `read_messages`, - `send_dm` all round-trip correctly through the mock. -- **Live end-to-end (2026-09-11)**: a real Vesktop client was run headless under Xvfb with the - token seeded from `~/.config/veskslop/token`. `get_state` returned the real account and 7 guilds, - and `send_dm` delivered a real DM to zzib (`sent: true`). Cache-only `read_messages` on that - never-opened DM returned zero messages, as expected. -- Pacing observed live: consecutive commands reached the mock **592 ms**, **1862 ms** and - **1439 ms** apart — inside the 500–2800 ms window. - -## Deferred (not testable in this container) - -- Real renderer handlers against a live Discord session (needs Electron + display). -- Headless display strategy (Xvfb/offscreen) and client lightening. -- Token injection into the official client. -- Voice join/leave and audio/video pipelines (need a voice channel; ffmpeg/PipeWire absent). -- Remote client auth over LAN/Tailscale and the web debug UI. +- QR login works end-to-end. The token came from a phone scan. +- The CDP transport works. `get_state` returned the real account and 7 guilds. `send_dm` delivered + a real DM to zzib. +- Pacing stayed inside the 500 to 2800 ms window. ## Findings -- Official `discord.js` is bot-only (`/gateway/bot` → 401 with a user token), and - `discord.js-selfbot-v13` is archived — do not build on either. The core is the real Vesktop - client, puppeted through its own interactions. -- MCP SDK `1.30.0` stateless Streamable HTTP works as used here. -- Vesktop's existing `sendRendererCommand`/`onIpcCommand` bus is enough to puppet interactions; the - socket only needed a JSON-lines framing and a command map. -- pnpm 11: this folder is its own workspace root; build approvals live in `pnpm-workspace.yaml` +- Official `discord.js` is bot-only. It fails at login with a user token. The popular selfbot fork + is archived. Do not use either. The core is the real Vesktop client. +- MCP SDK `1.30.0` Streamable HTTP works as used here. +- CDP gives control and events with no Vesktop patch. Raw CDP has no guardrails. The validated core + bridge is the target for this reason. +- pnpm 11: this folder is its own workspace root. Build approvals live in `pnpm-workspace.yaml` (`allowBuilds`). diff --git a/poc/package.json b/poc/package.json index c7a00bd..095efbc 100644 --- a/poc/package.json +++ b/poc/package.json @@ -7,7 +7,8 @@ "scripts": { "poc:qr": "tsx src/qr-login.ts", "poc:dm": "tsx src/test-dm.ts", - "control:mock": "tsx src/mock-vesktop.ts", + "test:comm": "tsx src/test-communication.ts", + "cdp:probe": "tsx src/cdp-probe.ts", "control:bridge": "tsx src/control-bridge.ts", "check": "tsc --noEmit" }, diff --git a/poc/src/cdp-client.ts b/poc/src/cdp-client.ts new file mode 100644 index 0000000..3e4f07b --- /dev/null +++ b/poc/src/cdp-client.ts @@ -0,0 +1,102 @@ +/* + * Veskslop — minimal Chrome DevTools Protocol client. + * + * Connects to the Discord page target exposed by Vesktop's + * --remote-debugging-port, evaluates expressions in the page, and receives + * pushed events through a CDP binding. This is the entire integration surface: + * Vesktop itself is unmodified. + */ + +import WebSocket from "ws"; + +export type CdpListener = (event: any) => void; + +interface PendingRequest { + resolve: (value: any) => void; + reject: (error: Error) => void; +} + +export class CdpClient { + private ws!: WebSocket; + private nextId = 0; + private readonly pending = new Map(); + private readonly listeners = new Set(); + + static async connect(cdpUrl = process.env.CDP_URL ?? "http://127.0.0.1:9222"): Promise { + const targets = (await (await fetch(`${cdpUrl}/json`)).json()) as any[]; + const page = targets.find(target => target.type === "page" && target.url.includes("discord.com")); + if (!page) throw new Error(`No Discord page target at ${cdpUrl}`); + + const client = new CdpClient(); + client.ws = new WebSocket(page.webSocketDebuggerUrl); + + await new Promise((resolve, reject) => { + client.ws.once("open", resolve); + client.ws.once("error", reject); + }); + + client.ws.on("message", (raw: WebSocket.RawData) => client.onMessage(raw)); + + await client.send("Runtime.enable"); + await client.send("Runtime.addBinding", { name: "veskslopEmit" }); + + return client; + } + + private onMessage(raw: WebSocket.RawData) { + const message = JSON.parse(raw.toString()); + + if (message.id && this.pending.has(message.id)) { + const request = this.pending.get(message.id)!; + this.pending.delete(message.id); + + if (message.error) request.reject(new Error(message.error.message)); + else request.resolve(message.result); + + return; + } + + if (message.method === "Runtime.bindingCalled" && message.params?.name === "veskslopEmit") { + try { + const event = JSON.parse(message.params.payload); + for (const listener of this.listeners) listener(event); + } catch { + // ignore malformed payloads + } + } + } + + send(method: string, params?: unknown): Promise { + const id = ++this.nextId; + + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify({ id, method, params })); + }); + } + + async evaluate(expression: string): Promise { + const result = await this.send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }); + + if (result.exceptionDetails) { + const description = + result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "evaluation failed"; + throw new Error(description); + } + + return result.result?.value as T; + } + + onEvent(listener: CdpListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + close() { + this.ws.close(); + } +} diff --git a/poc/src/cdp-probe.ts b/poc/src/cdp-probe.ts new file mode 100644 index 0000000..1be89c1 --- /dev/null +++ b/poc/src/cdp-probe.ts @@ -0,0 +1,84 @@ +/* + * Veskslop — Chrome DevTools Protocol feasibility probe. + * + * Verifies that an external process can, with zero Vesktop patches: + * - reach the Discord page target + * - evaluate against Vencord/Discord internals + * - subscribe to Flux events and receive them via a CDP binding + * + * Usage: start Vesktop with --remote-debugging-port=9222, then: pnpm cdp:probe + */ + +import WebSocket from "ws"; + +const CDP = process.env.CDP_URL ?? "http://127.0.0.1:9222"; + +const targets = (await (await fetch(`${CDP}/json`)).json()) as any[]; +const page = targets.find(target => target.type === "page" && target.url.includes("discord.com")); + +if (!page) { + console.error("No Discord page target. Targets:", targets.map(t => `${t.type} ${t.url}`).join("\n")); + process.exit(1); +} + +console.log("target:", page.url); + +const ws = new WebSocket(page.webSocketDebuggerUrl); +let nextId = 0; +const pending = new Map void>(); + +ws.on("message", raw => { + const message = JSON.parse(raw.toString()); + + if (message.id && pending.has(message.id)) { + pending.get(message.id)!(message); + pending.delete(message.id); + return; + } + + if (message.method === "Runtime.bindingCalled") { + console.log("BINDING EVENT:", message.params.name, "->", message.params.payload); + } +}); + +function send(method: string, params?: unknown): Promise { + const id = ++nextId; + return new Promise(resolve => { + pending.set(id, resolve); + ws.send(JSON.stringify({ id, method, params })); + }); +} + +await new Promise(resolve => ws.once("open", () => resolve())); +await send("Runtime.enable"); + +async function evaluate(expression: string): Promise { + const response = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + + if (response.result?.exceptionDetails) { + return { error: response.result.exceptionDetails.text }; + } + + return response.result?.result?.value; +} + +console.log("Vencord global:", await evaluate("typeof Vencord")); +console.log("FluxDispatcher.subscribe:", await evaluate("typeof Vencord?.Webpack?.Common?.FluxDispatcher?.subscribe")); +console.log("sendMessage util:", await evaluate("typeof Vencord?.Util?.sendMessage")); +console.log("current user:", await evaluate("Vencord?.Webpack?.Common?.UserStore?.getCurrentUser?.()?.username ?? null")); + +await send("Runtime.addBinding", { name: "veskslopEmit" }); +await evaluate(`veskslopEmit("probe-ok")`); + +await evaluate(` + Vencord.Webpack.Common.FluxDispatcher.subscribe("MESSAGE_CREATE", event => { + window.veskslopEmit(JSON.stringify({ type: "MESSAGE_CREATE", channelId: event.channelId, guildId: event.guildId ?? null })); + }); + "subscribed" +`); + +console.log("waiting 5s for binding events..."); +await new Promise(resolve => setTimeout(resolve, 5000)); + +ws.close(); +console.log("probe done"); diff --git a/poc/src/communication.ts b/poc/src/communication.ts new file mode 100644 index 0000000..0c05c16 --- /dev/null +++ b/poc/src/communication.ts @@ -0,0 +1,83 @@ +/* + * Veskslop communication backend — pure logic (no Discord imports). + * + * Keeps a small ring buffer of trigger-qualifying messages: the last 100 from + * trusted users and the last 30 from untrusted ones. Also parses the + * `whitelist` commands and normalizes @mentions. + */ + +export interface PuppetMessage { + id: string; + channelId: string; + guildId?: string; + authorId: string; + authorName: string; + /** Raw message content as received. */ + content: string; + timestamp: string; + trusted: boolean; + /** Parsed command text when the message is one. */ + command?: string; +} + +export const TRUSTED_LOG_LIMIT = 100; +export const UNTRUSTED_LOG_LIMIT = 30; + +export class MessageLog { + trusted: PuppetMessage[] = []; + untrusted: PuppetMessage[] = []; + + add(message: PuppetMessage) { + const bucket = message.trusted ? this.trusted : this.untrusted; + const limit = message.trusted ? TRUSTED_LOG_LIMIT : UNTRUSTED_LOG_LIMIT; + + bucket.push(message); + if (bucket.length > limit) bucket.splice(0, bucket.length - limit); + } + + snapshot() { + return { trusted: [...this.trusted], untrusted: [...this.untrusted] }; + } +} + +const USER_MENTION = /<@!?(\d+)>/g; + +export function extractMentionIds(content: string, mentions?: Array<{ id: string }>): Set { + const ids = new Set(); + for (const mention of mentions ?? []) ids.add(mention.id); + for (const match of content.matchAll(USER_MENTION)) ids.add(match[1]); + return ids; +} + +export function stripMentions(content: string, ids: ReadonlySet): string { + return content + .replace(USER_MENTION, (full, id) => (ids.has(id) ? "" : full)) + .replace(/\s+/g, " ") + .trim(); +} + +export interface ParsedCommand { + name: string; + args: string[]; +} + +export function parseCommand(text: string): ParsedCommand | undefined { + const trimmed = text.trim(); + if (!trimmed) return undefined; + + const [name, ...args] = trimmed.split(/\s+/); + return { name: name.toLowerCase(), args }; +} + +export function isTrusted( + userId: string, + ownerId: string | undefined, + whitelist: readonly string[] | undefined +): boolean { + return userId === ownerId || !!whitelist?.includes(userId); +} + +/** Same 500-2800 ms pacing used by the control bridge, for autonomous replies. */ +export function randomActionDelay(): number { + return 500 + Math.floor(Math.random() * 2301); +} diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index 149ecfb..e6a152a 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -1,14 +1,13 @@ /* - * Veskslop Phase 0 spike — MCP control bridge. + * Veskslop daemon — MCP control bridge over Chrome DevTools Protocol. * - * Exposes MCP over Streamable HTTP and forwards tool calls to the Vesktop - * control socket, where the real client performs them. Every command is paced - * by the shared queue (300-800 ms), reads are coalesced, and state reads are - * cache-only. + * Talks to an unmodified Vesktop through --remote-debugging-port, evaluates + * actions against the real client, receives events through a CDP binding, and + * runs the trigger/whitelist/log logic locally. * * Usage: - * pnpm control:bridge - * VESKSLOP_SOCKET=/tmp/veskslop-mock.sock MCP_PORT=8788 pnpm control:bridge + * VESKSLOP_TOKEN_FILE=... pnpm control:bridge + * CDP_URL=http://127.0.0.1:9222 MCP_PORT=8788 pnpm control:bridge */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; @@ -16,18 +15,54 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import * as z from "zod/v4"; -import { dedupe, getQueryCount, paced } from "./pacing.js"; -import { sendVesktopCommand, socketPath } from "./vesktop-socket.js"; +import { CdpClient } from "./cdp-client.js"; +import { Discord } from "./discord.js"; +import { dedupe, getQueryCount, paced, sleep } from "./pacing.js"; +import { Puppet } from "./puppet.js"; const HOST = process.env.MCP_HOST ?? "127.0.0.1"; const PORT = Number(process.env.MCP_PORT ?? 8788); -function jsonText(value: unknown) { - return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; +let discord: Discord | undefined; +let puppet: Puppet | undefined; +let cdpConnected = false; + +async function connectLoop() { + for (;;) { + try { + const cdp = await CdpClient.connect(); + + discord = new Discord(cdp); + puppet = new Puppet(discord); + + await discord.installEventBridge(); + cdp.onEvent(event => { + puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error)); + }); + + cdpConnected = true; + console.log("[daemon] connected to Vesktop via CDP"); + + return; + } catch (error) { + console.warn(`[daemon] waiting for Vesktop CDP: ${(error as Error).message}`); + await sleep(2000); + } + } } -function command(name: string, data?: unknown): Promise { - return paced(() => sendVesktopCommand(name, data)); +function requireDiscord(): Discord { + if (!cdpConnected || !discord) throw new Error("Not connected to Vesktop (run it with --remote-debugging-port)"); + return discord; +} + +function requirePuppet(): Puppet { + if (!puppet) throw new Error("Puppet not connected yet"); + return puppet; +} + +function jsonText(value: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; } function buildServer(): McpServer { @@ -38,7 +73,7 @@ function buildServer(): McpServer { { description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries." }, - async () => jsonText(await command("state:get")) + async () => jsonText(await requireDiscord().getState()) ); server.registerTool( @@ -51,25 +86,8 @@ function buildServer(): McpServer { } }, async ({ channelId, content }) => { - const result = await command>("message:send", { channelId, content }); - return jsonText({ ...result, queriesUsed: getQueryCount() }); - } - ); - - server.registerTool( - "read_messages", - { - description: "Read messages from the client's cache. Identical concurrent reads are coalesced.", - inputSchema: { - channelId: z.string().describe("Target channel ID"), - limit: z.number().int().min(1).max(100).default(20).describe("How many messages") - } - }, - async ({ channelId, limit }) => { - const result = await dedupe(`read:${channelId}:${limit}`, () => - command<{ messages: unknown[]; cached: number; channelId: string }>("message:read", { channelId, limit }) - ); - return jsonText({ ...result, queriesUsed: getQueryCount() }); + await paced(() => requireDiscord().sendMessage(channelId, content)); + return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() }); } ); @@ -83,14 +101,71 @@ function buildServer(): McpServer { } }, async ({ userId, content }) => { - const result = await command>("dm:send", { userId, content }); - return jsonText({ ...result, queriesUsed: getQueryCount() }); + const result = await paced(() => requireDiscord().sendDm(userId, content)); + return jsonText({ ...result, userId, queriesUsed: getQueryCount() }); } ); + server.registerTool( + "read_messages", + { + description: "Read messages from the client's cache. Identical concurrent reads are coalesced.", + inputSchema: { + channelId: z.string().describe("Target channel ID"), + limit: z.number().int().min(1).max(100).default(20).describe("How many messages") + } + }, + async ({ channelId, limit }) => { + const result = await dedupe<{ cached: number; messages: unknown[] }>(`read:${channelId}:${limit}`, () => + paced(() => requireDiscord().readMessages(channelId, limit)) + ); + return jsonText({ ...result, channelId, queriesUsed: getQueryCount() }); + } + ); + + server.registerTool( + "get_message_log", + { + description: "Read the trigger log: last 100 messages from trusted users, last 30 from untrusted." + }, + async () => jsonText(requirePuppet().getLog()) + ); + + server.registerTool( + "whitelist_list", + { + description: "List the puppet owner and trusted (whitelisted) user IDs." + }, + async () => jsonText({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }) + ); + + server.registerTool( + "whitelist_add", + { + description: "Add users to the puppet whitelist.", + inputSchema: { + userIds: z.array(z.string()).min(1).describe("User IDs to trust") + } + }, + async ({ userIds }) => jsonText({ whitelist: requirePuppet().addToWhitelist(userIds) }) + ); + + server.registerTool( + "whitelist_remove", + { + description: "Remove users from the puppet whitelist. The owner cannot be removed.", + inputSchema: { + userIds: z.array(z.string()).min(1).describe("User IDs to untrust") + } + }, + async ({ userIds }) => jsonText({ whitelist: requirePuppet().removeFromWhitelist(userIds) }) + ); + return server; } +void connectLoop(); + const app = createMcpExpressApp({ host: HOST }); app.post("/mcp", async (req, res) => { @@ -128,7 +203,7 @@ app.delete("/mcp", (_req, res) => { }); app.get("/health", (_req, res) => { - res.json({ ok: true, socketPath, queries: getQueryCount() }); + res.json({ ok: true, cdpConnected, ownerId: puppet?.owner, queries: getQueryCount() }); }); app.listen(PORT, HOST, (error?: Error) => { @@ -136,8 +211,7 @@ app.listen(PORT, HOST, (error?: Error) => { console.error("Failed to start control bridge:", error); process.exit(1); } - console.log(`[control] MCP listening on http://${HOST}:${PORT}/mcp`); - console.log(`[control] Vesktop socket: ${socketPath}`); + console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`); }); process.on("SIGINT", () => process.exit(0)); diff --git a/poc/src/discord.ts b/poc/src/discord.ts new file mode 100644 index 0000000..112c99b --- /dev/null +++ b/poc/src/discord.ts @@ -0,0 +1,159 @@ +/* + * Veskslop — Discord actions executed inside the real client via CDP. + * + * Every expression runs in the Discord page against Vencord's exposed globals, + * so all traffic is performed by the official client. + */ + +import { CdpClient } from "./cdp-client.js"; + +export interface DiscordUser { + id: string; + username: string; +} + +export interface DiscordChannel { + id: string; + name: string; + type: number; +} + +export interface DiscordGuild { + id: string; + name: string; + channels: DiscordChannel[]; +} + +export interface DiscordState { + user: DiscordUser; + guilds: DiscordGuild[]; +} + +const stringify = (value: unknown) => JSON.stringify(value); + +export class Discord { + constructor(private readonly cdp: CdpClient) {} + + /** Install the page-side event pusher (idempotent across reconnects). */ + async installEventBridge(): Promise { + await this.cdp.evaluate(` + (() => { + if (globalThis.__veskslopBridgeInstalled) return "already"; + globalThis.__veskslopBridgeInstalled = true; + + const { FluxDispatcher, ChannelStore } = Vencord.Webpack.Common; + FluxDispatcher.subscribe("MESSAGE_CREATE", event => { + try { + const message = event.message; + if (!message?.author) return; + + veskSlopEmit(JSON.stringify({ + type: "MESSAGE_CREATE", + id: message.id, + channelId: event.channelId, + guildId: event.guildId ?? null, + channelType: ChannelStore.getChannel(event.channelId)?.type ?? null, + author: { + id: message.author.id, + username: message.author.username, + bot: !!message.author.bot + }, + content: message.content ?? "", + mentions: [ + ...(message.mentions ?? []).map(user => user.id), + ...(message.mentionIds ?? []) + ], + timestamp: message.timestamp ?? new Date().toISOString() + })); + } catch { + // never break the client because of the bridge + } + }); + + return "installed"; + })() + `); + } + + async getState(): Promise { + return this.cdp.evaluate(` + (() => { + const { UserStore, GuildStore, ChannelStore } = Vencord.Webpack.Common; + const user = UserStore.getCurrentUser(); + if (!user) return null; + + return { + user: { id: user.id, username: user.username }, + guilds: GuildStore.getGuildsArray().map(guild => ({ + id: guild.id, + name: guild.name, + channels: Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id)).map(channel => ({ + id: channel.id, + name: channel.name, + type: channel.type + })) + })) + }; + })() + `); + } + + async getSelfId(): Promise { + return (await this.getState())?.user.id; + } + + async sendMessage(channelId: string, content: string): Promise { + await this.cdp.evaluate(` + Vencord.Util.sendMessage(${stringify(channelId)}, { content: ${stringify(content)} }, false) + `); + } + + async sendDm(userId: string, content: string): Promise<{ sent: boolean; channelId?: string; error?: string }> { + return this.cdp.evaluate(` + (async () => { + const { ChannelStore } = Vencord.Webpack.Common; + const { openPrivateChannel, sendMessage } = Vencord.Util; + + let channelId = ChannelStore.getDMFromUserId(${stringify(userId)}); + if (!channelId) { + openPrivateChannel(${stringify(userId)}); + for (let i = 0; i < 20 && !channelId; i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + channelId = ChannelStore.getDMFromUserId(${stringify(userId)}); + } + } + + if (!channelId) return { sent: false, error: "No DM channel yet" }; + + await sendMessage(channelId, { content: ${stringify(content)} }, false); + return { sent: true, channelId }; + })() + `); + } + + async readMessages(channelId: string, limit: number) { + return this.cdp.evaluate(` + (() => { + const messages = Vencord.Webpack.Common.MessageStore.getMessages(${stringify(channelId)})?._array ?? []; + return { + cached: messages.length, + messages: messages.slice(${-Math.abs(limit)}).map(message => ({ + id: message.id, + author: { id: message.author.id, username: message.author.username }, + content: message.content, + timestamp: message.timestamp + })) + }; + })() + `); + } + + /** Seed the auth token and reload the client. Used for fresh profiles. */ + async seedToken(token: string): Promise { + await this.cdp.evaluate(` + localStorage.setItem("token", ${stringify(JSON.stringify(token))}); + location.reload(); + "reloading" + `); + } +} diff --git a/poc/src/mock-vesktop.ts b/poc/src/mock-vesktop.ts deleted file mode 100644 index 1e1d6f9..0000000 --- a/poc/src/mock-vesktop.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Veskslop Phase 0 spike — mock Vesktop control socket. - * - * Stands in for the real Vesktop main process so the MCP control bridge can be - * developed and tested without Electron. Implements the JSON-lines protocol. - * - * Usage: - * VESKSLOP_SOCKET=/tmp/veskslop-mock.sock pnpm control:mock - */ - -import { rmSync } from "node:fs"; -import { createServer } from "node:net"; - -import { socketPath } from "./vesktop-socket.js"; - -const FAKE_GUILDS = [ - { - id: "100000000000000001", - name: "Mock Guild", - channels: [ - { id: "200000000000000001", name: "general", type: 0 }, - { id: "200000000000000002", name: "voice", type: 2 } - ] - } -]; - -const FAKE_MESSAGES = [ - { id: "300000000000000001", author: { id: "1", username: "mockuser" }, content: "hello from the mock", timestamp: new Date().toISOString() } -]; - -function handle(command: string | undefined, data: any) { - switch (command) { - case "ping": - return "pong"; - case "state:get": - return { user: { id: "1", username: "mockuser" }, guilds: FAKE_GUILDS }; - case "message:send": - return { channelId: data?.channelId, content: data?.content, sent: true }; - case "message:read": - return { channelId: data?.channelId, cached: FAKE_MESSAGES.length, messages: FAKE_MESSAGES }; - case "dm:send": - return { userId: data?.userId, channelId: `dm-${data?.userId}`, content: data?.content, sent: true }; - default: - throw new Error(`Unknown command: ${command}`); - } -} - -rmSync(socketPath, { force: true }); - -const server = createServer(socket => { - socket.setEncoding("utf-8"); - let buffer = ""; - - socket.on("data", (chunk: string) => { - buffer += chunk; - - let newline: number; - while ((newline = buffer.indexOf("\n")) !== -1) { - const line = buffer.slice(0, newline).trim(); - buffer = buffer.slice(newline + 1); - if (!line) continue; - - const start = Date.now(); - try { - const request = JSON.parse(line); - const result = handle(request.command, request.data); - console.log(`[mock] ${new Date().toISOString()} ${request.command} (${Date.now() - start}ms)`); - socket.write(JSON.stringify({ id: request.id, ok: true, data: result }) + "\n"); - } catch (e) { - const error = e instanceof Error ? e.message : String(e); - console.log(`[mock] ${new Date().toISOString()} ERROR ${error}`); - socket.write(JSON.stringify({ ok: false, error }) + "\n"); - } - } - }); - - socket.on("error", () => socket.destroy()); -}); - -server.listen(socketPath, () => { - console.log(`[mock] listening on ${socketPath}`); -}); - -process.on("SIGINT", () => { - server.close(); - rmSync(socketPath, { force: true }); - process.exit(0); -}); diff --git a/poc/src/puppet.ts b/poc/src/puppet.ts new file mode 100644 index 0000000..9aa42ac --- /dev/null +++ b/poc/src/puppet.ts @@ -0,0 +1,185 @@ +/* + * Veskslop daemon — puppet state: trust list, trigger log and Discord-facing + * command handling. All of this used to live inside the Vesktop renderer; it + * now lives here so Vesktop stays stock. + */ + +import { readFileSync, writeFileSync } from "node:fs"; + +import { + isTrusted, + MessageLog, + parseCommand, + randomActionDelay, + stripMentions, + type ParsedCommand, + type PuppetMessage +} from "./communication.js"; +import type { Discord } from "./discord.js"; + +const DM_CHANNEL = 1; +const GROUP_DM_CHANNEL = 3; + +const DEFAULT_OWNER_ID = "177117296140091393"; +const STATE_FILE = + process.env.VESKSLOP_STATE_FILE ?? new URL("../.veskslop-state.json", import.meta.url).pathname; + +interface PersistedState { + ownerId?: string; + whitelist?: string[]; +} + +export class Puppet { + readonly log = new MessageLog(); + + private ownerId = DEFAULT_OWNER_ID; + private whitelistIds: string[] = []; + private selfId?: string; + + constructor(private readonly discord: Discord) { + try { + const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState; + if (state.ownerId) this.ownerId = state.ownerId; + if (Array.isArray(state.whitelist)) this.whitelistIds = state.whitelist; + } catch { + // first run: defaults apply + } + } + + get whitelist(): string[] { + return [...this.whitelistIds]; + } + + get owner() { + return this.ownerId; + } + + getLog() { + return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist }; + } + + isTrusted(userId: string): boolean { + return isTrusted(userId, this.ownerId, this.whitelistIds); + } + + addToWhitelist(userIds: string[]): string[] { + const next = new Set(this.whitelistIds); + for (const id of userIds) if (id && id !== this.ownerId) next.add(id); + + this.whitelistIds = [...next]; + this.save(); + return this.whitelist; + } + + removeFromWhitelist(userIds: string[]): string[] { + const remove = new Set(userIds); + this.whitelistIds = this.whitelistIds.filter(id => !remove.has(id)); + this.save(); + return this.whitelist; + } + + private save() { + try { + writeFileSync(STATE_FILE, JSON.stringify({ ownerId: this.ownerId, whitelist: this.whitelistIds }, null, 4)); + } catch (error) { + console.error("[puppet] failed to save state:", error); + } + } + + async handleEvent(event: any) { + if (event?.type !== "MESSAGE_CREATE") return; + + const { author, channelId, guildId, channelType, content, mentions } = event; + if (!author || !channelId) return; + if (author.bot) return; + + this.selfId ??= await this.discord.getSelfId(); + if (!this.selfId || author.id === this.selfId) return; + + const isGroupDm = channelType === GROUP_DM_CHANNEL; + const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null)); + + const mentionIds = new Set(mentions ?? []); + if (!isDirectDm && !mentionIds.has(this.selfId)) return; + + const trusted = this.isTrusted(author.id); + const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId])); + const command = parseCommand(text); + + const entry: PuppetMessage = { + id: event.id, + channelId, + guildId: guildId ?? undefined, + authorId: author.id, + authorName: author.username, + content: content ?? "", + timestamp: event.timestamp ?? new Date().toISOString(), + trusted, + command: command ? `${command.name}${command.args.length ? " " + command.args.join(" ") : ""}` : undefined + }; + + this.log.add(entry); + + if (command?.name === "whitelist" && trusted) { + await this.handleWhitelistCommand(command, author.id, channelId); + } + } + + private extractUserIds(text: string): string[] { + const ids = new Set(); + for (const match of text.matchAll(/<@!?(\d+)>/g)) ids.add(match[1]); + for (const part of text.split(/\s+/)) if (/^\d{17,20}$/.test(part)) ids.add(part); + return [...ids]; + } + + private async handleWhitelistCommand(command: ParsedCommand, authorId: string, channelId: string) { + if (authorId !== this.ownerId) return; + + const [sub, ...rest] = command.args; + + switch (sub) { + case "list": + await this.reply( + channelId, + `Trusted users:\n${[this.ownerId, ...this.whitelistIds].join("\n")}` + ); + return; + + case "add": { + const ids = this.extractUserIds(rest.join(" ")); + if (!ids.length) return this.reply(channelId, "Usage: whitelist add @user"); + this.addToWhitelist(ids); + return this.reply(channelId, `Added to whitelist:\n${ids.join("\n")}`); + } + + case "remove": { + const ids = this.extractUserIds(rest.join(" ")); + if (!ids.length) return this.reply(channelId, "Usage: whitelist remove @user"); + const removed = ids.filter(id => id !== this.ownerId); + this.removeFromWhitelist(removed); + return this.reply( + channelId, + [ + removed.length ? `Removed from whitelist:\n${removed.join("\n")}` : "Nothing removed.", + ids.length !== removed.length ? "The owner cannot be removed." : "" + ] + .filter(Boolean) + .join("\n") + ); + } + + default: + return this.reply(channelId, "Usage: whitelist add|remove|list"); + } + } + + private async reply(channelId: string, content: string) { + // Autonomous replies use the same 500-2800 ms pacing as everything else. + await new Promise(resolve => setTimeout(resolve, randomActionDelay())); + try { + await this.discord.sendMessage(channelId, content); + } catch (error) { + console.error("[puppet] reply failed:", error); + } + } +} diff --git a/poc/src/test-communication.ts b/poc/src/test-communication.ts new file mode 100644 index 0000000..539069a --- /dev/null +++ b/poc/src/test-communication.ts @@ -0,0 +1,75 @@ +/* + * Veskslop — unit tests for the pure communication logic. + * + * Usage: pnpm test:comm + */ + +import assert from "node:assert/strict"; + +import { + MessageLog, + TRUSTED_LOG_LIMIT, + UNTRUSTED_LOG_LIMIT, + extractMentionIds, + isTrusted, + parseCommand, + randomActionDelay, + stripMentions, + type PuppetMessage +} from "./communication.js"; + +function entry(id: number, trusted: boolean): PuppetMessage { + return { + id: String(id), + channelId: "c", + authorId: trusted ? "owner" : "stranger", + authorName: "tester", + content: `msg ${id}`, + timestamp: new Date().toISOString(), + trusted + }; +} + +// ring buffer limits +{ + const log = new MessageLog(); + for (let i = 0; i < 150; i++) log.add(entry(i, true)); + assert.equal(log.trusted.length, TRUSTED_LOG_LIMIT); + assert.equal(log.trusted[0].id, "50"); + + for (let i = 0; i < 40; i++) log.add(entry(i, false)); + assert.equal(log.untrusted.length, UNTRUSTED_LOG_LIMIT); + assert.equal(log.untrusted[0].id, "10"); +} + +// mentions +{ + const ids = extractMentionIds("<@!42> hi <@7>", [{ id: "9" }]); + assert.deepEqual([...ids].sort(), ["42", "7", "9"]); + assert.equal(stripMentions("<@!42> whitelist list", new Set(["42"])), "whitelist list"); + assert.equal(stripMentions("<@42> <@99> hey", new Set(["42"])), "<@99> hey"); +} + +// commands +{ + assert.deepEqual(parseCommand(" WHITELIST add <@123> "), { name: "whitelist", args: ["add", "<@123>"] }); + assert.deepEqual(parseCommand("whitelist list"), { name: "whitelist", args: ["list"] }); + assert.equal(parseCommand(" "), undefined); +} + +// trust +{ + assert.equal(isTrusted("owner", "owner", []), true); + assert.equal(isTrusted("trusted", "owner", ["trusted"]), true); + assert.equal(isTrusted("stranger", "owner", ["trusted"]), false); +} + +// pacing stays in the 500-2800 ms window +{ + for (let i = 0; i < 200; i++) { + const delay = randomActionDelay(); + assert.ok(delay >= 500 && delay <= 2800, `delay out of range: ${delay}`); + } +} + +console.log("all communication tests passed"); diff --git a/poc/src/test-dm.ts b/poc/src/test-dm.ts index 23a48f9..eb93c4f 100644 --- a/poc/src/test-dm.ts +++ b/poc/src/test-dm.ts @@ -1,17 +1,15 @@ /* - * Veskslop Phase 0 — live DM send test through a running Vesktop instance. + * Veskslop — live DM send test through a running client. * - * Sanctioned test target: user zzib. Defaults to their user ID. - * This talks to the control socket directly (which must be a real Vesktop, - * not the mock) and paces the command like everything else. + * Sanctioned test target: user zzib (defaults to their user ID). * * Usage: * pnpm poc:dm * TEST_DM_USER_ID=... TEST_DM_CONTENT="hello" pnpm poc:dm */ -import { paced } from "./pacing.js"; -import { sendVesktopCommand, socketPath } from "./vesktop-socket.js"; +import { CdpClient } from "./cdp-client.js"; +import { Discord } from "./discord.js"; /** zzib — the only sanctioned live-send target. */ const DEFAULT_TEST_DM_USER_ID = "177117296140091393"; @@ -19,11 +17,10 @@ const DEFAULT_TEST_DM_USER_ID = "177117296140091393"; const userId = process.env.TEST_DM_USER_ID ?? DEFAULT_TEST_DM_USER_ID; const content = process.env.TEST_DM_CONTENT ?? "veskslop test dm"; -if (!userId) { - console.error("TEST_DM_USER_ID is required (sanctioned test target: zzib)"); - process.exit(1); -} +const cdp = await CdpClient.connect(); +const discord = new Discord(cdp); -console.log(`[dm] socket: ${socketPath}`); -const result = await paced(() => sendVesktopCommand("dm:send", { userId, content })); -console.log("[dm]", result); +console.log("[dm] connected via CDP"); +console.log("[dm]", await discord.sendDm(userId, content)); + +cdp.close(); diff --git a/poc/src/vesktop-socket.ts b/poc/src/vesktop-socket.ts deleted file mode 100644 index 7e5d8a9..0000000 --- a/poc/src/vesktop-socket.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Veskslop Phase 0 spike — JSON-lines client for Vesktop's control socket. - * - * The Vesktop main process listens on vesktop.sock and forwards commands to - * the renderer, which performs them using official client interactions. - */ - -import net from "node:net"; - -export const socketPath = - process.env.VESKSLOP_SOCKET ?? - (process.env.XDG_RUNTIME_DIR ? `${process.env.XDG_RUNTIME_DIR}/vesktop.sock` : "/tmp/vesktop.sock"); - -interface SocketResponse { - 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( - command: string, - data?: unknown, - timeoutMs = 30_000 -): Promise { - const id = nextId++; - - return new Promise((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; - 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))); - }); -} diff --git a/src/main/constants.ts b/src/main/constants.ts index 20c891c..458e11b 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -18,15 +18,10 @@ export const PORTABLE = !existsSync(join(vesktopDir, "Uninstall Vesktop.exe")); export const DATA_DIR = - process.env.VENCORD_USER_DATA_DIR || - process.env.VESKSLOP_DATA_DIR || - (PORTABLE ? join(vesktopDir, "Data") : join(app.getPath("appData"), "veskslop")); + process.env.VENCORD_USER_DATA_DIR || (PORTABLE ? join(vesktopDir, "Data") : join(app.getPath("userData"))); mkdirSync(DATA_DIR, { recursive: true }); -/** Durable auth credential. Seeded into the client before it boots. */ -export const TOKEN_FILE = process.env.VESKSLOP_TOKEN_FILE || join(DATA_DIR, "token"); - export const SESSION_DATA_DIR = join(DATA_DIR, "sessionData"); app.setPath("sessionData", SESSION_DATA_DIR); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index fd52136..aee11f3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -26,7 +26,6 @@ import { join } from "path"; import { IpcEvents } from "../shared/IpcEvents"; import { setBadgeCount } from "./appBadge"; import { autoStart } from "./autoStart"; -import { TOKEN_FILE } from "./constants"; import { enableHardwareAcceleration } from "./main"; import { mainWin } from "./mainWindow"; import { Settings, State } from "./settings"; @@ -61,17 +60,6 @@ handleSync(IpcEvents.GET_SETTINGS, () => Settings.plain); handleSync(IpcEvents.GET_VERSION, () => app.getVersion()); handleSync(IpcEvents.GET_ENABLE_HARDWARE_ACCELERATION, () => enableHardwareAcceleration); -handleSync(IpcEvents.VESKSLOP_GET_TOKEN, () => { - let token: string | undefined; - try { - token = readFileSync(TOKEN_FILE, "utf8").trim() || undefined; - } catch { - // No token file: the client falls back to its normal login. - } - - return { token, force: process.env.VESKSLOP_FORCE_TOKEN === "1" }; -}); - handleSync( IpcEvents.SUPPORTS_WINDOWS_TRANSPARENCY, () => process.platform === "win32" && Number(release().split(".").pop()) >= 22621 diff --git a/src/main/ipcSocket.ts b/src/main/ipcSocket.ts deleted file mode 100644 index e0d69b5..0000000 --- a/src/main/ipcSocket.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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 = { - "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 }); -}); diff --git a/src/main/main.ts b/src/main/main.ts index 0a31ba6..079bfc2 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,7 +14,6 @@ import { app, BrowserWindow, nativeTheme } from "electron"; import { DATA_DIR } from "./constants"; import { createFirstLaunchTour } from "./firstLaunch"; -import { initSocket } from "./ipcSocket"; import { createWindows, mainWin } from "./mainWindow"; import { registerMediaPermissionsHandler } from "./mediaPermissions"; import { registerScreenShareHandler } from "./screenShare"; @@ -106,7 +105,6 @@ function init() { registerScreenShareHandler(); registerMediaPermissionsHandler(); - initSocket(); bootstrap(); @@ -129,10 +127,10 @@ if (!app.requestSingleInstanceLock({ IS_DEV })) { } async function bootstrap() { - if (process.env.VESKSLOP_HEADLESS === "1" || Object.hasOwn(State.store, "firstLaunch")) { - createWindows(); - } else { + if (!Object.hasOwn(State.store, "firstLaunch")) { createFirstLaunchTour(); + } else { + createWindows(); } } diff --git a/src/preload/index.ts b/src/preload/index.ts index c2db704..6ed74f9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,20 +9,6 @@ import { contextBridge, ipcRenderer, webFrame } from "electron/renderer"; import { IpcEvents } from "../shared/IpcEvents"; import { VesktopNative } from "./VesktopNative"; -// Veskslop: seed the Discord auth token before the client boots, unless a session already exists. -try { - const seed = ipcRenderer.sendSync(IpcEvents.VESKSLOP_GET_TOKEN) as - | { token?: string; force?: boolean } - | undefined; - - if (seed?.token && (seed.force || !localStorage.getItem("token"))) { - localStorage.setItem("token", JSON.stringify(seed.token)); - console.log("[veskslop] seeded auth token"); - } -} catch (error) { - console.warn("[veskslop] failed to seed auth token", error); -} - contextBridge.exposeInMainWorld("VesktopNative", VesktopNative); // While sandboxed, Electron "polyfills" these APIs as local variables. diff --git a/src/renderer/ipcCommands.ts b/src/renderer/ipcCommands.ts index 44d7646..4d73f02 100644 --- a/src/renderer/ipcCommands.ts +++ b/src/renderer/ipcCommands.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { openPrivateChannel, sendMessage } from "@vencord/types/utils"; -import { ChannelStore, GuildStore, MessageStore, SettingsRouter, UserStore } from "@vencord/types/webpack/common"; +import { SettingsRouter } from "@vencord/types/webpack/common"; import { IpcCommands } from "shared/IpcEvents"; import { openScreenSharePicker } from "./components/ScreenSharePicker"; @@ -53,68 +52,3 @@ onIpcCommand(IpcCommands.NAVIGATE_SETTINGS, () => { onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages); 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 }, false); - 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 }, false); - return { userId: data.userId, channelId, sent: true }; -}); diff --git a/src/renderer/puppet/actions.ts b/src/renderer/puppet/actions.ts new file mode 100644 index 0000000..6d729d2 --- /dev/null +++ b/src/renderer/puppet/actions.ts @@ -0,0 +1,199 @@ +/* + * Veskslop core actions — the validated capability surface. + * + * Everything the external daemon can ask the client to do lives here. Inputs + * are validated, outbound actions are rate limited, and failures are returned + * as structured errors so a buggy daemon can never crash the client or send + * obviously malformed data to Discord. + */ + +import { openPrivateChannel, sendMessage } from "@vencord/types/utils"; +import { ChannelStore, GuildStore, MessageStore, UserStore } from "@vencord/types/webpack/common"; +import { isSnowflake, PUPPET_LIMITS, PuppetAction, PuppetErrorCode, type PuppetResult } from "shared/puppetProtocol"; + +/** Channel types that can receive text messages. */ +const SENDABLE_CHANNEL_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]); + +class ActionError extends Error { + constructor( + public readonly code: PuppetErrorCode, + message: string + ) { + super(message); + } +} + +function ok(result?: unknown): PuppetResult { + return { ok: true, result }; +} + +function fail(code: PuppetErrorCode, message: string): PuppetResult { + return { ok: false, error: { code, message } }; +} + +function assert(condition: unknown, code: PuppetErrorCode, message: string): asserts condition { + if (!condition) throw new ActionError(code, message); +} + +function validateContent(content: unknown): string { + assert(typeof content === "string", PuppetErrorCode.InvalidParam, "content must be a string"); + + const trimmed = content.trim(); + assert(trimmed.length > 0, PuppetErrorCode.ContentEmpty, "content is empty"); + assert( + trimmed.length <= PUPPET_LIMITS.maxContentLength, + PuppetErrorCode.ContentTooLong, + `content exceeds ${PUPPET_LIMITS.maxContentLength} characters` + ); + // eslint-disable-next-line no-control-regex + assert(!/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(trimmed), PuppetErrorCode.InvalidParam, "content has control characters"); + + return trimmed; +} + +const actionTimestamps: number[] = []; + +/** Hard rate limit on outbound actions; the daemon's pacing is not trusted on its own. */ +function enforceOutboundRateLimit() { + const now = Date.now(); + + while (actionTimestamps.length && now - actionTimestamps[0] > 60_000) actionTimestamps.shift(); + + assert( + actionTimestamps.length < PUPPET_LIMITS.maxActionsPerMinute, + PuppetErrorCode.RateLimited, + "too many outbound actions" + ); + + const last = actionTimestamps.at(-1); + assert( + !last || now - last >= PUPPET_LIMITS.minActionIntervalMs, + PuppetErrorCode.RateLimited, + "outbound actions are too frequent" + ); + + actionTimestamps.push(now); +} + +function getSendableChannel(channelId: string) { + const channel = ChannelStore.getChannel(channelId); + assert(channel, PuppetErrorCode.ChannelNotFound, `channel ${channelId} not found`); + assert( + SENDABLE_CHANNEL_TYPES.has(channel.type), + PuppetErrorCode.NotSendable, + `channel type ${channel.type} cannot receive messages` + ); + return channel; +} + +function buildState() { + const user = UserStore.getCurrentUser(); + if (!user) return null; + + return { + user: { id: user.id, username: user.username }, + guilds: GuildStore.getGuildsArray().map(guild => ({ + id: guild.id, + name: guild.name, + channels: Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id)) + .slice(0, PUPPET_LIMITS.maxChannelsPerGuild) + .map(channel => ({ id: channel.id, name: channel.name, type: channel.type })) + })) + }; +} + +async function send(channelId: string, content: string) { + try { + await sendMessage(channelId, { content }, false); + } catch (error) { + throw new ActionError(PuppetErrorCode.SendFailed, `Discord rejected the message: ${String(error)}`); + } +} + +async function resolveDmChannel(userId: string): Promise { + let channelId = ChannelStore.getDMFromUserId(userId); + if (!channelId) { + openPrivateChannel(userId); + + for (let i = 0; i < 20 && !channelId; i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + channelId = ChannelStore.getDMFromUserId(userId); + } + } + + assert(channelId, PuppetErrorCode.DmUnavailable, `no DM channel for user ${userId} yet`); + return channelId; +} + +export async function handlePuppetAction( + action: string, + params: Record = {} +): Promise { + try { + switch (action) { + case PuppetAction.Ping: + return ok("pong"); + + case PuppetAction.StateGet: { + const state = buildState(); + assert(state, PuppetErrorCode.NotLoggedIn, "no user is logged in"); + return ok(state); + } + + case PuppetAction.MessageSend: { + const { channelId } = params; + assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake"); + + const content = validateContent(params.content); + getSendableChannel(channelId); + enforceOutboundRateLimit(); + + await send(channelId, content); + return ok({ channelId, sent: true }); + } + + case PuppetAction.MessageRead: { + const { channelId, limit = 20 } = params; + assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake"); + assert( + Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= PUPPET_LIMITS.maxReadLimit, + PuppetErrorCode.InvalidParam, + `limit must be an integer between 1 and ${PUPPET_LIMITS.maxReadLimit}` + ); + + assert(ChannelStore.getChannel(channelId), PuppetErrorCode.ChannelNotFound, `channel ${channelId} not found`); + const messages = MessageStore.getMessages(channelId)?._array ?? []; + + return ok({ + cached: messages.length, + messages: messages.slice(-Number(limit)).map(message => ({ + id: message.id, + author: { id: message.author.id, username: message.author.username }, + content: message.content, + timestamp: message.timestamp + })) + }); + } + + case PuppetAction.DmSend: { + const { userId } = params; + assert(isSnowflake(userId), PuppetErrorCode.InvalidSnowflake, "userId must be a snowflake"); + + const content = validateContent(params.content); + enforceOutboundRateLimit(); + + const channelId = await resolveDmChannel(userId); + await send(channelId, content); + return ok({ userId, channelId, sent: true }); + } + + default: + return fail(PuppetErrorCode.UnknownAction, `unknown action: ${action}`); + } + } catch (error) { + if (error instanceof ActionError) return fail(error.code, error.message); + + console.error("[puppet] action failed:", action, error); + return fail(PuppetErrorCode.Internal, "action failed"); + } +} diff --git a/src/shared/IpcEvents.ts b/src/shared/IpcEvents.ts index f9440c4..55efe80 100644 --- a/src/shared/IpcEvents.ts +++ b/src/shared/IpcEvents.ts @@ -25,8 +25,6 @@ export const enum IpcEvents { GET_SETTINGS = "VCD_GET_SETTINGS", SET_SETTINGS = "VCD_SET_SETTINGS", - VESKSLOP_GET_TOKEN = "VCD_VESKSLOP_GET_TOKEN", - IS_USING_CUSTOM_VENCORD_DIR = "VCD_IS_USING_CUSTOM_VENCORD_DIR", SHOW_CUSTOM_VENCORD_DIR = "VCD_SHOW_CUSTOM_VENCORD_DIR", SELECT_VENCORD_DIR = "VCD_SELECT_VENCORD_DIR", @@ -85,11 +83,5 @@ export const enum IpcCommands { GET_LANGUAGES = "navigator.languages", - SCREEN_SHARE_PICKER = "screenshare:picker", - - /* Veskslop puppet commands */ - STATE_GET = "state:get", - MESSAGE_SEND = "message:send", - MESSAGE_READ = "message:read", - DM_SEND = "dm:send" + SCREEN_SHARE_PICKER = "screenshare:picker" } diff --git a/src/shared/puppetProtocol.ts b/src/shared/puppetProtocol.ts new file mode 100644 index 0000000..245e6e0 --- /dev/null +++ b/src/shared/puppetProtocol.ts @@ -0,0 +1,83 @@ +/* + * Vesktop ⇄ Veskslop daemon protocol. + * + * This is the only surface the external daemon can reach. Every action is + * validated by the core before anything touches Discord, and every failure is + * returned as a structured error instead of throwing. + */ + +export const enum PuppetAction { + Ping = "ping", + EventsSubscribe = "events.subscribe", + StateGet = "state.get", + MessageSend = "message.send", + MessageRead = "message.read", + DmSend = "dm.send" +} + +export const PuppetActions: readonly PuppetAction[] = [ + PuppetAction.Ping, + PuppetAction.EventsSubscribe, + PuppetAction.StateGet, + PuppetAction.MessageSend, + PuppetAction.MessageRead, + PuppetAction.DmSend +]; + +export const enum PuppetErrorCode { + InvalidRequest = "INVALID_REQUEST", + PayloadTooLarge = "PAYLOAD_TOO_LARGE", + UnknownAction = "UNKNOWN_ACTION", + CoreUnavailable = "CORE_UNAVAILABLE", + NotLoggedIn = "NOT_LOGGED_IN", + InvalidSnowflake = "INVALID_SNOWFLAKE", + InvalidParam = "INVALID_PARAM", + ContentEmpty = "CONTENT_EMPTY", + ContentTooLong = "CONTENT_TOO_LONG", + ChannelNotFound = "CHANNEL_NOT_FOUND", + NotSendable = "NOT_SENDABLE", + SendFailed = "SEND_FAILED", + DmUnavailable = "DM_UNAVAILABLE", + RateLimited = "RATE_LIMITED", + Internal = "INTERNAL" +} + +export interface PuppetError { + code: PuppetErrorCode | string; + message: string; +} + +export interface PuppetRequest { + id?: string | number; + action: string; + params?: Record; +} + +export type PuppetResult = + | { ok: true; result?: unknown } + | { ok: false; error: PuppetError }; + +export type PuppetResponse = + | { id?: string | number; ok: true; result?: unknown } + | { id?: string | number; ok: false; error: PuppetError }; + +export interface PuppetEvent { + type: string; + [key: string]: unknown; +} + +/** Hard safety limits applied core-side, regardless of what the daemon asks for. */ +export const PUPPET_LIMITS = { + maxContentLength: 2000, + maxReadLimit: 100, + minActionIntervalMs: 500, + maxActionsPerMinute: 30, + maxRequestBytes: 64 * 1024, + maxChannelsPerGuild: 1000 +}; + +const SNOWFLAKE_RE = /^\d{17,20}$/; + +export function isSnowflake(value: unknown): value is string { + return typeof value === "string" && SNOWFLAKE_RE.test(value); +}