Rework core integration: validated capability layer, docs, and session state

This commit is contained in:
Fisto
2026-09-11 14:09:04 +02:00
parent 2a15f057df
commit e62d3007d8
33 changed files with 1897 additions and 612 deletions
+51 -112
View File
@@ -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 `<DATA_DIR>/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
`<appData>/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
```
+46
View File
@@ -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/`
+13
View File
@@ -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
<div align="center">
<br />
+23
View File
@@ -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 |
+74
View File
@@ -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.
+63
View File
@@ -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 `<appData>/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 `<DATA_DIR>/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.
+86
View File
@@ -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`.
+129
View File
@@ -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.
+93
View File
@@ -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).
+81
View File
@@ -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
`<appData>/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.
+101
View File
@@ -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 `<DATA_DIR>/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. |
+1
View File
@@ -3,3 +3,4 @@ node_modules/
.env
.token
.secrets/
.veskslop-state.json
+36 -83
View File
@@ -1,7 +1,8 @@
# Veskslop Phase 0 spikes
# Veskslop daemon
Timeboxed proof-of-concept scripts, not production code. Project direction lives in
[../AGENTS.md](../AGENTS.md).
The external daemon and the development spikes. The daemon holds all logic and exposes MCP.
Read the project docs first: [../docs/INDEX.md](../docs/INDEX.md).
## Setup
@@ -9,98 +10,50 @@ Timeboxed proof-of-concept scripts, not production code. Project direction lives
pnpm install
```
## Current query policy
## Run commands
- Every command sent to the client is spaced by a random **500–2800 ms** and serialized through one
queue (`src/pacing.ts`).
- Identical concurrent reads are coalesced (`dedupe`), and state reads are cache-only.
- `DEBUG=1` logs every command and its delay.
| Command | Purpose |
| --- | --- |
| `pnpm poc:qr` | QR login. Writes the token file. |
| `pnpm control:bridge` | Start the daemon. It listens on `http://127.0.0.1:8788/mcp`. |
| `pnpm poc:dm` | Send one test DM to zzib. |
| `pnpm test:comm` | Run the unit tests for the pure logic. |
| `pnpm cdp:probe` | Check CDP access and event delivery. |
| `pnpm check` | Type check the daemon code. |
## Spikes
## Transport
### 0. QR login / token acquisition — `src/qr-login.ts`
The daemon uses Chrome DevTools Protocol at this time. Start the client with
`--remote-debugging-port=9222`. The daemon connects to the Discord page and runs the actions in the
real client.
```sh
pnpm poc:qr
TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr
```
The target transport is the validated core bridge. See
[../docs/core-bridge.md](../docs/core-bridge.md). The daemon logic is the same on both transports.
Connects to `wss://remote-auth-gateway.discord.gg/?v=2`, renders the approval QR directly in the
terminal, waits for a phone scan, exchanges the login ticket and writes the token to
`poc/.token` (mode 600, gitignored; override with `TOKEN_FILE`). This is the **puppet account's**
token. No existing token required.
If Discord responds with an hCaptcha challenge, the script prints the challenge and prompts for a
`HCAPTCHA_KEY` (or takes it from the environment).
## Pacing
The headless client consumes this file on startup via `VESKSLOP_TOKEN_FILE` (default
`<DATA_DIR>/token`, see [../AGENTS.md](../AGENTS.md) → Auth persistence).
Every command gets a random delay of 500 to 2800 ms. One queue serializes the commands. The queue
coalesces identical concurrent reads. State reads use the cache. `DEBUG=1` logs each command and
its delay.
### 1. Vesktop control bridge — `src/control-bridge.ts`
## Live test target
The MCP daemon. Exposes Streamable HTTP and forwards tool calls over the JSON-lines protocol on
Vesktop's control socket (default `$XDG_RUNTIME_DIR/vesktop.sock`, override with `VESKSLOP_SOCKET`).
```sh
pnpm control:bridge # http://127.0.0.1:8788/mcp
VESKSLOP_SOCKET=... MCP_PORT=... pnpm control:bridge
```
Tools: `get_state` (cache-only), `send_message`, `read_messages` (coalesced), `send_dm`.
Vesktop side lives in this repo:
- `src/main/ipcSocket.ts` — parses JSON lines `{ id, command, data }` and forwards them through
`sendRendererCommand`.
- `src/renderer/ipcCommands.ts` — handlers that use official client interactions (`sendMessage`,
`openPrivateChannel`, guild/channel/message stores).
Protocol commands: `ping`, `state:get`, `message:send`, `message:read`, `dm:send`.
### 2. Mock Vesktop — `src/mock-vesktop.ts`
```sh
VESKSLOP_SOCKET=/tmp/veskslop-mock.sock pnpm control:mock
```
Stands in for the real Vesktop process so the bridge can be developed without Electron/Xvfb.
### 3. Live DM test — `src/test-dm.ts`
```sh
pnpm poc:dm
TEST_DM_CONTENT="hello" pnpm poc:dm
```
Talks to a **real** running Vesktop's control socket and sends one paced DM. Defaults to the only
sanctioned live-send target, user **zzib** (`TEST_DM_USER_ID` overrides).
The only permitted live-send target is user zzib. See
[../docs/testing.md](../docs/testing.md) for the full setup.
## Verified
- QR login works end-to-end; the token in `.token` came from a phone scan.
- MCP bridge path: `initialize`, `tools/list`, `get_state`, `send_message`, `read_messages`,
`send_dm` all round-trip correctly through the mock.
- **Live end-to-end (2026-09-11)**: a real Vesktop client was run headless under Xvfb with the
token seeded from `~/.config/veskslop/token`. `get_state` returned the real account and 7 guilds,
and `send_dm` delivered a real DM to zzib (`sent: true`). Cache-only `read_messages` on that
never-opened DM returned zero messages, as expected.
- Pacing observed live: consecutive commands reached the mock **592 ms**, **1862 ms** and
**1439 ms** apart — inside the 500–2800 ms window.
## Deferred (not testable in this container)
- Real renderer handlers against a live Discord session (needs Electron + display).
- Headless display strategy (Xvfb/offscreen) and client lightening.
- Token injection into the official client.
- Voice join/leave and audio/video pipelines (need a voice channel; ffmpeg/PipeWire absent).
- Remote client auth over LAN/Tailscale and the web debug UI.
- QR login works end-to-end. The token came from a phone scan.
- The CDP transport works. `get_state` returned the real account and 7 guilds. `send_dm` delivered
a real DM to zzib.
- Pacing stayed inside the 500 to 2800 ms window.
## Findings
- Official `discord.js` is bot-only (`/gateway/bot` → 401 with a user token), and
`discord.js-selfbot-v13` is archived — do not build on either. The core is the real Vesktop
client, puppeted through its own interactions.
- MCP SDK `1.30.0` stateless Streamable HTTP works as used here.
- Vesktop's existing `sendRendererCommand`/`onIpcCommand` bus is enough to puppet interactions; the
socket only needed a JSON-lines framing and a command map.
- pnpm 11: this folder is its own workspace root; build approvals live in `pnpm-workspace.yaml`
- Official `discord.js` is bot-only. It fails at login with a user token. The popular selfbot fork
is archived. Do not use either. The core is the real Vesktop client.
- MCP SDK `1.30.0` Streamable HTTP works as used here.
- CDP gives control and events with no Vesktop patch. Raw CDP has no guardrails. The validated core
bridge is the target for this reason.
- pnpm 11: this folder is its own workspace root. Build approvals live in `pnpm-workspace.yaml`
(`allowBuilds`).
+2 -1
View File
@@ -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"
},
+102
View File
@@ -0,0 +1,102 @@
/*
* Veskslop — minimal Chrome DevTools Protocol client.
*
* Connects to the Discord page target exposed by Vesktop's
* --remote-debugging-port, evaluates expressions in the page, and receives
* pushed events through a CDP binding. This is the entire integration surface:
* Vesktop itself is unmodified.
*/
import WebSocket from "ws";
export type CdpListener = (event: any) => void;
interface PendingRequest {
resolve: (value: any) => void;
reject: (error: Error) => void;
}
export class CdpClient {
private ws!: WebSocket;
private nextId = 0;
private readonly pending = new Map<number, PendingRequest>();
private readonly listeners = new Set<CdpListener>();
static async connect(cdpUrl = process.env.CDP_URL ?? "http://127.0.0.1:9222"): Promise<CdpClient> {
const targets = (await (await fetch(`${cdpUrl}/json`)).json()) as any[];
const page = targets.find(target => target.type === "page" && target.url.includes("discord.com"));
if (!page) throw new Error(`No Discord page target at ${cdpUrl}`);
const client = new CdpClient();
client.ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise<void>((resolve, reject) => {
client.ws.once("open", resolve);
client.ws.once("error", reject);
});
client.ws.on("message", (raw: WebSocket.RawData) => client.onMessage(raw));
await client.send("Runtime.enable");
await client.send("Runtime.addBinding", { name: "veskslopEmit" });
return client;
}
private onMessage(raw: WebSocket.RawData) {
const message = JSON.parse(raw.toString());
if (message.id && this.pending.has(message.id)) {
const request = this.pending.get(message.id)!;
this.pending.delete(message.id);
if (message.error) request.reject(new Error(message.error.message));
else request.resolve(message.result);
return;
}
if (message.method === "Runtime.bindingCalled" && message.params?.name === "veskslopEmit") {
try {
const event = JSON.parse(message.params.payload);
for (const listener of this.listeners) listener(event);
} catch {
// ignore malformed payloads
}
}
}
send(method: string, params?: unknown): Promise<any> {
const id = ++this.nextId;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
});
}
async evaluate<T = unknown>(expression: string): Promise<T> {
const result = await this.send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: true
});
if (result.exceptionDetails) {
const description =
result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "evaluation failed";
throw new Error(description);
}
return result.result?.value as T;
}
onEvent(listener: CdpListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
close() {
this.ws.close();
}
}
+84
View File
@@ -0,0 +1,84 @@
/*
* Veskslop — Chrome DevTools Protocol feasibility probe.
*
* Verifies that an external process can, with zero Vesktop patches:
* - reach the Discord page target
* - evaluate against Vencord/Discord internals
* - subscribe to Flux events and receive them via a CDP binding
*
* Usage: start Vesktop with --remote-debugging-port=9222, then: pnpm cdp:probe
*/
import WebSocket from "ws";
const CDP = process.env.CDP_URL ?? "http://127.0.0.1:9222";
const targets = (await (await fetch(`${CDP}/json`)).json()) as any[];
const page = targets.find(target => target.type === "page" && target.url.includes("discord.com"));
if (!page) {
console.error("No Discord page target. Targets:", targets.map(t => `${t.type} ${t.url}`).join("\n"));
process.exit(1);
}
console.log("target:", page.url);
const ws = new WebSocket(page.webSocketDebuggerUrl);
let nextId = 0;
const pending = new Map<number, (message: any) => void>();
ws.on("message", raw => {
const message = JSON.parse(raw.toString());
if (message.id && pending.has(message.id)) {
pending.get(message.id)!(message);
pending.delete(message.id);
return;
}
if (message.method === "Runtime.bindingCalled") {
console.log("BINDING EVENT:", message.params.name, "->", message.params.payload);
}
});
function send(method: string, params?: unknown): Promise<any> {
const id = ++nextId;
return new Promise(resolve => {
pending.set(id, resolve);
ws.send(JSON.stringify({ id, method, params }));
});
}
await new Promise<void>(resolve => ws.once("open", () => resolve()));
await send("Runtime.enable");
async function evaluate(expression: string): Promise<any> {
const response = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
if (response.result?.exceptionDetails) {
return { error: response.result.exceptionDetails.text };
}
return response.result?.result?.value;
}
console.log("Vencord global:", await evaluate("typeof Vencord"));
console.log("FluxDispatcher.subscribe:", await evaluate("typeof Vencord?.Webpack?.Common?.FluxDispatcher?.subscribe"));
console.log("sendMessage util:", await evaluate("typeof Vencord?.Util?.sendMessage"));
console.log("current user:", await evaluate("Vencord?.Webpack?.Common?.UserStore?.getCurrentUser?.()?.username ?? null"));
await send("Runtime.addBinding", { name: "veskslopEmit" });
await evaluate(`veskslopEmit("probe-ok")`);
await evaluate(`
Vencord.Webpack.Common.FluxDispatcher.subscribe("MESSAGE_CREATE", event => {
window.veskslopEmit(JSON.stringify({ type: "MESSAGE_CREATE", channelId: event.channelId, guildId: event.guildId ?? null }));
});
"subscribed"
`);
console.log("waiting 5s for binding events...");
await new Promise(resolve => setTimeout(resolve, 5000));
ws.close();
console.log("probe done");
+83
View File
@@ -0,0 +1,83 @@
/*
* Veskslop communication backend — pure logic (no Discord imports).
*
* Keeps a small ring buffer of trigger-qualifying messages: the last 100 from
* trusted users and the last 30 from untrusted ones. Also parses the
* `whitelist` commands and normalizes @mentions.
*/
export interface PuppetMessage {
id: string;
channelId: string;
guildId?: string;
authorId: string;
authorName: string;
/** Raw message content as received. */
content: string;
timestamp: string;
trusted: boolean;
/** Parsed command text when the message is one. */
command?: string;
}
export const TRUSTED_LOG_LIMIT = 100;
export const UNTRUSTED_LOG_LIMIT = 30;
export class MessageLog {
trusted: PuppetMessage[] = [];
untrusted: PuppetMessage[] = [];
add(message: PuppetMessage) {
const bucket = message.trusted ? this.trusted : this.untrusted;
const limit = message.trusted ? TRUSTED_LOG_LIMIT : UNTRUSTED_LOG_LIMIT;
bucket.push(message);
if (bucket.length > limit) bucket.splice(0, bucket.length - limit);
}
snapshot() {
return { trusted: [...this.trusted], untrusted: [...this.untrusted] };
}
}
const USER_MENTION = /<@!?(\d+)>/g;
export function extractMentionIds(content: string, mentions?: Array<{ id: string }>): Set<string> {
const ids = new Set<string>();
for (const mention of mentions ?? []) ids.add(mention.id);
for (const match of content.matchAll(USER_MENTION)) ids.add(match[1]);
return ids;
}
export function stripMentions(content: string, ids: ReadonlySet<string>): string {
return content
.replace(USER_MENTION, (full, id) => (ids.has(id) ? "" : full))
.replace(/\s+/g, " ")
.trim();
}
export interface ParsedCommand {
name: string;
args: string[];
}
export function parseCommand(text: string): ParsedCommand | undefined {
const trimmed = text.trim();
if (!trimmed) return undefined;
const [name, ...args] = trimmed.split(/\s+/);
return { name: name.toLowerCase(), args };
}
export function isTrusted(
userId: string,
ownerId: string | undefined,
whitelist: readonly string[] | undefined
): boolean {
return userId === ownerId || !!whitelist?.includes(userId);
}
/** Same 500-2800 ms pacing used by the control bridge, for autonomous replies. */
export function randomActionDelay(): number {
return 500 + Math.floor(Math.random() * 2301);
}
+112 -38
View File
@@ -1,14 +1,13 @@
/*
* Veskslop Phase 0 spike — MCP control bridge.
* Veskslop daemon — MCP control bridge over Chrome DevTools Protocol.
*
* Exposes MCP over Streamable HTTP and forwards tool calls to the Vesktop
* control socket, where the real client performs them. Every command is paced
* by the shared queue (300-800 ms), reads are coalesced, and state reads are
* cache-only.
* Talks to an unmodified Vesktop through --remote-debugging-port, evaluates
* actions against the real client, receives events through a CDP binding, and
* runs the trigger/whitelist/log logic locally.
*
* Usage:
* pnpm control:bridge
* VESKSLOP_SOCKET=/tmp/veskslop-mock.sock MCP_PORT=8788 pnpm control:bridge
* VESKSLOP_TOKEN_FILE=... pnpm control:bridge
* CDP_URL=http://127.0.0.1:9222 MCP_PORT=8788 pnpm control:bridge
*/
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
@@ -16,18 +15,54 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import * as z from "zod/v4";
import { dedupe, getQueryCount, paced } from "./pacing.js";
import { sendVesktopCommand, socketPath } from "./vesktop-socket.js";
import { CdpClient } from "./cdp-client.js";
import { Discord } from "./discord.js";
import { dedupe, getQueryCount, paced, sleep } from "./pacing.js";
import { Puppet } from "./puppet.js";
const HOST = process.env.MCP_HOST ?? "127.0.0.1";
const PORT = Number(process.env.MCP_PORT ?? 8788);
function jsonText(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
let discord: Discord | undefined;
let puppet: Puppet | undefined;
let cdpConnected = false;
async function connectLoop() {
for (;;) {
try {
const cdp = await CdpClient.connect();
discord = new Discord(cdp);
puppet = new Puppet(discord);
await discord.installEventBridge();
cdp.onEvent(event => {
puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error));
});
cdpConnected = true;
console.log("[daemon] connected to Vesktop via CDP");
return;
} catch (error) {
console.warn(`[daemon] waiting for Vesktop CDP: ${(error as Error).message}`);
await sleep(2000);
}
}
}
function command<T>(name: string, data?: unknown): Promise<T> {
return paced(() => sendVesktopCommand<T>(name, data));
function requireDiscord(): Discord {
if (!cdpConnected || !discord) throw new Error("Not connected to Vesktop (run it with --remote-debugging-port)");
return discord;
}
function requirePuppet(): Puppet {
if (!puppet) throw new Error("Puppet not connected yet");
return puppet;
}
function jsonText(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
}
function buildServer(): McpServer {
@@ -38,7 +73,7 @@ function buildServer(): McpServer {
{
description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries."
},
async () => jsonText(await command("state:get"))
async () => jsonText(await requireDiscord().getState())
);
server.registerTool(
@@ -51,25 +86,8 @@ function buildServer(): McpServer {
}
},
async ({ channelId, content }) => {
const result = await command<Record<string, unknown>>("message:send", { channelId, content });
return jsonText({ ...result, queriesUsed: getQueryCount() });
}
);
server.registerTool(
"read_messages",
{
description: "Read messages from the client's cache. Identical concurrent reads are coalesced.",
inputSchema: {
channelId: z.string().describe("Target channel ID"),
limit: z.number().int().min(1).max(100).default(20).describe("How many messages")
}
},
async ({ channelId, limit }) => {
const result = await dedupe(`read:${channelId}:${limit}`, () =>
command<{ messages: unknown[]; cached: number; channelId: string }>("message:read", { channelId, limit })
);
return jsonText({ ...result, queriesUsed: getQueryCount() });
await paced(() => requireDiscord().sendMessage(channelId, content));
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
}
);
@@ -83,14 +101,71 @@ function buildServer(): McpServer {
}
},
async ({ userId, content }) => {
const result = await command<Record<string, unknown>>("dm:send", { userId, content });
return jsonText({ ...result, queriesUsed: getQueryCount() });
const result = await paced(() => requireDiscord().sendDm(userId, content));
return jsonText({ ...result, userId, queriesUsed: getQueryCount() });
}
);
server.registerTool(
"read_messages",
{
description: "Read messages from the client's cache. Identical concurrent reads are coalesced.",
inputSchema: {
channelId: z.string().describe("Target channel ID"),
limit: z.number().int().min(1).max(100).default(20).describe("How many messages")
}
},
async ({ channelId, limit }) => {
const result = await dedupe<{ cached: number; messages: unknown[] }>(`read:${channelId}:${limit}`, () =>
paced(() => requireDiscord().readMessages(channelId, limit))
);
return jsonText({ ...result, channelId, queriesUsed: getQueryCount() });
}
);
server.registerTool(
"get_message_log",
{
description: "Read the trigger log: last 100 messages from trusted users, last 30 from untrusted."
},
async () => jsonText(requirePuppet().getLog())
);
server.registerTool(
"whitelist_list",
{
description: "List the puppet owner and trusted (whitelisted) user IDs."
},
async () => jsonText({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist })
);
server.registerTool(
"whitelist_add",
{
description: "Add users to the puppet whitelist.",
inputSchema: {
userIds: z.array(z.string()).min(1).describe("User IDs to trust")
}
},
async ({ userIds }) => jsonText({ whitelist: requirePuppet().addToWhitelist(userIds) })
);
server.registerTool(
"whitelist_remove",
{
description: "Remove users from the puppet whitelist. The owner cannot be removed.",
inputSchema: {
userIds: z.array(z.string()).min(1).describe("User IDs to untrust")
}
},
async ({ userIds }) => jsonText({ whitelist: requirePuppet().removeFromWhitelist(userIds) })
);
return server;
}
void connectLoop();
const app = createMcpExpressApp({ host: HOST });
app.post("/mcp", async (req, res) => {
@@ -128,7 +203,7 @@ app.delete("/mcp", (_req, res) => {
});
app.get("/health", (_req, res) => {
res.json({ ok: true, socketPath, queries: getQueryCount() });
res.json({ ok: true, cdpConnected, ownerId: puppet?.owner, queries: getQueryCount() });
});
app.listen(PORT, HOST, (error?: Error) => {
@@ -136,8 +211,7 @@ app.listen(PORT, HOST, (error?: Error) => {
console.error("Failed to start control bridge:", error);
process.exit(1);
}
console.log(`[control] MCP listening on http://${HOST}:${PORT}/mcp`);
console.log(`[control] Vesktop socket: ${socketPath}`);
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
});
process.on("SIGINT", () => process.exit(0));
+159
View File
@@ -0,0 +1,159 @@
/*
* Veskslop — Discord actions executed inside the real client via CDP.
*
* Every expression runs in the Discord page against Vencord's exposed globals,
* so all traffic is performed by the official client.
*/
import { CdpClient } from "./cdp-client.js";
export interface DiscordUser {
id: string;
username: string;
}
export interface DiscordChannel {
id: string;
name: string;
type: number;
}
export interface DiscordGuild {
id: string;
name: string;
channels: DiscordChannel[];
}
export interface DiscordState {
user: DiscordUser;
guilds: DiscordGuild[];
}
const stringify = (value: unknown) => JSON.stringify(value);
export class Discord {
constructor(private readonly cdp: CdpClient) {}
/** Install the page-side event pusher (idempotent across reconnects). */
async installEventBridge(): Promise<void> {
await this.cdp.evaluate(`
(() => {
if (globalThis.__veskslopBridgeInstalled) return "already";
globalThis.__veskslopBridgeInstalled = true;
const { FluxDispatcher, ChannelStore } = Vencord.Webpack.Common;
FluxDispatcher.subscribe("MESSAGE_CREATE", event => {
try {
const message = event.message;
if (!message?.author) return;
veskSlopEmit(JSON.stringify({
type: "MESSAGE_CREATE",
id: message.id,
channelId: event.channelId,
guildId: event.guildId ?? null,
channelType: ChannelStore.getChannel(event.channelId)?.type ?? null,
author: {
id: message.author.id,
username: message.author.username,
bot: !!message.author.bot
},
content: message.content ?? "",
mentions: [
...(message.mentions ?? []).map(user => user.id),
...(message.mentionIds ?? [])
],
timestamp: message.timestamp ?? new Date().toISOString()
}));
} catch {
// never break the client because of the bridge
}
});
return "installed";
})()
`);
}
async getState(): Promise<DiscordState | null> {
return this.cdp.evaluate<DiscordState | null>(`
(() => {
const { UserStore, GuildStore, ChannelStore } = Vencord.Webpack.Common;
const user = UserStore.getCurrentUser();
if (!user) return null;
return {
user: { id: user.id, username: user.username },
guilds: GuildStore.getGuildsArray().map(guild => ({
id: guild.id,
name: guild.name,
channels: Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id)).map(channel => ({
id: channel.id,
name: channel.name,
type: channel.type
}))
}))
};
})()
`);
}
async getSelfId(): Promise<string | undefined> {
return (await this.getState())?.user.id;
}
async sendMessage(channelId: string, content: string): Promise<void> {
await this.cdp.evaluate(`
Vencord.Util.sendMessage(${stringify(channelId)}, { content: ${stringify(content)} }, false)
`);
}
async sendDm(userId: string, content: string): Promise<{ sent: boolean; channelId?: string; error?: string }> {
return this.cdp.evaluate(`
(async () => {
const { ChannelStore } = Vencord.Webpack.Common;
const { openPrivateChannel, sendMessage } = Vencord.Util;
let channelId = ChannelStore.getDMFromUserId(${stringify(userId)});
if (!channelId) {
openPrivateChannel(${stringify(userId)});
for (let i = 0; i < 20 && !channelId; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
channelId = ChannelStore.getDMFromUserId(${stringify(userId)});
}
}
if (!channelId) return { sent: false, error: "No DM channel yet" };
await sendMessage(channelId, { content: ${stringify(content)} }, false);
return { sent: true, channelId };
})()
`);
}
async readMessages(channelId: string, limit: number) {
return this.cdp.evaluate(`
(() => {
const messages = Vencord.Webpack.Common.MessageStore.getMessages(${stringify(channelId)})?._array ?? [];
return {
cached: messages.length,
messages: messages.slice(${-Math.abs(limit)}).map(message => ({
id: message.id,
author: { id: message.author.id, username: message.author.username },
content: message.content,
timestamp: message.timestamp
}))
};
})()
`);
}
/** Seed the auth token and reload the client. Used for fresh profiles. */
async seedToken(token: string): Promise<void> {
await this.cdp.evaluate(`
localStorage.setItem("token", ${stringify(JSON.stringify(token))});
location.reload();
"reloading"
`);
}
}
-88
View File
@@ -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);
});
+185
View File
@@ -0,0 +1,185 @@
/*
* Veskslop daemon — puppet state: trust list, trigger log and Discord-facing
* command handling. All of this used to live inside the Vesktop renderer; it
* now lives here so Vesktop stays stock.
*/
import { readFileSync, writeFileSync } from "node:fs";
import {
isTrusted,
MessageLog,
parseCommand,
randomActionDelay,
stripMentions,
type ParsedCommand,
type PuppetMessage
} from "./communication.js";
import type { Discord } from "./discord.js";
const DM_CHANNEL = 1;
const GROUP_DM_CHANNEL = 3;
const DEFAULT_OWNER_ID = "177117296140091393";
const STATE_FILE =
process.env.VESKSLOP_STATE_FILE ?? new URL("../.veskslop-state.json", import.meta.url).pathname;
interface PersistedState {
ownerId?: string;
whitelist?: string[];
}
export class Puppet {
readonly log = new MessageLog();
private ownerId = DEFAULT_OWNER_ID;
private whitelistIds: string[] = [];
private selfId?: string;
constructor(private readonly discord: Discord) {
try {
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
if (state.ownerId) this.ownerId = state.ownerId;
if (Array.isArray(state.whitelist)) this.whitelistIds = state.whitelist;
} catch {
// first run: defaults apply
}
}
get whitelist(): string[] {
return [...this.whitelistIds];
}
get owner() {
return this.ownerId;
}
getLog() {
return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist };
}
isTrusted(userId: string): boolean {
return isTrusted(userId, this.ownerId, this.whitelistIds);
}
addToWhitelist(userIds: string[]): string[] {
const next = new Set(this.whitelistIds);
for (const id of userIds) if (id && id !== this.ownerId) next.add(id);
this.whitelistIds = [...next];
this.save();
return this.whitelist;
}
removeFromWhitelist(userIds: string[]): string[] {
const remove = new Set(userIds);
this.whitelistIds = this.whitelistIds.filter(id => !remove.has(id));
this.save();
return this.whitelist;
}
private save() {
try {
writeFileSync(STATE_FILE, JSON.stringify({ ownerId: this.ownerId, whitelist: this.whitelistIds }, null, 4));
} catch (error) {
console.error("[puppet] failed to save state:", error);
}
}
async handleEvent(event: any) {
if (event?.type !== "MESSAGE_CREATE") return;
const { author, channelId, guildId, channelType, content, mentions } = event;
if (!author || !channelId) return;
if (author.bot) return;
this.selfId ??= await this.discord.getSelfId();
if (!this.selfId || author.id === this.selfId) return;
const isGroupDm = channelType === GROUP_DM_CHANNEL;
const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null));
const mentionIds = new Set<string>(mentions ?? []);
if (!isDirectDm && !mentionIds.has(this.selfId)) return;
const trusted = this.isTrusted(author.id);
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
const command = parseCommand(text);
const entry: PuppetMessage = {
id: event.id,
channelId,
guildId: guildId ?? undefined,
authorId: author.id,
authorName: author.username,
content: content ?? "",
timestamp: event.timestamp ?? new Date().toISOString(),
trusted,
command: command ? `${command.name}${command.args.length ? " " + command.args.join(" ") : ""}` : undefined
};
this.log.add(entry);
if (command?.name === "whitelist" && trusted) {
await this.handleWhitelistCommand(command, author.id, channelId);
}
}
private extractUserIds(text: string): string[] {
const ids = new Set<string>();
for (const match of text.matchAll(/<@!?(\d+)>/g)) ids.add(match[1]);
for (const part of text.split(/\s+/)) if (/^\d{17,20}$/.test(part)) ids.add(part);
return [...ids];
}
private async handleWhitelistCommand(command: ParsedCommand, authorId: string, channelId: string) {
if (authorId !== this.ownerId) return;
const [sub, ...rest] = command.args;
switch (sub) {
case "list":
await this.reply(
channelId,
`Trusted users:\n${[this.ownerId, ...this.whitelistIds].join("\n")}`
);
return;
case "add": {
const ids = this.extractUserIds(rest.join(" "));
if (!ids.length) return this.reply(channelId, "Usage: whitelist add @user");
this.addToWhitelist(ids);
return this.reply(channelId, `Added to whitelist:\n${ids.join("\n")}`);
}
case "remove": {
const ids = this.extractUserIds(rest.join(" "));
if (!ids.length) return this.reply(channelId, "Usage: whitelist remove @user");
const removed = ids.filter(id => id !== this.ownerId);
this.removeFromWhitelist(removed);
return this.reply(
channelId,
[
removed.length ? `Removed from whitelist:\n${removed.join("\n")}` : "Nothing removed.",
ids.length !== removed.length ? "The owner cannot be removed." : ""
]
.filter(Boolean)
.join("\n")
);
}
default:
return this.reply(channelId, "Usage: whitelist add|remove|list");
}
}
private async reply(channelId: string, content: string) {
// Autonomous replies use the same 500-2800 ms pacing as everything else.
await new Promise(resolve => setTimeout(resolve, randomActionDelay()));
try {
await this.discord.sendMessage(channelId, content);
} catch (error) {
console.error("[puppet] reply failed:", error);
}
}
}
+75
View File
@@ -0,0 +1,75 @@
/*
* Veskslop — unit tests for the pure communication logic.
*
* Usage: pnpm test:comm
*/
import assert from "node:assert/strict";
import {
MessageLog,
TRUSTED_LOG_LIMIT,
UNTRUSTED_LOG_LIMIT,
extractMentionIds,
isTrusted,
parseCommand,
randomActionDelay,
stripMentions,
type PuppetMessage
} from "./communication.js";
function entry(id: number, trusted: boolean): PuppetMessage {
return {
id: String(id),
channelId: "c",
authorId: trusted ? "owner" : "stranger",
authorName: "tester",
content: `msg ${id}`,
timestamp: new Date().toISOString(),
trusted
};
}
// ring buffer limits
{
const log = new MessageLog();
for (let i = 0; i < 150; i++) log.add(entry(i, true));
assert.equal(log.trusted.length, TRUSTED_LOG_LIMIT);
assert.equal(log.trusted[0].id, "50");
for (let i = 0; i < 40; i++) log.add(entry(i, false));
assert.equal(log.untrusted.length, UNTRUSTED_LOG_LIMIT);
assert.equal(log.untrusted[0].id, "10");
}
// mentions
{
const ids = extractMentionIds("<@!42> hi <@7>", [{ id: "9" }]);
assert.deepEqual([...ids].sort(), ["42", "7", "9"]);
assert.equal(stripMentions("<@!42> whitelist list", new Set(["42"])), "whitelist list");
assert.equal(stripMentions("<@42> <@99> hey", new Set(["42"])), "<@99> hey");
}
// commands
{
assert.deepEqual(parseCommand(" WHITELIST add <@123> "), { name: "whitelist", args: ["add", "<@123>"] });
assert.deepEqual(parseCommand("whitelist list"), { name: "whitelist", args: ["list"] });
assert.equal(parseCommand(" "), undefined);
}
// trust
{
assert.equal(isTrusted("owner", "owner", []), true);
assert.equal(isTrusted("trusted", "owner", ["trusted"]), true);
assert.equal(isTrusted("stranger", "owner", ["trusted"]), false);
}
// pacing stays in the 500-2800 ms window
{
for (let i = 0; i < 200; i++) {
const delay = randomActionDelay();
assert.ok(delay >= 500 && delay <= 2800, `delay out of range: ${delay}`);
}
}
console.log("all communication tests passed");
+10 -13
View File
@@ -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();
-74
View File
@@ -1,74 +0,0 @@
/*
* Veskslop Phase 0 spike — JSON-lines client for Vesktop's control socket.
*
* The Vesktop main process listens on vesktop.sock and forwards commands to
* the renderer, which performs them using official client interactions.
*/
import net from "node:net";
export const socketPath =
process.env.VESKSLOP_SOCKET ??
(process.env.XDG_RUNTIME_DIR ? `${process.env.XDG_RUNTIME_DIR}/vesktop.sock` : "/tmp/vesktop.sock");
interface SocketResponse<T> {
id?: number;
ok: boolean;
data?: T;
error?: string;
}
let nextId = 1;
/** Send one command and resolve with its result. Throws on error/timeout. */
export function sendVesktopCommand<T = unknown>(
command: string,
data?: unknown,
timeoutMs = 30_000
): Promise<T> {
const id = nextId++;
return new Promise<T>((resolve, reject) => {
const socket = net.connect(socketPath);
socket.setEncoding("utf-8");
let buffer = "";
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
fn();
};
const timer = setTimeout(() => finish(() => reject(new Error(`Command timed out: ${command}`))), timeoutMs);
socket.on("connect", () => {
socket.write(JSON.stringify({ id, command, data }) + "\n");
});
socket.on("data", (chunk: string) => {
buffer += chunk;
const newline = buffer.indexOf("\n");
if (newline === -1) return;
const line = buffer.slice(0, newline);
let response: SocketResponse<T>;
try {
response = JSON.parse(line);
} catch {
return finish(() => reject(new Error(`Invalid response: ${line}`)));
}
if (response.ok) {
finish(() => resolve(response.data as T));
} else {
finish(() => reject(new Error(response.error ?? `Command failed: ${command}`)));
}
});
socket.on("error", error => finish(() => reject(error)));
});
}
+1 -6
View File
@@ -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);
-12
View File
@@ -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
-90
View File
@@ -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<string, IpcCommands> = {
"state:get": IpcCommands.STATE_GET,
"message:send": IpcCommands.MESSAGE_SEND,
"message:read": IpcCommands.MESSAGE_READ,
"dm:send": IpcCommands.DM_SEND
};
export async function initSocket() {
await rm(SOCKET_PATH, { force: true });
server = createServer(socket => {
socket.setEncoding("utf-8");
let buffer = "";
socket.on("data", (chunk: string) => {
buffer += chunk;
let newline: number;
while ((newline = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (line) void handleLine(socket, line);
}
});
socket.on("error", () => socket.destroy());
});
server.listen(SOCKET_PATH);
server.on("error", err => {
console.error("Vesktop IPC Socket Error:", err);
});
}
async function handleLine(socket: Socket, line: string) {
let request: { id?: string | number; command?: string; data?: unknown };
try {
request = JSON.parse(line);
} catch {
socket.write(JSON.stringify({ ok: false, error: "Invalid JSON" }) + "\n");
return;
}
try {
const data = await dispatch(request.command, request.data);
socket.write(JSON.stringify({ id: request.id, ok: true, data }) + "\n");
} catch (e) {
const error = e instanceof Error ? e.message : String(e);
socket.write(JSON.stringify({ id: request.id, ok: false, error }) + "\n");
}
}
async function dispatch(command: string | undefined, data: unknown) {
if (command === "ping") return "pong";
const ipcCommand = command && COMMANDS[command];
if (!ipcCommand) throw new Error(`Unknown command: ${command}`);
return sendRendererCommand(ipcCommand, data);
}
app.on("will-quit", () => {
server?.close();
rmSync(SOCKET_PATH, { force: true });
});
+3 -5
View File
@@ -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();
}
}
-14
View File
@@ -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.
+1 -67
View File
@@ -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 };
});
+199
View File
@@ -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<string> {
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<string, unknown> = {}
): Promise<PuppetResult> {
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");
}
}
+1 -9
View File
@@ -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"
}
+83
View File
@@ -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<string, unknown>;
}
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);
}