From 2bcd8c6d7a4317104a024ae81e9f44f522f7191d Mon Sep 17 00:00:00 2001 From: Fisto Date: Sun, 13 Sep 2026 15:47:47 +0200 Subject: [PATCH] Add Nocturne debug web UI, core process control, and web QR login --- poc/README.md | 33 +- poc/WebUIMockup.zip | Bin 0 -> 90729 bytes poc/package.json | 2 + poc/pnpm-lock.yaml | 226 ++++++ poc/public/api.js | 61 ++ poc/public/app.js | 1351 ++++++++++++++++++++++++++++++++++ poc/public/icons.js | 44 ++ poc/public/index.html | 262 +++++++ poc/public/nocturne.css | 294 ++++++++ poc/public/style.css | 1449 +++++++++++++++++++++++++++++++++++++ poc/src/auth-service.ts | 81 +++ poc/src/control-bridge.ts | 137 +++- poc/src/core-process.ts | 251 +++++++ poc/src/qr-login.ts | 216 ++---- poc/src/qr-session.ts | 248 +++++++ poc/src/webui.ts | 349 +++++++++ 16 files changed, 4806 insertions(+), 198 deletions(-) create mode 100644 poc/WebUIMockup.zip create mode 100644 poc/public/api.js create mode 100644 poc/public/app.js create mode 100644 poc/public/icons.js create mode 100644 poc/public/index.html create mode 100644 poc/public/nocturne.css create mode 100644 poc/public/style.css create mode 100644 poc/src/auth-service.ts create mode 100644 poc/src/core-process.ts create mode 100644 poc/src/qr-session.ts create mode 100644 poc/src/webui.ts 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 0000000000000000000000000000000000000000..d74016d4cd9194b50a76c0d88a5f517c02ae2644 GIT binary patch literal 90729 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

*-jP&O%}|HG<`@dhD4AYWX@e)-TYuj)d?pg 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

hrY-VJbj4-qtEIh_9e&=No<^P_S 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'} + + 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); + } + } + } + }; +}