Add Nocturne debug web UI, core process control, and web QR login
This commit is contained in:
+24
-9
@@ -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.
@@ -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",
|
||||||
|
|||||||
Generated
+226
@@ -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
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
};
|
||||||
@@ -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 & 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>
|
||||||
@@ -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); }
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user