Add Nocturne debug web UI, core process control, and web QR login

This commit is contained in:
Fisto
2026-09-13 15:47:47 +02:00
parent 4e1046fae7
commit 2bcd8c6d7a
16 changed files with 4806 additions and 198 deletions
+24 -9
View File
@@ -15,7 +15,7 @@ pnpm install
| Command | Purpose | | Command | Purpose |
| --- | --- | | --- | --- |
| `pnpm poc:qr` | QR login. Writes the token file. | | `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 control:bridge` | Start the daemon. MCP on `http://127.0.0.1:8788/mcp`, web UI on `http://0.0.0.0:8789/`. The web UI can start/stop the client and run QR login. |
| `pnpm control:mock` | Start the mock core. Test the daemon without Electron. | | `pnpm control:mock` | Start the mock core. Test the daemon without Electron. |
| `pnpm test:trigger` | Inject a trigger event into the mock core. | | `pnpm test:trigger` | Inject a trigger event into the mock core. |
| `pnpm poc:dm` | Send one test DM to zzib. | | `pnpm poc:dm` | Send one test DM to zzib. |
@@ -36,9 +36,11 @@ core-side guardrails. Use it for debugging only.
## Pacing ## Pacing
Every command gets a random delay of 500 to 2800 ms. One queue serializes the commands. The queue Outbound sends use a human-emulation policy: the first send of a burst waits 30 to 200 ms, later
coalesces identical concurrent reads. State reads use the cache. `DEBUG=1` logs each command and sends wait 500 to 2800 ms, and a burst ends after a random 5 to 20 seconds of quiet. Read queries
its delay. are serialized with a 100 to 300 ms gap and do not reset the burst. One queue serializes
everything. The queue coalesces identical concurrent reads. State reads use the cache. `DEBUG=1`
logs each command and its delay. See [../docs/daemon.md](../docs/daemon.md).
## Live test target ## Live test target
@@ -50,12 +52,25 @@ The only permitted live-send target is user zzib. See
- QR login works end-to-end. The token came from a phone scan. - QR login works end-to-end. The token came from a phone scan.
- The core bridge works live. `get_state` returned the real account and 7 guilds. `send_dm` reached - The core bridge works live. `get_state` returned the real account and 7 guilds. `send_dm` reached
zzib. A synthetic owner message reached the daemon log with the command parsed. zzib. A synthetic owner message reached the daemon log with the command parsed.
- The mock path works. The daemon handles state, sends, commands, tiers, continuation, polls, - The mock path works. The daemon handles state, sends, commands, tiers, continuation, user search,
context, and the action log. voice state, context, and the action log.
- Pacing stayed inside the 500 to 2800 ms window. - The web UI API and the SSE stream work against the mock core.
- The web UI follows the Nocturne design handoff (`WebUIMockup.zip`). The 7a shell, the
locked/unlocked composer, the console drawer, and the Settings slide-over were verified by
rendering the page headless in Electron under Xvfb.
- Core process control works live: the web UI start spawned the client under auto-Xvfb, the bridge
connected, `state.get` returned the real account and guilds, and stop exited with code 0.
Electron needs `libasound2`; Xvfb is auto-started when `DISPLAY` is unset.
- The QR login session reaches `pending` with a scannable QR against the real gateway, and cancels
cleanly. A full approval was not run in this environment.
- Pacing follows the burst policy: 30-200 ms for the first send, 500-2800 ms afterwards.
Open live item: `reaction.add` and the message ID return are mock-verified only. The first live run Open live items:
must confirm Discord's internal `addReaction` lookup.
- `reaction.add` and the message ID return are mock-verified only. The first live run must confirm
Discord's internal `addReaction` lookup.
- `user.search`, `voice.overview`, and `VOICE_STATE_UPDATE` are mock-verified only.
- Voice join, leave, and move are not implemented.
## Findings ## Findings
Binary file not shown.
+2
View File
@@ -19,6 +19,7 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"express": "^5.2.1", "express": "^5.2.1",
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"ws": "^8.19.0", "ws": "^8.19.0",
"zod": "^4.6.2" "zod": "^4.6.2"
@@ -26,6 +27,7 @@
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^22.19.0", "@types/node": "^22.19.0",
"@types/qrcode": "^1.5.6",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"tsx": "^4.23.12", "tsx": "^4.23.12",
+226
View File
@@ -14,6 +14,9 @@ importers:
express: express:
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.1 version: 5.2.1
qrcode:
specifier: ^1.5.4
version: 1.5.4
qrcode-terminal: qrcode-terminal:
specifier: ^0.12.0 specifier: ^0.12.0
version: 0.12.0 version: 0.12.0
@@ -30,6 +33,9 @@ importers:
'@types/node': '@types/node':
specifier: ^22.19.0 specifier: ^22.19.0
version: 22.20.2 version: 22.20.2
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/qrcode-terminal': '@types/qrcode-terminal':
specifier: ^0.12.2 specifier: ^0.12.2
version: 0.12.2 version: 0.12.2
@@ -238,6 +244,9 @@ packages:
'@types/qrcode-terminal@0.12.2': '@types/qrcode-terminal@0.12.2':
resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/qs@6.15.1': '@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
@@ -268,6 +277,14 @@ packages:
ajv@8.20.0: ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
body-parser@2.3.0: body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -284,6 +301,20 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
content-disposition@1.1.0: content-disposition@1.1.0:
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -321,10 +352,17 @@ packages:
supports-color: supports-color:
optional: true optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
depd@2.0.0: depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -332,6 +370,9 @@ packages:
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
encodeurl@2.0.0: encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -388,6 +429,10 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'} engines: {node: '>= 18.0.0'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
forwarded@0.2.0: forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -404,6 +449,10 @@ packages:
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -447,6 +496,10 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-promise@4.0.0: is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -462,6 +515,10 @@ packages:
json-schema-typed@8.0.2: json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -504,10 +561,26 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
parseurl@1.3.3: parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-key@3.1.1: path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -519,6 +592,10 @@ packages:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'} engines: {node: '>=16.20.0'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
proxy-addr@2.0.7: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -527,6 +604,11 @@ packages:
resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==}
hasBin: true hasBin: true
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.16.0: qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@@ -539,10 +621,17 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2: require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
router@2.2.0: router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -558,6 +647,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -589,6 +681,14 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
toidentifier@1.0.1: toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@@ -618,11 +718,18 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -638,6 +745,17 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
zod-to-json-schema@3.25.2: zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies: peerDependencies:
@@ -782,6 +900,10 @@ snapshots:
'@types/qrcode-terminal@0.12.2': {} '@types/qrcode-terminal@0.12.2': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.20.2
'@types/qs@6.15.1': {} '@types/qs@6.15.1': {}
'@types/range-parser@1.2.7': {} '@types/range-parser@1.2.7': {}
@@ -815,6 +937,12 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
body-parser@2.3.0: body-parser@2.3.0:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@@ -841,6 +969,20 @@ snapshots:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
camelcase@5.3.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
content-disposition@1.1.0: {} content-disposition@1.1.0: {}
content-type@1.0.5: {} content-type@1.0.5: {}
@@ -866,8 +1008,12 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decamelize@1.2.0: {}
depd@2.0.0: {} depd@2.0.0: {}
dijkstrajs@1.0.3: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -876,6 +1022,8 @@ snapshots:
ee-first@1.1.1: {} ee-first@1.1.1: {}
emoji-regex@8.0.0: {}
encodeurl@2.0.0: {} encodeurl@2.0.0: {}
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
@@ -981,6 +1129,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
forwarded@0.2.0: {} forwarded@0.2.0: {}
fresh@2.0.0: {} fresh@2.0.0: {}
@@ -990,6 +1143,8 @@ snapshots:
function-bind@1.1.2: {} function-bind@1.1.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -1036,6 +1191,8 @@ snapshots:
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-fullwidth-code-point@3.0.0: {}
is-promise@4.0.0: {} is-promise@4.0.0: {}
isexe@2.0.0: {} isexe@2.0.0: {}
@@ -1046,6 +1203,10 @@ snapshots:
json-schema-typed@8.0.2: {} json-schema-typed@8.0.2: {}
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
media-typer@1.1.1: {} media-typer@1.1.1: {}
@@ -1076,14 +1237,28 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-try@2.2.0: {}
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
path-to-regexp@8.4.2: {} path-to-regexp@8.4.2: {}
pkce-challenge@5.0.1: {} pkce-challenge@5.0.1: {}
pngjs@5.0.0: {}
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@@ -1091,6 +1266,12 @@ snapshots:
qrcode-terminal@0.12.0: {} qrcode-terminal@0.12.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.16.0: qs@6.16.0:
dependencies: dependencies:
es-define-property: 1.0.1 es-define-property: 1.0.1
@@ -1105,8 +1286,12 @@ snapshots:
iconv-lite: 0.7.3 iconv-lite: 0.7.3
unpipe: 1.0.0 unpipe: 1.0.0
require-directory@2.1.1: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
router@2.2.0: router@2.2.0:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -1144,6 +1329,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
set-blocking@2.0.0: {}
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
shebang-command@2.0.0: shebang-command@2.0.0:
@@ -1182,6 +1369,16 @@ snapshots:
statuses@2.0.2: {} statuses@2.0.2: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
toidentifier@1.0.1: {} toidentifier@1.0.1: {}
tsx@4.23.13: tsx@4.23.13:
@@ -1204,14 +1401,43 @@ snapshots:
vary@1.1.2: {} vary@1.1.2: {}
which-module@2.0.1: {}
which@2.0.2: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {} wrappy@1.0.2: {}
ws@8.21.3: {} ws@8.21.3: {}
y18n@4.0.3: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
zod-to-json-schema@3.25.2(zod@4.6.2): zod-to-json-schema@3.25.2(zod@4.6.2):
dependencies: dependencies:
zod: 4.6.2 zod: 4.6.2
+61
View File
@@ -0,0 +1,61 @@
"use strict";
window.VK = window.VK ?? {};
const bus = new Map();
window.VK.bus = {
on(type, handler) {
const handlers = bus.get(type) ?? new Set();
handlers.add(handler);
bus.set(type, handlers);
return () => handlers.delete(handler);
},
emit(type, payload) {
for (const handler of bus.get(type) ?? []) {
try {
handler(payload);
} catch (error) {
console.error(`[bus] ${type} handler failed`, error);
}
}
}
};
async function request(path, options) {
const response = await fetch(path, options);
let body;
try {
body = await response.json();
} catch {
throw new Error(`request failed (${response.status})`);
}
if (!body.ok) throw new Error(body.error ?? `request failed (${response.status})`);
return body.result;
}
window.VK.api = {
get: path => request(path),
post: (path, data) =>
request(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data ?? {})
})
};
window.VK.connectEvents = function connectEvents() {
const source = new EventSource("/api/events");
source.onmessage = message => {
let data;
try {
data = JSON.parse(message.data);
} catch {
return;
}
window.VK.bus.emit("event", data);
};
source.onerror = () => window.VK.bus.emit("sse-error");
};
+1351
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
"use strict";
window.VK = window.VK ?? {};
const ICON_PATHS = {
hash: '<path d="M6 2L4.5 14M11.5 2L10 14M2.5 5.5h12M2 10.5h12"/>',
volume: '<path d="M8.5 2.5 4.5 6H2v4h2.5l4 3.5z"/><path d="M11 5.5a3.5 3.5 0 0 1 0 5"/>',
mic: '<rect x="5.5" y="1.5" width="5" height="7.5" rx="2.5"/><path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2"/>',
micSlash:
'<rect x="5.5" y="1.5" width="5" height="7.5" rx="2.5"/><path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2"/><path d="M2.5 13.5 13.5 2.5"/>',
speaker: '<path d="M3 10V8a5 5 0 0 1 10 0v2"/><rect x="1.5" y="9.5" width="3" height="4.5" rx="1.5"/><rect x="11.5" y="9.5" width="3" height="4.5" rx="1.5"/>',
speakerSlash:
'<path d="M3 10V8a5 5 0 0 1 10 0v2"/><rect x="1.5" y="9.5" width="3" height="4.5" rx="1.5"/><rect x="11.5" y="9.5" width="3" height="4.5" rx="1.5"/><path d="M2 14 14 2"/>',
monitor: '<rect x="1.5" y="2.5" width="13" height="9" rx="1.5"/><path d="M5.5 14h5M8 11.5v2.5"/>',
camera: '<rect x="1.5" y="4.5" width="9" height="7" rx="1.5"/><path d="M10.5 8l4-2.2v4.4z"/>',
caretDown: '<path d="M3.5 6l4.5 4.5L12.5 6"/>',
caretUp: '<path d="M3.5 10L8 5.5l4.5 4.5"/>',
x: '<path d="M3.5 3.5l9 9M12.5 3.5l-9 9"/>',
lock: '<rect x="3.5" y="7" width="9" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>',
search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
gear: '<circle cx="8" cy="8" r="2.2"/><path d="M8 1.8v2M8 12.2v2M1.8 8h2M12.2 8h2M3.6 3.6l1.4 1.4M11 11l1.4 1.4M12.4 3.6 11 5M5 11l-1.4 1.4"/>',
wave: '<path d="M2 6.5V4.5h6v2M5 4.5V12"/><path d="M10.5 5.5a3.2 3.2 0 0 1 0 5"/><path d="M12.8 3.5a6 6 0 0 1 0 9"/>',
warning: '<path d="M8 2 1.5 13.5h13z"/><path d="M8 6.5v3.5M8 12v.5"/>',
refresh: '<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5V6H10"/>',
play: '<path d="M5 3.5 12.5 8 5 12.5z"/>',
microphone: '<rect x="5.5" y="1.5" width="5" height="7.5" rx="2.5"/><path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2"/>'
};
const FILLED = new Set(["star"]);
ICON_PATHS.star = '<path d="M8 1.6l1.9 4 4.4.6-3.2 3 .8 4.3L8 11.5 4.1 13.5l.8-4.3-3.2-3 4.4-.6z"/>';
window.VK.icon = function icon(name, size = 13) {
const path = ICON_PATHS[name] ?? "";
const fill = FILLED.has(name) ? "currentColor" : "none";
return `<svg width="${size}" height="${size}" viewBox="0 0 16 16" fill="${fill}" stroke="currentColor" stroke-width="1.3" aria-hidden="true">${path}</svg>`;
};
window.VK.iconEl = function iconEl(name, size = 13) {
const span = document.createElement("span");
span.className = "vk-icon";
span.innerHTML = window.VK.icon(name, size);
return span;
};
+262
View File
@@ -0,0 +1,262 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>veskslop debug</title>
<link rel="stylesheet" href="nocturne.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="vk-app">
<header class="vk-header">
<span class="vk-brand">
<span class="vk-brand-name">veskslop</span>
<span class="vk-brand-version">debug</span>
</span>
<div class="vk-guild-picker">
<button id="guild-picker" class="btn btn-secondary vk-guild-button" type="button">
<span id="guild-picker-label">Direct messages</span>
<span class="vk-icon" data-icon="caretDown"></span>
</button>
<div id="guild-menu" class="vk-menu" hidden></div>
</div>
<div class="vk-health">
<span class="vk-health-item">
<span id="health-dot" class="vk-dot"></span>
<span id="health-bridge" class="vk-mono">bridge …</span>
</span>
<span id="health-uptime" class="vk-mono vk-dim">up …</span>
<span id="health-events" class="vk-mono vk-dim">0.0 ev/s</span>
</div>
<div class="vk-header-right">
<label class="vk-takeover" title="Read-only unless takeover is on">
<input type="checkbox" id="takeover" />
<span class="vk-switch"><span class="vk-knob"></span></span>
<span class="vk-takeover-label" id="takeover-label">Takeover</span>
</label>
<button id="settings-open" class="btn btn-secondary vk-settings" type="button">
<span class="vk-icon" data-icon="gear"></span>
Settings
</button>
</div>
</header>
<div id="call-strip" class="vk-call-strip" hidden></div>
<div class="vk-body">
<aside class="vk-rail">
<div id="guild-switcher" class="vk-guild-switcher"></div>
<div class="vk-rail-section">
<div class="vk-rail-label">Text</div>
<div id="text-channels" class="vk-rail-list"></div>
</div>
<div class="vk-rail-section">
<div class="vk-rail-label">Voice</div>
<div id="voice-channels" class="vk-rail-list"></div>
</div>
<div class="vk-rail-section">
<div class="vk-rail-label">Direct</div>
<div id="direct-list" class="vk-rail-list"></div>
</div>
<div id="agent-identity" class="vk-rail-footer"></div>
</aside>
<main class="vk-center">
<div class="vk-channel-header">
<span id="channel-icon" class="vk-icon vk-dim"></span>
<span id="channel-title" class="vk-channel-title">No channel selected</span>
<span id="channel-context" class="vk-mono vk-dim"></span>
<div class="vk-channel-actions">
<button
id="channel-refresh"
class="btn btn-secondary vk-icon-btn"
type="button"
title="Reload the latest 50 messages"
>
<span class="vk-icon" data-icon="refresh"></span>
</button>
<button id="raw-events" class="btn btn-secondary vk-small-btn" type="button">Raw events</button>
</div>
</div>
<div id="message-scroll" class="vk-message-scroll">
<div id="message-list" class="vk-message-list"></div>
</div>
<div class="vk-composer-wrap">
<div id="tts-panel" class="vk-tts" hidden>
<div class="vk-tts-title">
<span class="vk-icon" data-icon="wave"></span>
<span class="vk-tts-heading">Text to speech</span>
<span id="tts-target" class="vk-mono vk-tts-sub">not implemented</span>
<button id="tts-close" class="btn btn-ghost vk-icon-btn" type="button" aria-label="Close">
<span class="vk-icon" data-icon="x"></span>
</button>
</div>
<div class="vk-tts-settings">
<span class="vk-micro-label">Voice settings</span>
<span class="vk-chip vk-chip-on">sv-SE · Mattias</span>
<span class="vk-chip">rate 1.0×</span>
<span class="vk-chip vk-chip-on">yield to call</span>
<span class="vk-chip">duck 30%</span>
<span class="vk-chip vk-chip-on">queue lines</span>
</div>
<div class="vk-tts-body">
<textarea id="tts-input" class="vk-tts-field" rows="3" placeholder="Speak text into the call…"></textarea>
<div class="vk-tts-meta">
<span class="vk-mono vk-dim">voice audio is not implemented yet</span>
<button id="tts-speak" class="btn btn-primary vk-small-btn" type="button">Speak</button>
</div>
</div>
</div>
<div id="composer" class="vk-composer">
<span id="composer-state-icon" class="vk-icon vk-dim"></span>
<span id="composer-copy" class="vk-composer-copy"></span>
<button id="tts-toggle" class="btn btn-secondary vk-icon-btn vk-tts-toggle" type="button" title="Text to speech">
<span class="vk-icon" data-icon="wave"></span>
</button>
<button id="send" class="btn btn-primary vk-send" type="button" disabled>Send</button>
</div>
</div>
</main>
<aside class="vk-right">
<div class="vk-right-scroll">
<section class="vk-right-section">
<div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">In voice</span>
<span id="voice-count" class="vk-mono vk-dim"></span>
</div>
<div id="right-voice" class="vk-right-list"></div>
</section>
<section class="vk-right-section">
<div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">Favourites</span>
</div>
<div id="right-favourites" class="vk-right-list"></div>
</section>
<section class="vk-right-section">
<div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">Open DMs &amp; groups</span>
<span id="dm-count" class="vk-mono vk-dim"></span>
</div>
<div id="right-dms" class="vk-right-list"></div>
</section>
</div>
<div class="vk-right-footer vk-mono vk-dim">full roster sits behind the guild header</div>
</aside>
</div>
<section class="vk-console">
<div class="vk-console-bar">
<button id="console-toggle" class="vk-console-button" type="button">
<span id="console-caret" class="vk-icon" data-icon="caretUp"></span>
Agent console
</button>
<div class="vk-console-tabs">
<button class="vk-console-tab" data-console-tab="tools" type="button">Tool calls</button>
<button class="vk-console-tab" data-console-tab="events" type="button">Events</button>
<button class="vk-console-tab" data-console-tab="transcript" type="button">Transcript</button>
</div>
<div class="vk-ticker"><span id="ticker-line">waiting for events</span></div>
<div class="vk-console-status vk-mono">
<span id="console-count">0 events</span>
<span class="vk-health-item"><span id="mcp-dot" class="vk-dot"></span><span id="mcp-state">bridge …</span></span>
</div>
</div>
<div id="console-drawer" class="vk-console-drawer" hidden>
<div id="console-panel-tools" class="vk-console-panel">
<div class="vk-roadmap">
<span class="tag tag-outline">roadmap</span>
<p>MCP tool-call tracing is not implemented yet. Agent tool calls will appear here once the LLM attachment lands.</p>
</div>
</div>
<div id="console-panel-events" class="vk-console-panel">
<div id="event-log" class="vk-event-log"></div>
</div>
<div id="console-panel-transcript" class="vk-console-panel">
<div class="vk-roadmap">
<span class="tag tag-outline">roadmap</span>
<p>Voice transcription (STT/TTS) is not implemented yet. Transcript segments will appear here once voice audio lands.</p>
</div>
</div>
</div>
</section>
</div>
<div id="settings-overlay" class="vk-overlay" hidden>
<div class="vk-settings-panel elev-lg" role="dialog" aria-label="Settings">
<div class="vk-settings-head">
<span class="vk-settings-title">Settings</span>
<button id="settings-close" class="btn btn-ghost vk-icon-btn" type="button" aria-label="Close">
<span class="vk-icon" data-icon="x"></span>
</button>
</div>
<div class="vk-settings-body">
<section class="vk-settings-section">
<div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">Core process</span>
<span class="vk-settings-actions">
<button id="core-start" class="btn btn-primary vk-small-btn" type="button">Start</button>
<button id="core-stop" class="btn btn-secondary vk-small-btn" type="button">Stop</button>
<button id="core-restart" class="btn btn-secondary vk-small-btn" type="button">Restart</button>
</span>
</div>
<div id="core-status" class="vk-settings-card"></div>
</section>
<section class="vk-settings-section">
<div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">Account login</span>
<span class="vk-settings-actions">
<button id="auth-start" class="btn btn-primary vk-small-btn" type="button">Start QR login</button>
<button id="auth-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
<button id="auth-restart" class="btn btn-secondary vk-small-btn" type="button">Restart with token</button>
</span>
</div>
<div id="auth-status" class="vk-settings-card"></div>
<div id="auth-qr" class="vk-qr" hidden></div>
<form id="auth-captcha" class="vk-settings-row" hidden>
<input id="auth-captcha-key" class="input" placeholder="hCaptcha response token" />
<button class="btn btn-secondary vk-small-btn" type="submit">Submit captcha</button>
</form>
</section>
<section class="vk-settings-section">
<div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">Visible guilds</span>
<button id="guilds-show-all" class="btn btn-secondary vk-small-btn" type="button">Show all</button>
</div>
<div id="guild-visibility" class="vk-settings-card"></div>
</section>
<section class="vk-settings-section">
<div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">Action log</span>
<button id="logs-refresh" class="btn btn-secondary vk-small-btn" type="button">Refresh</button>
</div>
<div id="action-log" class="vk-log"></div>
</section>
</div>
</div>
</div>
<div id="toasts" class="vk-toasts"></div>
<script src="icons.js"></script>
<script src="api.js"></script>
<script src="app.js"></script>
</body>
</html>
+294
View File
@@ -0,0 +1,294 @@
/* Nocturne — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
--color-bg: #161826;
--color-surface: #232532;
--color-text: #e9e9ed;
--color-accent: #9184d9; /* review round 7 (Barron): the accent moves to
the product's blurple — OKLCH hue 289.2, the hue of the app's own Pro
accent (#9a8fe0, --om-accent-pro) — at the same lightness the steel
accent held (L 0.660, so the documented ratios survive) and chroma
in the product blurple's own range (its token sits at C 0.118; the
accent takes 0.125, from the old steel's 0.030):
the accent now reads as an accent rather than another neutral. */
--color-accent-2: #a7a1db; /* the machine-derived stand-in follows: same
hue, same L 0.734, chroma at the same 2:3 proportion it held */
--color-divider: color-mix(in srgb, #e9e9ed 16%, transparent);
/* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
same step of any role matches the others in visual value. */
--color-neutral-100: #f3f5fe;
--color-neutral-200: #e4e7f5;
--color-neutral-300: #cfd3e5;
--color-neutral-400: #b2b6ca;
--color-neutral-500: #9397ab;
--color-neutral-600: #75798c;
--color-neutral-700: #595d6c;
--color-neutral-800: #3f424d;
--color-neutral-900: #292b31;
--color-accent-100: #f5f4ff;
--color-accent-200: #e7e5fe;
--color-accent-300: #d2cefd;
--color-accent-400: #b5abfc;
--color-accent-500: #968ae0;
--color-accent-600: #796cbf;
--color-accent-700: #5d5294;
--color-accent-800: #423a6a;
--color-accent-900: #2b2741;
--color-accent-2-100: #f5f4ff;
--color-accent-2-200: #e7e5fe;
--color-accent-2-300: #d2cefd;
--color-accent-2-400: #b5afe8;
--color-accent-2-500: #9690c9;
--color-accent-2-600: #7972a9;
--color-accent-2-700: #5c5783;
--color-accent-2-800: #423e5d;
--color-accent-2-900: #2b293a;
/* Deck section-divider ground — review round 3: saturation as presence.
Dividers take a saturated deep indigo (hue 277, the neutral ramp's own
family; C 0.094 — written when the accent held C 0.030, before the
round-7 blurple raised it past the ground's) with a lighter bloom and
a ghost tint of the same hue, while the slide grounds stay
desaturated. Deck-scale fills only — not interface colors. */
--color-section: #262a60;
--color-section-glow: #353b80;
--color-section-ghost: #4c5397;
--font-heading: "Inter", system-ui, sans-serif;
--font-heading-weight: 500;
--font-body: "Inter", system-ui, sans-serif;
--space-1: 2.8px;
--space-2: 5.6px;
--space-3: 8.4px;
--space-4: 11.2px;
--space-6: 16.8px;
--space-8: 22.4px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 14px;
/* Elevation — derived from the ground: soft ink-tinted shadows on a
light theme, a hairline edge + ambient darkness on a dark one. */
--shadow-sm: 0 0 0 1px #3f424d;
--shadow-md: 0 0 0 1px #595d6c, 0 6px 18px rgba(0,0,0,0.55);
--shadow-lg: 0 0 0 1px #9397ab, 0 16px 40px rgba(0,0,0,0.65);
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
}
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
.lighten{mix-blend-mode:lighten;background-color:transparent}
/* ══════════════════════════════════════════════════════════════════════════
Components — built with the tokens above. Plain CSS
on plain HTML: no JavaScript, no build step. Each class is documented in
readme.md and demonstrated in foundations/ and components/.
══════════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
}
h1 { font-size: 42px; }
h2 { font-size: 32px; }
h3 { font-size: 25px; }
h4 { font-size: 20px; }
h5 { font-size: 16px; }
h6 { font-size: 13px; }
h6 { letter-spacing: 0.08em; text-transform: uppercase; }
p { margin: 0 0 var(--space-3); }
a { color: var(--color-accent); text-underline-offset: 3px; }
img { display: block; max-width: 100%; }
figure { margin: 0; }
figcaption {
font-size: 11px; margin-top: var(--space-1);
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
/* — rules — */
/* Rules fade to transparent at both ends instead of stopping cleanly — a
Nocturne signature. The ramp is 48px an end (one deck baseline unit),
painted from the same divider token. Freestanding rules fade; box
outlines, in-control separators, and short accent marks stay solid. */
.hr {
height: 1px; border: 0; margin: var(--space-4) 0;
background: linear-gradient(to right,
transparent, var(--color-divider) 48px,
var(--color-divider) calc(100% - 48px), transparent);
}
/* — buttons — */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
cursor: pointer; text-decoration: none;
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
the pair sits side by side in sign-up rows */
background: transparent; border: 1px solid transparent;
padding: var(--space-2) calc(var(--space-3) * 1.2);
border-radius: var(--radius-md);
}
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { color: var(--color-accent); border-color: var(--color-accent); }
.btn-primary:hover { background: color-mix(in srgb, var(--color-accent) 12%, transparent); }
.btn-primary:active { background: color-mix(in srgb, var(--color-accent) 22%, transparent); }
.btn-secondary { border-color: var(--color-divider); }
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
.btn-icon { width: 36px; height: 36px; padding: 0; }
.btn-block { width: 100%; margin-top: var(--space-2); }
/* — forms — */
.field > label {
display: block; font-size: 12px; margin-bottom: 5px;
color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
.input {
width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
background: var(--color-surface);
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
textarea.input { min-height: 90px; resize: vertical; }
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
.radio input, .seg-opt input {
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
}
.radio .dot {
width: 16px; height: 16px; flex: none; border-radius: 50%;
border: 1.5px solid var(--color-divider);
}
.radio:hover .dot { border-color: var(--color-accent); }
.radio input:checked + .dot {
border-color: var(--color-accent); background: var(--color-accent);
box-shadow: inset 0 0 0 4px var(--color-bg);
}
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.seg {
display: inline-flex; overflow: hidden;
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.seg-opt {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 12px; font-size: 13px; cursor: pointer;
}
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
.seg-opt:has(input:checked) { color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); }
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
/* — cards — */
.card {
display: flex; flex-direction: column; gap: var(--space-2);
padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
}
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
.card-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 17px; line-height: 1.2;
}
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
.card-meta {
display: flex; align-items: center; gap: 6px; font-size: 11px;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
.elev-sm { box-shadow: var(--shadow-sm); }
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }
/* — tags — */
.tag {
display: inline-flex; align-items: center; font-size: 11px;
letter-spacing: 0.02em; padding: 3px 10px;
border-radius: calc(var(--radius-md) * 0.75);
}
.tag-accent { background: var(--color-accent-800); color: var(--color-accent-100); }
.tag-accent-2 { background: var(--color-accent-2-800); color: var(--color-accent-2-100); }
.tag-neutral { background: var(--color-neutral-800); color: var(--color-neutral-100); }
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
/* — navigation — */
.nav {
display: flex; align-items: center; gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: none;
}
.nav-brand {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 18px; margin-right: auto;
}
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
/* — tables — */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th {
text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
padding: var(--space-2); border-bottom: 1px solid transparent; /* keeps layout; the row paints the fading rule */
}
.table td {
padding: var(--space-2);
border-bottom: 1px solid transparent;
}
/* Row rules as row-level strips so the end-fade spans the row, not each cell. */
.table thead tr {
background: linear-gradient(to right,
transparent, var(--color-divider) 48px,
var(--color-divider) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px;
}
.table tbody tr {
background: linear-gradient(to right,
transparent, color-mix(in srgb, var(--color-text) 8%, transparent) 48px,
color-mix(in srgb, var(--color-text) 8%, transparent) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px;
}
.table tbody tr:hover {
/* the hover tint layers over the row rule, which keeps painting */
background:
linear-gradient(color-mix(in srgb, var(--color-text) 4%, transparent),
color-mix(in srgb, var(--color-text) 4%, transparent)) no-repeat 0 0 / 100% 100%,
linear-gradient(to right,
transparent, color-mix(in srgb, var(--color-text) 8%, transparent) 48px,
color-mix(in srgb, var(--color-text) 8%, transparent) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px;
}
/* — dialog — */
.dialog-backdrop {
position: fixed; inset: 0; display: grid; place-items: center;
padding: var(--space-4);
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
}
.dialog {
width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
padding: var(--space-4); border-radius: var(--radius-lg);
background: var(--color-surface); box-shadow: var(--shadow-lg);
}
.dialog-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 20px;
}
.dialog-body { font-size: 14px; opacity: 0.85; }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
+1449
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
/*
* Veskslop daemon — auth state for the web UI.
*
* Owns at most one QR login session. The terminal tool and the web UI share
* the session class. The token file stays the single credential store.
*/
import { existsSync } from "node:fs";
import { isTerminalQrState, QrLoginSession, type QrLoginState, type QrLoginUpdate } from "./qr-session.js";
export interface AuthSessionSnapshot {
state: QrLoginState;
url?: string;
user?: object;
error?: string;
captcha?: object;
}
export interface AuthStatus {
tokenFile: string;
hasToken: boolean;
session: AuthSessionSnapshot | null;
}
export class AuthService {
private session?: QrLoginSession;
private snapshot: AuthSessionSnapshot | null = null;
private freshLogin = false;
constructor(
private readonly tokenFile: string,
private readonly onUpdate?: (update: QrLoginUpdate) => void
) {}
status(): AuthStatus {
return {
tokenFile: this.tokenFile,
hasToken: existsSync(this.tokenFile),
session: this.snapshot ? structuredClone(this.snapshot) : null
};
}
startQr(): AuthStatus {
if (this.session && !isTerminalQrState(this.session.state)) return this.status();
this.snapshot = { state: "connecting" };
this.session = new QrLoginSession({
tokenFile: this.tokenFile,
debug: process.env.DEBUG === "1",
onUpdate: update => {
this.snapshot = { ...update };
if (update.state === "finished") this.freshLogin = true;
this.onUpdate?.(update);
}
});
this.session.start();
return this.status();
}
cancelQr(): AuthStatus {
this.session?.cancel();
return this.status();
}
submitCaptcha(key: string): boolean {
return this.session?.submitCaptcha(key) ?? false;
}
/** True once after a successful login. Used to force the new token on start. */
consumeFreshLogin(): boolean {
const value = this.freshLogin;
this.freshLogin = false;
return value;
}
clearFreshLogin(): void {
this.freshLogin = false;
}
}
+101 -8
View File
@@ -14,9 +14,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { AuthService } from "./auth-service.js";
import { tokenFile } from "./config.js";
import { CoreClient, coreSocketPath } from "./core-client.js"; import { CoreClient, coreSocketPath } from "./core-client.js";
import { dedupe, getQueryCount, paced, sleep } from "./pacing.js"; import { CoreProcess } from "./core-process.js";
import { dedupe, getQueryCount, paced, pacedSend, sleep } from "./pacing.js";
import { Puppet } from "./puppet.js"; import { Puppet } from "./puppet.js";
import { startWebUi } from "./webui.js";
const HOST = process.env.MCP_HOST ?? "127.0.0.1"; const HOST = process.env.MCP_HOST ?? "127.0.0.1";
const PORT = Number(process.env.MCP_PORT ?? 8788); const PORT = Number(process.env.MCP_PORT ?? 8788);
@@ -24,8 +28,13 @@ const PORT = Number(process.env.MCP_PORT ?? 8788);
let core: CoreClient | undefined; let core: CoreClient | undefined;
let puppet: Puppet | undefined; let puppet: Puppet | undefined;
let coreConnected = false; let coreConnected = false;
let connecting = false;
async function connectLoop() { async function connectLoop(): Promise<void> {
if (connecting) return;
connecting = true;
try {
for (;;) { for (;;) {
try { try {
const client = await CoreClient.connect(); const client = await CoreClient.connect();
@@ -35,10 +44,21 @@ async function connectLoop() {
client.onEvent(event => { client.onEvent(event => {
void puppet!.enqueueEvent(event); void puppet!.enqueueEvent(event);
webui.broadcast(event);
});
client.onClose(() => {
console.warn("[daemon] core bridge disconnected");
coreConnected = false;
core = undefined;
puppet = undefined;
webui.broadcast({ type: "CORE_STATUS", coreConnected: false });
void connectLoop();
}); });
coreConnected = true; coreConnected = true;
console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`); console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
webui.broadcast({ type: "CORE_STATUS", coreConnected: true });
return; return;
} catch (error) { } catch (error) {
@@ -46,6 +66,9 @@ async function connectLoop() {
await sleep(2000); await sleep(2000);
} }
} }
} finally {
connecting = false;
}
} }
function requireCore(): CoreClient { function requireCore(): CoreClient {
@@ -58,6 +81,32 @@ function requirePuppet(): Puppet {
return puppet; return puppet;
} }
const coreProcess = new CoreProcess({ tokenFile });
const auth = new AuthService(tokenFile, update => {
webui.broadcast({ type: "AUTH_QR", update });
if (update.state === "finished" && coreProcess.status().running) {
setTimeout(() => {
void coreProcess.restart(auth.consumeFreshLogin()).catch(error => {
console.error("[core] restart after login failed:", error);
});
}, 500);
}
});
const webui = startWebUi({
getCore: () => core,
getPuppet: () => puppet,
isCoreConnected: () => coreConnected,
coreProcess,
auth
});
coreProcess.onExit(() => {
webui.broadcast({ type: "CORE_STATUS", coreConnected, process: coreProcess.status() });
});
function jsonText(value: unknown) { function jsonText(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
} }
@@ -66,6 +115,9 @@ function jsonText(value: unknown) {
const READ_ONLY_TOOLS: Record<string, (args: any) => Promise<unknown> | unknown> = { const READ_ONLY_TOOLS: Record<string, (args: any) => Promise<unknown> | unknown> = {
get_state: () => requireCore().getState(), get_state: () => requireCore().getState(),
get_voice_state: args => requireCore().voiceGet(args?.userId, args?.guildId), get_voice_state: args => requireCore().voiceGet(args?.userId, args?.guildId),
get_voice_overview: args => requireCore().voiceOverview(args?.guildId),
search_users: args => requireCore().userSearch(args?.query, args?.guildId, args?.limit),
list_dms: args => requireCore().dmList(args?.limit),
get_context: args => get_context: args =>
requirePuppet().getContext(args?.channelId, { requirePuppet().getContext(args?.channelId, {
maxMessages: args?.maxMessages, maxMessages: args?.maxMessages,
@@ -100,7 +152,7 @@ function buildServer(): McpServer {
} }
}, },
async ({ channelId, content }) => { async ({ channelId, content }) => {
await paced(() => requireCore().sendMessage(channelId, content)); await pacedSend(() => requireCore().sendMessage(channelId, content));
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() }); return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
} }
); );
@@ -115,7 +167,7 @@ function buildServer(): McpServer {
} }
}, },
async ({ userId, content }) => { async ({ userId, content }) => {
const result = await paced(() => requireCore().sendDm(userId, content)); const result = await pacedSend(() => requireCore().sendDm(userId, content));
return jsonText({ ...result, userId, queriesUsed: getQueryCount() }); return jsonText({ ...result, userId, queriesUsed: getQueryCount() });
} }
); );
@@ -199,12 +251,47 @@ function buildServer(): McpServer {
description: "Which voice channel a user is in and who else is there. Defaults to the logged-in user.", description: "Which voice channel a user is in and who else is there. Defaults to the logged-in user.",
inputSchema: { inputSchema: {
userId: z.string().optional().describe("User ID. Defaults to the logged-in user."), userId: z.string().optional().describe("User ID. Defaults to the logged-in user."),
guildId: z.string().optional().describe("Guild ID. Omit for a DM call.") guildId: z.string().optional().describe("Guild ID. Omit to search all guilds.")
} }
}, },
async ({ userId, guildId }) => jsonText(await requireCore().voiceGet(userId, guildId)) async ({ userId, guildId }) => jsonText(await requireCore().voiceGet(userId, guildId))
); );
server.registerTool(
"get_voice_overview",
{
description: "List the occupied voice channels in a guild and their members.",
inputSchema: {
guildId: z.string().describe("Guild ID")
}
},
async ({ guildId }) => jsonText(await requireCore().voiceOverview(guildId))
);
server.registerTool(
"search_users",
{
description: "Search users in the client cache by username, global name, guild nickname, or user ID.",
inputSchema: {
query: z.string().min(1).describe("Username, global name, nickname, or user ID"),
guildId: z.string().optional().describe("Limit the search to one guild"),
limit: z.number().int().min(1).max(25).default(10).describe("Max results")
}
},
async ({ query, guildId, limit }) => jsonText(await requireCore().userSearch(query, guildId, limit))
);
server.registerTool(
"list_dms",
{
description: "List the cached DM and group DM channels with their recipients.",
inputSchema: {
limit: z.number().int().min(1).max(50).default(25).describe("Max channels")
}
},
async ({ limit }) => jsonText(await requireCore().dmList(limit))
);
server.registerTool( server.registerTool(
"get_action_log", "get_action_log",
{ {
@@ -338,7 +425,13 @@ app.listen(PORT, HOST, (error?: Error) => {
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`); console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
}); });
process.on("SIGINT", () => { function shutdown() {
core?.close(); core?.close();
process.exit(0); void coreProcess
}); .dispose(4000)
.catch(() => undefined)
.finally(() => process.exit(0));
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
+251
View File
@@ -0,0 +1,251 @@
/*
* Veskslop daemon — Vesktop core process control.
*
* The daemon can start, stop, and restart the Electron client that hosts the
* core bridge. Only a process started by this daemon instance is managed.
*
* On Linux without a DISPLAY, the daemon starts Xvfb and points the client at
* it. Set VESKSLOP_XVFB=0 to disable that.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("../..", import.meta.url));
const DEFAULT_ARGS = "--no-sandbox --disable-gpu --disable-dev-shm-usage";
const XVFB_ARGS = "-screen 0 1280x720x24 -nolisten tcp";
const MAX_OUTPUT_LINES = 20;
export interface CoreExit {
code: number | null;
signal: string | null;
at: string;
}
export interface CoreProcessStatus {
managed: boolean;
running: boolean;
pid: number | null;
startedAt: string | null;
command: string | null;
lastExit: CoreExit | null;
output: string[];
}
export interface CoreProcessOptions {
tokenFile: string;
}
function prefixLines(prefix: string, chunk: Buffer): string {
return chunk
.toString()
.split("\n")
.map(line => (line ? `${prefix} ${line}` : line))
.join("\n");
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function findFreeDisplay(): string {
for (let number = 99; number < 110; number++) {
if (!existsSync(`/tmp/.X11-unix/X${number}`)) return `:${number}`;
}
return ":99";
}
export class CoreProcess {
private child?: ChildProcess;
private startedAt?: number;
private command?: string;
private xvfb?: ChildProcess;
private display?: string;
private lastExit: CoreExit | null = null;
private readonly output: string[] = [];
private readonly exitListeners = new Set<() => void>();
constructor(private readonly options: CoreProcessOptions) {}
status(): CoreProcessStatus {
const running = !!this.child && this.child.exitCode === null && !this.child.killed;
return {
managed: !!this.child,
running,
pid: this.child?.pid ?? null,
startedAt: this.startedAt ? new Date(this.startedAt).toISOString() : null,
command: this.command ?? null,
lastExit: this.lastExit,
output: [...this.output]
};
}
onExit(listener: () => void) {
this.exitListeners.add(listener);
return () => this.exitListeners.delete(listener);
}
async start(forceToken = false): Promise<CoreProcessStatus> {
if (this.child) return this.status();
const override = process.env.VESKSLOP_CORE_CMD;
const args = (process.env.VESKSLOP_CORE_ARGS ?? DEFAULT_ARGS).split(/\s+/).filter(Boolean);
const display = await this.ensureDisplay();
const env = {
...process.env,
...(display ? { DISPLAY: display } : {}),
VESKSLOP_HEADLESS: process.env.VESKSLOP_HEADLESS ?? "1",
VESKSLOP_TOKEN_FILE: this.options.tokenFile,
...(forceToken ? { VESKSLOP_FORCE_TOKEN: "1" } : {})
};
let command: string;
let child: ChildProcess;
if (override) {
command = override;
child = spawn(override, { cwd: ROOT, env, shell: true, stdio: ["ignore", "pipe", "pipe"] });
} else {
const electron = join(
ROOT,
"node_modules",
".bin",
process.platform === "win32" ? "electron.cmd" : "electron"
);
if (!existsSync(electron)) {
throw new Error("electron is not installed; run pnpm install or set VESKSLOP_CORE_CMD");
}
command = `${electron} . ${args.join(" ")}`;
child = spawn(electron, [".", ...args], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
}
this.child = child;
this.startedAt = Date.now();
this.command = command;
this.lastExit = null;
this.output.length = 0;
child.stdout?.on("data", chunk => {
this.appendOutput(chunk);
process.stdout.write(prefixLines("[core]", chunk));
});
child.stderr?.on("data", chunk => {
this.appendOutput(chunk);
process.stderr.write(prefixLines("[core]", chunk));
});
child.on("error", error => {
console.error("[core] failed to start:", error.message);
this.appendOutput(Buffer.from(error.message));
});
child.on("exit", (code, signal) => {
console.log(`[core] exited (code ${code ?? "null"}${signal ? `, signal ${signal}` : ""})`);
this.lastExit = { code, signal, at: new Date().toISOString() };
this.child = undefined;
this.startedAt = undefined;
this.command = undefined;
for (const listener of this.exitListeners) listener();
});
console.log(`[core] starting: ${command}${display ? ` (DISPLAY=${display})` : ""}`);
return this.status();
}
async stop(timeoutMs = 8000): Promise<CoreProcessStatus> {
const child = this.child;
if (!child) return this.status();
await new Promise<void>(resolve => {
const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// already gone
}
}, timeoutMs);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
try {
child.kill("SIGTERM");
} catch {
clearTimeout(timer);
resolve();
}
});
return this.status();
}
async restart(forceToken = false): Promise<CoreProcessStatus> {
await this.stop();
return this.start(forceToken);
}
/** Stop the core and any Xvfb that this daemon started. */
async dispose(timeoutMs = 4000): Promise<void> {
await this.stop(timeoutMs);
this.stopXvfb();
}
private appendOutput(chunk: Buffer) {
for (const line of chunk.toString().split("\n")) {
if (!line.trim()) continue;
this.output.push(line);
if (this.output.length > MAX_OUTPUT_LINES) this.output.shift();
}
}
private async ensureDisplay(): Promise<string | undefined> {
if (process.env.DISPLAY) return process.env.DISPLAY;
if (process.platform !== "linux" || process.env.VESKSLOP_XVFB === "0") return undefined;
if (this.xvfb && this.xvfb.exitCode === null) return this.display;
const display = findFreeDisplay();
const child = spawn("Xvfb", [display, ...XVFB_ARGS.split(" ")], { stdio: ["ignore", "ignore", "pipe"] });
child.stderr?.on("data", chunk => process.stderr.write(prefixLines("[xvfb]", chunk)));
child.on("error", error => {
console.warn(`[core] Xvfb is not available: ${error.message}`);
this.xvfb = undefined;
this.display = undefined;
});
child.on("exit", () => {
this.xvfb = undefined;
this.display = undefined;
});
this.xvfb = child;
this.display = display;
// Wait for the X socket so Electron does not race the server startup.
const socket = `/tmp/.X11-unix/X${display.slice(1)}`;
for (let attempt = 0; attempt < 30 && !existsSync(socket); attempt++) await sleep(100);
if (!existsSync(socket)) console.warn(`[core] Xvfb socket ${socket} did not appear; continuing anyway`);
console.log(`[core] Xvfb on ${display} (pid ${child.pid})`);
return display;
}
private stopXvfb() {
const child = this.xvfb;
this.xvfb = undefined;
this.display = undefined;
if (child && child.exitCode === null) {
try {
child.kill("SIGTERM");
} catch {
// already gone
}
}
}
}
+45 -163
View File
@@ -1,53 +1,20 @@
/* /*
* Veskslop Phase 0 — Discord remote-auth QR login. * Veskslop Phase 0 — Discord remote-auth QR login (terminal front end).
*
* 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: * Usage:
* pnpm poc:qr * pnpm poc:qr
* TOKEN_FILE=/path/to/token DEBUG=1 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 * HCAPTCHA_KEY=... pnpm poc:qr # only if Discord challenges the ticket exchange
* *
* The token file is written with mode 600 and is gitignored. * The session logic lives in qr-session.ts and is shared with the web UI.
*/ */
import crypto from "node:crypto";
import fs from "node:fs";
import readline from "node:readline"; import readline from "node:readline";
import qrcode from "qrcode-terminal"; import qrcode from "qrcode-terminal";
import WebSocket from "ws";
import { tokenFile } from "./config.js"; import { tokenFile } from "./config.js";
import { QrLoginSession } from "./qr-session.js";
const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2";
const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login";
const USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
const debug = process.env.DEBUG === "1";
const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const encodedPublicKey = keyPair.publicKey.export({ type: "spki", format: "der" }).toString("base64");
function log(...args: unknown[]) {
console.log("[qr]", ...args);
}
function debugLog(...args: unknown[]) {
if (debug) console.error("[qr:debug]", ...args);
}
function decryptPayload(payload: string): Buffer {
return crypto.privateDecrypt(
{
key: keyPair.privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256"
},
Buffer.from(payload, "base64")
);
}
function promptCaptchaKey(): Promise<string | undefined> { function promptCaptchaKey(): Promise<string | undefined> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -59,143 +26,58 @@ function promptCaptchaKey(): Promise<string | undefined> {
}); });
} }
async function exchangeTicket(ticket: string, captchaKey?: string, captchaRqToken?: string): Promise<string | undefined> { const session = new QrLoginSession({
const body: Record<string, unknown> = { ticket }; tokenFile,
if (captchaKey) { debug: process.env.DEBUG === "1",
body.captcha_key = captchaKey; onUpdate: update => {
body.captcha_rqtoken = captchaRqToken; switch (update.state) {
} case "pending":
console.log("[qr] Scan this QR code with the Discord mobile app:\n");
if (update.url) qrcode.generate(update.url, { small: true });
console.log(`[qr] Or open this link on the phone: ${update.url}\n`);
break;
const response = await fetch(LOGIN_URL, { case "scanned":
method: "POST", console.log(`[qr] Phone approved: ${update.user?.tag} (${update.user?.id})`);
headers: { break;
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) { case "captcha":
return decryptPayload(data.encrypted_token).toString(); console.log("[qr] Discord wants a captcha to finish this login.");
} console.log(` service: ${update.captcha?.service}`);
console.log(` sitekey: ${update.captcha?.sitekey}`);
if (data.captcha_sitekey || data.captcha_rqtoken) { console.log(` rqdata: ${update.captcha?.rqdata}`);
log("Discord wants a captcha to finish this login."); void (async () => {
log(` service: ${data.captcha_service}`);
log(` sitekey: ${data.captcha_sitekey}`);
log(` rqdata: ${data.captcha_rqdata}`);
const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey()); const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey());
if (!key) { if (!key) session.cancel();
log("No captcha key provided, giving up."); else session.submitCaptcha(key);
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; break;
case "heartbeat_ack": case "finished":
console.log("\n=== token acquired ===");
console.log(session.token);
console.log(`[qr] Saved to ${tokenFile} (mode 600).`);
process.exit(0);
break; break;
default: case "failed":
debugLog("unhandled op", packet); console.error(`[qr] Login failed: ${update.error}`);
process.exit(1);
break;
case "cancelled":
console.log("[qr] Login was cancelled.");
process.exit(0);
break;
}
} }
}); });
ws.on("close", () => { console.log("[qr] Connecting to Discord remote-auth gateway...");
if (heartbeat) clearInterval(heartbeat); session.start();
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", () => { process.on("SIGINT", () => {
log("Interrupted."); console.log("[qr] Interrupted.");
ws.close(); session.cancel();
setTimeout(() => process.exit(0), 100);
}); });
+248
View File
@@ -0,0 +1,248 @@
/*
* Veskslop daemon — Discord remote-auth QR login session.
*
* The terminal tool and the web UI share this class. It connects to Discord's
* remote-auth gateway, produces the approval URL, waits for phone approval,
* and writes the token file on success.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import WebSocket from "ws";
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";
export type QrLoginState = "connecting" | "pending" | "scanned" | "captcha" | "finished" | "failed" | "cancelled";
export interface QrLoginUser {
id: string;
tag: string;
avatar?: string;
}
export interface QrLoginCaptcha {
service?: string;
sitekey?: string;
rqdata?: string;
}
export interface QrLoginUpdate {
state: QrLoginState;
url?: string;
user?: QrLoginUser;
error?: string;
captcha?: QrLoginCaptcha;
}
export interface QrLoginOptions {
tokenFile: string;
onUpdate?: (update: QrLoginUpdate) => void;
debug?: boolean;
}
export function isTerminalQrState(state: QrLoginState): boolean {
return state === "finished" || state === "failed" || state === "cancelled";
}
export class QrLoginSession {
state: QrLoginState = "connecting";
url?: string;
user?: QrLoginUser;
error?: string;
captcha?: QrLoginCaptcha;
token?: string;
private readonly keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
private readonly encodedPublicKey = this.keyPair.publicKey
.export({ type: "spki", format: "der" })
.toString("base64");
private ws?: WebSocket;
private heartbeat?: NodeJS.Timeout;
private ticket?: string;
private captchaRqToken?: string;
private closed = false;
constructor(private readonly options: QrLoginOptions) {}
start(): void {
this.ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } });
this.ws.on("open", () => this.debug("connected, requesting QR"));
this.ws.on("message", raw => {
void this.handle(raw).catch(error => this.fail(String(error?.message ?? error)));
});
this.ws.on("close", () => {
if (this.heartbeat) clearInterval(this.heartbeat);
if (!this.closed) this.fail("gateway connection closed");
});
this.ws.on("error", error => {
this.debug(`gateway error: ${error.message}`);
this.fail(error.message);
});
}
cancel(): void {
if (this.closed) return;
this.closed = true;
this.ws?.close();
this.update({ state: "cancelled" });
}
submitCaptcha(key: string): boolean {
if (this.state !== "captcha" || !this.ticket) return false;
void this.finishWithTicket(key, this.captchaRqToken);
return true;
}
private update(update: QrLoginUpdate) {
this.state = update.state;
if (update.url) this.url = update.url;
if (update.user) this.user = update.user;
if (update.error) this.error = update.error;
if (update.captcha) this.captcha = update.captcha;
this.options.onUpdate?.(update);
}
private fail(error: string) {
if (this.closed) return;
this.closed = true;
this.ws?.close();
this.update({ state: "failed", error });
}
private async handle(raw: WebSocket.RawData) {
const packet: any = JSON.parse(raw.toString());
this.debug(`<- ${packet.op}`);
switch (packet.op) {
case "hello":
this.heartbeat = setInterval(
() => this.ws?.send(JSON.stringify({ op: "heartbeat" })),
packet.heartbeat_interval
);
this.ws?.send(JSON.stringify({ op: "init", encoded_public_key: this.encodedPublicKey }));
break;
case "nonce_proof": {
const nonce = this.decrypt(packet.encrypted_nonce);
const proof = crypto.createHash("sha256").update(nonce).digest("base64url");
this.ws?.send(JSON.stringify({ op: "nonce_proof", proof }));
break;
}
case "pending_remote_init":
this.update({ state: "pending", url: `https://discord.com/ra/${packet.fingerprint}` });
break;
case "pending_ticket": {
const payload = this.decrypt(packet.encrypted_user_payload).toString();
const [id, discriminator, avatar, username] = payload.split(":");
const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username;
this.update({ state: "scanned", user: { id, tag, ...(avatar ? { avatar } : {}) } });
break;
}
case "pending_login":
this.ticket = packet.ticket;
await this.finishWithTicket();
break;
case "cancel":
this.closed = true;
this.ws?.close();
this.update({ state: "cancelled" });
break;
case "heartbeat_ack":
break;
default:
this.debug(`unhandled op ${packet.op}`);
}
}
private async finishWithTicket(captchaKey?: string, captchaRqToken?: string) {
const token = await this.exchangeTicket(this.ticket!, captchaKey, captchaRqToken);
if (!token) return;
try {
fs.writeFileSync(this.options.tokenFile, token + "\n", { mode: 0o600 });
} catch (error) {
this.fail(`could not write the token file: ${String(error)}`);
return;
}
this.token = token;
this.closed = true;
this.ws?.close();
this.update({ state: "finished", user: this.user });
}
private async exchangeTicket(
ticket: string,
captchaKey?: string,
captchaRqToken?: string
): Promise<string | undefined> {
const body: Record<string, unknown> = { ticket };
if (captchaKey) {
body.captcha_key = captchaKey;
body.captcha_rqtoken = captchaRqToken;
}
const response = await fetch(LOGIN_URL, {
method: "POST",
headers: {
Accept: "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
Origin: "https://discord.com",
Referer: "https://discord.com/login",
"User-Agent": USER_AGENT
},
body: JSON.stringify(body)
});
const data: any = await response.json();
this.debug(`ticket exchange ${response.status}`);
if (data.encrypted_token) return this.decrypt(data.encrypted_token).toString();
if (data.captcha_sitekey || data.captcha_rqtoken) {
this.captchaRqToken = data.captcha_rqtoken;
this.update({
state: "captcha",
captcha: {
service: data.captcha_service,
sitekey: data.captcha_sitekey,
rqdata: data.captcha_rqdata
}
});
return undefined;
}
this.fail(`ticket exchange failed (HTTP ${response.status})`);
return undefined;
}
private decrypt(payload: string): Buffer {
return crypto.privateDecrypt(
{
key: this.keyPair.privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256"
},
Buffer.from(payload, "base64")
);
}
private debug(...args: unknown[]) {
if (this.options.debug) console.error("[qr:debug]", ...args);
}
}
+349
View File
@@ -0,0 +1,349 @@
/*
* Veskslop daemon — web control UI.
*
* A separate Express listener serves static HTML/JS and a small JSON API on
* WEBUI_HOST:WEBUI_PORT. Remote clients can use the API directly, so the page
* stays replaceable. There is no authentication: bind to LAN or Tailscale only.
* See docs/webui.md for the API contract.
*/
import express, { type Request, type Response } from "express";
import { fileURLToPath } from "node:url";
import QRCode from "qrcode";
import type { AuthService, AuthStatus } from "./auth-service.js";
import { CoreError, type CoreClient } from "./core-client.js";
import type { CoreProcess } from "./core-process.js";
import { dedupe, getQueryCount, paced, pacedSend } from "./pacing.js";
import type { Puppet } from "./puppet.js";
const HOST = process.env.WEBUI_HOST ?? "0.0.0.0";
const PORT = Number(process.env.WEBUI_PORT ?? 8789);
export interface WebUiDeps {
getCore: () => CoreClient | undefined;
getPuppet: () => Puppet | undefined;
isCoreConnected: () => boolean;
coreProcess: CoreProcess;
auth: AuthService;
}
export interface WebUiHandle {
broadcast(event: unknown): void;
}
class HttpError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message);
}
}
function clampLimit(value: unknown, fallback: number, max: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(Math.trunc(parsed), 1), max);
}
/** Render the pending approval URL as a PNG data URL for the login page. */
async function qrDataUrl(status: AuthStatus): Promise<string | undefined> {
const session = status.session;
if (session?.state !== "pending" || !session.url) return undefined;
return QRCode.toDataURL(session.url, { margin: 1, width: 280 });
}
export function startWebUi(deps: WebUiDeps): WebUiHandle {
const app = express();
app.use(express.json({ limit: "64kb" }));
app.use(express.static(fileURLToPath(new URL("../public", import.meta.url))));
const clients = new Set<Response>();
let eventCount = 0;
const requireCore = (): CoreClient => {
const core = deps.getCore();
if (!deps.isCoreConnected() || !core) throw new HttpError(503, "not connected to Vesktop");
return core;
};
const requirePuppet = (): Puppet => {
const puppet = deps.getPuppet();
if (!puppet) throw new HttpError(503, "puppet not connected yet");
return puppet;
};
const wrap =
(handler: (req: Request, res: Response) => Promise<void>) => (req: Request, res: Response) => {
handler(req, res).catch(error => {
if (error instanceof CoreError) {
res.status(400).json({ ok: false, code: error.code, error: error.message });
return;
}
const status = error instanceof HttpError ? error.status : 500;
res.status(status).json({ ok: false, error: String(error?.message ?? error) });
});
};
const coreStatus = () => ({
bridgeConnected: deps.isCoreConnected(),
process: deps.coreProcess.status()
});
const authStatus = async () => {
const status = deps.auth.status();
return { ...status, qrDataUrl: await qrDataUrl(status) };
};
app.get("/api/health", (_req, res) => {
res.json({
ok: true,
result: {
coreConnected: deps.isCoreConnected(),
ownerId: deps.getPuppet()?.owner ?? null,
process: deps.coreProcess.status(),
uptimeSeconds: Math.floor(process.uptime()),
events: eventCount,
queries: getQueryCount()
}
});
});
app.get("/api/core/status", (_req, res) => {
res.json({ ok: true, result: coreStatus() });
});
app.post(
"/api/core/start",
wrap(async (req, res) => {
const forceToken = !!req.body?.forceToken || deps.auth.consumeFreshLogin();
await deps.coreProcess.start(forceToken);
res.json({ ok: true, result: coreStatus() });
})
);
app.post(
"/api/core/stop",
wrap(async (_req, res) => {
await deps.coreProcess.stop();
res.json({ ok: true, result: coreStatus() });
})
);
app.post(
"/api/core/restart",
wrap(async (req, res) => {
const forceToken = !!req.body?.forceToken || deps.auth.consumeFreshLogin();
await deps.coreProcess.restart(forceToken);
res.json({ ok: true, result: coreStatus() });
})
);
app.get(
"/api/auth",
wrap(async (_req, res) => {
res.json({ ok: true, result: await authStatus() });
})
);
app.post(
"/api/auth/qr",
wrap(async (_req, res) => {
deps.auth.startQr();
res.json({ ok: true, result: await authStatus() });
})
);
app.post(
"/api/auth/qr/cancel",
wrap(async (_req, res) => {
deps.auth.cancelQr();
res.json({ ok: true, result: await authStatus() });
})
);
app.post(
"/api/auth/qr/captcha",
wrap(async (req, res) => {
const key = String(req.body?.key ?? "").trim();
if (!key) throw new HttpError(400, "a captcha key is required");
res.json({ ok: true, result: { accepted: deps.auth.submitCaptcha(key) } });
})
);
app.get(
"/api/state",
wrap(async (_req, res) => {
res.json({ ok: true, result: await requireCore().getState() });
})
);
app.get(
"/api/messages",
wrap(async (req, res) => {
const channelId = String(req.query.channelId ?? "");
const limit = clampLimit(req.query.limit, 50, 100);
const refresh = req.query.refresh === "1" || req.query.refresh === "true";
// refresh fetches from Discord and fills the client cache. Attachments
// stay as CDN links; nothing is stored as a file.
const result = await dedupe(`messages:${channelId}:${limit}:${refresh}`, () =>
paced(() =>
refresh
? requireCore().fetchMessages(channelId, limit)
: requireCore().readMessages(channelId, limit)
)
);
res.json({ ok: true, result });
})
);
app.post(
"/api/messages",
wrap(async (req, res) => {
const { channelId, content } = req.body ?? {};
const result = await pacedSend(() => requireCore().sendMessage(String(channelId), String(content ?? "")));
deps.getPuppet()?.appendAction({
type: "manual",
mode: "channel",
channelId: String(channelId),
contentLength: String(content ?? "").length
});
res.json({ ok: true, result });
})
);
app.post(
"/api/dm",
wrap(async (req, res) => {
const { content } = req.body ?? {};
let userId = req.body?.userId ? String(req.body.userId) : undefined;
if (!userId) {
const username = String(req.body?.username ?? "").trim();
if (!username) throw new HttpError(400, "userId or username is required");
const search = (await requireCore().userSearch(username, undefined, 10)) as {
users?: Array<{ id: string; username: string }>;
};
const needle = username.replace(/^@/, "").toLowerCase();
const match =
search?.users?.find(user => user.id === username) ??
search?.users?.find(user => user.username?.toLowerCase() === needle) ??
search?.users?.[0];
if (!match) throw new HttpError(404, `no cached user matches ${username}`);
userId = match.id;
}
const result = await pacedSend(() => requireCore().sendDm(String(userId), String(content ?? "")));
deps.getPuppet()?.appendAction({
type: "manual",
mode: "dm",
userId: String(userId),
contentLength: String(content ?? "").length
});
res.json({ ok: true, result });
})
);
app.get(
"/api/users",
wrap(async (req, res) => {
const query = String(req.query.q ?? "");
const guildId = req.query.guildId ? String(req.query.guildId) : undefined;
const limit = clampLimit(req.query.limit, 10, 25);
res.json({ ok: true, result: await requireCore().userSearch(query, guildId, limit) });
})
);
app.get(
"/api/dms",
wrap(async (req, res) => {
const limit = clampLimit(req.query.limit, 25, 50);
res.json({ ok: true, result: await requireCore().dmList(limit) });
})
);
app.get(
"/api/voice",
wrap(async (req, res) => {
const userId = req.query.userId ? String(req.query.userId) : undefined;
const guildId = req.query.guildId ? String(req.query.guildId) : undefined;
res.json({ ok: true, result: await requireCore().voiceGet(userId, guildId) });
})
);
app.get(
"/api/voice/overview",
wrap(async (req, res) => {
const guildId = String(req.query.guildId ?? "");
res.json({ ok: true, result: await requireCore().voiceOverview(guildId) });
})
);
app.post("/api/voice/join", (_req, res) => {
res.status(501).json({ ok: false, error: "voice.join is not implemented yet" });
});
app.post("/api/voice/leave", (_req, res) => {
res.status(501).json({ ok: false, error: "voice.leave is not implemented yet" });
});
app.get("/api/logs", (req, res) => {
const puppet = requirePuppet();
const limit = clampLimit(req.query.limit, 100, 500);
res.json({ ok: true, result: puppet.readActionLog(limit) });
});
app.get("/api/events", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no"
});
res.write(
`data: ${JSON.stringify({
type: "hello",
coreConnected: deps.isCoreConnected(),
ownerId: deps.getPuppet()?.owner ?? null
})}\n\n`
);
clients.add(res);
const keepAlive = setInterval(() => res.write(": ping\n\n"), 15000);
req.on("close", () => {
clearInterval(keepAlive);
clients.delete(res);
});
});
const server = app.listen(PORT, HOST, () => {
console.log(`[webui] listening on http://${HOST}:${PORT} (no auth; LAN or Tailscale only)`);
});
server.on("error", error => {
console.error("[webui] failed to start:", error);
});
return {
broadcast(event: unknown) {
eventCount++;
const line = `data: ${JSON.stringify(event)}\n\n`;
for (const client of clients) {
try {
client.write(line);
} catch {
clients.delete(client);
}
}
}
};
}