diff --git a/AGENTS.md b/AGENTS.md index 667a3bd..88ed408 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,76 +2,93 @@ ## What this is -Veskslop is a **headless Discord client daemon** built on the Vesktop codebase. It turns one or -more **Discord user accounts** into a controllable service: +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. -- an **MCP server** (Streamable HTTP) lets AI agents drive a "puppet" account; -- a second, read-only account can collect stats/data without acting; -- **audio is piped in and out** over PipeWire; -- **video streams** can be pushed into voice channels; +- 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. **Vesktop is a separate project with separate goals.** This repo shares history with it but not -direction. Do not carry Vesktop UI features here unless the daemon needs them. +direction. ## Decisions (locked unless the user changes them) -- **Core:** TypeScript on Node with pnpm. `discord.js` for gateway/REST, `@discordjs/voice` for voice. +- **Core:** run the actual Vesktop/Discord client (Electron) headless. Do **not** build on + third-party selfbot protocol libraries — the popular ones are archived/deprecated and stale + protocol emulation is a ban risk. +- **Control:** puppet Vesktop's own interactions through its IPC command bus: + `vesktop.sock` (external) → `sendRendererCommand` (main) → renderer handlers that call + Vencord/Discord internals (`sendMessage`, stores, voice actions). +- **MCP:** an external local daemon speaks a JSON-lines protocol over `vesktop.sock` and exposes + Streamable HTTP MCP. It must run without Electron so it is testable standalone. +- **QR login:** our own terminal remote-auth tool (`poc/qr-login.ts`) acquires a user token. + Feeding that token into the headless client is deferred until it can be tested. - **Accounts:** multiple user tokens. Expected setup is one read-only collector account plus one full-control puppet account; design for N identities behind one control surface. -- **MCP transport:** Streamable HTTP only (no stdio/SSE unless asked). - **Network:** bind localhost by default. Remote access is always over LAN/Tailscale — never expose to the public internet. A client will later authenticate to the proxy for remote control. -- **Audio:** PipeWire only. -- **GUI:** optional minimal **web debug UI** if it is cheap to build; otherwise keep the existing - Vesktop Electron build runnable for debugging. The daemon must never require Electron. -- **Pacing/safety:** actions emulate a human using the Vesktop client through a queue: - - first action is snappy (~30–80 ms delay); - - subsequent bulk actions get randomized delays (~500–2800 ms); - - a **risky fast mode** is opt-in and minimizes delays; - - default capabilities are clamped to free-account limits (upload size, stream resolution, etc.); - - individual abilities can be toggled off. +- **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. ## How to proceed -**Phase 0 — PoC, do this first.** Find the smallest end-to-end thing that works, then stop and -report back so the user can choose a direction. Candidate spikes, timeboxed: +**Current milestone — puppeting bridge (testable without Electron).** -1. `discord.js` login + list guilds + send a message from a throwaway test account. -2. Minimal MCP Streamable HTTP server exposing `send_message` / `read_messages`, wired to #1. -3. Join a voice channel and play a local file via `@discordjs/voice` (PipeWire output). +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. -Prefer whichever is quickest to prove; do not build production architecture before the user picks. - -**After regrouping:** headless daemon skeleton → multi-account session manager → pacing queue → -MCP tool surface → audio pipelines → video pipeline → remote proxy/auth client → web debug UI. +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 core ──▶ Discord (user tokens) -remote web client ─────auth over LAN─────▶ │ - ├─ action queue (human-emulation pacing) - ├─ capability policy + limits + audit log - ├─ audio pipeline (PipeWire) - └─ video pipeline +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** are the only sanctioned real-send test target. - Run `pnpm testTypes` and `pnpm lint` before calling work done; `pnpm build` to compile. ## Open questions -- Which Phase 0 spike becomes the real foundation? (user decides after the spikes) -- Voice E2EE (DAVE) support status in the current voice stack — research before committing. -- Video pipeline feasibility: required codecs/containers and how to feed synthetic frames. +- Headless Electron strategy: Xvfb vs offscreen rendering, and what to strip to lighten the client. +- Token injection path for the official client (localStorage seeding vs client QR UI). +- 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. diff --git a/poc/.gitignore b/poc/.gitignore new file mode 100644 index 0000000..97c6885 --- /dev/null +++ b/poc/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.log +.env +.token +.secrets/ diff --git a/poc/README.md b/poc/README.md new file mode 100644 index 0000000..f5ded70 --- /dev/null +++ b/poc/README.md @@ -0,0 +1,97 @@ +# Veskslop Phase 0 spikes + +Timeboxed proof-of-concept scripts, not production code. Project direction lives in +[../AGENTS.md](../AGENTS.md). + +## Setup + +```sh +pnpm install +``` + +## Current query policy + +- Every command sent to the client is spaced by a random **500–2800 ms** and serialized through one + queue (`src/pacing.ts`). +- Identical concurrent reads are coalesced (`dedupe`), and state reads are cache-only. +- `DEBUG=1` logs every command and its delay. + +## Spikes + +### 0. QR login / token acquisition — `src/qr-login.ts` + +```sh +pnpm poc:qr +TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr +``` + +Connects to `wss://remote-auth-gateway.discord.gg/?v=2`, renders the approval QR directly in the +terminal, waits for a phone scan, exchanges the login ticket and writes the token to +`poc/.token` (mode 600, gitignored; override with `TOKEN_FILE`). No existing token required. +If Discord responds with an hCaptcha challenge, the script prints the challenge and prompts for a +`HCAPTCHA_KEY` (or takes it from the environment). + +### 1. Vesktop control bridge — `src/control-bridge.ts` + +The MCP daemon. Exposes Streamable HTTP and forwards tool calls over the JSON-lines protocol on +Vesktop's control socket (default `$XDG_RUNTIME_DIR/vesktop.sock`, override with `VESKSLOP_SOCKET`). + +```sh +pnpm control:bridge # http://127.0.0.1:8788/mcp +VESKSLOP_SOCKET=... MCP_PORT=... pnpm control:bridge +``` + +Tools: `get_state` (cache-only), `send_message`, `read_messages` (coalesced), `send_dm`. + +Vesktop side lives in this repo: + +- `src/main/ipcSocket.ts` — parses JSON lines `{ id, command, data }` and forwards them through + `sendRendererCommand`. +- `src/renderer/ipcCommands.ts` — handlers that use official client interactions (`sendMessage`, + `openPrivateChannel`, guild/channel/message stores). + +Protocol commands: `ping`, `state:get`, `message:send`, `message:read`, `dm:send`. + +### 2. Mock Vesktop — `src/mock-vesktop.ts` + +```sh +VESKSLOP_SOCKET=/tmp/veskslop-mock.sock pnpm control:mock +``` + +Stands in for the real Vesktop process so the bridge can be developed without Electron/Xvfb. + +### 3. Live DM test — `src/test-dm.ts` + +```sh +TEST_DM_USER_ID=... pnpm poc:dm +``` + +Talks to a **real** running Vesktop's control socket and sends one paced DM. The only sanctioned +live-send target is user **zzib** (provide their user ID). + +## Verified + +- QR login works end-to-end; the token in `.token` came from a phone scan. +- MCP bridge path: `initialize`, `tools/list`, `get_state`, `send_message`, `read_messages`, + `send_dm` all round-trip correctly through the mock. +- Pacing observed live: consecutive commands reached the mock **592 ms**, **1862 ms** and + **1439 ms** apart — inside the 500–2800 ms window. + +## Deferred (not testable in this container) + +- Real renderer handlers against a live Discord session (needs Electron + display). +- Headless display strategy (Xvfb/offscreen) and client lightening. +- Token injection into the official client. +- Voice join/leave and audio/video pipelines (need a voice channel; ffmpeg/PipeWire absent). +- Remote client auth over LAN/Tailscale and the web debug UI. + +## Findings + +- Official `discord.js` is bot-only (`/gateway/bot` → 401 with a user token), and + `discord.js-selfbot-v13` is archived — do not build on either. The core is the real Vesktop + client, puppeted through its own interactions. +- MCP SDK `1.30.0` stateless Streamable HTTP works as used here. +- Vesktop's existing `sendRendererCommand`/`onIpcCommand` bus is enough to puppet interactions; the + socket only needed a JSON-lines framing and a command map. +- pnpm 11: this folder is its own workspace root; build approvals live in `pnpm-workspace.yaml` + (`allowBuilds`). diff --git a/poc/package.json b/poc/package.json new file mode 100644 index 0000000..c7a00bd --- /dev/null +++ b/poc/package.json @@ -0,0 +1,29 @@ +{ + "name": "veskslop-poc", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Timeboxed Phase 0 spikes for Veskslop. Not production code.", + "scripts": { + "poc:qr": "tsx src/qr-login.ts", + "poc:dm": "tsx src/test-dm.ts", + "control:mock": "tsx src/mock-vesktop.ts", + "control:bridge": "tsx src/control-bridge.ts", + "check": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "express": "^5.2.1", + "qrcode-terminal": "^0.12.0", + "ws": "^8.19.0", + "zod": "^4.6.2" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^22.19.0", + "@types/qrcode-terminal": "^0.12.2", + "@types/ws": "^8.18.1", + "tsx": "^4.23.12", + "typescript": "^5.9.3" + } +} diff --git a/poc/pnpm-lock.yaml b/poc/pnpm-lock.yaml new file mode 100644 index 0000000..6aa71f1 --- /dev/null +++ b/poc/pnpm-lock.yaml @@ -0,0 +1,1219 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(zod@4.6.2) + express: + specifier: ^5.2.1 + version: 5.2.1 + qrcode-terminal: + specifier: ^0.12.0 + version: 0.12.0 + ws: + specifier: ^8.19.0 + version: 8.21.3 + zod: + specifier: ^4.6.2 + version: 4.6.2 + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^22.19.0 + version: 22.20.2 + '@types/qrcode-terminal': + specifier: ^0.12.2 + version: 0.12.2 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + tsx: + specifier: ^4.23.12 + version: 4.23.13 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/node@22.20.2': + resolution: {integrity: sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==} + + '@types/qrcode-terminal@0.12.2': + resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.7.0: + resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qrcode-terminal@0.12.0: + resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} + hasBin: true + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.2: + resolution: {integrity: sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@hono/node-server@2.1.1(hono@4.13.7)': + dependencies: + hono: 4.13.7 + + '@modelcontextprotocol/sdk@1.30.0(zod@4.6.2)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.7) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.7.0(express@5.2.1) + hono: 4.13.7 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.2 + zod-to-json-schema: 3.25.2(zod@4.6.2) + transitivePeerDependencies: + - supports-color + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.20.2 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.20.2 + + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 22.20.2 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/node@22.20.2': + dependencies: + undici-types: 6.21.0 + + '@types/qrcode-terminal@0.12.2': {} + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.20.2 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.20.2 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.2 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + express-rate-limit@8.7.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.7.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.7: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.13.7: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.0: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.12: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pkce-challenge@5.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qrcode-terminal@0.12.0: {} + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + ws@8.21.3: {} + + zod-to-json-schema@3.25.2(zod@4.6.2): + dependencies: + zod: 4.6.2 + + zod@4.6.2: {} diff --git a/poc/pnpm-workspace.yaml b/poc/pnpm-workspace.yaml new file mode 100644 index 0000000..d9d896e --- /dev/null +++ b/poc/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +minimumReleaseAgeExclude: + - zod@4.6.2 +allowBuilds: + esbuild: true diff --git a/poc/src/config.ts b/poc/src/config.ts new file mode 100644 index 0000000..44d1ee9 --- /dev/null +++ b/poc/src/config.ts @@ -0,0 +1,4 @@ +import { fileURLToPath } from "node:url"; + +/** Where QR login writes the token; overridable with TOKEN_FILE. */ +export const tokenFile = process.env.TOKEN_FILE ?? fileURLToPath(new URL("../.token", import.meta.url)); diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts new file mode 100644 index 0000000..149ecfb --- /dev/null +++ b/poc/src/control-bridge.ts @@ -0,0 +1,143 @@ +/* + * Veskslop Phase 0 spike — MCP control bridge. + * + * Exposes MCP over Streamable HTTP and forwards tool calls to the Vesktop + * control socket, where the real client performs them. Every command is paced + * by the shared queue (300-800 ms), reads are coalesced, and state reads are + * cache-only. + * + * Usage: + * pnpm control:bridge + * VESKSLOP_SOCKET=/tmp/veskslop-mock.sock MCP_PORT=8788 pnpm control:bridge + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import * as z from "zod/v4"; + +import { dedupe, getQueryCount, paced } from "./pacing.js"; +import { sendVesktopCommand, socketPath } from "./vesktop-socket.js"; + +const HOST = process.env.MCP_HOST ?? "127.0.0.1"; +const PORT = Number(process.env.MCP_PORT ?? 8788); + +function jsonText(value: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; +} + +function command(name: string, data?: unknown): Promise { + return paced(() => sendVesktopCommand(name, data)); +} + +function buildServer(): McpServer { + const server = new McpServer({ name: "veskslop-control", version: "0.0.0" }); + + server.registerTool( + "get_state", + { + description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries." + }, + async () => jsonText(await command("state:get")) + ); + + server.registerTool( + "send_message", + { + description: "Send a message as the logged-in user through the real client. Query-paced.", + inputSchema: { + channelId: z.string().describe("Target channel ID"), + content: z.string().min(1).describe("Message content") + } + }, + async ({ channelId, content }) => { + const result = await command>("message:send", { channelId, content }); + return jsonText({ ...result, queriesUsed: getQueryCount() }); + } + ); + + server.registerTool( + "read_messages", + { + description: "Read messages from the client's cache. Identical concurrent reads are coalesced.", + inputSchema: { + channelId: z.string().describe("Target channel ID"), + limit: z.number().int().min(1).max(100).default(20).describe("How many messages") + } + }, + async ({ channelId, limit }) => { + const result = await dedupe(`read:${channelId}:${limit}`, () => + command<{ messages: unknown[]; cached: number; channelId: string }>("message:read", { channelId, limit }) + ); + return jsonText({ ...result, queriesUsed: getQueryCount() }); + } + ); + + server.registerTool( + "send_dm", + { + description: "Send a DM as the logged-in user. Sanctioned live-test target: user zzib.", + inputSchema: { + userId: z.string().describe("Recipient user ID"), + content: z.string().min(1).describe("Message content") + } + }, + async ({ userId, content }) => { + const result = await command>("dm:send", { userId, content }); + return jsonText({ ...result, queriesUsed: getQueryCount() }); + } + ); + + return server; +} + +const app = createMcpExpressApp({ host: HOST }); + +app.post("/mcp", async (req, res) => { + const server = buildServer(); + try { + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on("close", () => { + transport.close(); + server.close(); + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null + }); + } + } +}); + +app.get("/mcp", (_req, res) => { + res.writeHead(405).end( + JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null }) + ); +}); + +app.delete("/mcp", (_req, res) => { + res.writeHead(405).end( + JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null }) + ); +}); + +app.get("/health", (_req, res) => { + res.json({ ok: true, socketPath, queries: getQueryCount() }); +}); + +app.listen(PORT, HOST, (error?: Error) => { + if (error) { + console.error("Failed to start control bridge:", error); + process.exit(1); + } + console.log(`[control] MCP listening on http://${HOST}:${PORT}/mcp`); + console.log(`[control] Vesktop socket: ${socketPath}`); +}); + +process.on("SIGINT", () => process.exit(0)); diff --git a/poc/src/mock-vesktop.ts b/poc/src/mock-vesktop.ts new file mode 100644 index 0000000..1e1d6f9 --- /dev/null +++ b/poc/src/mock-vesktop.ts @@ -0,0 +1,88 @@ +/* + * Veskslop Phase 0 spike — mock Vesktop control socket. + * + * Stands in for the real Vesktop main process so the MCP control bridge can be + * developed and tested without Electron. Implements the JSON-lines protocol. + * + * Usage: + * VESKSLOP_SOCKET=/tmp/veskslop-mock.sock pnpm control:mock + */ + +import { rmSync } from "node:fs"; +import { createServer } from "node:net"; + +import { socketPath } from "./vesktop-socket.js"; + +const FAKE_GUILDS = [ + { + id: "100000000000000001", + name: "Mock Guild", + channels: [ + { id: "200000000000000001", name: "general", type: 0 }, + { id: "200000000000000002", name: "voice", type: 2 } + ] + } +]; + +const FAKE_MESSAGES = [ + { id: "300000000000000001", author: { id: "1", username: "mockuser" }, content: "hello from the mock", timestamp: new Date().toISOString() } +]; + +function handle(command: string | undefined, data: any) { + switch (command) { + case "ping": + return "pong"; + case "state:get": + return { user: { id: "1", username: "mockuser" }, guilds: FAKE_GUILDS }; + case "message:send": + return { channelId: data?.channelId, content: data?.content, sent: true }; + case "message:read": + return { channelId: data?.channelId, cached: FAKE_MESSAGES.length, messages: FAKE_MESSAGES }; + case "dm:send": + return { userId: data?.userId, channelId: `dm-${data?.userId}`, content: data?.content, sent: true }; + default: + throw new Error(`Unknown command: ${command}`); + } +} + +rmSync(socketPath, { force: true }); + +const server = createServer(socket => { + socket.setEncoding("utf-8"); + let buffer = ""; + + socket.on("data", (chunk: string) => { + buffer += chunk; + + let newline: number; + while ((newline = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + + const start = Date.now(); + try { + const request = JSON.parse(line); + const result = handle(request.command, request.data); + console.log(`[mock] ${new Date().toISOString()} ${request.command} (${Date.now() - start}ms)`); + socket.write(JSON.stringify({ id: request.id, ok: true, data: result }) + "\n"); + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + console.log(`[mock] ${new Date().toISOString()} ERROR ${error}`); + socket.write(JSON.stringify({ ok: false, error }) + "\n"); + } + } + }); + + socket.on("error", () => socket.destroy()); +}); + +server.listen(socketPath, () => { + console.log(`[mock] listening on ${socketPath}`); +}); + +process.on("SIGINT", () => { + server.close(); + rmSync(socketPath, { force: true }); + process.exit(0); +}); diff --git a/poc/src/pacing.ts b/poc/src/pacing.ts new file mode 100644 index 0000000..e1ebc02 --- /dev/null +++ b/poc/src/pacing.ts @@ -0,0 +1,55 @@ +/* + * Veskslop Phase 0 spike — global query pacing. + * + * Current policy: every command sent to the client is spaced by a random + * 500-2800 ms. Commands are serialized through one queue, and identical + * concurrent queries are coalesced so the total query count stays minimal. + * + * The long-term human-emulation policy (snappy first action, slower bulk + * actions, risky fast mode) lives in AGENTS.md and is not active yet. + */ + +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** Current per-query delay: 500-2800 ms. */ +export function queryDelay(): number { + return 500 + Math.floor(Math.random() * 2301); +} + +let queue: Promise = Promise.resolve(); +let queryCount = 0; + +/** Serialize a REST query behind the queue with the configured delay. */ +export function paced(task: () => Promise): Promise { + const delay = queryDelay(); + const run = queue.then(async () => { + await sleep(delay); + queryCount++; + if (process.env.DEBUG === "1") { + console.error(`[rest] query #${queryCount} (+${delay}ms)`); + } + return task(); + }); + queue = run.then( + () => undefined, + () => undefined + ); + return run; +} + +export function getQueryCount(): number { + return queryCount; +} + +const inFlight = new Map>(); + +/** Coalesce identical concurrent queries into a single request. */ +export function dedupe(key: string, task: () => Promise): Promise { + const existing = inFlight.get(key); + if (existing) return existing as Promise; + const promise = task().finally(() => inFlight.delete(key)); + inFlight.set(key, promise); + return promise; +} diff --git a/poc/src/qr-login.ts b/poc/src/qr-login.ts new file mode 100644 index 0000000..702f17f --- /dev/null +++ b/poc/src/qr-login.ts @@ -0,0 +1,201 @@ +/* + * Veskslop Phase 0 — Discord remote-auth QR login. + * + * Connects to Discord's remote-auth gateway, renders an approval QR in the + * terminal, waits for the phone to approve, and prints/saves the user token. + * + * Usage: + * pnpm poc:qr + * TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr + * HCAPTCHA_KEY=... pnpm poc:qr # only if Discord challenges the ticket exchange + * + * The token file is written with mode 600 and is gitignored. + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import readline from "node:readline"; + +import qrcode from "qrcode-terminal"; +import WebSocket from "ws"; + +import { tokenFile } from "./config.js"; + +const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2"; +const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login"; +const USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"; +const debug = process.env.DEBUG === "1"; + +const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); +const encodedPublicKey = keyPair.publicKey.export({ type: "spki", format: "der" }).toString("base64"); + +function log(...args: unknown[]) { + console.log("[qr]", ...args); +} + +function debugLog(...args: unknown[]) { + if (debug) console.error("[qr:debug]", ...args); +} + +function decryptPayload(payload: string): Buffer { + return crypto.privateDecrypt( + { + key: keyPair.privateKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256" + }, + Buffer.from(payload, "base64") + ); +} + +function promptCaptchaKey(): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question("hCaptcha key (blank to abort): ", answer => { + rl.close(); + resolve(answer.trim() || undefined); + }); + }); +} + +async function exchangeTicket(ticket: string, captchaKey?: string, captchaRqToken?: string): Promise { + const body: Record = { ticket }; + if (captchaKey) { + body.captcha_key = captchaKey; + body.captcha_rqtoken = captchaRqToken; + } + + const response = await fetch(LOGIN_URL, { + method: "POST", + headers: { + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Content-Type": "application/json", + Origin: "https://discord.com", + Referer: "https://discord.com/login", + "User-Agent": USER_AGENT + }, + body: JSON.stringify(body) + }); + const data: any = await response.json(); + debugLog("ticket exchange", response.status, JSON.stringify(data)); + + if (data.encrypted_token) { + return decryptPayload(data.encrypted_token).toString(); + } + + if (data.captcha_sitekey || data.captcha_rqtoken) { + log("Discord wants a captcha to finish this login."); + log(` service: ${data.captcha_service}`); + log(` sitekey: ${data.captcha_sitekey}`); + log(` rqdata: ${data.captcha_rqdata}`); + const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey()); + if (!key) { + log("No captcha key provided, giving up."); + return undefined; + } + return exchangeTicket(ticket, key, data.captcha_rqtoken); + } + + log(`Ticket exchange failed (HTTP ${response.status}):`, JSON.stringify(data)); + return undefined; +} + +log("Connecting to Discord remote-auth gateway..."); +const ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } }); + +let heartbeat: NodeJS.Timeout | undefined; +let finished = false; + +ws.on("open", () => log("Connected. Requesting QR...")); + +ws.on("message", async (raw: WebSocket.RawData) => { + const packet: any = JSON.parse(raw.toString()); + debugLog("<-", packet.op ?? packet); + + switch (packet.op) { + case "hello": { + heartbeat = setInterval( + () => ws.send(JSON.stringify({ op: "heartbeat" })), + packet.heartbeat_interval + ); + ws.send(JSON.stringify({ op: "init", encoded_public_key: encodedPublicKey })); + break; + } + + case "nonce_proof": { + const nonce = decryptPayload(packet.encrypted_nonce); + const proof = crypto.createHash("sha256").update(nonce).digest("base64url"); + ws.send(JSON.stringify({ op: "nonce_proof", proof })); + break; + } + + case "pending_remote_init": { + const url = `https://discord.com/ra/${packet.fingerprint}`; + log("Scan this QR code with the Discord mobile app (you may need the camera/QR scanner):\n"); + qrcode.generate(url, { small: true }); + log(`Or open this link on the phone: ${url}\n`); + break; + } + + case "pending_ticket": { + const payload = decryptPayload(packet.encrypted_user_payload).toString(); + const [id, discriminator, avatar, username] = payload.split(":"); + debugLog("user payload:", payload); + const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username; + log(`Phone approved: ${tag} (${id})${avatar ? " with avatar " + avatar : ""}`); + break; + } + + case "pending_login": { + const token = await exchangeTicket(packet.ticket); + if (!token) { + finished = true; + ws.close(); + process.exitCode = 1; + break; + } + + finished = true; + fs.writeFileSync(tokenFile, token + "\n", { mode: 0o600 }); + log("\n=== token acquired ==="); + console.log(token); + log(`Saved to ${tokenFile} (mode 600).`); + ws.close(); + break; + } + + case "cancel": + log("Login was cancelled on the phone."); + finished = true; + ws.close(); + break; + + case "heartbeat_ack": + break; + + default: + debugLog("unhandled op", packet); + } +}); + +ws.on("close", () => { + if (heartbeat) clearInterval(heartbeat); + done(); +}); + +ws.on("error", error => { + console.error("[qr] gateway error:", error.message); + process.exitCode = 1; +}); + +function done() { + if (!finished) log("Connection closed before login completed."); + process.exit(process.exitCode ?? 0); +} + +process.on("SIGINT", () => { + log("Interrupted."); + ws.close(); +}); diff --git a/poc/src/test-dm.ts b/poc/src/test-dm.ts new file mode 100644 index 0000000..e193738 --- /dev/null +++ b/poc/src/test-dm.ts @@ -0,0 +1,26 @@ +/* + * Veskslop Phase 0 — live DM send test through a running Vesktop instance. + * + * Sanctioned test target: user zzib. Set TEST_DM_USER_ID to their user ID. + * This talks to the control socket directly (which must be a real Vesktop, + * not the mock) and paces the command like everything else. + * + * Usage: + * TEST_DM_USER_ID=... pnpm poc:dm + * TEST_DM_USER_ID=... TEST_DM_CONTENT="hello" pnpm poc:dm + */ + +import { paced } from "./pacing.js"; +import { sendVesktopCommand, socketPath } from "./vesktop-socket.js"; + +const userId = process.env.TEST_DM_USER_ID; +const content = process.env.TEST_DM_CONTENT ?? "veskslop test dm"; + +if (!userId) { + console.error("TEST_DM_USER_ID is required (sanctioned test target: zzib)"); + process.exit(1); +} + +console.log(`[dm] socket: ${socketPath}`); +const result = await paced(() => sendVesktopCommand("dm:send", { userId, content })); +console.log("[dm]", result); diff --git a/poc/src/vesktop-socket.ts b/poc/src/vesktop-socket.ts new file mode 100644 index 0000000..7e5d8a9 --- /dev/null +++ b/poc/src/vesktop-socket.ts @@ -0,0 +1,74 @@ +/* + * Veskslop Phase 0 spike — JSON-lines client for Vesktop's control socket. + * + * The Vesktop main process listens on vesktop.sock and forwards commands to + * the renderer, which performs them using official client interactions. + */ + +import net from "node:net"; + +export const socketPath = + process.env.VESKSLOP_SOCKET ?? + (process.env.XDG_RUNTIME_DIR ? `${process.env.XDG_RUNTIME_DIR}/vesktop.sock` : "/tmp/vesktop.sock"); + +interface SocketResponse { + id?: number; + ok: boolean; + data?: T; + error?: string; +} + +let nextId = 1; + +/** Send one command and resolve with its result. Throws on error/timeout. */ +export function sendVesktopCommand( + command: string, + data?: unknown, + timeoutMs = 30_000 +): Promise { + const id = nextId++; + + return new Promise((resolve, reject) => { + const socket = net.connect(socketPath); + socket.setEncoding("utf-8"); + + let buffer = ""; + let settled = false; + + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + fn(); + }; + + const timer = setTimeout(() => finish(() => reject(new Error(`Command timed out: ${command}`))), timeoutMs); + + socket.on("connect", () => { + socket.write(JSON.stringify({ id, command, data }) + "\n"); + }); + + socket.on("data", (chunk: string) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + + const line = buffer.slice(0, newline); + let response: SocketResponse; + try { + response = JSON.parse(line); + } catch { + return finish(() => reject(new Error(`Invalid response: ${line}`))); + } + + if (response.ok) { + finish(() => resolve(response.data as T)); + } else { + finish(() => reject(new Error(response.error ?? `Command failed: ${command}`))); + } + }); + + socket.on("error", error => finish(() => reject(error))); + }); +} diff --git a/poc/tsconfig.json b/poc/tsconfig.json new file mode 100644 index 0000000..f87becc --- /dev/null +++ b/poc/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/src/main/ipcSocket.ts b/src/main/ipcSocket.ts new file mode 100644 index 0000000..e0d69b5 --- /dev/null +++ b/src/main/ipcSocket.ts @@ -0,0 +1,90 @@ +/* + * Vesktop, a desktop app aiming to give you a snappier Discord Experience + * Copyright (c) 2026 Vendicated and Vesktop contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { app } from "electron"; +import { rmSync } from "fs"; +import { rm } from "fs/promises"; +import { createServer, Server, Socket } from "net"; +import { join } from "path"; +import { IpcCommands } from "shared/IpcEvents"; + +import { sendRendererCommand } from "./ipcCommands"; + +const { env, platform } = process; + +let server: Server; + +const SOCKET_PATH = + platform === "win32" + ? "\\\\?\\pipe\\vesktop.sock" + : join(env.XDG_RUNTIME_DIR || env.TMPDIR || env.TMP || env.TEMP || "/tmp", "vesktop.sock"); + +/** JSON command name -> renderer IpcCommand. Extend here to expose more interactions. */ +const COMMANDS: Record = { + "state:get": IpcCommands.STATE_GET, + "message:send": IpcCommands.MESSAGE_SEND, + "message:read": IpcCommands.MESSAGE_READ, + "dm:send": IpcCommands.DM_SEND +}; + +export async function initSocket() { + await rm(SOCKET_PATH, { force: true }); + + server = createServer(socket => { + socket.setEncoding("utf-8"); + let buffer = ""; + + socket.on("data", (chunk: string) => { + buffer += chunk; + + let newline: number; + while ((newline = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line) void handleLine(socket, line); + } + }); + + socket.on("error", () => socket.destroy()); + }); + + server.listen(SOCKET_PATH); + server.on("error", err => { + console.error("Vesktop IPC Socket Error:", err); + }); +} + +async function handleLine(socket: Socket, line: string) { + let request: { id?: string | number; command?: string; data?: unknown }; + try { + request = JSON.parse(line); + } catch { + socket.write(JSON.stringify({ ok: false, error: "Invalid JSON" }) + "\n"); + return; + } + + try { + const data = await dispatch(request.command, request.data); + socket.write(JSON.stringify({ id: request.id, ok: true, data }) + "\n"); + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + socket.write(JSON.stringify({ id: request.id, ok: false, error }) + "\n"); + } +} + +async function dispatch(command: string | undefined, data: unknown) { + if (command === "ping") return "pong"; + + const ipcCommand = command && COMMANDS[command]; + if (!ipcCommand) throw new Error(`Unknown command: ${command}`); + + return sendRendererCommand(ipcCommand, data); +} + +app.on("will-quit", () => { + server?.close(); + rmSync(SOCKET_PATH, { force: true }); +}); diff --git a/src/main/main.ts b/src/main/main.ts index 079bfc2..163450b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,6 +14,7 @@ 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"; @@ -105,6 +106,7 @@ function init() { registerScreenShareHandler(); registerMediaPermissionsHandler(); + initSocket(); bootstrap(); diff --git a/src/renderer/ipcCommands.ts b/src/renderer/ipcCommands.ts index 4d73f02..dba003f 100644 --- a/src/renderer/ipcCommands.ts +++ b/src/renderer/ipcCommands.ts @@ -4,7 +4,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { SettingsRouter } from "@vencord/types/webpack/common"; +import { openPrivateChannel, sendMessage } from "@vencord/types/utils"; +import { ChannelStore, GuildStore, MessageStore, SettingsRouter, UserStore } from "@vencord/types/webpack/common"; import { IpcCommands } from "shared/IpcEvents"; import { openScreenSharePicker } from "./components/ScreenSharePicker"; @@ -52,3 +53,68 @@ 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 }); + return { channelId: data.channelId, sent: true }; +}); + +onIpcCommand(IpcCommands.MESSAGE_READ, (data: { channelId?: string; limit?: number }) => { + if (!data?.channelId) throw new Error("message:read requires { channelId }"); + + const limit = Math.min(Math.max(data.limit ?? 20, 1), 100); + const messages = MessageStore.getMessages(data.channelId)?._array ?? []; + + return { + channelId: data.channelId, + cached: messages.length, + messages: messages.slice(-limit).map(message => ({ + id: message.id, + author: { id: message.author.id, username: message.author.username }, + content: message.content, + timestamp: message.timestamp + })) + }; +}); + +onIpcCommand(IpcCommands.DM_SEND, async (data: { userId?: string; content?: string }) => { + if (!data?.userId || typeof data.content !== "string") { + throw new Error("dm:send requires { userId, content }"); + } + + let channelId = ChannelStore.getDMFromUserId(data.userId); + if (!channelId) { + openPrivateChannel(data.userId); + + for (let i = 0; i < 20 && !channelId; i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + channelId = ChannelStore.getDMFromUserId(data.userId); + } + } + + if (!channelId) throw new Error(`No DM channel for user ${data.userId} yet`); + + await sendMessage(channelId, { content: data.content }); + return { userId: data.userId, channelId, sent: true }; +}); diff --git a/src/shared/IpcEvents.ts b/src/shared/IpcEvents.ts index 55efe80..35c672f 100644 --- a/src/shared/IpcEvents.ts +++ b/src/shared/IpcEvents.ts @@ -83,5 +83,11 @@ export const enum IpcCommands { GET_LANGUAGES = "navigator.languages", - SCREEN_SHARE_PICKER = "screenshare:picker" + SCREEN_SHARE_PICKER = "screenshare:picker", + + /* Veskslop puppet commands */ + STATE_GET = "state:get", + MESSAGE_SEND = "message:send", + MESSAGE_READ = "message:read", + DM_SEND = "dm:send" }