Rework core integration: validated capability layer, docs, and session state
This commit is contained in:
@@ -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
@@ -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/`
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
@@ -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. |
|
||||
@@ -3,3 +3,4 @@ node_modules/
|
||||
.env
|
||||
.token
|
||||
.secrets/
|
||||
.veskslop-state.json
|
||||
|
||||
+36
-83
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user