diff --git a/poc/README.md b/poc/README.md index 32e98f1..b558316 100644 --- a/poc/README.md +++ b/poc/README.md @@ -15,7 +15,7 @@ pnpm install | Command | Purpose | | --- | --- | | `pnpm poc:qr` | QR login. Writes the token file. | -| `pnpm control:bridge` | Start the daemon. It listens on `http://127.0.0.1:8788/mcp`. | +| `pnpm 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 test:trigger` | Inject a trigger event into the mock core. | | `pnpm poc:dm` | Send one test DM to zzib. | @@ -36,9 +36,11 @@ core-side guardrails. Use it for debugging only. ## Pacing -Every command gets a random delay of 500 to 2800 ms. One queue serializes the commands. The queue -coalesces identical concurrent reads. State reads use the cache. `DEBUG=1` logs each command and -its delay. +Outbound sends use a human-emulation policy: the first send of a burst waits 30 to 200 ms, later +sends wait 500 to 2800 ms, and a burst ends after a random 5 to 20 seconds of quiet. Read queries +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 @@ -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. - 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. -- The mock path works. The daemon handles state, sends, commands, tiers, continuation, polls, - context, and the action log. -- Pacing stayed inside the 500 to 2800 ms window. +- The mock path works. The daemon handles state, sends, commands, tiers, continuation, user search, + voice state, context, and the action log. +- 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 -must confirm Discord's internal `addReaction` lookup. +Open live items: + +- `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 diff --git a/poc/WebUIMockup.zip b/poc/WebUIMockup.zip new file mode 100644 index 0000000..d74016d Binary files /dev/null and b/poc/WebUIMockup.zip differ diff --git a/poc/package.json b/poc/package.json index 219b9e2..3af3073 100644 --- a/poc/package.json +++ b/poc/package.json @@ -19,6 +19,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "express": "^5.2.1", + "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", "ws": "^8.19.0", "zod": "^4.6.2" @@ -26,6 +27,7 @@ "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^22.19.0", + "@types/qrcode": "^1.5.6", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", "tsx": "^4.23.12", diff --git a/poc/pnpm-lock.yaml b/poc/pnpm-lock.yaml index 6aa71f1..bc89e06 100644 --- a/poc/pnpm-lock.yaml +++ b/poc/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 qrcode-terminal: specifier: ^0.12.0 version: 0.12.0 @@ -30,6 +33,9 @@ importers: '@types/node': specifier: ^22.19.0 version: 22.20.2 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/qrcode-terminal': specifier: ^0.12.2 version: 0.12.2 @@ -238,6 +244,9 @@ packages: '@types/qrcode-terminal@0.12.2': 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': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -268,6 +277,14 @@ packages: ajv@8.20.0: 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: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -284,6 +301,20 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} 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: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -321,10 +352,17 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -332,6 +370,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -388,6 +429,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -404,6 +449,10 @@ packages: function-bind@1.1.2: 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: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -447,6 +496,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} 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: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -462,6 +515,10 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -504,10 +561,26 @@ packages: once@1.4.0: 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: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -519,6 +592,10 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -527,6 +604,11 @@ packages: resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.16.0: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} @@ -539,10 +621,17 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} 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: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -558,6 +647,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -589,6 +681,14 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} 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: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -618,11 +718,18 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -638,6 +745,17 @@ packages: utf-8-validate: 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: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -782,6 +900,10 @@ snapshots: '@types/qrcode-terminal@0.12.2': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.20.2 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -815,6 +937,12 @@ snapshots: json-schema-traverse: 1.0.0 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: dependencies: bytes: 3.1.2 @@ -841,6 +969,20 @@ snapshots: call-bind-apply-helpers: 1.0.2 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-type@1.0.5: {} @@ -866,8 +1008,12 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + depd@2.0.0: {} + dijkstrajs@1.0.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -876,6 +1022,8 @@ snapshots: ee-first@1.1.1: {} + emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} es-define-property@1.0.1: {} @@ -981,6 +1129,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -990,6 +1143,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1036,6 +1191,8 @@ snapshots: ipaddr.js@1.9.1: {} + is-fullwidth-code-point@3.0.0: {} + is-promise@4.0.0: {} isexe@2.0.0: {} @@ -1046,6 +1203,10 @@ snapshots: json-schema-typed@8.0.2: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + math-intrinsics@1.1.0: {} media-typer@1.1.1: {} @@ -1076,14 +1237,28 @@ snapshots: dependencies: 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: {} + path-exists@4.0.0: {} + path-key@3.1.1: {} path-to-regexp@8.4.2: {} pkce-challenge@5.0.1: {} + pngjs@5.0.0: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -1091,6 +1266,12 @@ snapshots: 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: dependencies: es-define-property: 1.0.1 @@ -1105,8 +1286,12 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -1144,6 +1329,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -1182,6 +1369,16 @@ snapshots: 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: {} tsx@4.23.13: @@ -1204,14 +1401,43 @@ snapshots: vary@1.1.2: {} + which-module@2.0.1: {} + which@2.0.2: dependencies: 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: {} 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): dependencies: zod: 4.6.2 diff --git a/poc/public/api.js b/poc/public/api.js new file mode 100644 index 0000000..0f39954 --- /dev/null +++ b/poc/public/api.js @@ -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"); +}; diff --git a/poc/public/app.js b/poc/public/app.js new file mode 100644 index 0000000..3604f59 --- /dev/null +++ b/poc/public/app.js @@ -0,0 +1,1351 @@ +"use strict"; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + +const SENDABLE_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]); +const TEXT_TYPES = new Set([0, 5]); +const VOICE_TYPES = new Set([2, 13]); + +const state = { + user: null, + guilds: [], + guildId: null, + channelId: null, + channel: null, + messages: [], + dms: [], + voiceOverview: null, + voiceSelf: null, + health: null, + eventCount: 0, + lastEventSample: null, + eventRate: 0, + events: [], + visibleGuilds: null, + composerChannelId: null, + takeover: localStorage.getItem("veskslop-takeover") === "1", + consoleOpen: false, + consoleTab: "events", + voiceReloadAt: 0 +}; + +/* Drafts survive re-renders and reloads. Nothing else is persisted client-side. */ + +const draftKey = channelId => `veskslop-draft:${channelId}`; +const TTS_DRAFT_KEY = "veskslop-tts-draft"; + +function isGuildVisible(guildId) { + return !state.visibleGuilds || state.visibleGuilds.has(guildId); +} + +function visibleGuildsList() { + return state.guilds.filter(guild => isGuildVisible(guild.id)); +} + +function loadVisibleGuilds() { + try { + const raw = localStorage.getItem("veskslop-visible-guilds"); + state.visibleGuilds = raw ? new Set(JSON.parse(raw)) : null; + } catch { + state.visibleGuilds = null; + } +} + +function saveVisibleGuilds() { + if (!state.visibleGuilds || state.visibleGuilds.size === state.guilds.length) { + state.visibleGuilds = null; + localStorage.removeItem("veskslop-visible-guilds"); + return; + } + localStorage.setItem("veskslop-visible-guilds", JSON.stringify([...state.visibleGuilds])); +} + +/* Helpers */ + +function el(tag, props = {}, ...children) { + const node = document.createElement(tag); + + for (const [key, value] of Object.entries(props)) { + if (value === undefined || value === null) continue; + if (key === "class") node.className = value; + else if (key === "text") node.textContent = value; + else if (key === "html") node.innerHTML = value; + else if (key.startsWith("on")) node.addEventListener(key.slice(2).toLowerCase(), value); + else if (key === "disabled") node.disabled = !!value; + else node.setAttribute(key, value); + } + + for (const child of children.flat()) { + if (child === undefined || child === null) continue; + node.append(child.nodeType ? child : document.createTextNode(String(child))); + } + + return node; +} + +function icon(name, size = 13) { + const span = el("span", { class: "vk-icon" }); + span.innerHTML = VK.icon(name, size); + return span; +} + +function hydrateIcons(root = document) { + for (const span of $$("[data-icon]", root)) span.innerHTML = VK.icon(span.dataset.icon); +} + +function toast(message, kind = "") { + const node = el("div", { class: `vk-toast ${kind}`, text: message }); + $("#toasts").append(node); + setTimeout(() => node.remove(), 5000); +} + +function monogram(name) { + const clean = String(name ?? "?").replace(/[^a-zA-Z0-9]/g, ""); + return (clean[0] ?? "?").toUpperCase(); +} + +const CDN_BASE = "https://cdn.discordapp.com"; + +function cdnHashUrl(route, id, hash, size = 64) { + if (!hash) return null; + const ext = String(hash).startsWith("a_") ? "gif" : "png"; + return `${CDN_BASE}/${route}/${id}/${hash}.${ext}?size=${size}`; +} + +function defaultAvatarUrl(id) { + try { + return `${CDN_BASE}/embed/avatars/${Number(BigInt(String(id)) >> 22n) % 6}.png`; + } catch { + return null; + } +} + +function userAvatarUrl(user, size = 64) { + if (!user?.id) return null; + return cdnHashUrl("avatars", user.id, user.avatar, size) ?? defaultAvatarUrl(user.id); +} + +function guildIconUrl(guild, size = 64) { + return cdnHashUrl("icons", guild.id, guild.icon, size); +} + +function dmIconUrl(dm, size = 64) { + if (dm?.icon) return cdnHashUrl("channel-icons", dm.id, dm.icon, size); + const recipient = dm?.recipients?.[0]; + return recipient ? userAvatarUrl(recipient, size) : null; +} + +function avatar(entity, { agent = false, size = "", url } = {}) { + const name = typeof entity === "string" ? entity : entity?.username ?? entity?.name ?? "?"; + const resolved = url ?? (typeof entity === "object" && entity ? userAvatarUrl(entity) : null); + const node = el("span", { class: `vk-avatar ${agent ? "agent" : ""} ${size}`.trim() }); + + if (resolved) { + const img = el("img", { src: resolved, alt: "", loading: "lazy" }); + img.addEventListener("error", () => { + img.remove(); + node.textContent = monogram(name); + }); + node.append(img); + } else { + node.textContent = monogram(name); + } + + return node; +} + +function dmAvatar(dm, opts = {}) { + return avatar(dmLabel(dm), { ...opts, url: dmIconUrl(dm) }); +} + +function guildMarkContent(mark, guild) { + const url = guildIconUrl(guild); + if (!url) { + mark.textContent = monogram(guild.name); + return; + } + + const img = el("img", { src: url, alt: "", loading: "lazy" }); + img.addEventListener("error", () => { + img.remove(); + mark.textContent = monogram(guild.name); + }); + mark.append(img); +} + +function formatTime(timestamp) { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function formatUptime(seconds) { + if (!Number.isFinite(seconds)) return "up —"; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days) return `up ${days}d ${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; + if (hours) return `up ${hours}h ${String(minutes).padStart(2, "0")}m`; + return `up ${minutes}m`; +} + +function currentGuild() { + return state.guilds.find(guild => guild.id === state.guildId) ?? null; +} + +function findGuildChannel(channelId) { + for (const guild of state.guilds) { + const channel = guild.channels.find(item => item.id === channelId); + if (channel) return channel; + } + return null; +} + +function dmById(channelId) { + return state.dms.find(dm => dm.id === channelId) ?? null; +} + +function dmLabel(dm) { + if (dm.name) return dm.name; + const names = (dm.recipients ?? []).map(recipient => recipient.username); + if (!names.length) return "DM"; + if (names.length <= 2) return names.join(", "); + return `${names[0]} + ${names.length - 1}`; +} + +function channelLabel() { + if (!state.channel) return "No channel selected"; + return state.channel.name ?? channelLabelFromDm(); +} + +function channelLabelFromDm() { + const dm = state.channelId ? dmById(state.channelId) : null; + return dm ? dmLabel(dm) : "Direct message"; +} + +/* Data loading */ + +async function loadState() { + let data; + try { + data = await VK.api.get("/api/state"); + } catch { + renderIdentity(); + renderGuildPicker(); + renderGuildSwitcher(); + return; + } + + state.user = data.user ?? null; + state.guilds = data.guilds ?? []; + + const visible = visibleGuildsList(); + if (!state.guildId || !visible.some(guild => guild.id === state.guildId)) { + state.guildId = visible[0]?.id ?? null; + } + + renderIdentity(); + renderGuildPicker(); + renderGuildSwitcher(); + renderVisibleGuilds(); + renderRail(); + + const guild = currentGuild(); + if (!state.channelId && guild) { + const first = guild.channels.find(channel => SENDABLE_TYPES.has(channel.type)); + if (first) await selectChannel(first.id, guild.id); + } else if (state.channelId) { + renderComposer(); + } +} + +async function loadDms() { + try { + const result = await VK.api.get("/api/dms"); + state.dms = result.channels ?? []; + } catch { + state.dms = []; + } + renderDirect(); + renderRightDms(); + renderRail(); +} + +async function loadVoice() { + try { + state.voiceSelf = await VK.api.get("/api/voice"); + } catch { + state.voiceSelf = null; + } + + if (state.guildId) { + try { + const overview = await VK.api.get(`/api/voice/overview?guildId=${encodeURIComponent(state.guildId)}`); + state.voiceOverview = overview.channels ?? []; + } catch { + state.voiceOverview = []; + } + } else { + state.voiceOverview = []; + } + + renderVoiceRail(); + renderRightVoice(); + renderCallStrip(); +} + +async function loadMessages({ refresh = false } = {}) { + if (!state.channelId) { + state.messages = []; + renderMessages(); + return; + } + + const channelId = state.channelId; + + try { + // refresh fetches the latest 50 from Discord and fills the client cache. + // Attachments stay CDN links; nothing is downloaded or cached locally. + const result = await VK.api.get( + `/api/messages?channelId=${encodeURIComponent(channelId)}&limit=50${refresh ? "&refresh=1" : ""}` + ); + if (channelId !== state.channelId) return; + state.messages = result.messages ?? []; + } catch (error) { + if (channelId !== state.channelId) return; + state.messages = []; + toast(error.message, "error"); + } + + renderMessages(); +} + +let refreshing = false; + +/** Reload everything from the daemon without touching any draft input. */ +async function refreshAll() { + if (refreshing) return; + refreshing = true; + + try { + await loadState(); + await loadDms(); + await loadVoice(); + if (state.channelId) await loadMessages({ refresh: true }); + } catch (error) { + toast(error.message, "error"); + } finally { + refreshing = false; + } +} + +async function selectGuild(guildId) { + state.guildId = guildId; + state.channelId = null; + state.channel = null; + $("#guild-menu").hidden = true; + + renderGuildPicker(); + renderGuildSwitcher(); + renderRail(); + renderRightVoice(); + + const guild = currentGuild(); + const first = guild?.channels.find(channel => SENDABLE_TYPES.has(channel.type)); + if (first) await selectChannel(first.id, guildId); + + await loadVoice(); +} + +async function selectChannel(channelId, guildId) { + if (guildId && guildId !== state.guildId) state.guildId = guildId; + + state.channelId = channelId; + state.channel = findGuildChannel(channelId); + + if (!state.channel) { + const dm = dmById(channelId); + if (dm) state.channel = { id: dm.id, name: dmLabel(dm), type: dm.type, dm }; + } + + renderGuildPicker(); + renderRail(); + renderCenterHeader(); + renderRightDms(); + renderComposer(); + await loadMessages({ refresh: true }); + await loadVoice(); +} + +async function sendMessage() { + if (!state.takeover) { + toast("Composer is read-only — turn Takeover on to send", "error"); + return; + } + + const input = $("#composer-input"); + const content = input?.value.trim() ?? ""; + if (!content || !state.channelId) return; + + try { + await VK.api.post("/api/messages", { channelId: state.channelId, content }); + localStorage.removeItem(draftKey(state.channelId)); + input.value = ""; + autoGrow(input); + toast("Message sent", "ok"); + await loadMessages(); + } catch (error) { + toast(error.message, "error"); + } +} + +function autoGrow(input) { + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, 160)}px`; +} + +/* Header and rail rendering */ + +function renderIdentity() { + const footer = $("#agent-identity"); + const name = state.user?.username ?? "not logged in"; + + footer.replaceChildren( + state.user ? avatar(state.user, { agent: true }) : avatar(name, { agent: true }), + el( + "span", + { class: "vk-right-name" }, + el("span", { class: "vk-agent-name", text: name }), + el("span", { class: "vk-agent-sub", text: "agent · headless" }) + ) + ); +} + +function renderGuildPicker() { + const guild = currentGuild(); + $("#guild-picker-label").textContent = guild?.name ?? "Direct messages"; +} + +function renderGuildSwitcher() { + const root = $("#guild-switcher"); + root.replaceChildren(); + + for (const guild of visibleGuildsList()) { + const button = el("button", { + class: `vk-guild-mark ${guild.id === state.guildId ? "active" : ""}`, + type: "button", + title: guild.name, + onclick: () => void selectGuild(guild.id) + }); + guildMarkContent(button, guild); + root.append(button); + } +} + +function setGuildVisible(guildId, visible) { + if (!state.visibleGuilds) state.visibleGuilds = new Set(state.guilds.map(guild => guild.id)); + + if (visible) state.visibleGuilds.add(guildId); + else state.visibleGuilds.delete(guildId); + saveVisibleGuilds(); + + if (state.guildId && !isGuildVisible(state.guildId)) { + const next = visibleGuildsList()[0]; + if (next) void selectGuild(next.id); + } + + renderGuildPicker(); + renderGuildSwitcher(); + renderVisibleGuilds(); +} + +function renderVisibleGuilds() { + const root = $("#guild-visibility"); + if (!root) return; + root.replaceChildren(); + + if (!state.guilds.length) { + root.append(el("div", { class: "vk-empty", text: "No guilds cached." })); + return; + } + + for (const guild of state.guilds) { + const checkbox = el("input", { type: "checkbox" }); + checkbox.checked = isGuildVisible(guild.id); + checkbox.addEventListener("change", () => setGuildVisible(guild.id, checkbox.checked)); + + const mark = el("span", { class: "vk-guild-icon-sm" }); + guildMarkContent(mark, guild); + + root.append(el("label", { class: "vk-visibility-row" }, checkbox, mark, el("span", { text: guild.name }))); + } +} + +function voiceMarks(member) { + const marks = el("span", { class: "vk-marks" }); + + if (member.selfMute || member.mute) { + marks.append(el("span", { class: "neutral", title: member.mute ? "muted by server" : "self-muted", html: VK.icon("micSlash") })); + } + if (member.selfDeaf || member.deaf) { + marks.append(el("span", { class: "neutral", title: "deafened", html: VK.icon("speakerSlash") })); + } + + return marks; +} + +function renderRail() { + renderTextChannels(); + renderVoiceRail(); + renderDirect(); +} + +function renderTextChannels() { + const root = $("#text-channels"); + root.replaceChildren(); + const guild = currentGuild(); + if (!guild) return; + + const channels = guild.channels.filter(channel => TEXT_TYPES.has(channel.type)); + if (!channels.length) { + root.append(el("div", { class: "vk-empty", text: "No text channels cached." })); + return; + } + + for (const channel of channels) { + root.append( + el( + "button", + { + class: `vk-rail-row ${channel.id === state.channelId ? "active" : ""}`, + type: "button", + onclick: () => void selectChannel(channel.id) + }, + el("span", { class: "vk-icon vk-dim", html: VK.icon("hash") }), + el("span", { class: "vk-rail-name", text: channel.name }) + ) + ); + } +} + +function renderVoiceRail() { + const root = $("#voice-channels"); + if (!root) return; + root.replaceChildren(); + + const guild = currentGuild(); + if (!guild) return; + + const voiceChannels = guild.channels.filter(channel => VOICE_TYPES.has(channel.type)); + if (!voiceChannels.length) { + root.append(el("div", { class: "vk-empty", text: "No voice channels cached." })); + return; + } + + const occupied = new Map((state.voiceOverview ?? []).map(entry => [entry.channelId, entry])); + + for (const channel of voiceChannels) { + const call = occupied.get(channel.id); + const active = state.voiceSelf?.channelId === channel.id; + + if (call?.members?.length) { + const block = el("div", { class: "vk-rail-voice" }); + block.append( + el( + "div", + { class: "vk-rail-voice-head" }, + el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }), + el("span", { class: "vk-rail-name", style: "font-weight:500", text: channel.name }), + el( + "span", + { class: "vk-rail-trail" }, + el("span", { + style: `display:inline-block;width:5px;height:5px;border-radius:50%;margin-right:5px;background:var(--color-${active ? "accent" : "neutral-500"})` + }), + `${call.members.length}` + ) + ) + ); + + const members = el("div", { class: "vk-rail-voice-members" }); + for (const member of call.members) { + members.append( + el( + "span", + { class: "vk-voice-member" }, + el("span", { + class: "vk-name", + style: member.id === state.user?.id ? "color: var(--color-accent-300)" : "", + text: member.username + }), + voiceMarks(member) + ) + ); + } + block.append(members); + root.append(block); + } else { + root.append( + el( + "button", + { + class: `vk-rail-row ${active ? "active" : ""}`, + type: "button", + onclick: () => joinVoice(channel.id) + }, + el("span", { class: "vk-icon vk-dim", html: VK.icon("volume") }), + el("span", { class: "vk-rail-name", text: channel.name }), + el("span", { class: "vk-rail-trail", text: "0" }) + ) + ); + } + } +} + +function renderDirect() { + const root = $("#direct-list"); + if (!root) return; + root.replaceChildren(); + + if (!state.dms.length) { + root.append(el("div", { class: "vk-empty", text: "No cached DMs." })); + return; + } + + for (const dm of state.dms.slice(0, 12)) { + root.append( + el( + "button", + { + class: `vk-rail-row ${dm.id === state.channelId ? "active" : ""}`, + type: "button", + onclick: () => void selectChannel(dm.id) + }, + dmAvatar(dm, { size: "sm" }), + el("span", { class: "vk-rail-name", text: dmLabel(dm) }) + ) + ); + } +} + +/* Center rendering */ + +function renderCenterHeader() { + const title = channelLabel(); + const channel = state.channel; + const guild = currentGuild(); + + $("#channel-title").textContent = title; + $("#channel-icon").innerHTML = VK.icon( + channel && VOICE_TYPES.has(channel.type) ? "volume" : channel?.dm ? "speaker" : "hash" + ); + + let context = ""; + if (channel?.dm) { + const count = channel.dm.recipients?.length ?? 0; + context = channel.dm.type === 3 ? `group · ${count} recipients` : "direct message"; + } else if (guild) { + context = `${guild.name} · ${guild.channels.length} channels cached`; + } + + $("#channel-context").textContent = context; +} + +function renderMessages() { + const root = $("#message-list"); + root.replaceChildren(); + + if (!state.channelId) { + root.append(el("div", { class: "vk-empty", text: "Pick a channel on the left." })); + return; + } + + if (!state.messages.length) { + root.append( + el("div", { + class: "vk-empty", + text: "No cached messages. The client only holds channels it has loaded." + }) + ); + return; + } + + for (const message of state.messages) { + const author = message.author?.username ?? "unknown"; + const isAgent = message.author?.id === state.user?.id; + const body = el( + "div", + { class: "vk-message-body-wrap" }, + el( + "span", + { class: "vk-message-head" }, + el("span", { class: "vk-message-author", text: author }), + isAgent ? el("span", { class: "tag tag-outline vk-tag-agent", text: "agent" }) : null, + el("span", { class: "vk-mono vk-dim", text: formatTime(message.timestamp) }) + ), + el("span", { class: "vk-message-body", text: message.content }) + ); + + for (const attachment of message.attachments ?? []) { + if (!attachment) continue; + + const filename = attachment.filename ?? "attachment"; + const isImage = + String(attachment.contentType ?? "").startsWith("image/") || + /\.(png|jpe?g|gif|webp|avif)$/i.test(filename); + + if (isImage && attachment.url) { + const image = el("img", { + class: "vk-message-image", + src: attachment.url, + alt: filename, + loading: "lazy" + }); + image.addEventListener("error", () => image.remove()); + body.append(image); + } else if (attachment.url) { + body.append( + el("a", { + class: "vk-attachment", + href: attachment.url, + target: "_blank", + rel: "noreferrer", + text: filename + }) + ); + } else if (filename) { + body.append(el("span", { class: "vk-attachment", text: filename })); + } + } + + root.append(el("div", { class: "vk-message" }, avatar(message.author ?? author, { agent: isAgent, size: "lg" }), body)); + } + + const scroll = $("#message-scroll"); + scroll.scrollTop = scroll.scrollHeight; +} + +function ttsToggleButton() { + return el("button", { + id: "tts-toggle", + class: "btn btn-secondary vk-icon-btn vk-tts-toggle", + type: "button", + title: "Text to speech", + onclick: toggleTts, + html: VK.icon("wave") + }); +} + +function composerPlaceholder() { + const target = channelLabel() === "No channel selected" ? "" : channelLabel(); + return `Message ${target} as ${state.user?.username ?? "the account"}`; +} + +function renderComposer() { + const wrap = $("#composer"); + const existing = $("#composer-input"); + + if (state.takeover) { + wrap.classList.add("unlocked"); + + // Reuse the live input so typing and caret survive re-renders. + if (existing && state.composerChannelId === state.channelId) { + existing.placeholder = composerPlaceholder(); + return; + } + + const draft = state.channelId ? localStorage.getItem(draftKey(state.channelId)) ?? "" : ""; + const input = el("textarea", { + id: "composer-input", + class: "vk-composer-input", + rows: "1", + placeholder: composerPlaceholder() + }); + input.value = draft; + input.addEventListener("input", () => { + autoGrow(input); + if (state.channelId) localStorage.setItem(draftKey(state.channelId), input.value); + }); + input.addEventListener("keydown", event => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void sendMessage(); + } + }); + + state.composerChannelId = state.channelId; + wrap.replaceChildren( + ttsToggleButton(), + input, + el("button", { + id: "send", + class: "btn btn-primary vk-send", + type: "button", + text: "Send", + onclick: () => void sendMessage() + }) + ); + requestAnimationFrame(() => autoGrow(input)); + return; + } + + state.composerChannelId = null; + wrap.classList.remove("unlocked"); + wrap.replaceChildren( + el("span", { class: "tag tag-neutral vk-readonly-tag", text: "read-only" }), + icon("lock"), + el("span", { + class: "vk-composer-copy", + text: `Composer locked — takeover is off. Flip Takeover to send as ${state.user?.username ?? "the account"}.` + }), + ttsToggleButton(), + el("button", { class: "btn btn-secondary vk-send", type: "button", text: "Send", disabled: true }) + ); +} + +function toggleTts() { + const panel = $("#tts-panel"); + panel.hidden = !panel.hidden; + $("#tts-target").textContent = `as ${state.user?.username ?? "account"} → ${channelLabel()}`; +} + +/* Right column */ + +function renderRightVoice() { + const root = $("#right-voice"); + if (!root) return; + root.replaceChildren(); + + const channels = (state.voiceOverview ?? []).filter(entry => entry.members?.length); + const rows = channels.flatMap(entry => entry.members.map(member => ({ member, entry }))); + + $("#voice-count").textContent = rows.length ? `${Math.min(rows.length, 7)} of ${rows.length} shown` : ""; + + if (!rows.length) { + root.append(el("div", { class: "vk-empty", text: "No voice channels occupied." })); + return; + } + + for (const { member, entry } of rows.slice(0, 7)) { + const isSelf = member.id === state.user?.id; + + root.append( + el( + "div", + { class: `vk-right-row ${isSelf ? "highlight" : ""}` }, + avatar(member), + el( + "span", + { class: "vk-right-name" }, + el("span", { class: "vk-name", text: member.username }), + el("span", { class: "vk-sub", text: entry.channelName ?? entry.channelId }) + ), + el( + "span", + { class: "vk-right-trail" }, + voiceMarks(member), + isSelf + ? el("button", { + class: "btn btn-secondary vk-small-btn", + type: "button", + text: "View", + onclick: () => toast("Voice monitoring is not implemented yet", "error") + }) + : el("button", { + class: "btn btn-primary vk-small-btn", + type: "button", + text: "Join", + onclick: () => joinVoice(entry.channelId) + }) + ) + ) + ); + } +} + +function renderRightDms() { + const root = $("#right-dms"); + if (!root) return; + root.replaceChildren(); + + $("#dm-count").textContent = state.dms.length ? `${state.dms.length} threads` : ""; + + if (!state.dms.length) { + root.append(el("div", { class: "vk-empty", text: "No cached DMs." })); + return; + } + + for (const dm of state.dms.slice(0, 8)) { + root.append( + el( + "button", + { + class: `vk-rail-row ${dm.id === state.channelId ? "active" : ""}`, + type: "button", + onclick: () => void selectChannel(dm.id) + }, + dmAvatar(dm, { size: "sm" }), + el("span", { class: "vk-rail-name", text: dmLabel(dm) }) + ) + ); + } +} + +function renderCallStrip() { + const strip = $("#call-strip"); + const call = state.voiceSelf; + + if (!call?.channelId || call.channelId === state.channelId) { + strip.hidden = true; + strip.replaceChildren(); + return; + } + + strip.replaceChildren( + el( + "span", + { class: "vk-call-title" }, + el("span", { class: "vk-call-live" }), + el("span", { class: "vk-call-name", text: call.channelName ?? call.channelId }), + el("span", { class: "vk-mono vk-dim", text: `${call.members?.length ?? 0} in call` }) + ), + el( + "div", + { class: "vk-call-pills" }, + ...(call.members ?? []).map(member => + el( + "span", + { class: "vk-strip-pill" }, + avatar(member, { size: "sm" }), + el("span", { class: "vk-strip-name", text: member.username }), + voiceMarks(member) + ) + ) + ), + el( + "div", + { class: "vk-call-actions" }, + el("button", { + class: "btn btn-secondary vk-small-btn", + type: "button", + text: "Return to call", + onclick: () => void selectChannel(call.channelId) + }), + el("button", { + class: "btn btn-secondary vk-small-btn", + type: "button", + text: "Leave", + onclick: () => voiceAction("/api/voice/leave") + }) + ) + ); + + strip.hidden = false; +} + +async function joinVoice(channelId) { + await voiceAction("/api/voice/join", { channelId }); +} + +async function voiceAction(path, body) { + try { + await VK.api.post(path, body ?? {}); + toast("Voice action accepted", "ok"); + await loadVoice(); + } catch (error) { + toast(error.message, "error"); + } +} + +/* Console */ + +function addEvent(event) { + state.events.unshift(event); + if (state.events.length > 300) state.events.pop(); + state.eventCount++; + + const log = $("#event-log"); + if (log) { + log.prepend( + el( + "div", + { class: "vk-event-line" }, + el("span", { class: "vk-event-time", text: new Date().toLocaleTimeString() }), + el("span", { class: "vk-event-type", text: event.type ?? "event" }), + el("span", { text: JSON.stringify(event) }) + ) + ); + + while (log.childElementCount > 200) log.lastElementChild.remove(); + } + + const ticker = $("#ticker-line"); + if (ticker) ticker.textContent = `${event.type ?? "event"} ${JSON.stringify(event).slice(0, 180)}`; + + syncConsoleCount(); +} + +function syncConsoleCount() { + const count = $("#console-count"); + if (count) count.textContent = `${state.eventCount} events`; +} + +function setConsoleOpen(open) { + state.consoleOpen = open; + $("#console-drawer").hidden = !open; + $("#console-caret").innerHTML = VK.icon(open ? "caretDown" : "caretUp"); +} + +function setConsoleTab(tab) { + state.consoleTab = tab; + for (const button of $$(".vk-console-tab")) button.classList.toggle("active", button.dataset.consoleTab === tab); + for (const panel of $$(".vk-console-panel")) panel.hidden = panel.id !== `console-panel-${tab}`; + if (!state.consoleOpen) setConsoleOpen(true); +} + +/* Settings */ + +function openSettings() { + $("#settings-overlay").hidden = false; + void refreshCoreStatus(); + void refreshAuth(); + void refreshLogs(); +} + +function closeSettings() { + $("#settings-overlay").hidden = true; +} + +function statusRow(label, value) { + return el( + "div", + { class: "vk-status-row" }, + el("span", { class: "vk-status-label", text: label }), + el("span", { class: "vk-status-value" }, value) + ); +} + +function badge(text, kind = "") { + return el("span", { class: `tag ${kind === "ok" ? "tag-accent" : "tag-neutral"}`, text }); +} + +function renderCoreStatus(status) { + const card = $("#core-status"); + const process = status.process ?? {}; + const rows = [ + statusRow("bridge", badge(status.bridgeConnected ? "connected" : "disconnected", status.bridgeConnected ? "ok" : "")), + statusRow("process", badge(process.running ? "running" : "stopped", process.running ? "ok" : "")), + statusRow("pid", el("code", { text: process.pid ?? "—" })), + statusRow("managed", el("span", { text: process.managed ? "yes" : process.running ? "external" : "no" })), + statusRow("started", el("span", { text: process.startedAt ? new Date(process.startedAt).toLocaleString() : "—" })), + statusRow("command", el("code", { text: process.command ?? "—" })) + ]; + + if (process.lastExit) { + const exit = process.lastExit; + const text = exit.signal ? `signal ${exit.signal}` : `code ${exit.code ?? "?"}`; + rows.push(statusRow("last exit", badge(text, exit.code === 0 ? "ok" : ""))); + } + + card.replaceChildren(...rows); + + if (process.output?.length) { + card.append(el("pre", { class: "vk-core-output", text: process.output.join("\n") })); + } +} + +async function refreshCoreStatus() { + try { + renderCoreStatus(await VK.api.get("/api/core/status")); + } catch (error) { + toast(error.message, "error"); + } +} + +async function coreAction(path, label) { + try { + renderCoreStatus(await VK.api.post(path)); + toast(`${label} requested`, "ok"); + void refreshHealth(); + } catch (error) { + toast(error.message, "error"); + } +} + +function renderAuth(status) { + const card = $("#auth-status"); + const session = status.session; + const rows = [ + statusRow("token file", el("code", { text: status.tokenFile })), + statusRow("token", badge(status.hasToken ? "present" : "missing", status.hasToken ? "ok" : "")), + statusRow("session", el("span", { text: session?.state ?? "none" })) + ]; + + if (session?.user) rows.push(statusRow("user", el("span", { text: `${session.user.tag} — ${session.user.id}` }))); + if (session?.captcha) rows.push(statusRow("captcha sitekey", el("code", { text: session.captcha.sitekey ?? "—" }))); + if (session?.error) rows.push(statusRow("error", el("span", { text: session.error }))); + + card.replaceChildren(...rows); + + const qr = $("#auth-qr"); + if (status.qrDataUrl) { + qr.replaceChildren(el("img", { src: status.qrDataUrl, alt: "Discord login QR" })); + qr.hidden = false; + } else { + qr.replaceChildren(); + qr.hidden = true; + } + + $("#auth-captcha").hidden = session?.state !== "captcha"; +} + +async function refreshAuth() { + try { + renderAuth(await VK.api.get("/api/auth")); + } catch (error) { + toast(error.message, "error"); + } +} + +async function authAction(path, label) { + try { + renderAuth(await VK.api.post(path)); + if (label) toast(label, "ok"); + } catch (error) { + toast(error.message, "error"); + } +} + +async function refreshLogs() { + const root = $("#action-log"); + try { + const entries = await VK.api.get("/api/logs?limit=100"); + root.replaceChildren(); + + if (!entries.length) { + root.append(el("div", { class: "vk-empty", text: "Action log is empty." })); + return; + } + + for (const entry of [...entries].reverse()) { + root.append( + el( + "div", + { class: "vk-log-line" }, + el("span", { class: "vk-log-time", text: entry.timestamp?.slice(11, 19) ?? "" }), + el("span", { class: "vk-log-type", text: entry.type ?? "?" }), + el("span", { text: JSON.stringify(entry).slice(0, 220) }) + ) + ); + } + } catch (error) { + root.replaceChildren(el("div", { class: "vk-empty", text: error.message })); + } +} + +/* Health */ + +async function refreshHealth() { + try { + const health = await VK.api.get("/api/health"); + state.health = health; + + const now = Date.now(); + if (state.lastEventSample) { + const seconds = (now - state.lastEventSample.at) / 1000; + const delta = (health.events ?? 0) - state.lastEventSample.events; + if (seconds > 0) state.eventRate = delta / seconds; + } + state.lastEventSample = { at: now, events: health.events ?? 0 }; + + const dot = $("#health-dot"); + dot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`; + $("#health-bridge").textContent = health.coreConnected ? "bridge ok" : "bridge down"; + $("#health-uptime").textContent = formatUptime(health.uptimeSeconds); + $("#health-events").textContent = `${state.eventRate.toFixed(1)} ev/s`; + + const mcpDot = $("#mcp-dot"); + mcpDot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`; + $("#mcp-state").textContent = health.coreConnected ? "mcp connected" : "mcp offline"; + } catch { + $("#health-dot").className = "vk-dot offline"; + $("#health-bridge").textContent = "daemon down"; + } +} + +/* Live events */ + +function applyHello(event) { + if (typeof event.coreConnected !== "boolean") return; + $("#health-dot").className = `vk-dot ${event.coreConnected ? "online" : "offline"}`; + $("#health-bridge").textContent = event.coreConnected ? "bridge ok" : "bridge down"; + const mcpDot = $("#mcp-dot"); + if (mcpDot) mcpDot.className = `vk-dot ${event.coreConnected ? "online" : "offline"}`; + + // The page may have loaded before the bridge was up. + if (event.coreConnected && !state.user) void refreshAll(); +} + +function renderRightFavourites() { + const root = $("#right-favourites"); + if (!root) return; + root.replaceChildren(el("div", { class: "vk-empty", text: "Favourites are not implemented yet." })); +} + +function handleLiveEvent(event) { + if (event.type === "MESSAGE_CREATE" && event.channelId === state.channelId) { + void loadMessages(); + } + + if (event.type === "VOICE_STATE_UPDATE") { + const now = Date.now(); + if (now - state.voiceReloadAt > 1000) { + state.voiceReloadAt = now; + void loadVoice(); + } + } + + if (event.type === "CORE_STATUS") { + void refreshHealth(); + if (event.coreConnected) void refreshAll(); + if (!$("#settings-overlay").hidden) void refreshCoreStatus(); + } + + if (event.type === "AUTH_QR" && !$("#settings-overlay").hidden) { + void refreshAuth(); + } +} + +/* Wiring */ + +function init() { + hydrateIcons(); + loadVisibleGuilds(); + + const takeover = $("#takeover"); + takeover.checked = state.takeover; + $("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover"; + takeover.addEventListener("change", () => { + state.takeover = takeover.checked; + localStorage.setItem("veskslop-takeover", state.takeover ? "1" : "0"); + $("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover"; + renderComposer(); + }); + + $("#guild-picker").addEventListener("click", () => { + const menu = $("#guild-menu"); + menu.hidden = !menu.hidden; + if (menu.hidden) return; + + menu.replaceChildren( + ...visibleGuildsList().map(guild => + el("button", { + class: `vk-menu-item ${guild.id === state.guildId ? "active" : ""}`, + type: "button", + text: guild.name, + onclick: () => void selectGuild(guild.id) + }) + ) + ); + }); + + $("#settings-open").addEventListener("click", openSettings); + $("#settings-close").addEventListener("click", closeSettings); + $("#settings-overlay").addEventListener("click", event => { + if (event.target === $("#settings-overlay")) closeSettings(); + }); + + $("#core-start").addEventListener("click", () => void coreAction("/api/core/start", "Start")); + $("#core-stop").addEventListener("click", () => void coreAction("/api/core/stop", "Stop")); + $("#core-restart").addEventListener("click", () => void coreAction("/api/core/restart", "Restart")); + + $("#auth-start").addEventListener("click", () => void authAction("/api/auth/qr", "QR login started")); + $("#auth-cancel").addEventListener("click", () => void authAction("/api/auth/qr/cancel", "Login cancelled")); + $("#auth-restart").addEventListener("click", async () => { + try { + renderCoreStatus(await VK.api.post("/api/core/restart", { forceToken: true })); + toast("Restarting with the token file", "ok"); + } catch (error) { + toast(error.message, "error"); + } + }); + + $("#auth-captcha").addEventListener("submit", async event => { + event.preventDefault(); + const key = $("#auth-captcha-key").value.trim(); + if (!key) return; + + try { + await VK.api.post("/api/auth/qr/captcha", { key }); + $("#auth-captcha-key").value = ""; + toast("Captcha submitted", "ok"); + void refreshAuth(); + } catch (error) { + toast(error.message, "error"); + } + }); + + $("#logs-refresh").addEventListener("click", () => void refreshLogs()); + + $("#console-toggle").addEventListener("click", () => setConsoleOpen(!state.consoleOpen)); + for (const tab of $$(".vk-console-tab")) { + tab.addEventListener("click", () => setConsoleTab(tab.dataset.consoleTab)); + } + + $("#raw-events").addEventListener("click", () => setConsoleTab("events")); + $("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true })); + + $("#guilds-show-all").addEventListener("click", () => { + state.visibleGuilds = null; + localStorage.removeItem("veskslop-visible-guilds"); + renderVisibleGuilds(); + renderGuildSwitcher(); + renderGuildPicker(); + }); + + const ttsInput = $("#tts-input"); + ttsInput.value = localStorage.getItem(TTS_DRAFT_KEY) ?? ""; + ttsInput.addEventListener("input", () => localStorage.setItem(TTS_DRAFT_KEY, ttsInput.value)); + + $("#tts-close").addEventListener("click", () => { + $("#tts-panel").hidden = true; + }); + $("#tts-speak").addEventListener("click", () => toast("TTS is not implemented yet", "error")); + + document.addEventListener("keydown", event => { + if (event.key !== "Escape") return; + if (!$("#settings-overlay").hidden) closeSettings(); + else if (state.consoleOpen) setConsoleOpen(false); + }); + + VK.bus.on("event", event => { + if (event.type === "hello") { + applyHello(event); + return; + } + + addEvent(event); + handleLiveEvent(event); + }); + + VK.connectEvents(); + + setConsoleTab("events"); + setConsoleOpen(false); + syncConsoleCount(); + renderRightFavourites(); + + void loadState(); + void loadDms(); + void loadVoice(); + void refreshHealth(); + + setInterval(refreshHealth, 5000); + setInterval(() => { + if (!$("#settings-overlay").hidden) { + if (!$("#auth-qr").hidden || $("#auth-captcha").hidden === false) void refreshAuth(); + } + }, 3000); +} + +init(); diff --git a/poc/public/icons.js b/poc/public/icons.js new file mode 100644 index 0000000..bb4709a --- /dev/null +++ b/poc/public/icons.js @@ -0,0 +1,44 @@ +"use strict"; + +window.VK = window.VK ?? {}; + +const ICON_PATHS = { + hash: '', + volume: '', + mic: '', + micSlash: + '', + speaker: '', + speakerSlash: + '', + monitor: '', + camera: '', + caretDown: '', + caretUp: '', + x: '', + lock: '', + search: '', + gear: '', + wave: '', + warning: '', + refresh: '', + play: '', + microphone: '' +}; + +const FILLED = new Set(["star"]); + +ICON_PATHS.star = ''; + +window.VK.icon = function icon(name, size = 13) { + const path = ICON_PATHS[name] ?? ""; + const fill = FILLED.has(name) ? "currentColor" : "none"; + return ``; +}; + +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; +}; diff --git a/poc/public/index.html b/poc/public/index.html new file mode 100644 index 0000000..ce973c9 --- /dev/null +++ b/poc/public/index.html @@ -0,0 +1,262 @@ + + + + + + veskslop debug + + + + +
+
+ + veskslop + debug + + +
+ + +
+ +
+ + + bridge … + + up … + 0.0 ev/s +
+ +
+ + +
+
+ + + +
+ + +
+
+ + No channel selected + +
+ + +
+
+ +
+
+
+ +
+ + +
+ + + + +
+
+
+ + +
+ +
+
+ +
+ + + +
+
waiting for events
+
+ 0 events + bridge … +
+
+ +
+
+ + + +
+ + + + + + diff --git a/poc/public/nocturne.css b/poc/public/nocturne.css new file mode 100644 index 0000000..4be249a --- /dev/null +++ b/poc/public/nocturne.css @@ -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); } + diff --git a/poc/public/style.css b/poc/public/style.css new file mode 100644 index 0000000..62ecca1 --- /dev/null +++ b/poc/public/style.css @@ -0,0 +1,1449 @@ +/* Veskslop debug UI — application layer on top of the Nocturne tokens. */ + +:root { + --vk-header-h: 50px; + --vk-strip-h: 46px; + --vk-console-h: 35px; + --vk-rail-w: 236px; + --vk-right-w: 306px; + --vk-drawer-h: 250px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); + font-size: 13.5px; + line-height: 1.5; + overflow: hidden; +} + +button { + font: inherit; +} + +.vk-app { + display: flex; + flex-direction: column; + height: 100vh; + min-height: 0; +} + +.vk-mono { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 10.5px; + line-height: 1.3; +} + +.vk-dim { + color: var(--color-neutral-500); +} + +.vk-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; +} + +.vk-icon svg { + display: block; +} + +.vk-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-neutral-600); +} + +.vk-dot.online { + background: var(--color-accent-400); +} + +.vk-dot.offline { + background: var(--color-neutral-600); +} + +.vk-dot.error { + background: var(--color-neutral-200); +} + +.vk-small-btn { + padding: 4px 9px; + font-size: 11.5px; + gap: 6px; +} + +.vk-icon-btn { + width: 28px; + height: 26px; + padding: 0; +} + +.vk-micro-label { + flex: none; + font: 500 9.5px/1 var(--font-heading); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-neutral-400); + padding-right: 2px; +} + +/* Header */ + +.vk-header { + display: flex; + align-items: center; + gap: 14px; + height: var(--vk-header-h); + flex: none; + padding: 0 14px; + background: linear-gradient(to bottom, var(--color-surface), var(--color-bg)); +} + +.vk-brand { + display: flex; + align-items: baseline; + gap: 7px; +} + +.vk-brand-name { + font: 500 15px/1 var(--font-heading); + letter-spacing: -0.01em; +} + +.vk-brand-version { + font: 400 10px/1 ui-monospace, Menlo, monospace; + color: var(--color-neutral-400); +} + +.vk-guild-picker { + position: relative; +} + +.vk-guild-button { + gap: 8px; + padding: 5px 10px; + font-size: 13px; +} + +.vk-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 220px; + max-height: 60vh; + overflow-y: auto; + z-index: 40; + padding: 4px; + background: var(--color-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); +} + +.vk-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13px; +} + +.vk-menu-item:hover { + background: var(--color-neutral-900); +} + +.vk-menu-item.active { + color: var(--color-accent-300); + box-shadow: inset 2px 0 0 var(--color-accent); +} + +.vk-health { + display: flex; + align-items: center; + gap: 16px; + padding-left: 4px; +} + +.vk-health-item { + display: flex; + align-items: center; + gap: 6px; +} + +.vk-header-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; +} + +.vk-takeover { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 4px 10px; + border-radius: var(--radius-md); + border: 1px solid var(--color-divider); +} + +.vk-takeover input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.vk-switch { + width: 26px; + height: 14px; + flex: none; + border-radius: 7px; + background: var(--color-neutral-800); + box-shadow: inset 0 0 0 1px var(--color-neutral-700); + position: relative; + transition: background 0.15s; +} + +.vk-knob { + position: absolute; + left: 2px; + top: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-neutral-500); + transition: transform 0.15s, background 0.15s; +} + +.vk-takeover input:checked + .vk-switch { + background: color-mix(in srgb, var(--color-accent) 30%, transparent); + box-shadow: inset 0 0 0 1px var(--color-accent); +} + +.vk-takeover input:checked + .vk-switch .vk-knob { + transform: translateX(12px); + background: var(--color-accent-200); +} + +.vk-takeover input:focus-visible + .vk-switch { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.vk-takeover-label { + font: 500 11px/1 var(--font-heading); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-neutral-400); +} + +.vk-takeover input:checked ~ .vk-takeover-label { + color: var(--color-accent); +} + +.vk-settings { + gap: 7px; + padding: 5px 10px; + font-size: 12.5px; +} + +/* Call strip */ + +.vk-call-strip { + flex: none; + display: flex; + align-items: center; + gap: 12px; + padding: 9px 14px; + min-height: var(--vk-strip-h); + background: linear-gradient(to right, var(--color-accent-900), var(--color-surface) 70%); + box-shadow: inset 0 -1px 0 var(--color-accent-800); +} + +.vk-call-strip .vk-call-title { + display: flex; + align-items: center; + gap: 8px; + flex: none; +} + +.vk-call-live { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-accent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 22%, transparent); +} + +.vk-call-name { + font: 500 13px/1.2 var(--font-heading); +} + +.vk-call-strip .vk-call-pills { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + overflow: hidden; +} + +.vk-strip-pill { + display: flex; + align-items: center; + gap: 6px; + flex: none; + padding: 3px 9px 3px 4px; + border-radius: 14px; + background: var(--color-neutral-900); +} + +.vk-strip-pill.speaking { + box-shadow: inset 0 0 0 1px var(--color-accent); +} + +.vk-strip-pill .vk-strip-name { + font: 500 11.5px/1 var(--font-heading); +} + +.vk-call-strip .vk-call-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; + flex: none; +} + +/* Body grid */ + +.vk-body { + flex: 1; + display: grid; + grid-template-columns: var(--vk-rail-w) minmax(0, 1fr) var(--vk-right-w); + min-height: 0; +} + +/* Left rail */ + +.vk-rail { + display: flex; + flex-direction: column; + min-height: 0; + padding: 10px 8px; + gap: 14px; + background: var(--color-surface); + overflow-y: auto; + overflow-x: hidden; +} + +.vk-guild-switcher { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.vk-guild-mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + flex: none; + overflow: hidden; + border-radius: var(--radius-md); + background: var(--color-neutral-900); + font: 500 12px/1 var(--font-heading); + color: var(--color-neutral-500); + cursor: pointer; + border: none; + padding: 0; +} + +.vk-guild-mark img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.vk-guild-mark:hover { + color: var(--color-text); +} + +.vk-guild-mark.active { + background: var(--color-accent-900); + box-shadow: inset 0 0 0 1px var(--color-accent-700); + color: var(--color-accent-300); +} + +.vk-rail-section { + display: flex; + flex-direction: column; + gap: 2px; +} + +.vk-rail-label { + font: 500 10px/1 var(--font-heading); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-neutral-400); + padding: 0 8px 6px; +} + +.vk-rail-label-flat { + padding: 0; +} + +.vk-rail-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.vk-rail-row { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 8px; + border-radius: var(--radius-sm); + color: var(--color-neutral-400); + cursor: pointer; + border: none; + background: none; + text-align: left; + width: 100%; + font-size: 13px; +} + +.vk-rail-row:hover { + color: var(--color-text); + background: color-mix(in srgb, var(--color-text) 4%, transparent); +} + +.vk-rail-row.active { + background: var(--color-neutral-900); + box-shadow: inset 2px 0 0 var(--color-accent); + color: var(--color-text); + font-weight: 500; +} + +.vk-rail-row .vk-rail-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vk-rail-trail { + margin-left: auto; + font: 400 10px/1 ui-monospace, Menlo, monospace; + color: var(--color-neutral-400); + flex: none; +} + +.vk-rail-badge { + display: grid; + place-items: center; + min-width: 17px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: var(--color-accent-800); + font: 500 10px/1 ui-monospace, Menlo, monospace; + color: var(--color-accent-100); +} + +.vk-rail-voice { + display: flex; + flex-direction: column; + gap: 3px; + padding: 5px 8px 7px; + border-radius: var(--radius-sm); + background: var(--color-neutral-900); +} + +.vk-rail-voice-head { + display: flex; + align-items: center; + gap: 7px; +} + +.vk-rail-voice-members { + display: flex; + flex-direction: column; + gap: 2px; + padding-left: 20px; +} + +.vk-voice-member { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--color-neutral-400); +} + +.vk-voice-member .vk-marks { + margin-left: auto; +} + +.vk-rail-footer { + margin-top: auto; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 8px; + border-radius: var(--radius-md); + background: var(--color-neutral-900); + flex: none; +} + +.vk-agent-name { + font: 500 12px/1.2 var(--font-heading); +} + +.vk-agent-sub { + font: 400 9.5px/1.3 ui-monospace, Menlo, monospace; + color: var(--color-neutral-500); +} + +/* Monogram avatars */ + +.vk-avatar { + display: grid; + place-items: center; + width: 26px; + height: 26px; + flex: none; + overflow: hidden; + border-radius: 50%; + background: var(--color-neutral-900); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); + font: 500 11px/1 var(--font-heading); + color: var(--color-neutral-400); +} + +.vk-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.vk-avatar.agent { + border-radius: var(--radius-sm); + background: var(--color-accent-900); + box-shadow: inset 0 0 0 1px var(--color-accent-700); + color: var(--color-accent-300); +} + +.vk-avatar.sm { + width: 22px; + height: 22px; + font-size: 9px; +} + +.vk-avatar.lg { + width: 34px; + height: 34px; + font-size: 14px; +} + +.vk-avatar-wrap { + position: relative; + flex: none; + display: inline-flex; +} + +.vk-presence { + position: absolute; + right: -1px; + bottom: -1px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-accent-400); + box-shadow: 0 0 0 2px var(--color-surface); +} + +.vk-presence.idle { + background: var(--color-neutral-200); +} + +.vk-presence.offline { + background: var(--color-surface); + box-shadow: 0 0 0 1.5px var(--color-neutral-600), 0 0 0 3px var(--color-surface); +} + +/* Center */ + +.vk-center { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +} + +.vk-channel-header { + display: flex; + align-items: center; + gap: 10px; + flex: none; + height: 44px; + padding: 0 18px; + background: linear-gradient( + to right, + transparent, + var(--color-divider) 48px, + var(--color-divider) calc(100% - 48px), + transparent + ) + no-repeat bottom / 100% 1px; +} + +.vk-channel-title { + font: 500 15px/1.2 var(--font-heading); +} + +.vk-channel-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; +} + +.vk-message-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 18px 10px; +} + +.vk-message-list { + display: flex; + flex-direction: column; + gap: 14px; +} + +.vk-message { + display: flex; + gap: 11px; +} + +.vk-message-body-wrap { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.vk-message-head { + display: flex; + align-items: baseline; + gap: 7px; + flex-wrap: wrap; +} + +.vk-message-author { + font: 500 13.5px/1.2 var(--font-heading); +} + +.vk-message-body { + font: 400 13.5px/1.55 var(--font-body); + color: var(--color-neutral-200); + white-space: pre-wrap; + word-break: break-word; +} + +.vk-message-image { + display: block; + margin-top: 6px; + max-width: min(420px, 100%); + max-height: 280px; + border-radius: var(--radius-sm); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); + object-fit: contain; +} + +.vk-attachment { + display: inline-flex; + align-self: flex-start; + margin-top: 6px; + padding: 3px 8px; + border-radius: var(--radius-sm); + background: var(--color-neutral-900); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); + color: var(--color-neutral-300); + font: 400 10.5px/1.4 ui-monospace, Menlo, monospace; + text-decoration: none; +} + +a.vk-attachment:hover { + color: var(--color-accent-300); +} + +.vk-readonly-tag { + flex: none; + font-size: 9.5px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.vk-visibility-row { + display: flex; + align-items: center; + gap: 9px; + padding: 5px 2px; + font-size: 12.5px; + cursor: pointer; +} + +.vk-visibility-row input { + accent-color: var(--color-accent); +} + +.vk-guild-icon-sm { + display: grid; + place-items: center; + width: 22px; + height: 22px; + flex: none; + overflow: hidden; + border-radius: var(--radius-sm); + background: var(--color-neutral-900); + font: 500 9px/1 var(--font-heading); + color: var(--color-neutral-400); +} + +.vk-guild-icon-sm img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.vk-tag-agent { + padding: 1px 6px; + font-size: 9.5px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.vk-empty { + color: var(--color-neutral-500); + font-size: 12.5px; + padding: 8px 0; +} + +/* Composer */ + +.vk-composer-wrap { + flex: none; + padding: 0 18px 14px; +} + +.vk-composer { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 12px; + border-radius: var(--radius-md); + background: var(--color-surface); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); +} + +.vk-composer.unlocked { + align-items: flex-end; +} + +.vk-composer-copy { + flex: 1; + min-width: 0; + font-size: 13px; + color: var(--color-neutral-400); +} + +.vk-composer-input { + flex: 1; + min-height: 38px; + max-height: 160px; + resize: none; + padding: 8px 10px; + border-radius: var(--radius-sm); + background: var(--color-bg); + color: var(--color-text); + border: 1px solid var(--color-divider); + font: inherit; +} + +.vk-composer-input:focus-visible { + border-color: var(--color-accent); + outline: none; +} + +.vk-tts-toggle { + border-color: var(--color-accent); + color: var(--color-accent); + flex: none; +} + +/* TTS panel */ + +.vk-tts { + display: flex; + flex-direction: column; + margin-bottom: 9px; + border-radius: var(--radius-md); + background: var(--color-accent-900); + box-shadow: inset 0 0 0 1px var(--color-accent-600), var(--shadow-md); + overflow: hidden; +} + +.vk-tts-title { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 11px; + background: var(--color-accent-800); + color: var(--color-accent-200); +} + +.vk-tts-heading { + font: 500 10.5px/1 var(--font-heading); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.vk-tts-sub { + color: var(--color-accent-200); +} + +.vk-tts-title .vk-icon-btn { + margin-left: auto; + color: var(--color-accent-300); + flex: none; +} + +.vk-tts-settings { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 9px 11px 8px; +} + +.vk-chip { + display: flex; + align-items: center; + gap: 6px; + flex: none; + padding: 4px 9px; + border-radius: var(--radius-sm); + background: var(--color-bg); + box-shadow: inset 0 0 0 1px var(--color-accent-800); + color: var(--color-neutral-300); + font: 400 11px/1 ui-monospace, Menlo, monospace; +} + +.vk-chip-on { + background: var(--color-accent-700); + box-shadow: inset 0 0 0 1px var(--color-accent-500); + color: var(--color-accent-100); +} + +.vk-tts-body { + display: flex; + flex-direction: column; + gap: 9px; + margin: 9px; + padding: 11px 12px; + border-radius: var(--radius-sm); + background: var(--color-bg); +} + +.vk-tts-field { + width: 100%; + max-height: 150px; + resize: none; + border: none; + outline: none; + background: transparent; + color: var(--color-neutral-100); + font: 400 13.5px/1.6 var(--font-body); +} + +.vk-tts-meta { + display: flex; + align-items: center; + gap: 10px; +} + +.vk-tts-meta .vk-small-btn { + margin-left: auto; +} + +/* Right column */ + +.vk-right { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: var(--color-surface); + box-shadow: inset 1px 0 0 var(--color-neutral-800); + padding: 12px 10px; +} + +.vk-right-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 15px; +} + +.vk-right-section { + display: flex; + flex-direction: column; +} + +.vk-section-head { + display: flex; + align-items: baseline; + gap: 8px; + padding: 0 6px 8px; +} + +.vk-section-head .vk-dim, +.vk-section-head .vk-mono { + margin-left: auto; +} + +.vk-right-list { + display: flex; + flex-direction: column; + gap: 1px; +} + +.vk-right-row { + display: flex; + align-items: center; + gap: 9px; + padding: 5px 7px; + border-radius: var(--radius-sm); + min-width: 0; +} + +.vk-right-row.highlight { + background: var(--color-neutral-900); +} + +.vk-right-row.focused { + background: var(--color-neutral-900); + box-shadow: inset 0 0 0 1px var(--color-accent-700); +} + +.vk-right-name { + display: flex; + flex-direction: column; + min-width: 0; +} + +.vk-right-name .vk-name { + font: 500 12.5px/1.2 var(--font-heading); + color: var(--color-neutral-300); +} + +.vk-right-name .vk-sub { + font: 400 10px/1.3 ui-monospace, Menlo, monospace; + color: var(--color-neutral-400); +} + +.vk-right-trail { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; + flex: none; +} + +.vk-right-footer { + flex: none; + padding: 10px 6px 0; +} + +.vk-roadmap { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 2px; +} + +.vk-roadmap p { + margin: 0; + font-size: 12.5px; + color: var(--color-neutral-500); +} + +/* Voice state marks */ + +.vk-marks { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.vk-marks .accent { + color: var(--color-accent-300); +} + +.vk-marks .neutral { + color: var(--color-neutral-500); +} + +.vk-levels { + display: flex; + align-items: flex-end; + gap: 2px; + height: 11px; +} + +.vk-levels span { + width: 3px; + height: 100%; + border-radius: 1px; + background: var(--color-accent); + transform-origin: bottom; + animation: vk-lvl 0.7s ease-in-out infinite; +} + +.vk-levels span:nth-child(2) { + animation-delay: 0.1s; +} +.vk-levels span:nth-child(3) { + animation-delay: 0.2s; +} +.vk-levels span:nth-child(4) { + animation-delay: 0.3s; +} + +@keyframes vk-lvl { + 0%, + 100% { + transform: scaleY(0.25); + } + 50% { + transform: scaleY(1); + } +} + +@keyframes vk-pulse { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .vk-levels span { + animation: none; + transform: scaleY(0.6); + } + .vk-pulse { + animation: none; + } +} + +/* Agent console */ + +.vk-console { + flex: none; + display: flex; + flex-direction: column; + background: var(--color-surface); +} + +.vk-console-bar { + display: flex; + align-items: center; + gap: 14px; + height: var(--vk-console-h); + flex: none; + padding: 8px 14px; + box-shadow: inset 0 1px 0 var(--color-neutral-800); +} + +.vk-console-button { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + color: var(--color-text); + font: 500 11px/1 var(--font-heading); + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + padding: 0; +} + +.vk-console-tabs { + display: flex; + align-items: center; + gap: 12px; +} + +.vk-console-tab { + position: relative; + background: none; + border: none; + color: var(--color-neutral-400); + font-size: 12px; + cursor: pointer; + padding: 2px 0; +} + +.vk-console-tab:hover { + color: var(--color-text); +} + +.vk-console-tab.active { + color: var(--color-accent-300); +} + +.vk-console-tab .vk-live-dot { + position: absolute; + top: -1px; + right: -8px; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--color-accent); + animation: vk-pulse 1.8s ease-in-out infinite; +} + +.vk-ticker { + flex: 1; + min-width: 0; + height: 18px; + overflow: hidden; + display: flex; + align-items: center; + mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 30px), transparent); + -webkit-mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 30px), transparent); +} + +.vk-ticker span { + font: 400 11px/1.2 ui-monospace, Menlo, monospace; + color: var(--color-neutral-400); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.vk-console-status { + display: flex; + align-items: center; + gap: 14px; + flex: none; + color: var(--color-neutral-500); +} + +.vk-console-drawer { + height: var(--vk-drawer-h); + flex: none; + display: flex; + flex-direction: column; + min-height: 0; + box-shadow: inset 0 1px 0 var(--color-neutral-800); + position: relative; +} + +.vk-console-resize { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 6px; + cursor: ns-resize; +} + +.vk-console-panel { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 14px; +} + +.vk-console-panel[hidden] { + display: none; +} + +.vk-event-log { + display: flex; + flex-direction: column; + font-family: ui-monospace, Menlo, monospace; + font-size: 11px; +} + +.vk-event-line { + display: flex; + gap: 8px; + padding: 2px 0; + color: var(--color-neutral-400); + word-break: break-all; +} + +.vk-event-line .vk-event-time { + flex: none; + color: var(--color-neutral-500); +} + +.vk-event-line .vk-event-type { + flex: none; + color: var(--color-accent-300); +} + +/* Settings overlay */ + +.vk-overlay { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + justify-content: flex-end; + background: color-mix(in srgb, var(--color-neutral-900) 55%, transparent); +} + +.vk-overlay[hidden] { + display: none; +} + +.vk-settings-panel { + width: min(680px, 100%); + height: 100%; + display: flex; + flex-direction: column; + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.vk-settings-head { + display: flex; + align-items: center; + gap: 10px; + flex: none; + padding: 12px 16px; + box-shadow: inset 0 -1px 0 var(--color-neutral-800); +} + +.vk-settings-title { + font: 500 15px/1.2 var(--font-heading); +} + +.vk-settings-head .vk-icon-btn { + margin-left: auto; +} + +.vk-settings-body { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 22px; + padding: 16px; +} + +.vk-settings-section { + display: flex; + flex-direction: column; +} + +.vk-settings-actions { + margin-left: auto; + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.vk-settings-card { + display: flex; + flex-direction: column; + padding: 8px 10px; + border-radius: var(--radius-md); + background: var(--color-bg); +} + +.vk-settings-row { + display: flex; + gap: 8px; + padding: 8px 0; +} + +.vk-settings-row .input { + flex: 1; +} + +.vk-status-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 5px 0; + font-size: 12.5px; + box-shadow: inset 0 -1px 0 var(--color-neutral-900); +} + +.vk-status-row:last-child { + box-shadow: none; +} + +.vk-status-row .vk-status-label { + color: var(--color-neutral-500); + flex: none; +} + +.vk-status-row .vk-status-value { + min-width: 0; + text-align: right; + word-break: break-all; +} + +.vk-qr { + margin: 10px 0; + padding: 12px; + width: fit-content; + background: #ffffff; + border-radius: var(--radius-md); +} + +.vk-qr img { + display: block; + width: 240px; + height: 240px; +} + +.vk-core-output { + margin: 8px 0 0; + padding: 8px 10px; + max-height: 180px; + overflow-y: auto; + background: var(--color-bg); + border-radius: var(--radius-sm); + font: 400 10.5px/1.5 ui-monospace, Menlo, monospace; + color: var(--color-neutral-500); + white-space: pre-wrap; + word-break: break-word; +} + +.vk-log { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border-radius: var(--radius-md); + background: var(--color-bg); + font: 400 11px/1.5 ui-monospace, Menlo, monospace; + max-height: 320px; + overflow-y: auto; +} + +.vk-log-line { + display: flex; + gap: 8px; + color: var(--color-neutral-400); + word-break: break-all; +} + +.vk-log-line .vk-log-time { + flex: none; + color: var(--color-neutral-500); +} + +.vk-log-line .vk-log-type { + flex: none; + color: var(--color-accent-300); +} + +/* Toasts */ + +.vk-toasts { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 100; + display: flex; + flex-direction: column; + gap: 8px; +} + +.vk-toast { + max-width: 380px; + padding: 10px 14px; + border-radius: var(--radius-md); + background: var(--color-surface); + box-shadow: var(--shadow-md); + font-size: 12.5px; +} + +.vk-toast.error { + box-shadow: var(--shadow-md), inset 3px 0 0 var(--color-neutral-200); +} + +.vk-toast.ok { + box-shadow: var(--shadow-md), inset 3px 0 0 var(--color-accent-400); +} + +/* Responsive */ + +@media (max-width: 1280px) { + :root { + --vk-right-w: 280px; + } +} + +@media (max-width: 1024px) { + .vk-right { + display: none; + } + + .vk-body { + grid-template-columns: var(--vk-rail-w) minmax(0, 1fr); + } +} + +@media (max-width: 768px) { + .vk-body { + grid-template-columns: minmax(0, 1fr); + } + + .vk-rail { + display: none; + } + + .vk-health { + display: none; + } +} diff --git a/poc/src/auth-service.ts b/poc/src/auth-service.ts new file mode 100644 index 0000000..1ba82bf --- /dev/null +++ b/poc/src/auth-service.ts @@ -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; + } +} diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index 065ab33..c4b1168 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -14,9 +14,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; 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 { 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 { startWebUi } from "./webui.js"; const HOST = process.env.MCP_HOST ?? "127.0.0.1"; const PORT = Number(process.env.MCP_PORT ?? 8788); @@ -24,27 +28,46 @@ const PORT = Number(process.env.MCP_PORT ?? 8788); let core: CoreClient | undefined; let puppet: Puppet | undefined; let coreConnected = false; +let connecting = false; -async function connectLoop() { - for (;;) { - try { - const client = await CoreClient.connect(); +async function connectLoop(): Promise { + if (connecting) return; + connecting = true; - core = client; - puppet = new Puppet(client); + try { + for (;;) { + try { + const client = await CoreClient.connect(); - client.onEvent(event => { - void puppet!.enqueueEvent(event); - }); + core = client; + puppet = new Puppet(client); - coreConnected = true; - console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`); + client.onEvent(event => { + void puppet!.enqueueEvent(event); + webui.broadcast(event); + }); - return; - } catch (error) { - console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`); - await sleep(2000); + 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; + console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`); + webui.broadcast({ type: "CORE_STATUS", coreConnected: true }); + + return; + } catch (error) { + console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`); + await sleep(2000); + } } + } finally { + connecting = false; } } @@ -58,6 +81,32 @@ function requirePuppet(): 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) { 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 Promise | unknown> = { get_state: () => requireCore().getState(), 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 => requirePuppet().getContext(args?.channelId, { maxMessages: args?.maxMessages, @@ -100,7 +152,7 @@ function buildServer(): McpServer { } }, async ({ channelId, content }) => { - await paced(() => requireCore().sendMessage(channelId, content)); + await pacedSend(() => requireCore().sendMessage(channelId, content)); return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() }); } ); @@ -115,7 +167,7 @@ function buildServer(): McpServer { } }, 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() }); } ); @@ -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.", inputSchema: { 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)) ); + 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( "get_action_log", { @@ -338,7 +425,13 @@ app.listen(PORT, HOST, (error?: Error) => { console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`); }); -process.on("SIGINT", () => { +function shutdown() { core?.close(); - process.exit(0); -}); + void coreProcess + .dispose(4000) + .catch(() => undefined) + .finally(() => process.exit(0)); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/poc/src/core-process.ts b/poc/src/core-process.ts new file mode 100644 index 0000000..4144397 --- /dev/null +++ b/poc/src/core-process.ts @@ -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 { + 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 { + 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 { + const child = this.child; + if (!child) return this.status(); + + await new Promise(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 { + await this.stop(); + return this.start(forceToken); + } + + /** Stop the core and any Xvfb that this daemon started. */ + async dispose(timeoutMs = 4000): Promise { + 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 { + 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 + } + } + } +} diff --git a/poc/src/qr-login.ts b/poc/src/qr-login.ts index 702f17f..4c977bd 100644 --- a/poc/src/qr-login.ts +++ b/poc/src/qr-login.ts @@ -1,53 +1,20 @@ /* - * Veskslop Phase 0 — Discord remote-auth QR login. - * - * Connects to Discord's remote-auth gateway, renders an approval QR in the - * terminal, waits for the phone to approve, and prints/saves the user token. + * Veskslop Phase 0 — Discord remote-auth QR login (terminal front end). * * Usage: * pnpm poc:qr * TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr * HCAPTCHA_KEY=... pnpm poc:qr # only if Discord challenges the ticket exchange * - * The token file is written with mode 600 and is gitignored. + * 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 qrcode from "qrcode-terminal"; -import WebSocket from "ws"; import { tokenFile } from "./config.js"; - -const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2"; -const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login"; -const USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"; -const debug = process.env.DEBUG === "1"; - -const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); -const encodedPublicKey = keyPair.publicKey.export({ type: "spki", format: "der" }).toString("base64"); - -function log(...args: unknown[]) { - console.log("[qr]", ...args); -} - -function debugLog(...args: unknown[]) { - if (debug) console.error("[qr:debug]", ...args); -} - -function decryptPayload(payload: string): Buffer { - return crypto.privateDecrypt( - { - key: keyPair.privateKey, - padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, - oaepHash: "sha256" - }, - Buffer.from(payload, "base64") - ); -} +import { QrLoginSession } from "./qr-session.js"; function promptCaptchaKey(): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -59,143 +26,58 @@ function promptCaptchaKey(): Promise { }); } -async function exchangeTicket(ticket: string, captchaKey?: string, captchaRqToken?: string): Promise { - const body: Record = { ticket }; - if (captchaKey) { - body.captcha_key = captchaKey; - body.captcha_rqtoken = captchaRqToken; - } - - const response = await fetch(LOGIN_URL, { - method: "POST", - headers: { - Accept: "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Content-Type": "application/json", - Origin: "https://discord.com", - Referer: "https://discord.com/login", - "User-Agent": USER_AGENT - }, - body: JSON.stringify(body) - }); - const data: any = await response.json(); - debugLog("ticket exchange", response.status, JSON.stringify(data)); - - if (data.encrypted_token) { - return decryptPayload(data.encrypted_token).toString(); - } - - if (data.captcha_sitekey || data.captcha_rqtoken) { - log("Discord wants a captcha to finish this login."); - log(` service: ${data.captcha_service}`); - log(` sitekey: ${data.captcha_sitekey}`); - log(` rqdata: ${data.captcha_rqdata}`); - const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey()); - if (!key) { - log("No captcha key provided, giving up."); - return undefined; - } - return exchangeTicket(ticket, key, data.captcha_rqtoken); - } - - log(`Ticket exchange failed (HTTP ${response.status}):`, JSON.stringify(data)); - return undefined; -} - -log("Connecting to Discord remote-auth gateway..."); -const ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } }); - -let heartbeat: NodeJS.Timeout | undefined; -let finished = false; - -ws.on("open", () => log("Connected. Requesting QR...")); - -ws.on("message", async (raw: WebSocket.RawData) => { - const packet: any = JSON.parse(raw.toString()); - debugLog("<-", packet.op ?? packet); - - switch (packet.op) { - case "hello": { - heartbeat = setInterval( - () => ws.send(JSON.stringify({ op: "heartbeat" })), - packet.heartbeat_interval - ); - ws.send(JSON.stringify({ op: "init", encoded_public_key: encodedPublicKey })); - break; - } - - case "nonce_proof": { - const nonce = decryptPayload(packet.encrypted_nonce); - const proof = crypto.createHash("sha256").update(nonce).digest("base64url"); - ws.send(JSON.stringify({ op: "nonce_proof", proof })); - break; - } - - case "pending_remote_init": { - const url = `https://discord.com/ra/${packet.fingerprint}`; - log("Scan this QR code with the Discord mobile app (you may need the camera/QR scanner):\n"); - qrcode.generate(url, { small: true }); - log(`Or open this link on the phone: ${url}\n`); - break; - } - - case "pending_ticket": { - const payload = decryptPayload(packet.encrypted_user_payload).toString(); - const [id, discriminator, avatar, username] = payload.split(":"); - debugLog("user payload:", payload); - const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username; - log(`Phone approved: ${tag} (${id})${avatar ? " with avatar " + avatar : ""}`); - break; - } - - case "pending_login": { - const token = await exchangeTicket(packet.ticket); - if (!token) { - finished = true; - ws.close(); - process.exitCode = 1; +const session = new QrLoginSession({ + tokenFile, + debug: process.env.DEBUG === "1", + onUpdate: update => { + 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; - } - 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 "scanned": + console.log(`[qr] Phone approved: ${update.user?.tag} (${update.user?.id})`); + break; + + case "captcha": + console.log("[qr] Discord wants a captcha to finish this login."); + console.log(` service: ${update.captcha?.service}`); + console.log(` sitekey: ${update.captcha?.sitekey}`); + console.log(` rqdata: ${update.captcha?.rqdata}`); + void (async () => { + const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey()); + if (!key) session.cancel(); + else session.submitCaptcha(key); + })(); + break; + + case "finished": + console.log("\n=== token acquired ==="); + console.log(session.token); + console.log(`[qr] Saved to ${tokenFile} (mode 600).`); + process.exit(0); + break; + + case "failed": + console.error(`[qr] Login failed: ${update.error}`); + process.exit(1); + break; + + case "cancelled": + console.log("[qr] Login was cancelled."); + process.exit(0); + break; } - - case "cancel": - log("Login was cancelled on the phone."); - finished = true; - ws.close(); - break; - - case "heartbeat_ack": - break; - - default: - debugLog("unhandled op", packet); } }); -ws.on("close", () => { - if (heartbeat) clearInterval(heartbeat); - done(); -}); - -ws.on("error", error => { - console.error("[qr] gateway error:", error.message); - process.exitCode = 1; -}); - -function done() { - if (!finished) log("Connection closed before login completed."); - process.exit(process.exitCode ?? 0); -} +console.log("[qr] Connecting to Discord remote-auth gateway..."); +session.start(); process.on("SIGINT", () => { - log("Interrupted."); - ws.close(); + console.log("[qr] Interrupted."); + session.cancel(); + setTimeout(() => process.exit(0), 100); }); diff --git a/poc/src/qr-session.ts b/poc/src/qr-session.ts new file mode 100644 index 0000000..bd55439 --- /dev/null +++ b/poc/src/qr-session.ts @@ -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 { + const body: Record = { ticket }; + if (captchaKey) { + body.captcha_key = captchaKey; + body.captcha_rqtoken = captchaRqToken; + } + + const response = await fetch(LOGIN_URL, { + method: "POST", + headers: { + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Content-Type": "application/json", + Origin: "https://discord.com", + Referer: "https://discord.com/login", + "User-Agent": USER_AGENT + }, + body: JSON.stringify(body) + }); + const data: any = await response.json(); + 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); + } +} diff --git a/poc/src/webui.ts b/poc/src/webui.ts new file mode 100644 index 0000000..f5b171c --- /dev/null +++ b/poc/src/webui.ts @@ -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 { + 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(); + 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) => (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); + } + } + } + }; +}