diff --git a/apps/widget-discord/.gitignore b/apps/widget-discord/.gitignore new file mode 100644 index 00000000..d4d7e3f6 --- /dev/null +++ b/apps/widget-discord/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.log diff --git a/apps/widget-discord/README.md b/apps/widget-discord/README.md new file mode 100644 index 00000000..6658ff91 --- /dev/null +++ b/apps/widget-discord/README.md @@ -0,0 +1,193 @@ +# @vojo/widget-discord + +Vojo Discord bridge management widget — mounts inside `/bots/discord` +in the Vojo client. Mirrors the Telegram widget contract; protocol +specifics differ because mautrix-discord runs on the **legacy** mautrix +command framework, not bridgev2 (the Discord bridge had not yet been +ported to v2 as of January 2026 — see +https://mau.fi/blog/2026-01-mautrix-release/). + +This is **not** a Discord client. It's a small panel that drives the +mautrix-discord bridge bot (`@discordbot:vojo.chat`) by sending text +commands in the control DM and rendering the bot's text replies. It +ships QR-only login (the Discord token-login flow stays accessible via +chat-fallback for power users). + +## Layout + +``` +src/ +├── bootstrap.ts Parse URL params (matches BotWidgetEmbed.ts) +├── widget-api.ts Inline matrix-widget-api postMessage transport +├── App.tsx UI: status pill, QR panel, logout / reconnect cards, transcript +├── main.tsx Entry: bootstrap + render +├── state.ts LoginState reducer + hydrate-from-timeline +├── styles.css Theme-aware CSS variables (Dawn palette) +├── i18n/ Tiny RU/EN dictionary harness +└── bridge-protocol/ + ├── types.ts LoginEvent + ParsableEvent types + ├── parser.ts Dialect dispatch shim + └── dialects/ + └── legacy_v076.ts mautrix-discord v0.7.6 wording +``` + +## Login flow (QR only) + +1. Widget sends `!discord login-qr`. +2. Bridge replies with an `m.image` event whose `body` is a Discord + remoteauth URL (`https://discord.com/ra/`). The host driver + strips `url`/`file`/`info` so the widget never touches the uploaded + PNG bytes — it re-encodes the URL into an SVG QR matrix client-side + via `qrcode-generator`. +3. The user scans the QR with the **Discord mobile app** (Settings → + Devices → Scan QR Code). Discord's remoteauth gateway requires the + mobile app — desktop Discord and the browser cannot scan. +4. Bridge redacts the `m.image` event after a successful scan and sends + `Successfully logged in as @`. +5. Widget fires `!discord ping` to pick up the discord snowflake for + the connected pill. + +If Discord asks for a CAPTCHA, the bridge replies with the standard +error line plus a hint about token-login. The widget surfaces an amber +warning suggesting the user retry later or use chat-fallback. + +## Status probe + +Discord's legacy command system has no `list-logins` API; status is +queried via `!discord ping`. The four reply variants map to four UI +states: + +- `You're not logged in` → disconnected +- `You're logged in as @x (\`\`)` → connected +- `You have a Discord token stored, but are not connected for some reason 🤔` → connected_dead (token_stored) +- `You're logged in, but the Discord connection seems to be dead 💥` → connected_dead (connection_dead) + +`connected_dead` exposes a «Переподключиться» card that sends +`!discord reconnect`. `disconnect` is recognised for chat-fallback +typists but never sent by the widget. + +## Local development + +Same overlay mechanism as the Telegram widget — create +`config.local.json` at the project root (gitignored) with a `bots[]` +entry overriding the discord widget's `experience.url` to your local +dev server: + +```bash +# one-time: install widget deps +cd apps/widget-discord && npm install + +# config.local.json (gitignored) at the project root +cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON' +{ + "bots": [ + { + "id": "discord", + "experience": { + "type": "matrix-widget", + "url": "http://localhost:8082/", + "commandPrefix": "!discord" + } + } + ] +} +JSON +``` + +`http://localhost:*` URLs pass the host's URL validator only in dev +builds — see `src/app/features/bots/catalog.ts` `import.meta.env.DEV` +branch. Production builds drop the branch via Vite's dead-code +elimination AND enforce an origin allowlist (`PROD_WIDGET_ORIGINS`). + +Run both servers: + +```bash +# terminal 1 — widget on :8082 with HMR +cd apps/widget-discord && npm run dev + +# terminal 2 — host SPA on :8080 +cd /home/ubuntu/projects/vojo/cinny && npm start +``` + +Open `http://localhost:8080/bots/discord`. The Telegram widget on :8081 +can run in parallel with no port conflict. + +## Build + +```bash +npm run build +``` + +Outputs to `apps/widget-discord/dist/`. Deploy by rsyncing `dist/*` into +`~/vojo/widgets/discord/` on the production host (Caddy serves this via +the `widgets.vojo.chat` block). + +## Hosting (server-side, runbook) + +Pre-requisite: `widgets.vojo.chat` already exists for the Telegram +widget — only the Caddy `widgets.vojo.chat` block needs a new +`handle_path` and the docker host needs a new directory. + +1. `~/vojo/caddy/Caddyfile` — append to the existing + `widgets.vojo.chat { … }` block, beside the Telegram `handle_path`: + ``` + handle_path /discord/* { + root * /var/www/widgets/discord + try_files {path} /index.html + file_server + } + ``` +2. `mkdir -p ~/vojo/widgets/discord` (placeholder so the bind-mount has + something to serve), then `docker compose up -d caddy` (or `reload`). +3. Verify directly: + `curl -I https://widgets.vojo.chat/discord/index.html` should + return 200 and the `Content-Security-Policy` header. + +## Adding the discord bridge to docker-compose + +```yaml +discord-bridge: + image: dock.mau.dev/mautrix/discord:v0.7.6 + restart: unless-stopped + volumes: + - ./mautrix-discord:/data +``` + +Then `~/vojo/synapse/homeserver.yaml` needs the discord registration +file added to `app_service_config_files`: + +```yaml +app_service_config_files: + - /data/telegram-registration.yaml + - /data/discord-registration.yaml +``` + +The bridge's `command_prefix` defaults to `!discord` — keep it that +way so it matches the widget's `experience.commandPrefix`. If you +override it in `mautrix-discord/config.yaml`, mirror the override in +`/config.json`. + +## Capacitor (Android) + +`capacitor.config.ts` already allow-navigates `widgets.vojo.chat` for +the Telegram widget; no further change needed. + +## Capability contract + +The widget requests EXACTLY this set (matches the host's +`BotWidgetDriver.getBotWidgetCapabilities`): + +``` +org.matrix.msc2762.timeline: +org.matrix.msc2762.send.event:m.room.message#m.text +org.matrix.msc2762.receive.event:m.room.message#m.text +org.matrix.msc2762.receive.event:m.room.message#m.notice +org.matrix.msc2762.receive.event:m.room.message#m.image +org.matrix.msc2762.receive.event:m.room.redaction +org.matrix.msc2762.receive.state_event:m.room.member +``` + +`m.image` is the QR carrier; `m.room.redaction` signals the bridge +consumed the QR after a successful scan. The host sanitizer strips +`url`/`file`/`info` from `m.image` content, so only the QR URL string +inside `body` survives the boundary. diff --git a/apps/widget-discord/index.html b/apps/widget-discord/index.html new file mode 100644 index 00000000..b34eb454 --- /dev/null +++ b/apps/widget-discord/index.html @@ -0,0 +1,12 @@ + + + + + + Discord bridge — Vojo + + +
+ + + diff --git a/apps/widget-discord/package-lock.json b/apps/widget-discord/package-lock.json new file mode 100644 index 00000000..a50f8f98 --- /dev/null +++ b/apps/widget-discord/package-lock.json @@ -0,0 +1,1999 @@ +{ + "name": "@vojo/widget-discord", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@vojo/widget-discord", + "version": "0.0.1", + "dependencies": { + "preact": "10.22.1", + "qrcode-generator": "1.4.4" + }, + "devDependencies": { + "@preact/preset-vite": "2.9.0", + "typescript": "5.4.5", + "vite": "5.4.19" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.9.0.tgz", + "integrity": "sha512-B9yVT7AkR6owrt84K3pLNyaKSvlioKdw65VqE/zMiR6HMovPekpsrwBNs5DJhBFEd5cvLMtCjHNHZ9P7Oblveg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/plugin-transform-react-jsx": "^7.22.15", + "@babel/plugin-transform-react-jsx-development": "^7.22.5", + "@prefresh/vite": "^2.4.1", + "@rollup/pluginutils": "^4.1.1", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.3.4", + "kolorist": "^1.8.0", + "magic-string": "0.30.5", + "node-html-parser": "^6.1.10", + "resolve": "^1.22.8", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz", + "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz", + "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.22.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.22.1.tgz", + "integrity": "sha512-jRYbDDgMpIb5LHq3hkI0bbl+l/TQ9UnkdQ0ww+lp+4MMOdqaUYdFc5qeyP+IV8FAd/2Em7drVPeKdQxsiWCf/A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0.tgz", + "integrity": "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/apps/widget-discord/package.json b/apps/widget-discord/package.json new file mode 100644 index 00000000..7572db66 --- /dev/null +++ b/apps/widget-discord/package.json @@ -0,0 +1,21 @@ +{ + "name": "@vojo/widget-discord", + "version": "0.0.1", + "private": true, + "description": "Vojo Discord bridge management widget — mounts inside /bots/discord", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "preact": "10.22.1", + "qrcode-generator": "1.4.4" + }, + "devDependencies": { + "@preact/preset-vite": "2.9.0", + "typescript": "5.4.5", + "vite": "5.4.19" + } +} diff --git a/apps/widget-discord/src/App.tsx b/apps/widget-discord/src/App.tsx new file mode 100644 index 00000000..a13ab00a --- /dev/null +++ b/apps/widget-discord/src/App.tsx @@ -0,0 +1,965 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; +import qrcodeGenerator from 'qrcode-generator'; +import type { WidgetBootstrap } from './bootstrap'; +import { WidgetApi, type RoomEvent } from './widget-api'; +import { createT, type T } from './i18n'; +import { parseEvent } from './bridge-protocol/parser'; +import { + hydrateFromTimeline, + initialLoginState, + loginReducer, + type HydrateInput, + type LoginErrorFlag, +} from './state'; + +// Visual canon mirrors the Telegram widget — Dawn palette, fleet-violet +// accent, monospace handles. The Discord widget keeps Vojo's accent (per +// product decision: «used Vojo style») rather than adopting Discord +// blurple, so the panel reads as a coherent continuation of the host UI. + +type TranscriptKind = 'from-bot' | 'from-user' | 'diag' | 'error'; + +type TranscriptLine = { + id: string; + ts: number; + kind: TranscriptKind; + text: string; +}; + +type Props = { + bootstrap: WidgetBootstrap; + // The WidgetApi is constructed in main.tsx synchronously, BEFORE React's + // first render — see widget-telegram for the cached-bundle race rationale. + api: WidgetApi; +}; + +const TRANSCRIPT_MAX = 200; + +// Inline SVG refresh icon — same as TG widget for visual consistency. +const RefreshIcon = () => ( + +); + +// Linkifier — same heuristic as TG widget. +const URL_RE = /https?:\/\/[^\s)]+/g; + +// Defense-in-depth: a Discord remoteauth login URL is the LIVE login +// secret. Today the bridge only emits it via `m.image` (which we route +// to a generic «QR-код выдан» diag, never a verbatim transcript line). +// But if a future bridge revision started echoing the URL into m.notice +// — say, for a chat-fallback fallback path — the existing transcript +// append would (a) store the URL in the DOM, (b) survive page reload via +// the hydrate replay, and (c) the linkifier would turn it into a +// clickable anchor that opens in the parent browser, leaving the active +// login token in the user's history. Scrubbing here makes the leak +// path closed even if the upstream wiring drifts. +const REMOTEAUTH_URL_RE = + /https:\/\/(?:[a-z0-9-]+\.)?(?:discordapp|discord)\.com\/(?:ra|login\/handoff)\/[A-Za-z0-9_\-+=.~?&/]+/gi; +const scrubLoginSecret = (body: string): string => + body.replace(REMOTEAUTH_URL_RE, '[redacted login URL]'); + +const formatTime = (ts: number): string => { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; +}; + +const renderBody = (body: string): ComponentChildren => { + const out: ComponentChildren[] = []; + let lastIndex = 0; + for (const match of body.matchAll(URL_RE)) { + const idx = match.index ?? 0; + if (idx > lastIndex) out.push(body.slice(lastIndex, idx)); + out.push( + + {match[0]} + + ); + lastIndex = idx + match[0].length; + } + if (lastIndex < body.length) out.push(body.slice(lastIndex)); + return out.length === 0 ? body : out; +}; + +const localizeError = (err: LoginErrorFlag, t: T): string => { + switch (err.kind) { + case 'login_failed': + return t('auth-error.login-failed', { reason: err.reason ?? '' }); + case 'captcha_required': + return t('auth-error.captcha-required'); + case 'login_websocket_failed': + return t('auth-error.websocket-failed', { reason: err.reason ?? '' }); + case 'connect_after_login_failed': + return t('auth-error.connect-after-login-failed', { reason: err.reason ?? '' }); + case 'prepare_login_failed': + return t('auth-error.prepare-failed', { reason: err.reason ?? '' }); + case 'already_logged_in': + return t('auth-error.already-logged-in'); + case 'unknown_command': + return t('auth-error.unknown-command'); + default: { + const exhaustive: never = err; + return String(exhaustive); + } + } +}; + +// Captcha is the only «not really an error, more of a suggestion» case — +// surface as warn (amber) rather than red. Everything else is a hard +// failure of the login attempt and gets red treatment. +const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => { + if (err.kind === 'captcha_required') return 'warn'; + if (err.kind === 'already_logged_in') return 'warn'; + return 'error'; +}; + +// -------------------------------------------------------------------------- +// QR panel +// -------------------------------------------------------------------------- + +// Discord remoteauth's server-side timeout sits around 2 minutes of +// inactivity (the bridge holds the websocket; Discord's gateway closes +// it from its side). 3 minutes is a slight safety margin: the user +// sees «expired» a touch after the server probably already dropped +// the WS, but never before, so they can't trust a dead QR. This MUST +// match HYDRATE_FRESHNESS_MS in state.ts so the timeline-resume window +// agrees with the panel countdown — diverging the two would mean a +// reload at e.g. 4 min restores the panel even though the panel +// itself would render «expired». Telegram's MTProto QR rotates and +// lives ~10 min, which is why the TG widget uses 10 min for both. +const QR_TIMEOUT_MS = 3 * 60 * 1000; + +// Error-correction level M is a good trade-off for short URLs — more +// resilient to camera glare than L, smaller modules than Q. typeNumber=0 +// auto-picks the smallest QR version that fits the payload. +const buildQrModules = (data: string): boolean[][] | null => { + if (!data) return null; + try { + const qr = qrcodeGenerator(0, 'M'); + qr.addData(data); + qr.make(); + const count = qr.getModuleCount(); + const matrix: boolean[][] = []; + for (let r = 0; r < count; r += 1) { + const row: boolean[] = []; + for (let c = 0; c < count; c += 1) { + row.push(qr.isDark(r, c)); + } + matrix.push(row); + } + return matrix; + } catch { + return null; + } +}; + +// Render the QR matrix as elements inside an SVG. We deliberately +// avoid `dangerouslySetInnerHTML` and any external QR-rendering service: +// the `https://discord.com/ra/...` URL IS the login secret, so it must +// never leave the iframe and must never reach a stringified-HTML path +// that bypasses Preact's escaping. +type QrSvgProps = { matrix: boolean[][]; pixelSize: number; ariaLabel: string }; +const QrSvg = ({ matrix, pixelSize, ariaLabel }: QrSvgProps) => { + const count = matrix.length; + const margin = 4; + const totalUnits = count + margin * 2; + const cellPx = pixelSize / totalUnits; + const rects: ComponentChildren[] = []; + for (let r = 0; r < count; r += 1) { + for (let c = 0; c < count; c += 1) { + if (!matrix[r][c]) continue; + rects.push( + + ); + } + } + return ( + + {rects} + + ); +}; + +type QrPanelProps = { + state: { + kind: 'awaiting_qr_scan'; + discordUrl: string; + firstShownAt: number; + lastError?: LoginErrorFlag; + }; + t: T; + onCancel: () => void; +}; + +const QrPanel = ({ state, t, onCancel }: QrPanelProps) => { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + + const matrix = useMemo(() => buildQrModules(state.discordUrl), [state.discordUrl]); + const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0; + const remainingSeconds = Math.max(0, Math.ceil((QR_TIMEOUT_MS - elapsed) / 1000)); + const expired = elapsed >= QR_TIMEOUT_MS && state.firstShownAt > 0; + + return ( +
+
{t('auth-card.qr.title')}
+
{t('auth-card.qr.hint')}
+
+ {matrix ? ( + // The aria-label describes the PURPOSE, not the contents — the + // URL itself is the login secret and must not be exposed via + // AT-tree text content. + + ) : ( +
+ + {t('auth-card.qr.preparing')} +
+ )} +
+ {!expired ? ( +
+ {t('auth-card.qr.countdown', { + minutes: String(Math.floor(remainingSeconds / 60)), + seconds: String(remainingSeconds % 60).padStart(2, '0'), + })} +
+ ) : ( +
{t('auth-card.qr.expired')}
+ )} +
    +
  1. {t('auth-card.qr.step-1')}
  2. +
  3. {t('auth-card.qr.step-2')}
  4. +
  5. {t('auth-card.qr.step-3')}
  6. +
+ {state.lastError ? ( +
+ {localizeError(state.lastError, t)} +
+ ) : null} +
+ +
+
+ ); +}; + +// -------------------------------------------------------------------------- +// About card + modal +// -------------------------------------------------------------------------- + +type AboutCardProps = { + t: T; + onOpen: () => void; +}; + +const AboutCard = ({ t, onOpen }: AboutCardProps) => ( + +); + +type AboutModalProps = { + t: T; + onClose: () => void; +}; + +const AboutModal = ({ t, onClose }: AboutModalProps) => { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( + + ); +}; + +// -------------------------------------------------------------------------- +// Logout card with confirm-in-place +// -------------------------------------------------------------------------- + +type LogoutCardProps = { + t: T; + onConfirm: () => Promise; +}; + +const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => { + const [confirming, setConfirming] = useState(false); + const [submitting, setSubmitting] = useState(false); + // Belt-and-suspenders against double-submit. `disabled={submitting}` covers + // 99% of cases, but there's a microtask window between click and Preact + // rendering the disabled state where a fast second click could fire. + const inFlight = useRef(false); + + if (confirming) { + return ( +
+
+ {t('card.logout.confirm-prompt')} + + +
+
+ ); + } + + return ( + + ); +}; + +// -------------------------------------------------------------------------- +// Main App +// -------------------------------------------------------------------------- + +export function App({ bootstrap, api }: Props) { + const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme); + const [transcript, setTranscript] = useState([]); + const [handshakeOk, setHandshakeOk] = useState(false); + const [aboutOpen, setAboutOpen] = useState(false); + // True while a `ping` probe is in flight from a refresh-card click. + const [refreshing, setRefreshing] = useState(false); + const seenEventIds = useRef(new Set()); + const [state, dispatch] = useReducer(loginReducer, initialLoginState); + + // stateRef mirrors latest reducer state so async live-event listeners + // (attached once at mount) read current state without their stale + // closure capturing the initial `unknown` snapshot. Used by transcript + // diag gate for `qr_redacted`. + const stateRef = useRef(state); + useEffect(() => { + stateRef.current = state; + }, [state]); + + const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + }, [theme]); + + const append = useCallback((line: Omit) => { + setTranscript((prev) => { + const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }]; + return next.length > TRANSCRIPT_MAX ? next.slice(-TRANSCRIPT_MAX) : next; + }); + }, []); + + // Newest-at-top — pin scroll to top on each new line. + const transcriptRef = useRef(null); + useEffect(() => { + const el = transcriptRef.current; + if (!el) return; + el.scrollTop = 0; + }, [transcript.length]); + + // Subscribe to widget-api events for capability handshake completion, + // live events, and theme updates. The `api` itself is constructed in + // main.tsx BEFORE React's first render so its postMessage listener is + // already attached — this effect only wires React state to the api's + // event surface. WidgetApi.on('ready', ...) self-replays if the + // handshake already finished by the time we attach. + useEffect(() => { + let disposed = false; + + api.on('ready', () => { + setHandshakeOk(true); + append({ kind: 'diag', text: t('diag.ready') }); + append({ kind: 'diag', text: t('diag.checking-status') }); + + void (async () => { + // Timeline-resume: scan the recent room history BEFORE firing + // ping. Discord's QR flow doesn't have multi-step prompts (no + // phone/code/password ladder), but a reload during an active QR + // scan SHOULD restore the QR panel — otherwise the user reloads, + // sees disconnected, hits «Войти по QR» again, and the bridge + // creates a SECOND remoteauth session in parallel with the first + // (commands.go has no session-deduplication; each call spins a + // fresh remoteauth.Client goroutine). The hydrate path here is + // identical in shape to the TG widget's: pull notices, images, + // and redactions in parallel and feed them chronologically into + // the hydrate reducer. + let hydrated = false; + try { + const settled = await Promise.allSettled([ + api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.notice' }), + api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.text' }), + // QR images: Discord doesn't rotate, so 10 events is plenty + // (each login attempt produces exactly one m.image). Keep + // headroom for back-history if the user did multiple + // attempts in this room over time. + api.readTimeline({ limit: 10, type: 'm.room.message', msgtype: 'm.image' }), + api.readTimeline({ limit: 10, type: 'm.room.redaction' }), + ]); + if (disposed) return; + const pickValue = (s: PromiseSettledResult): RoomEvent[] => + s.status === 'fulfilled' ? s.value : []; + const notices = pickValue(settled[0]); + const texts = pickValue(settled[1]); + const qrImages = pickValue(settled[2]); + const redactions = pickValue(settled[3]); + + const fromBot = (events: RoomEvent[]) => + events.filter((e) => e.sender === bootstrap.botMxid); + + // Sort by origin_server_ts ascending, tie-break on event_id. + // Without the tie-break, equal-timestamp events from different + // streams could process in nondeterministic order. + const merged = [ + ...fromBot(notices), + ...fromBot(texts), + ...fromBot(qrImages), + ...fromBot(redactions), + ].sort((a, b) => { + const tsDiff = a.origin_server_ts - b.origin_server_ts; + if (tsDiff !== 0) return tsDiff; + return a.event_id < b.event_id ? -1 : a.event_id > b.event_id ? 1 : 0; + }); + + const inputs: HydrateInput[] = merged.map((e) => ({ + ev: parseEvent(e), + ts: e.origin_server_ts, + })); + const restored = hydrateFromTimeline(inputs); + + if (restored) { + // Conservative transcript replay. m.image events are replaced + // with a generic «QR-код выдан» diag — never replay the raw + // discord.com/ra/ body, that would persist the login + // token in DOM history past the bridge's redaction. Bot + // notices replay verbatim (they're already redacted of + // sensitive data by the bridge). + let appendedAnyHistory = false; + const seenQrIds = new Set(); + for (const e of merged) { + if (seenEventIds.current.has(e.event_id)) continue; + seenEventIds.current.add(e.event_id); + const parsed = parseEvent(e); + if (parsed.kind === 'qr_displayed') { + seenQrIds.add(parsed.eventId); + if (parsed.replacesEventId) seenQrIds.add(parsed.replacesEventId); + append({ kind: 'diag', text: t('diag.qr-issued') }); + appendedAnyHistory = true; + } else if (parsed.kind === 'qr_redacted') { + if (seenQrIds.has(parsed.redactsEventId)) { + append({ kind: 'diag', text: t('diag.qr-consumed') }); + appendedAnyHistory = true; + } + } else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') { + // m.text / m.notice — body is safe to replay verbatim, + // BUT we still scrub any login-URL-shaped substring as + // belt-and-suspenders against future bridge wording + // drift that could echo the URL through a notice. + append({ + kind: 'from-bot', + text: `← ${scrubLoginSecret(e.content.body ?? '')}`, + }); + appendedAnyHistory = true; + } + } + if (appendedAnyHistory) { + append({ kind: 'diag', text: t('diag.history-marker') }); + } + + dispatch({ kind: 'hydrate', state: restored }); + hydrated = true; + } + } catch { + if (!disposed) { + append({ kind: 'diag', text: t('diag.history-unavailable') }); + } + } + + if (disposed) return; + if (!hydrated) { + // Discord's status probe is `ping`, not `list-logins`. The reply + // routes through the reducer to disconnected / connected / + // connected_dead. + api.sendCommand('ping').catch((err) => { + if (disposed) return; + append({ + kind: 'error', + text: t('diag.send-failed', { message: (err as Error).message }), + }); + }); + } + })(); + }); + + api.on('themeChange', (name) => setTheme(name)); + + api.on('liveEvent', (ev: RoomEvent) => { + if (seenEventIds.current.has(ev.event_id)) return; + seenEventIds.current.add(ev.event_id); + // Defense-in-depth sender filter — the host's strict 1:1 invariant + // already guarantees this, but pinning to bootstrap.botMxid prevents + // (a) skipping our own outbound echoes (already appended optimistically), + // (b) third-party noise that somehow slips past the 1:1 invariant. + if (ev.sender !== bootstrap.botMxid) return; + + const event = parseEvent(ev); + + // Transcript routing is GATED on the parser's verdict, not raw event + // type. Same logic as TG widget: m.image bodies are NEVER appended + // verbatim (they ARE the login secret); QR-redaction diag fires only + // for the active QR. + if (event.kind === 'qr_displayed') { + append({ kind: 'diag', text: t('diag.qr-issued') }); + } else if (event.kind === 'qr_redacted') { + const liveState = stateRef.current; + if ( + liveState.kind === 'awaiting_qr_scan' && + liveState.qrEventId === event.redactsEventId + ) { + append({ kind: 'diag', text: t('diag.qr-consumed') }); + } + } else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') { + const body = ev.content.body ?? ''; + append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` }); + } + + dispatch({ kind: 'event', event }); + + // Fire `ping` after lifecycle transitions that need authoritative + // state reconciliation: + // * login_success — the success line lacks the discordId; ping + // picks it up so the connected pill can show the snowflake. + // * reconnect_ok / reconnect_no_op — flips us back into connected + // but with potentially-stale handle; ping refreshes. + // * already_logged_in — bridge says we tried login while already + // in. Without a re-ping the QR-form stays open with a warn + // banner forever (no QR will ever come because the bridge + // bails before remoteauth.New). Re-pinging routes us to the + // connected pill so the user can click logout if they wanted + // a fresh login. + if ( + event.kind === 'login_success' || + event.kind === 'reconnect_ok' || + event.kind === 'reconnect_no_op' || + event.kind === 'already_logged_in' + ) { + api.sendCommand('ping').catch(() => { + /* surface in diag is overkill; the connected pill still shows + the handle even without the snowflake */ + }); + } + }); + + append({ kind: 'diag', text: t('diag.connecting') }); + + return () => { + disposed = true; + api.dispose(); + }; + // `api`, `bootstrap`, `t`, and `append` are stable for the App's lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Outbound bare-command + transcript echo. Errors append to transcript + // AND rethrow — callers decide whether to roll back optimistic transitions. + // `api` is a stable singleton owned by main.tsx; closing over it directly + // is safe (the App's lifetime is the iframe's, and api.dispose() in the + // unmount cleanup makes any in-flight sends fail loudly). + const sendBare = useCallback( + async (command: string): Promise => { + append({ kind: 'from-user', text: `→ ${command}` }); + try { + await api.sendCommand(command); + } catch (err) { + append({ + kind: 'error', + text: t('diag.send-failed', { message: (err as Error).message }), + }); + throw err; + } + }, + [append, api, t] + ); + + // In-flight guard against double-tap. The button is on the disconnected + // screen which unmounts as soon as state advances, BUT a rapid second + // click can fire in the microtask window between dispatch and the next + // Preact commit (especially on Android WebView, where a tap-rebound can + // synthesise a second click). For login-qr, a duplicate would spin a + // SECOND remoteauth goroutine on the bridge in parallel — harmless but + // wastes a remoteauth session. + const loginInFlight = useRef(false); + + const onClickLoginQr = useCallback(async () => { + if (loginInFlight.current) return; + loginInFlight.current = true; + dispatch({ kind: 'start_qr_login' }); + try { + await sendBare('login-qr'); + } catch { + dispatch({ kind: 'cancel_pending' }); + } finally { + loginInFlight.current = false; + } + }, [sendBare]); + + // Cancel is LOCAL — Discord legacy mautrix has no `cancel` command. + // Returns the widget to disconnected; the bridge's remoteauth goroutine + // continues until success / failure / internal timeout. + const onClickCancel = useCallback(() => { + dispatch({ kind: 'cancel_pending' }); + }, []); + + const onClickRefresh = useCallback(async () => { + if (refreshing) return; + setRefreshing(true); + const start = Date.now(); + try { + await sendBare('ping'); + } catch { + /* transcript carries the failure */ + } + // 500 ms minimum visible loading state — without this, a fast healthy + // transport (<100ms round-trip) skips a paint frame entirely and the + // click goes visually unacknowledged. + const elapsed = Date.now() - start; + if (elapsed < 500) { + await new Promise((resolve) => { + window.setTimeout(resolve, 500 - elapsed); + }); + } + setRefreshing(false); + }, [refreshing, sendBare]); + + const onConfirmLogout = useCallback(async () => { + dispatch({ kind: 'request_logout' }); + try { + await sendBare('logout'); + } catch { + // Recovery: refire ping so the reducer recalibrates from bridge truth + // instead of leaving the UI stuck in logging_out forever. + sendBare('ping').catch(() => { + /* user can hit refresh */ + }); + } + }, [sendBare]); + + const onClickReconnect = useCallback(async () => { + // Carry the current handle through `reconnecting` so the post-reconnect + // success path can flip directly to `connected{handle}` without + // bouncing through `unknown`. The handle is read from whichever + // pre-reconnect state we're in (connected_dead is the typical + // entry, but a manual disconnect path could leave us in connected + // and trigger reconnect from there). + const handle = + state.kind === 'connected_dead' || state.kind === 'connected' + ? state.handle + : undefined; + dispatch({ kind: 'request_reconnect', handle }); + try { + await sendBare('reconnect'); + } catch { + sendBare('ping').catch(() => { + /* user can hit refresh */ + }); + } + }, [sendBare, state]); + + // Convenience: render a status pill with optional recovery button. + type StatusRowProps = { + tone: 'connected' | 'disconnected' | 'checking'; + label: string; + recovery?: { label: string; icon?: ComponentChildren; onClick: () => void; disabled?: boolean }; + }; + const StatusRow = ({ tone, label, recovery }: StatusRowProps) => { + const pill = ( + + + {label} + + ); + if (!recovery) return pill; + return ( +
+ {pill} + +
+ ); + }; + + return ( +
+ {/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no + * longer renders an avatar/name/handle/description block — the host + * panel above the iframe carries that information plus the + * three-dots menu. «О боте» lives HERE in the widget body so it + * sits adjacent to the login/logout actions it explains. */} + + {handshakeOk && state.kind === 'unknown' ? ( +
+ +
+ ) : null} + + {handshakeOk && state.kind === 'disconnected' ? ( +
+ + {state.lastError ? ( +
+ {localizeError(state.lastError, t)} +
+ ) : null} +
+ + + setAboutOpen(true)} /> +
+
+ ) : null} + + {state.kind === 'awaiting_qr_scan' ? ( +
+ +
+ ) : null} + + {state.kind === 'qr_verifying' ? ( +
+ +
+ ) : null} + + {state.kind === 'logging_out' ? ( +
+ +
+ ) : null} + + {state.kind === 'reconnecting' ? ( +
+ +
+ ) : null} + + {state.kind === 'connected' ? ( +
+ +
+ + setAboutOpen(true)} /> +
+
+ ) : null} + + {state.kind === 'connected_dead' ? ( +
+ +
+ {/* Reconnect — primary action for this state. The button uses + * the same command-card chrome so it visually matches Login / + * Logout cards. */} + + + setAboutOpen(true)} /> +
+
+ ) : null} + + {aboutOpen ? setAboutOpen(false)} /> : null} + +
+
+ {transcript.length === 0 ? ( +
{/* placeholder */}
+ ) : ( + transcript + .slice() + .reverse() + .map((line) => ( +
+ {formatTime(line.ts)} + + {line.kind === 'from-bot' ? renderBody(line.text) : line.text} + +
+ )) + )} +
+
+
+ ); +} diff --git a/apps/widget-discord/src/bootstrap.ts b/apps/widget-discord/src/bootstrap.ts new file mode 100644 index 00000000..2408de0a --- /dev/null +++ b/apps/widget-discord/src/bootstrap.ts @@ -0,0 +1,69 @@ +// Parse the URL params the Phase 2 bot widget host appends when loading +// experience.url. Source of truth on the host side: +// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl). +// Keep this in sync if the host adds params. +// +// Identical shape to apps/widget-telegram/src/bootstrap.ts on purpose — +// the host emits the same param set for every bot. Differences between +// telegram and discord live in the bridge protocol, not in bootstrap. + +export type WidgetBootstrap = { + widgetId: string; + parentUrl: string; + parentOrigin: string; + roomId: string; + userId: string; + botId: string; + botMxid: string; + /** Bridge command prefix (e.g. `!discord`). Always non-empty — the host + * validator (catalog.ts) defaults missing values to `!tg` and rejects + * malformed overrides, so the discord bot's /config.json entry MUST set + * `experience.commandPrefix: "!discord"` to override the default. The + * widget prepends ` ` to every outbound command. */ + commandPrefix: string; + theme: 'light' | 'dark'; + clientLanguage: string; +}; + +export type BootstrapResult = + | { ok: true; bootstrap: WidgetBootstrap } + | { ok: false; missing: string[] }; + +const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const; + +export const readBootstrap = (search: string): BootstrapResult => { + const params = new URLSearchParams(search); + const get = (k: string) => params.get(k) ?? ''; + + const missing = REQUIRED.filter((k) => !params.get(k)); + if (missing.length > 0) return { ok: false, missing: [...missing] }; + + // Origin is what the widget validates against on incoming postMessage — + // see widget-api.ts. Falling back to '*' would defeat the security + // boundary, so a malformed parentUrl bails out as a missing-param error. + let parentOrigin: string; + try { + parentOrigin = new URL(get('parentUrl')).origin; + } catch { + return { ok: false, missing: ['parentUrl'] }; + } + + const themeRaw = get('theme'); + const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light'; + + return { + ok: true, + bootstrap: { + widgetId: get('widgetId'), + parentUrl: get('parentUrl'), + parentOrigin, + roomId: get('roomId'), + userId: get('userId'), + botId: get('botId'), + botMxid: get('botMxid'), + commandPrefix: get('commandPrefix'), + theme, + clientLanguage: get('clientLanguage'), + }, + }; +}; diff --git a/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts b/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts new file mode 100644 index 00000000..928103a5 --- /dev/null +++ b/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts @@ -0,0 +1,482 @@ +// Dialect: mautrix-discord v0.7.6 (16 Feb 2026). The bridge runs on the +// LEGACY mautrix command framework — `maunium.net/go/mautrix/bridge/commands`, +// NOT bridgev2. As of January 2026 the mautrix maintainers flagged Discord +// as «not yet migrated to bridgev2» (mau.fi/blog/2026-01-mautrix-release/), +// so this dialect is the canonical one until the v2 migration lands. +// +// Each regex below is paired with its upstream source line in +// github.com/mautrix/discord/blob/v0.7.6/commands.go. If wording drifts in +// a future patch, replace this file with a sibling `legacy_v077.ts` +// (or whatever) and switch the import in ../parser.ts. +// +// Body encoding note: legacy mautrix commands use `ce.Reply(...)` which +// renders through `format.RenderMarkdown` in the bridge framework. Our +// host driver strips `formatted_body` (Phase 2 contract), so the widget +// only sees the markdown source — backticks, asterisks, escaped angle- +// brackets stay literal. + +import type { LoginEvent, ParsableEvent } from '../types'; + +// --- Regex table ---------------------------------------------------------- + +// Ping replies — commands.go:fnPing (l.297-310 in v0.7.6). All four are +// distinct phrasings; we capture each separately so the state machine can +// route them to different status pills. +// +// «You're logged in as @ (``)» — the trailing parens hold the +// numeric Discord snowflake wrapped in markdown backticks. Both are useful +// for surfacing in the UI. +const PING_LOGGED_IN_RE = /^you'?re logged in as\s+@?(.+?)\s+\(`?(\d+)`?\)\.?$/i; +// «You're not logged in» — exact match, no period. The legacy framework +// doesn't append punctuation here. +const PING_NOT_LOGGED_IN_RE = /^you'?re not logged in\.?$/i; +// «You have a Discord token stored, but are not connected for some reason 🤔» +// — the emoji is part of the literal upstream string and we tolerate optional +// trailing whitespace / period. +const PING_TOKEN_STORED_RE = /^you have a discord token stored, but are not connected/i; +// «You're logged in, but the Discord connection seems to be dead 💥» +const PING_CONNECTION_DEAD_RE = /^you'?re logged in, but the discord connection seems to be dead/i; + +// login-token / login-qr success — commands.go:fnLoginToken (l.156) and +// fnLoginQR (l.220). Format: `Successfully logged in as @`. The +// QR-login path doesn't include the snowflake; the token-login path has +// «Connecting to Discord as user ID %d» BEFORE the success line, but we +// only need the success terminator. Capturing the handle is enough — App +// fires `ping` after to pick up the snowflake. +const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i; + +// login-qr CAPTCHA path — commands.go:fnLoginQR (l.207-209). The bridge +// appends «CAPTCHAs are currently not supported - use token login instead» +// to the standard `Error logging in: %v` reply. Detect the suffix +// independently of the leading error verb so we surface a useful hint +// rather than a raw stack-trace tail. +const CAPTCHA_REQUIRED_RE = /captchas? are currently not supported/i; + +// Generic «Error logging in: %v» — fnLoginQR l.205 / 211 (different +// branches funnel through the same Reply call). Capture the Go-error tail +// as `reason`. Order matters: CAPTCHA_REQUIRED must be checked BEFORE this +// trap because the captcha case is a more specific subset. +const LOGIN_FAILED_RE = /^error logging in:\s*(.*)$/i; + +// «Error connecting to login websocket: %v» — fnLoginQR l.184. Pre-QR +// failure (couldn't reach Discord's remoteauth gateway). Distinguish from +// LOGIN_FAILED so the App can surface a more accurate message. +const LOGIN_WEBSOCKET_FAILED_RE = /^error connecting to login websocket:\s*(.*)$/i; + +// «Error connecting after login: %v» — fnLoginQR l.213. Post-QR rare path: +// remoteauth handed us a token but the immediate Discord connect failed. +const CONNECT_AFTER_LOGIN_FAILED_RE = /^error connecting after login:\s*(.*)$/i; + +// «Failed to prepare login: %v» — fnLoginQR l.176. Pre-QR initialisation +// failure (remoteauth couldn't even start). Routes back to disconnected. +const PREPARE_LOGIN_FAILED_RE = /^failed to prepare login:\s*(.*)$/i; + +// «You're already logged in» — both fnLoginToken (l.117) and fnLoginQR +// (l.171). Replied when the user clicks login but ping would have shown +// connected. We dispatch a re-ping to reconcile. +const ALREADY_LOGGED_IN_RE = /^you'?re already logged in\.?$/i; + +// Logout — commands.go:fnLogout (l.275-280). +const LOGOUT_OK_RE = /^logged out successfully\.?$/i; +const LOGOUT_NO_OP_RE = /^you weren'?t logged in, but data was re-cleared/i; + +// Disconnect — commands.go:fnDisconnect (l.318-326). User-typed-only path +// (the widget never sends `disconnect`), but recognising the replies keeps +// chat-fallback typists from confusing the state machine. +const DISCONNECT_OK_RE = /^successfully disconnected\.?$/i; +const DISCONNECT_NO_OP_RE = /^you'?re already not connected\.?$/i; +const DISCONNECT_FAILED_RE = /^error while disconnecting:\s*(.*)$/i; + +// Reconnect — commands.go:fnReconnect (l.339-347). Used as recovery from +// `connection_dead` / `token_stored_not_connected` ping replies. +const RECONNECT_OK_RE = /^successfully reconnected\.?$/i; +const RECONNECT_NO_OP_RE = /^you'?re already connected\.?$/i; +const RECONNECT_FAILED_RE = /^error while reconnecting:\s*(.*)$/i; + +// Unknown command — bridge/commands/processor.go (legacy framework). The +// exact wording differs between framework versions; this regex tolerates +// the canonical «Unknown command. Try `help`.» phrasing. +const UNKNOWN_COMMAND_RE = /^unknown command\.?\s*(?:try\s+)?`?help`?/i; + +// --- Body parser ---------------------------------------------------------- + +const trimReplyBody = (raw: string): string => raw.trim(); + +export const parseLegacyV076Body = (rawBody: string): LoginEvent => { + const body = trimReplyBody(rawBody); + if (body.length === 0) return { kind: 'unknown' }; + + // ORDER MATTERS: + // 1. CAPTCHA must be checked before the generic LOGIN_FAILED — captcha + // bodies match LOGIN_FAILED_RE but carry the more-specific suffix. + // 2. LOGIN_SUCCESS_RE has a permissive `(.+?)` capture; we keep it AFTER + // explicit ping replies so a future ping wording drift can't swallow + // a success line. + + // Ping replies (most common) — try first. + if (PING_NOT_LOGGED_IN_RE.test(body)) return { kind: 'not_logged_in' }; + if (PING_TOKEN_STORED_RE.test(body)) return { kind: 'token_stored_not_connected' }; + if (PING_CONNECTION_DEAD_RE.test(body)) return { kind: 'connection_dead' }; + const pingLoggedInMatch = PING_LOGGED_IN_RE.exec(body); + if (pingLoggedInMatch) { + return { + kind: 'logged_in', + handle: pingLoggedInMatch[1].trim(), + discordId: pingLoggedInMatch[2], + }; + } + + // Login lifecycle. + if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' }; + + const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body); + if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() }; + + const connectAfterMatch = CONNECT_AFTER_LOGIN_FAILED_RE.exec(body); + if (connectAfterMatch) + return { kind: 'connect_after_login_failed', reason: connectAfterMatch[1].trim() }; + + const prepareMatch = PREPARE_LOGIN_FAILED_RE.exec(body); + if (prepareMatch) return { kind: 'prepare_login_failed', reason: prepareMatch[1].trim() }; + + const loginFailedMatch = LOGIN_FAILED_RE.exec(body); + if (loginFailedMatch) return { kind: 'login_failed', reason: loginFailedMatch[1].trim() }; + + if (ALREADY_LOGGED_IN_RE.test(body)) return { kind: 'already_logged_in' }; + + // Login success — capture the handle. Discord usernames may include `.` + // and other ASCII punctuation; the regex's `(.+?)` is greedy-enough. + const successMatch = LOGIN_SUCCESS_RE.exec(body); + if (successMatch) { + const handleRaw = successMatch[1].trim(); + return { kind: 'login_success', handle: handleRaw }; + } + + // Logout / disconnect / reconnect lifecycle. + if (LOGOUT_OK_RE.test(body)) return { kind: 'logout_ok' }; + if (LOGOUT_NO_OP_RE.test(body)) return { kind: 'logout_no_op' }; + + if (DISCONNECT_OK_RE.test(body)) return { kind: 'disconnect_ok' }; + if (DISCONNECT_NO_OP_RE.test(body)) return { kind: 'disconnect_no_op' }; + const disconnectFailedMatch = DISCONNECT_FAILED_RE.exec(body); + if (disconnectFailedMatch) + return { kind: 'disconnect_failed', reason: disconnectFailedMatch[1].trim() }; + + if (RECONNECT_OK_RE.test(body)) return { kind: 'reconnect_ok' }; + if (RECONNECT_NO_OP_RE.test(body)) return { kind: 'reconnect_no_op' }; + const reconnectFailedMatch = RECONNECT_FAILED_RE.exec(body); + if (reconnectFailedMatch) + return { kind: 'reconnect_failed', reason: reconnectFailedMatch[1].trim() }; + + if (UNKNOWN_COMMAND_RE.test(body)) return { kind: 'unknown_command' }; + + return { kind: 'unknown' }; +}; + +// --- Full-event parser ---------------------------------------------------- +// +// `parseEventLegacyV076` dispatches on `event.type`: +// +// * `m.room.redaction` → `qr_redacted`. The state machine pairs the +// redaction's `redacts` against the active QR event id; an unrelated +// redaction is dropped silently. +// +// * `m.room.message` + `msgtype=m.image` → `qr_displayed` when the body +// contains a Discord remoteauth URL. Discord doesn't rotate the QR (no +// m.replace edits), but we still honour `m.relates_to.rel_type=m.replace` +// for forward-compat with a hypothetical future bridge that does. +// +// * `m.room.message` + `msgtype=m.text|m.notice` → existing +// `parseLegacyV076Body(body)` path. + +// Discord remoteauth URLs encode the auth handshake in a path on +// `discordapp.com` (the OLD Discord domain — Discord still uses it as +// the canonical remoteauth host because the URL is consumed by the +// mobile app's deep-link handler, not by browser routing). +// +// Verified upstream: mautrix/discord/remoteauth/serverpackets.go at v0.7.6 +// builds the QR string as `"https://discordapp.com/ra/" + Fingerprint` — +// see https://github.com/mautrix/discord/blob/v0.7.6/remoteauth/serverpackets.go. +// +// We accept both `discordapp.com` (canonical) AND `discord.com` because +// Discord has been gradually consolidating onto discord.com over years +// and a future bridge release could flip — keeping both means the +// widget survives the transition without a co-ordinated push. +// Subdomains (`canary.`, `ptb.`) aren't expected here (bridge talks to +// production remoteauth) but we tolerate them as belt-and-suspenders. +const DISCORD_REMOTEAUTH_URL_RE = + /https:\/\/(?:[a-z0-9-]+\.)?(?:discordapp|discord)\.com\/[A-Za-z0-9/_\-+=.~?&]+/i; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => { + if (event.type === 'm.room.redaction') { + const target = + typeof event.redacts === 'string' + ? event.redacts + : isObject(event.content) && typeof event.content.redacts === 'string' + ? event.content.redacts + : undefined; + if (!target) return { kind: 'unknown' }; + return { kind: 'qr_redacted', redactsEventId: target }; + } + + if (event.type !== 'm.room.message') return { kind: 'unknown' }; + + const msgtype = event.content?.msgtype; + + if (msgtype === 'm.image') { + // Edits replace `body` by spec; bridges typically also mirror the new + // body into `m.new_content.body`. Discord's bridge doesn't edit QRs in + // the v0.7.6 timeline, but we read both spots so a future change + // doesn't quietly break the parser. + const newContent = isObject(event.content['m.new_content']) + ? (event.content['m.new_content'] as { body?: unknown }) + : undefined; + const editedBody = typeof newContent?.body === 'string' ? newContent.body : undefined; + const directBody = typeof event.content.body === 'string' ? event.content.body : ''; + const body = editedBody ?? directBody; + + const match = body.match(DISCORD_REMOTEAUTH_URL_RE); + if (!match) return { kind: 'unknown' }; + + const relatesTo = isObject(event.content['m.relates_to']) + ? (event.content['m.relates_to'] as { rel_type?: unknown; event_id?: unknown }) + : undefined; + const replacesEventId = + relatesTo?.rel_type === 'm.replace' && typeof relatesTo.event_id === 'string' + ? relatesTo.event_id + : undefined; + + return { + kind: 'qr_displayed', + discordUrl: match[0], + eventId: event.event_id, + replacesEventId, + }; + } + + if (msgtype !== 'm.text' && msgtype !== 'm.notice') return { kind: 'unknown' }; + + const body = typeof event.content.body === 'string' ? event.content.body : ''; + return parseLegacyV076Body(body); +}; + +// --- DEV sanity assertions ------------------------------------------------ +// Vite tree-shakes this branch in production builds: `import.meta.env.DEV` +// is replaced with the literal `false` and the call site collapses, so the +// fixture array never ships. Failure throws — HMR/dev-overlay surfaces the +// first regression on reload. + +if (import.meta.env.DEV) { + runSanityChecks(); +} + +function runSanityChecks(): void { + // Body-only cases (`parseLegacyV076Body`). + const cases: Array<[string, LoginEvent]> = [ + // Ping replies. + ["You're not logged in", { kind: 'not_logged_in' }], + ["You're not logged in.", { kind: 'not_logged_in' }], + [ + 'You have a Discord token stored, but are not connected for some reason 🤔', + { kind: 'token_stored_not_connected' }, + ], + [ + "You're logged in, but the Discord connection seems to be dead 💥", + { kind: 'connection_dead' }, + ], + [ + "You're logged in as @example (`123456789`)", + { kind: 'logged_in', handle: 'example', discordId: '123456789' }, + ], + // Discord usernames support `.` since the 2026 username migration. + [ + "You're logged in as @user.name (`987654321`)", + { kind: 'logged_in', handle: 'user.name', discordId: '987654321' }, + ], + + // Login success (post-QR scan). No snowflake in this line; App fires + // `ping` afterwards to pick up the discordId. + [ + 'Successfully logged in as @example', + { kind: 'login_success', handle: 'example' }, + ], + [ + 'Successfully logged in as @user.name', + { kind: 'login_success', handle: 'user.name' }, + ], + + // Login failure paths. + [ + 'Error logging in: rate limited 429', + { kind: 'login_failed', reason: 'rate limited 429' }, + ], + // CAPTCHA — must pre-empt LOGIN_FAILED_RE because both match. The + // suffix detector is independent of the leading verb so it catches the + // case even if Discord changes the body wrapping. + [ + 'Error logging in: captcha-required 400\n\nCAPTCHAs are currently not supported - use token login instead', + { kind: 'captcha_required' }, + ], + [ + 'Error connecting to login websocket: dial tcp i/o timeout', + { kind: 'login_websocket_failed', reason: 'dial tcp i/o timeout' }, + ], + [ + 'Error connecting after login: gateway timeout', + { kind: 'connect_after_login_failed', reason: 'gateway timeout' }, + ], + [ + 'Failed to prepare login: remoteauth init failed', + { kind: 'prepare_login_failed', reason: 'remoteauth init failed' }, + ], + ["You're already logged in", { kind: 'already_logged_in' }], + + // Logout. + ['Logged out successfully.', { kind: 'logout_ok' }], + [ + "You weren't logged in, but data was re-cleared just to be safe.", + { kind: 'logout_no_op' }, + ], + + // Disconnect / reconnect. + ['Successfully disconnected', { kind: 'disconnect_ok' }], + ["You're already not connected", { kind: 'disconnect_no_op' }], + [ + 'Error while disconnecting: connection already closed', + { kind: 'disconnect_failed', reason: 'connection already closed' }, + ], + ['Successfully reconnected', { kind: 'reconnect_ok' }], + ["You're already connected", { kind: 'reconnect_no_op' }], + [ + 'Error while reconnecting: dial tcp connection refused', + { kind: 'reconnect_failed', reason: 'dial tcp connection refused' }, + ], + + // Unknown command — the bridge framework's wording. + ['Unknown command. Try `help`.', { kind: 'unknown_command' }], + + // Catch-all. + ['Some completely unknown bridge reply that does not match any anchor', { kind: 'unknown' }], + ]; + + for (const [body, expected] of cases) { + const actual = parseLegacyV076Body(body); + if (!sameEvent(actual, expected)) { + // eslint-disable-next-line no-console + console.error('[legacy_v076 sanity] mismatch', { body, actual, expected }); + throw new Error( + `legacy_v076 parser sanity failed for body ${JSON.stringify(body)} — see console for diff` + ); + } + } + + // Full-event cases — m.image / m.room.redaction / m.notice fall-through. + const eventCases: Array<[ParsableEvent, LoginEvent]> = [ + [ + // Canonical upstream form — `discordapp.com` (verified at v0.7.6 + // serverpackets.go). The legacy domain is what the Discord mobile + // app's deep-link handler accepts. + { + type: 'm.room.message', + event_id: '$qr1', + sender: '@discordbot:vojo.chat', + content: { msgtype: 'm.image', body: 'https://discordapp.com/ra/ABCDEF' }, + }, + { kind: 'qr_displayed', discordUrl: 'https://discordapp.com/ra/ABCDEF', eventId: '$qr1' }, + ], + [ + // Forward-compat: a future bridge release could flip to + // `discord.com`. The regex tolerates both. + { + type: 'm.room.message', + event_id: '$qr1b', + sender: '@discordbot:vojo.chat', + content: { msgtype: 'm.image', body: 'https://discord.com/ra/ABCDEF' }, + }, + { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/ABCDEF', eventId: '$qr1b' }, + ], + [ + // Bare m.image without a discord URL — bridge has no business sending + // these here, but the parser declines to invent state. + { + type: 'm.room.message', + event_id: '$rand', + sender: '@discordbot:vojo.chat', + content: { msgtype: 'm.image', body: 'random non-discord image caption' }, + }, + { kind: 'unknown' }, + ], + [ + // Forward-compat: hypothetical future edit. Verifies the rotation + // path works even though Discord doesn't currently rotate. + { + type: 'm.room.message', + event_id: '$qr2', + sender: '@discordbot:vojo.chat', + content: { + msgtype: 'm.image', + body: 'https://discordapp.com/ra/OLD', + 'm.relates_to': { rel_type: 'm.replace', event_id: '$qr1' }, + 'm.new_content': { msgtype: 'm.image', body: 'https://discordapp.com/ra/ROTATED' }, + }, + }, + { + kind: 'qr_displayed', + discordUrl: 'https://discordapp.com/ra/ROTATED', + eventId: '$qr2', + replacesEventId: '$qr1', + }, + ], + [ + // Redaction — top-level `redacts` (host sanitizer mirrors there). + { + type: 'm.room.redaction', + event_id: '$red1', + sender: '@discordbot:vojo.chat', + content: { redacts: '$qr1' }, + redacts: '$qr1', + }, + { kind: 'qr_redacted', redactsEventId: '$qr1' }, + ], + [ + // Redaction missing target — sanitizer should already reject; defence + // in depth. + { + type: 'm.room.redaction', + event_id: '$red2', + sender: '@discordbot:vojo.chat', + content: {}, + }, + { kind: 'unknown' }, + ], + [ + // m.notice fall-through — preserves the body-side parser path. + { + type: 'm.room.message', + event_id: '$n1', + sender: '@discordbot:vojo.chat', + content: { msgtype: 'm.notice', body: "You're not logged in" }, + }, + { kind: 'not_logged_in' }, + ], + ]; + + for (const [event, expected] of eventCases) { + const actual = parseEventLegacyV076(event); + if (!sameEvent(actual, expected)) { + // eslint-disable-next-line no-console + console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected }); + throw new Error( + `legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? ''}` + ); + } + } +} + +function sameEvent(a: LoginEvent, b: LoginEvent): boolean { + if (a.kind !== b.kind) return false; + return JSON.stringify(a) === JSON.stringify(b); +} diff --git a/apps/widget-discord/src/bridge-protocol/parser.ts b/apps/widget-discord/src/bridge-protocol/parser.ts new file mode 100644 index 00000000..5f29b5dc --- /dev/null +++ b/apps/widget-discord/src/bridge-protocol/parser.ts @@ -0,0 +1,18 @@ +// Parser shim. The widget consumes a single `parseEvent(rawEvent)` and +// the dialect handles the full event surface — m.text, m.notice, m.image +// (QR broadcasts), m.room.redaction (post-scan cleanup). M-discord ships +// one dialect, `legacy_v076`, for the operator's current bridge image. +// When mautrix-discord eventually migrates to bridgev2 (the team flagged +// this as «not yet» as of 2026-01), add a sibling dialect file and +// switch the import below. +// +// The dialects/ subdirectory is kept as a seam for that swap; we don't +// implement runtime autodetect (the operator owns one bridge image at a +// time and a parser pin is honest about that). + +import type { LoginEvent, ParsableEvent } from './types'; +import { parseEventLegacyV076 } from './dialects/legacy_v076'; + +export type { ParsableEvent }; + +export const parseEvent = (event: ParsableEvent): LoginEvent => parseEventLegacyV076(event); diff --git a/apps/widget-discord/src/bridge-protocol/types.ts b/apps/widget-discord/src/bridge-protocol/types.ts new file mode 100644 index 00000000..2b7ffbc0 --- /dev/null +++ b/apps/widget-discord/src/bridge-protocol/types.ts @@ -0,0 +1,110 @@ +// LoginEvent — discriminated union the parser emits and the state machine +// consumes. One LoginEvent per inbound m.room.message / m.room.redaction +// from the bridge bot. +// +// Source-of-truth for every kind below is mautrix/discord legacy command +// system (commands.go), tag v0.7.6 — see ./dialects/legacy_v076.ts for the +// per-string upstream pointers. Discord uses the OLDER mautrix command +// processor (`maunium.net/go/mautrix/bridge/commands`), NOT bridgev2 — so +// the wording differs from mautrix-telegram and there's no list-logins +// API; status is queried via `ping`, and there's no per-account login id. + +// `ping` reply variants — the bridge's only status-probe surface for the +// legacy command system. Each variant maps to a different LoginEvent so +// the state machine can render distinct status pills. +export type PingResult = + | { kind: 'not_logged_in' } + | { kind: 'token_stored_not_connected' } + | { kind: 'connection_dead' } + | { kind: 'logged_in'; handle: string; discordId?: string }; + +// Shape of an inbound event the dialect parser needs to look at. Matches +// the wire shape produced by the host's BotWidgetDriver sanitizer; declared +// here (not in widget-api.ts) so the dialect doesn't import from the +// transport layer. +export type ParsableEvent = { + type: string; + event_id: string; + sender: string; + origin_server_ts?: number; + content: { msgtype?: string; body?: string; [k: string]: unknown }; + redacts?: string; +}; + +export type LoginEvent = + // --- ping reply ---------------------------------------------------------- + | { kind: 'not_logged_in' } + | { kind: 'token_stored_not_connected' } + | { kind: 'connection_dead' } + // `ping` says we're live; handle parsed from `You're logged in as @x (`id`)`. + // Same shape as `login_success` — App routes both into the connected state. + | { kind: 'logged_in'; handle: string; discordId?: string } + + // --- login-qr lifecycle -------------------------------------------------- + // `m.image` carrying the remoteauth URL inside `content.body`. The widget + // renders the QR client-side from that URL and never touches the uploaded + // PNG. Discord's remoteauth does NOT rotate the URL (unlike Telegram + // MTProto): the bridge sends one m.image per login attempt and either + // redacts it on success or leaves it (and replies with an error) on + // failure. `replacesEventId` is here for forward-compat / paranoia — if a + // future bridge ever does an edit, the state machine handles it gracefully. + | { kind: 'qr_displayed'; discordUrl: string; eventId: string; replacesEventId?: string } + // Bridge redacted the QR event after a successful scan. NOT terminal — the + // success line («Successfully logged in as @x») typically lands in the same + // breath; the state machine moves us into a `qr_verifying` interstitial + // until it does. + | { kind: 'qr_redacted'; redactsEventId: string } + + // Successful login (after QR scan). Captures handle and optional snowflake. + | { kind: 'login_success'; handle: string; discordId?: string } + // Generic login failure (wraps gotd / remoteauth Go errors). Most common + // bodies: «Error logging in: ...» — we surface the verbatim Go-error tail + // as a yellow warning on the QR panel. + | { kind: 'login_failed'; reason?: string } + // Special-cased login_failed branch: the bridge appends «CAPTCHAs are + // currently not supported - use token login instead» when Discord + // presents a captcha. Telegram never sees this; Discord's remoteauth + // throws CAPTCHA roughly proportionally to the user's account age and + // login frequency. Promotes to a hint-with-explanation banner instead + // of a raw stack-trace tail. + | { kind: 'captcha_required' } + // bridge sets up a websocket against Discord's remoteauth gateway; this is + // the «we couldn't even reach Discord» error — different from + // login_failed, which lands AFTER the websocket is up. + | { kind: 'login_websocket_failed'; reason?: string } + // Surfaces when QR-login starts but the bridge is already logged in. + // Race against ping/status — the App fires `ping` to reconcile. + | { kind: 'already_logged_in' } + // bridge couldn't initialise remoteauth at all (rare, indicates bridge- + // image misconfiguration). Routes back to disconnected with a warn line. + | { kind: 'prepare_login_failed'; reason?: string } + // bridge received the token but couldn't connect to Discord with it (rare + // post-scan failure; remoteauth can return a stale token if the gateway + // race trips). Surfaces as «Signed in, but couldn't connect: » + // and routes back to disconnected. + | { kind: 'connect_after_login_failed'; reason?: string } + + // --- logout -------------------------------------------------------------- + | { kind: 'logout_ok' } + // Bridge says the user wasn't logged in but cleared state defensively. + // Idempotent confirmation that we're now disconnected. + | { kind: 'logout_no_op' } + + // --- disconnect / reconnect --------------------------------------------- + // Used as recovery from `connection_dead` / `token_stored_not_connected`. + // The widget never SENDS `disconnect` — that's an admin-only state op — + // but if the user typed it manually in chat-fallback, the parser still + // recognises the reply. + | { kind: 'disconnect_ok' } + | { kind: 'disconnect_no_op' } + | { kind: 'disconnect_failed'; reason?: string } + | { kind: 'reconnect_ok' } + | { kind: 'reconnect_no_op' } + | { kind: 'reconnect_failed'; reason?: string } + + // --- bridge-side errors -------------------------------------------------- + // Generic «I don't know that command» — should not happen since we only + // ship known commands, but visible if the bridge image is misconfigured + // or the prefix in /config.json drifted from the bridge's command_prefix. + | { kind: 'unknown_command' } + | { kind: 'unknown' }; diff --git a/apps/widget-discord/src/i18n/en.ts b/apps/widget-discord/src/i18n/en.ts new file mode 100644 index 00000000..b208e5b6 --- /dev/null +++ b/apps/widget-discord/src/i18n/en.ts @@ -0,0 +1,78 @@ +// English fallback. Mirror the RU key set; `Record` enforces +// that every RU key has an EN counterpart at compile time. + +import type { StringKey } from './ru'; + +export const EN: Record = { + 'status.unknown': 'Checking status…', + 'status.disconnected': 'Discord not linked', + 'status.connected': 'Discord linked', + 'status.connected-as': 'Discord linked as {handle}', + 'status.connection-dead': 'Discord connection lost', + 'status.token-stored': 'Discord session is not active', + 'status.qr-verifying': 'Verifying sign-in…', + 'status.logging-out': 'Signing out…', + 'status.reconnecting': 'Reconnecting to Discord…', + 'card.login-qr.name': 'Sign in with QR code', + 'card.login-qr.desc': 'Scan a QR code from the Discord mobile app', + 'card.refresh.aria': 'Refresh status', + 'card.refresh.label': 'Refresh status', + 'card.refresh.name': 'Refresh status', + 'card.refresh.desc': 'Re-check whether Discord is linked', + 'card.refresh.in-flight': 'Checking…', + 'card.about.name': 'How the Discord bot works', + 'card.about.desc': 'Sign-in, safety, and source code', + 'about.title': 'About the Discord bot', + 'about.body-1': + 'This bot connects Discord to Vojo. After sign-in, your DMs and servers from Discord will appear in Vojo’s chat list, and replies from the Vojo app will be sent to your contacts as normal Discord messages.', + 'about.body-2': + 'Sign-in requires the Discord mobile app — scan the QR code via Settings → Devices → Scan QR Code. Desktop Discord and the browser cannot be used: the bridge uses Discord’s “remoteauth” mechanism, available only in the mobile app.', + 'about.body-3': + 'The connection runs through the open-source mautrix-discord bridge. It creates a Discord session on the Vojo server and uses it to connect Discord with your Vojo account: receive messages from Discord and send your replies back.', + 'about.github-label': 'The bridge source code is public on GitHub:', + 'about.github-url': 'https://github.com/mautrix/discord', + 'about.body-4': + 'You can revoke access at any time — either with the “Sign out of Discord” button here, or inside Discord itself under Settings → Devices → Log out of Vojo.', + 'about.close': 'Close', + 'about.aria-close': 'Close “About this bot”', + 'auth-card.qr.title': 'QR code sign-in', + 'auth-card.qr.hint': 'Open the Discord mobile app and scan this QR code.', + 'auth-card.qr.preparing': 'Preparing QR code…', + 'auth-card.qr.aria': 'QR code for Discord sign-in. Scan it with your phone.', + 'auth-card.qr.countdown': 'Time left to scan: {minutes}:{seconds}', + 'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.', + 'auth-card.qr.step-1': 'Open the Discord mobile app.', + 'auth-card.qr.step-2': 'Open Settings → Devices → Scan QR Code.', + 'auth-card.qr.step-3': 'Scan the QR and confirm sign-in on your phone.', + 'auth-card.cancel': 'Cancel', + 'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.', + 'auth-error.captcha-required': + 'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.', + 'auth-error.login-failed': 'Sign-in failed: {reason}', + 'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}', + 'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}', + 'auth-error.connect-after-login-failed': + 'Signed in, but could not connect to Discord: {reason}', + 'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.', + 'auth-error.unknown-command': + 'The bot does not recognise this command — check the prefix in config.json.', + 'auth-error.disconnect-failed': 'Disconnect failed: {reason}', + 'card.reconnect.name': 'Reconnect', + 'card.reconnect.desc': 'Restore the Discord connection without signing in again', + 'card.logout.name': 'Sign out of Discord', + 'card.logout.desc': 'End the session for this account', + 'card.logout.confirm-prompt': 'Sign out for real?', + 'card.logout.confirm-yes': 'Sign out', + 'card.logout.confirm-no': 'Cancel', + 'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.', + 'diag.ready': 'Ready to send commands.', + 'diag.checking-status': 'Checking connection status…', + 'diag.send-failed': 'send failed: {message}', + 'diag.history-marker': '─── history ───', + 'diag.history-unavailable': 'Could not read history — re-checking status.', + 'diag.qr-issued': 'QR code issued.', + 'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.', + 'bootstrap.failed': 'Widget failed to start', + 'bootstrap.missing-params': 'Missing required URL params: {names}.', + 'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.', +}; diff --git a/apps/widget-discord/src/i18n/index.ts b/apps/widget-discord/src/i18n/index.ts new file mode 100644 index 00000000..bb4b38f5 --- /dev/null +++ b/apps/widget-discord/src/i18n/index.ts @@ -0,0 +1,34 @@ +// Tiny i18n harness — Russian primary, English fallback (BCP-47 prefix +// match — any `en` variant). Bootstrap forwards `clientLanguage` from the +// host; main.tsx can also call `createT()` without args before bootstrap +// completes (falls back to navigator.language, then RU). +// +// Identical mechanics to apps/widget-telegram/src/i18n/index.ts; the +// Discord widget keeps its own dictionary file because the copy differs — +// QR-only flow, no SMS, no 2FA password form. + +import { RU, type StringKey } from './ru'; +import { EN } from './en'; + +const interpolate = (s: string, vars?: Record): string => { + if (!vars) return s; + return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`); +}; + +const pickDict = (clientLanguage: string | undefined): Record => { + const lang = ( + clientLanguage || + (typeof navigator !== 'undefined' ? navigator.language : '') || + 'ru' + ).toLowerCase(); + return lang.startsWith('en') ? EN : RU; +}; + +export type T = (key: StringKey, vars?: Record) => string; + +export const createT = (clientLanguage?: string): T => { + const dict = pickDict(clientLanguage); + return (key, vars) => interpolate(dict[key], vars); +}; + +export type { StringKey }; diff --git a/apps/widget-discord/src/i18n/ru.ts b/apps/widget-discord/src/i18n/ru.ts new file mode 100644 index 00000000..c7e4ddab --- /dev/null +++ b/apps/widget-discord/src/i18n/ru.ts @@ -0,0 +1,113 @@ +// Russian primary copy. To add a string: +// 1. add the key + RU value here (this file is the canonical key list — `en.ts` +// and the `StringKey` type derive from it), +// 2. add the same key + EN value in `en.ts`, +// 3. consume via `t('key', { var: 'x' })` in components. +// Interpolation uses `{name}` placeholders resolved against the second arg. +// +// The widget no longer renders a hero (avatar/name/handle/description) — +// that block lives in the host's BotShellHero. Status is surfaced inline +// inside the relevant section. + +export const RU = { + // --- Inline section status --------------------------------------------- + 'status.unknown': 'Проверка статуса…', + 'status.disconnected': 'Discord не привязан', + 'status.connected': 'Discord привязан', + 'status.connected-as': 'Discord привязан как {handle}', + 'status.connection-dead': 'Соединение с Discord потеряно', + 'status.token-stored': 'Сессия Discord не активна', + 'status.qr-verifying': 'Проверяем вход…', + 'status.logging-out': 'Завершение сеанса…', + 'status.reconnecting': 'Переподключаюсь к Discord…', + // --- Section headers --------------------------------------------------- + 'card.login-qr.name': 'Войти по QR-коду', + // Discord QR требует МОБИЛЬНОЕ приложение Discord (legacy remoteauth + // не работает с десктопным клиентом) — это важная подсказка, чтобы у + // пользователя без мобильного клиента не возникло тупика «попробовал + // и не работает». + 'card.login-qr.desc': 'Отсканировать QR из мобильного приложения Discord', + 'card.refresh.aria': 'Обновить статус', + 'card.refresh.label': 'Обновить статус', + 'card.refresh.name': 'Обновить статус', + 'card.refresh.desc': 'Перепроверить, привязан ли Discord', + 'card.refresh.in-flight': 'Проверяю…', + // --- About panel ------------------------------------------------------- + 'card.about.name': 'Как работает Discord-бот', + 'card.about.desc': 'Вход, безопасность и исходный код', + 'about.title': 'О боте Discord', + 'about.body-1': + 'Этот бот подключает Discord к Vojo. После входа личные чаты и серверы Discord появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в Discord.', + 'about.body-2': + 'Для входа нужен мобильный клиент Discord — отсканируйте QR-код через «Настройки → Устройства → Сканировать QR-код». Десктопный Discord или браузер для входа не подходят: используется механизм remoteauth, который доступен только в мобильном приложении.', + 'about.body-3': + 'Подключение работает через open-source мост mautrix-discord. Он создаёт Discord-сессию на сервере Vojo и использует её для связи Discord с вашим аккаунтом Vojo: получает сообщения из Discord и отправляет ваши ответы обратно.', + 'about.github-label': 'Исходный код моста открыт на GitHub:', + 'about.github-url': 'https://github.com/mautrix/discord', + 'about.body-4': + 'Отозвать доступ можно в любой момент — кнопкой «Выйти из Discord» здесь, либо в самом Discord через «Настройки → Устройства → Выйти из Vojo».', + 'about.close': 'Закрыть', + 'about.aria-close': 'Закрыть «О боте»', + // --- QR form ----------------------------------------------------------- + // Discord QR не ротируется в отличие от Telegram MTProto — мост держит + // одну сессию remoteauth до успеха, ошибки или таймаута. Поэтому в + // тексте говорим про «отсканируйте этот QR-код», без указаний на + // обновление, и таймаут показываем «всего окна» одной строкой. + 'auth-card.qr.title': 'Вход по QR-коду', + 'auth-card.qr.hint': 'Откройте мобильный Discord и отсканируйте этот QR-код.', + 'auth-card.qr.preparing': 'Готовим QR-код…', + 'auth-card.qr.aria': 'QR-код для входа в Discord. Отсканируйте его телефоном.', + 'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}', + 'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.', + 'auth-card.qr.step-1': 'Откройте мобильное приложение Discord.', + 'auth-card.qr.step-2': 'Откройте «Настройки → Устройства → Сканировать QR-код».', + 'auth-card.qr.step-3': 'Отсканируйте QR-код и подтвердите вход на телефоне.', + // --- Shared form chrome ------------------------------------------------ + // Cancel в Discord-flow ЛОКАЛЬНЫЙ: legacy-мост не имеет команды отмены + // активного login-qr, поэтому кнопка просто возвращает виджет в + // disconnected, а серверная сторона сама истекает по таймауту remoteauth + // (~2 минуты по умолчанию). Это написано в about.body-2, и пользователь, + // увидев «Окно входа истекло», понимает, что стало с QR. + 'auth-card.cancel': 'Отмена', + 'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.', + // --- Inline errors ----------------------------------------------------- + 'auth-error.captcha-required': + 'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.', + 'auth-error.login-failed': 'Не удалось войти: {reason}', + 'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', + 'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}', + 'auth-error.connect-after-login-failed': + 'Вход прошёл, но соединиться с Discord не получилось: {reason}', + 'auth-error.already-logged-in': 'Вы уже вошли в Discord — обновите статус.', + 'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.', + 'auth-error.disconnect-failed': 'Не удалось отключиться: {reason}', + // --- Logout / Reconnect ------------------------------------------------ + // Reconnect-action нужен только в connection_dead / token_stored — + // здоровая сессия не показывает кнопку. Текст глагольный, без префиксов. + 'card.reconnect.name': 'Переподключиться', + 'card.reconnect.desc': 'Восстановить соединение с Discord без повторного входа', + 'card.logout.name': 'Выйти из Discord', + 'card.logout.desc': 'Завершить сеанс на этом аккаунте', + 'card.logout.confirm-prompt': 'Точно выйти?', + 'card.logout.confirm-yes': 'Выйти', + 'card.logout.confirm-no': 'Отмена', + // --- Diagnostics in transcript ---------------------------------------- + 'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.', + 'diag.ready': 'Готов отправлять команды.', + 'diag.checking-status': 'Проверяю статус подключения…', + 'diag.send-failed': 'ошибка отправки: {message}', + 'diag.history-marker': '─── история ───', + 'diag.history-unavailable': 'Не удалось прочитать историю — проверяю статус заново.', + // QR-сообщения никогда не выводятся целиком в transcript — body содержит + // токен `https://discord.com/ra/…`, который мост стирает после скана; + // сохранять его в DOM-логе виджета означало бы пережить эту защиту. + // Поэтому в логе только нейтральные диагностические строки. + 'diag.qr-issued': 'QR-код выдан.', + 'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.', + // --- Bootstrap failure ------------------------------------------------- + 'bootstrap.failed': 'Widget не запустился', + 'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.', + 'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.', +} as const; + +export type StringKey = keyof typeof RU; diff --git a/apps/widget-discord/src/main.tsx b/apps/widget-discord/src/main.tsx new file mode 100644 index 00000000..4324950f --- /dev/null +++ b/apps/widget-discord/src/main.tsx @@ -0,0 +1,63 @@ +import { render } from 'preact'; +import { readBootstrap } from './bootstrap'; +import { App } from './App'; +import { createT } from './i18n'; +import { WidgetApi, buildCapabilities } from './widget-api'; +import './styles.css'; + +// Input-mode detector — see apps/widget-telegram/src/main.tsx for the +// full rationale. Capacitor Android WebView mis-reports `hover: hover` +// on touch devices, so we drive `:hover` styling off the actual +// `pointerdown.pointerType` instead of media queries. The initial guess +// based on `(any-pointer: coarse)` covers the pre-first-pointerdown +// frame so a touch device doesn't briefly show mouse-mode hover +// affordances if the user immediately taps a card. +const setInputMode = (mode: 'touch' | 'mouse'): void => { + document.documentElement.dataset.input = mode; +}; +setInputMode(window.matchMedia('(any-pointer: coarse)').matches ? 'touch' : 'mouse'); +window.addEventListener( + 'pointerdown', + (event) => { + setInputMode(event.pointerType === 'mouse' ? 'mouse' : 'touch'); + }, + { passive: true, capture: true } +); + +const root = document.getElementById('app'); +if (!root) { + throw new Error('#app root element missing — index.html out of sync'); +} + +const result = readBootstrap(window.location.search); + +if (!result.ok) { + // Either someone opened the widget URL directly (no host params), or a + // host bug failed to provide them. Render a self-contained diagnostic + // instead of going silent. Bootstrap failed before we could read + // clientLanguage, so let createT fall back to navigator.language. + const t = createT(); + render( +
+
+ {t('bootstrap.failed')} + {t('bootstrap.missing-params', { names: result.missing.join(', ') })}{' '} + {t('bootstrap.embedded-only', { route: '/bots/discord' })} +
+
, + root + ); +} else { + // Apply initial theme synchronously so the first paint isn't flashed + // through the wrong palette. + document.documentElement.dataset.theme = result.bootstrap.theme; + + // Instantiate WidgetApi BEFORE React render. The constructor attaches + // the message listener synchronously, so by the time the host's + // ClientWidgetApi fires its capabilities request on iframe `load`, + // we're already listening. Constructing inside a useEffect would race + // with the cached-bundle remount path. See widget-telegram for full + // rationale. + const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId)); + render(, root); +} diff --git a/apps/widget-discord/src/state.ts b/apps/widget-discord/src/state.ts new file mode 100644 index 00000000..ecf3ea64 --- /dev/null +++ b/apps/widget-discord/src/state.ts @@ -0,0 +1,765 @@ +// Login state machine — consumes LoginEvent (one per inbound bridge bot +// reply) and emits a typed UI state. The widget renders the QR panel and +// the status pill from this state, never from raw reply strings. +// +// Discord vs Telegram differences: +// - QR-only: there's no phone/code/password ladder, so the state space +// is much smaller than the Telegram reducer. +// - status comes from `ping` (legacy mautrix command system), not +// `list-logins` (bridgev2). The four ping replies map to four states: +// disconnected / connected / connection_dead / token_stored. +// - no list-logins-derived `loginId`; logout is the bare `logout` verb, +// so the connected state doesn't need to gate on a login id. +// - the QR is NOT rotated by Discord remoteauth (single image per +// login attempt). The state machine still tracks `qrEventId` so the +// redaction handler can match against it and ignore unrelated cleanup. +// +// State-gating policy: late-arriving replies from cancelled flows must +// not resurrect dead state. The `cancel_pending` action ALWAYS lands us +// in `disconnected` immediately; later bridge events arriving after +// cancel are filtered by the live reducer. + +import type { LoginEvent } from './bridge-protocol/types'; + +export type LoginErrorFlag = + | { kind: 'login_failed'; reason?: string } + | { kind: 'captcha_required' } + | { kind: 'login_websocket_failed'; reason?: string } + | { kind: 'connect_after_login_failed'; reason?: string } + | { kind: 'prepare_login_failed'; reason?: string } + | { kind: 'already_logged_in' } + | { kind: 'unknown_command' }; +// `reconnect_failed` is intentionally NOT a LoginErrorFlag arm: the live +// reducer routes that event back to `connected_dead` (no error surface +// there — the connected-dead pill IS the error indicator) without +// staging a reason for `localizeError`. If a future UI change wants to +// surface the reason, add `lastError?: ...` to the connected_dead state +// shape and route `reconnect_failed` through it. + +// A live form is open and waiting for user action. M-discord ships with +// only one: the QR panel. Hydrate's restorable shape collapses to this +// single variant + the `qr_verifying` interstitial. +export type PendingFormState = { + kind: 'awaiting_qr_scan'; + discordUrl: string; + qrEventId: string; + firstShownAt: number; + lastError?: LoginErrorFlag; +}; + +export type LoginState = + // Pre-handshake / pre-ping. Status pill: --faint. + | { kind: 'unknown' } + // ping returned `not_logged_in`, OR logout completed. Status pill: + // --rose. The card grid offers the QR-login affordance. + | { kind: 'disconnected'; lastError?: LoginErrorFlag } + // QR-login in progress. Optimistically transitioned by `start_qr_login`; + // overwritten with real discordUrl/qrEventId by the live `qr_displayed` + // event. Status pill: --amber. + | PendingFormState + // QR was redacted (i.e. the bridge accepted a scan), but we don't yet + // know whether login succeeded. Held as an intermediate spinner until + // the next bridge signal arrives. Status pill: --amber. + | { kind: 'qr_verifying' } + // logout in flight — waiting for `Logged out successfully.`. Status + // pill: --amber. + | { kind: 'logging_out' } + // reconnect in flight (recovery from connection_dead / token_stored). + // Waiting for `Successfully reconnected` or `You're already connected`. + // Status pill: --amber. `handle` is carried through from the + // connected_dead state so a successful reconnect can flip directly to + // `connected{handle}` without bouncing through a transient `unknown` + // (which would briefly paint a faint «Проверка статуса…» pill — bad + // UX immediately after the user took an action). + | { kind: 'reconnecting'; handle?: string } + // Live session — ping or login_success confirmed. Discord legacy bridge + // doesn't have a per-account loginId concept (single Discord account + // per Matrix user), so logout doesn't need an id. + | { kind: 'connected'; handle: string; discordId?: string } + // ping says we have a token but the connection's down. Status pill: + // green-ish but with a Reconnect recovery action exposed. The reducer + // distinguishes `connection_dead` (Discord WS dropped) from `token_stored` + // (we have the token but never got far enough to connect), but the UI + // collapses both into the same shape — they share the recovery path. + | { kind: 'connected_dead'; reason: 'connection_dead' | 'token_stored'; handle?: string }; + +// States that the hydrate path can restore after a reload. The QR panel +// (`awaiting_qr_scan`) survives reloads via the m.image / m.room.redaction +// timeline; `qr_verifying` covers the post-scan pre-success interstitial. +// Other transient states (logging_out, reconnecting) deliberately don't +// survive — those are tied to live in-flight commands and would feel +// stuck on reload; the hydrate path falls through to live ping. +export type HydrateRestoredState = + | PendingFormState + | { kind: 'qr_verifying' }; + +// Outbound user actions the App dispatches. Form-submit actions clear any +// pending lastError; structural transitions optimistically advance state — +// the App rolls them back on send-failure where the bot would otherwise +// leave us stuck. +export type LoginAction = + | { kind: 'event'; event: LoginEvent } + | { kind: 'start_qr_login' } // user clicked «Войти по QR» + | { kind: 'request_logout' } // user clicked «Выйти из Discord» + // user clicked «Переподключиться» — App passes the current handle + // (from `connected_dead.handle` or `connected.handle`) so the + // transient `reconnecting` state carries it forward; without this the + // post-reconnect_ok branch can't paint the connected pill until the + // follow-up ping resolves. + | { kind: 'request_reconnect'; handle?: string } + // Discord legacy mautrix has no `cancel` command. Cancel is LOCAL — + // returns the widget to disconnected immediately; the bridge's + // remoteauth websocket eventually times out on its own. The action is + // kept symmetrical with TG's reducer for shape consistency, but + // dispatching it doesn't trigger any send. + | { kind: 'cancel_pending' } + | { kind: 'hydrate'; state: HydrateRestoredState }; + +export const initialLoginState: LoginState = { kind: 'unknown' }; + +const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_qr_scan'; + +export const loginReducer = (state: LoginState, action: LoginAction): LoginState => { + if (action.kind === 'hydrate') { + // hydrate is a one-shot mount-time seed. If a live event already + // moved us off `unknown`, the live truth wins; the cached timeline + // snapshot is by definition older than what the live event just told + // us. Without this gate, a stale `awaiting_qr_scan` from a previous + // session could overwrite a legitimate `connected` that arrived + // during the readTimeline await. + if (state.kind !== 'unknown') return state; + return action.state; + } + if (action.kind === 'start_qr_login') { + // Optimistic placeholder; the live `qr_displayed` event overwrites + // discordUrl + qrEventId + firstShownAt. If the `!discord login-qr` + // send fails, the App rolls back to `disconnected`. + return { + kind: 'awaiting_qr_scan', + discordUrl: '', + qrEventId: '', + firstShownAt: Date.now(), + }; + } + if (action.kind === 'request_logout') { + return { kind: 'logging_out' }; + } + if (action.kind === 'request_reconnect') { + return { kind: 'reconnecting', handle: action.handle }; + } + if (action.kind === 'cancel_pending') { + // Optimistic: drop straight back to disconnected. Discord legacy mautrix + // has no `cancel` command — the bridge's remoteauth websocket continues + // until it succeeds or times out internally. From the user's POV the + // widget returns to disconnected, and any later QR redaction / login + // success / login failure event from the abandoned flow is filtered + // by the per-event gates below (qr_redacted gated on awaiting_qr_scan, + // login_success / login_failed gated on awaiting_qr_scan|qr_verifying). + return { kind: 'disconnected' }; + } + + const event = action.event; + switch (event.kind) { + // --- ping replies ---------------------------------------------------- + + case 'not_logged_in': + // Accept from states where flipping to disconnected is correct. + // Late-arriving `not_logged_in` MUST NOT clobber an active QR-scan + // (which was started after the ping was fired but before the reply + // landed) — that's the same race the TG reducer guards against. + if ( + state.kind === 'unknown' || + state.kind === 'disconnected' || + state.kind === 'logging_out' || + state.kind === 'qr_verifying' || + state.kind === 'reconnecting' || + state.kind === 'connected_dead' + ) { + return { kind: 'disconnected' }; + } + return state; + + case 'logged_in': + // Authoritative source — accept from any state. Used by both the + // initial ping AND the post-`login_success` re-ping that picks up + // the discordId snowflake. + return { + kind: 'connected', + handle: event.handle, + discordId: event.discordId, + }; + + case 'connection_dead': + // ping says token's good but the WS is down. Show the connected + // chrome with a Reconnect recovery action. + return { + kind: 'connected_dead', + reason: 'connection_dead', + handle: state.kind === 'connected' ? state.handle : undefined, + }; + + case 'token_stored_not_connected': + return { + kind: 'connected_dead', + reason: 'token_stored', + handle: state.kind === 'connected' ? state.handle : undefined, + }; + + // --- QR lifecycle ---------------------------------------------------- + + case 'qr_displayed': { + // Defence-in-depth: an inbound qr_displayed MUST carry a non-empty + // event id (the host driver rejects empty event_id at the sanitizer; + // this is a redundant guard). + if (event.eventId.length === 0) return state; + + // Initial QR from a fresh login attempt — accept from: + // * `unknown` — cold-start before ping resolves; + // * placeholder `awaiting_qr_scan{qrEventId=''}` from start_qr_login. + // + // We DO NOT accept from `disconnected`. Discord legacy mautrix has + // no cancel command, so when the user clicks Cancel locally the + // bridge's remoteauth goroutine continues until success / failure + // / internal timeout. The widget transitions to `disconnected` + // immediately, but the bridge eventually emits the m.image. If we + // accepted that here, the user would see a QR they didn't ask for + // — the bridge has no way to know the user moved on. Drop it + // silently; the user has to click «Войти по QR» again to express + // intent (which resets the placeholder and lets the next m.image + // land). + if ( + state.kind === 'unknown' || + (state.kind === 'awaiting_qr_scan' && state.qrEventId === '') + ) { + return { + kind: 'awaiting_qr_scan', + discordUrl: event.discordUrl, + qrEventId: event.eventId, + firstShownAt: + state.kind === 'awaiting_qr_scan' && state.firstShownAt + ? state.firstShownAt + : Date.now(), + }; + } + + if (state.kind !== 'awaiting_qr_scan') return state; + + // Hypothetical edit pointing at our anchor — repaint URL, keep id. + // Discord doesn't currently edit QRs but the path stays for + // forward-compat (cheaper to keep than to reconstruct). + if (event.replacesEventId === state.qrEventId) { + return { ...state, discordUrl: event.discordUrl }; + } + + // Fresh non-edit qr_displayed while we're already tracking one — + // could be a bridge-side restart (rare). Adopt as new anchor. + if (!event.replacesEventId) { + return { + kind: 'awaiting_qr_scan', + discordUrl: event.discordUrl, + qrEventId: event.eventId, + firstShownAt: Date.now(), + }; + } + + // Edit pointing at something we don't track — ignore. + return state; + } + + case 'qr_redacted': { + // Bridge cleaned up the QR after a successful scan (commands.go + // l.197: `_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)` + // — only fires on the success path). Held as `qr_verifying` until + // the success line lands. Only honour from awaiting_qr_scan with a + // matching event id. + if (state.kind !== 'awaiting_qr_scan') return state; + if (state.qrEventId !== event.redactsEventId) return state; + return { kind: 'qr_verifying' }; + } + + case 'login_success': + // Honour from any non-terminal state. The bridge's success line + // doesn't include the discordId; the App fires `ping` afterwards + // to upgrade to the full `connected{handle, discordId}` shape. + return { kind: 'connected', handle: event.handle }; + + case 'login_failed': + // Generic Discord-side login failure — bridge replies «Error logging + // in: ». Routes back to disconnected with the verbatim + // reason as a warn line. Only honour when a QR flow is in flight; + // otherwise it's stale (e.g. an old failure replaying after page + // reload while the user is already connected). + if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state; + return { + kind: 'disconnected', + lastError: { kind: 'login_failed', reason: event.reason }, + }; + + case 'captcha_required': + // Discord presented a captcha during remoteauth — QR flow is dead + // for this attempt. Surface as a hint suggesting token-login (which + // we don't expose in the widget; users can do it via chat-fallback). + if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state; + return { kind: 'disconnected', lastError: { kind: 'captcha_required' } }; + + case 'login_websocket_failed': + // Pre-QR failure: couldn't reach Discord remoteauth. The QR was + // never displayed in the first place. State `awaiting_qr_scan` with + // empty discordUrl is the placeholder set by `start_qr_login`; + // this fires before the first qr_displayed lands. + if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state; + return { + kind: 'disconnected', + lastError: { kind: 'login_websocket_failed', reason: event.reason }, + }; + + case 'connect_after_login_failed': + // Post-scan rare: remoteauth gave us a token, but the bridge couldn't + // connect to Discord with it. The bridge has the token cached and + // might recover on next ping; we still route to disconnected so the + // user can retry. + if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state; + return { + kind: 'disconnected', + lastError: { kind: 'connect_after_login_failed', reason: event.reason }, + }; + + case 'prepare_login_failed': + if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state; + return { + kind: 'disconnected', + lastError: { kind: 'prepare_login_failed', reason: event.reason }, + }; + + case 'already_logged_in': + // The user clicked «Войти по QR» but the bridge is already logged + // in — race against ping. Surface a soft warning and let the App's + // re-ping reconcile to the connected state. + if (isFormState(state)) { + return { ...state, lastError: { kind: 'already_logged_in' } }; + } + return state; + + // --- logout ---------------------------------------------------------- + + case 'logout_ok': + case 'logout_no_op': + // Late `Logged out` from a previous session can arrive while the + // user is mid-new-flow. Only honour from logging_out; other states + // keep their flow. + if (state.kind !== 'logging_out') return state; + return { kind: 'disconnected' }; + + // --- disconnect (read-only, never sent by widget) ------------------- + + case 'disconnect_ok': + case 'disconnect_no_op': + // User typed `disconnect` manually in chat-fallback while the widget + // was open. Reflect the bridge's truth: no token-loss, but no live + // connection either — same shape as `token_stored`. Both + // `connected` (string handle) and `connected_dead` (handle?: + // string) expose `handle` on the same key, so a single read works. + if (state.kind === 'connected' || state.kind === 'connected_dead') { + return { + kind: 'connected_dead', + reason: 'token_stored', + handle: state.handle, + }; + } + return state; + + case 'disconnect_failed': + // Manual disconnect attempt failed — keep current state, the widget + // doesn't surface a UI for this since it never sent the command. + return state; + + // --- reconnect ------------------------------------------------------- + + case 'reconnect_ok': + case 'reconnect_no_op': + // After a successful reconnect, ping is the source of truth for the + // handle. The App fires `ping` after this event lands to refresh. + // We flip to `connected` immediately so the user sees an immediate + // green pill confirming their click; the post-event ping refreshes + // the handle / discordId within ~100ms. Both `reconnecting` and + // `connected_dead` carry `handle?` — a missing handle still flips + // green with an empty handle, which the UI's + // `state.handle ? connected-as : connected` ternary tolerates. + // This avoids the `unknown` flap that the previous draft would + // produce when no handle was stashed. + if (state.kind === 'reconnecting' || state.kind === 'connected_dead') { + return { kind: 'connected', handle: state.handle ?? '' }; + } + return state; + + case 'reconnect_failed': + if (state.kind !== 'reconnecting') return state; + // Roll back to connected_dead carrying the previous handle. The + // user can hit Reconnect again or refresh. We don't surface the + // error reason here — the connected_dead pill itself reads as + // «something is wrong, try Reconnect» — adding a transient red + // banner adjacent to a recovery affordance is overkill. + return { + kind: 'connected_dead', + reason: 'connection_dead', + handle: state.handle, + }; + + // --- bridge-side errors --------------------------------------------- + + case 'unknown_command': + // Shouldn't happen — we only send commands the bridge knows. Visible + // when /config.json's commandPrefix drifts from the bridge's actual + // command_prefix. Surface loudly on disconnected. + return { kind: 'disconnected', lastError: { kind: 'unknown_command' } }; + + case 'unknown': + return state; + + default: { + // Exhaustiveness check — TS flags this if a new LoginEvent kind is + // added without a case here. + const exhaustive: never = event; + return exhaustive; + } + } +}; + +// --- hydrate-from-timeline ----------------------------------------------- +// +// Discord's hydrate is simpler than Telegram's because the QR flow has +// fewer states. We walk past→present and let each event freely transition +// the state — like the TG hydrate, this is permissive (no out-of-thin-air +// rejection) because we trust the bridge's durable timeline. + +// 3 minutes — Discord remoteauth's server-side timeout sits around 2 +// minutes (verified empirically against v0.7.6's remoteauth/client.go; +// no explicit constant in the lib, the server-side gateway closes the +// websocket on inactivity). We use 3 min as a slight safety margin so +// reload-after-success grace still works while the panel is still +// fresh enough to scan. Telegram's QR rotates internally and lives ~10 +// min, which is why the TG widget uses 10 min — Discord's single-shot +// remoteauth needs the tighter window. +const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000; + +export type HydrateInput = { + ev: LoginEvent; + ts: number; +}; + +type HydrateAccumulator = { + state: LoginState; + pendingTs: number | null; + terminated: boolean; +}; + +const stepHydrate = ( + prevAcc: HydrateAccumulator, + input: HydrateInput +): HydrateAccumulator => { + const { ev, ts } = input; + + // After a terminal event we normally stop — except if a fresh + // `qr_displayed` shows up, that's the bridge signature of a NEW login + // flow. The user cancelled (or finished) and is now logging in again; + // the chain should resume tracking from the new start. Without this + // re-entry, `[qr_displayed, login_success, qr_displayed]` (logout-then- + // re-login-mid-QR) would return null. + if (prevAcc.terminated && ev.kind !== 'qr_displayed') { + return prevAcc; + } + // Restart-on-re-entry: clear the terminated bit AND any prior tracked + // state so the new flow's first event becomes the new anchor without + // inheriting the old QR's eventId. + const acc: HydrateAccumulator = prevAcc.terminated + ? { state: { kind: 'unknown' }, pendingTs: null, terminated: false } + : prevAcc; + + switch (ev.kind) { + case 'qr_displayed': { + // Same anchor logic as the live reducer. + if (acc.state.kind !== 'awaiting_qr_scan') { + return { + state: { + kind: 'awaiting_qr_scan', + discordUrl: ev.discordUrl, + qrEventId: ev.eventId, + firstShownAt: ts, + }, + pendingTs: ts, + terminated: false, + }; + } + if (ev.replacesEventId === acc.state.qrEventId) { + return { + state: { ...acc.state, discordUrl: ev.discordUrl }, + pendingTs: ts, + terminated: false, + }; + } + if (!ev.replacesEventId) { + return { + state: { + kind: 'awaiting_qr_scan', + discordUrl: ev.discordUrl, + qrEventId: ev.eventId, + firstShownAt: ts, + }, + pendingTs: ts, + terminated: false, + }; + } + return acc; + } + + case 'qr_redacted': { + if (acc.state.kind !== 'awaiting_qr_scan') return acc; + if (acc.state.qrEventId !== ev.redactsEventId) return acc; + // Move into qr_verifying and keep the chain open — the success line + // typically follows in the same scan window. + return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false }; + } + + // Terminal events — collapse the chain. State becomes whatever the + // bot confirmed last; the caller returns null and lets live `ping` + // reconcile. + case 'login_success': + case 'logged_in': + case 'logout_ok': + case 'logout_no_op': + case 'not_logged_in': + case 'connection_dead': + case 'token_stored_not_connected': + case 'reconnect_ok': + case 'reconnect_no_op': + case 'reconnect_failed': + case 'disconnect_ok': + case 'disconnect_no_op': + case 'disconnect_failed': + case 'login_failed': + case 'captcha_required': + case 'login_websocket_failed': + case 'connect_after_login_failed': + case 'prepare_login_failed': + case 'unknown_command': + return { state: acc.state, pendingTs: null, terminated: true }; + + case 'already_logged_in': + case 'unknown': + // Soft no-op for hydrate. already_logged_in is a live-flow warning + // that doesn't reflect persistent state; unknown is a wording-drift + // catch-all. + return acc; + + default: { + const exhaustive: never = ev; + return exhaustive; + } + } +}; + +export const hydrateFromTimeline = ( + inputs: ReadonlyArray, + now: number = Date.now() +): HydrateRestoredState | null => { + const acc = inputs.reduce(stepHydrate, { + state: { kind: 'unknown' }, + pendingTs: null, + terminated: false, + }); + + if (acc.terminated) return null; + if (acc.pendingTs === null) return null; + if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) return null; + if (acc.state.kind === 'qr_verifying') return acc.state; + if (!isFormState(acc.state)) return null; + return acc.state; +}; + +// --- DEV sanity assertions ------------------------------------------------ + +if (import.meta.env.DEV) { + runHydrateSanity(); +} + +function runHydrateSanity(): void { + const t0 = 1_700_000_000_000; + const recent = (offset: number) => t0 + offset; + const now = t0 + 60 * 1000; + + const cases: Array<{ + name: string; + inputs: HydrateInput[]; + expected: LoginState | null; + nowOverride?: number; + }> = [ + { name: 'empty timeline → null', inputs: [], expected: null }, + { + name: 'lone qr_displayed → awaiting_qr_scan', + inputs: [ + { + ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, + ts: recent(0), + }, + ], + expected: { + kind: 'awaiting_qr_scan', + discordUrl: 'https://discord.com/ra/A', + qrEventId: '$qrA', + firstShownAt: recent(0), + }, + }, + { + name: 'qr_redacted with mismatched target → ignored', + inputs: [ + { + ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, + ts: recent(0), + }, + { ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) }, + ], + expected: { + kind: 'awaiting_qr_scan', + discordUrl: 'https://discord.com/ra/A', + qrEventId: '$qrA', + firstShownAt: recent(0), + }, + }, + { + name: 'qr scan → no follow-up → qr_verifying (reload during the gap)', + inputs: [ + { + ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, + ts: recent(0), + }, + { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, + ], + expected: { kind: 'qr_verifying' }, + }, + { + name: 'qr scan → login_success → null (terminal — let ping reconcile)', + inputs: [ + { + ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, + ts: recent(0), + }, + { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, + { ev: { kind: 'login_success', handle: 'example' }, ts: recent(31000) }, + ], + expected: null, + }, + { + name: 'login_failed after qr → null (terminal)', + inputs: [ + { + ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, + ts: recent(0), + }, + { ev: { kind: 'login_failed', reason: 'rate limited' }, ts: recent(15000) }, + ], + expected: null, + }, + { + name: 'captcha_required after qr → null (terminal)', + inputs: [ + { + ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, + ts: recent(0), + }, + { ev: { kind: 'captcha_required' }, ts: recent(10000) }, + ], + expected: null, + }, + { + name: 'logout-then-relogin-mid-qr → awaiting_qr_scan (resume tracking)', + inputs: [ + { + ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/OLD', eventId: '$qrOld' }, + ts: recent(0), + }, + { ev: { kind: 'qr_redacted', redactsEventId: '$qrOld' }, ts: recent(15000) }, + { ev: { kind: 'login_success', handle: 'old' }, ts: recent(16000) }, + { ev: { kind: 'logout_ok' }, ts: recent(20000) }, + { + ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/NEW', eventId: '$qrNew' }, + ts: recent(25000), + }, + ], + expected: { + kind: 'awaiting_qr_scan', + discordUrl: 'https://discord.com/ra/NEW', + qrEventId: '$qrNew', + firstShownAt: recent(25000), + }, + }, + { + name: 'pending too old (5 min) → null (freshness guard, 3-min window)', + inputs: [ + { + ev: { + kind: 'qr_displayed', + discordUrl: 'https://discordapp.com/ra/A', + eventId: '$qrA', + }, + ts: t0 - 5 * 60 * 1000, + }, + ], + expected: null, + nowOverride: t0, + }, + { + name: 'pending just inside window (2 min) → state', + inputs: [ + { + ev: { + kind: 'qr_displayed', + discordUrl: 'https://discordapp.com/ra/A', + eventId: '$qrA', + }, + ts: t0 - 2 * 60 * 1000, + }, + ], + expected: { + kind: 'awaiting_qr_scan', + discordUrl: 'https://discordapp.com/ra/A', + qrEventId: '$qrA', + firstShownAt: t0 - 2 * 60 * 1000, + }, + nowOverride: t0, + }, + { + name: 'connection_dead alone → null (terminal — let live ping reconcile)', + inputs: [{ ev: { kind: 'connection_dead' }, ts: recent(0) }], + expected: null, + }, + { + name: 'token_stored_not_connected alone → null (terminal — let live ping reconcile)', + inputs: [{ ev: { kind: 'token_stored_not_connected' }, ts: recent(0) }], + expected: null, + }, + { + name: 'logged_in alone → null (terminal — let live ping reconcile)', + inputs: [{ ev: { kind: 'logged_in', handle: 'x' }, ts: recent(0) }], + expected: null, + }, + { + name: 'unknown alone → null', + inputs: [{ ev: { kind: 'unknown' }, ts: recent(0) }], + expected: null, + }, + ]; + + for (const c of cases) { + const actual = hydrateFromTimeline(c.inputs, c.nowOverride ?? now); + if (!sameLoginState(actual, c.expected)) { + // eslint-disable-next-line no-console + console.error('[hydrate sanity] mismatch', { case: c.name, actual, expected: c.expected }); + throw new Error(`hydrate sanity failed: ${c.name}`); + } + } +} + +function sameLoginState(a: LoginState | null, b: LoginState | null): boolean { + if (a === null || b === null) return a === b; + return JSON.stringify(a) === JSON.stringify(b); +} diff --git a/apps/widget-discord/src/styles.css b/apps/widget-discord/src/styles.css new file mode 100644 index 00000000..4539359d --- /dev/null +++ b/apps/widget-discord/src/styles.css @@ -0,0 +1,740 @@ +/* Dawn palette — must stay in sync with + * docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx + * (DAWN const, lines 4-23). The widget renders inside the Vojo chat slot + * which is itself a Dawn surface; the iframe inherits the same visual + * canon to feel like a continuation of the host. + * + * Identical visual vocabulary to apps/widget-telegram/src/styles.css — + * the Discord widget keeps fleet-violet (Vojo accent) rather than + * adopting Discord blurple, per product decision: «used Vojo style». */ + +:root { + --bg: #181a20; + --bg2: #0d0e11; + --surface: #21232b; + --surface2: #2a2d36; + --divider: rgba(255, 255, 255, 0.06); + --hairline: rgba(255, 255, 255, 0.08); + --text: #e6e6e9; + --muted: rgba(230, 230, 233, 0.55); + --faint: rgba(230, 230, 233, 0.32); + --fleet: #9580ff; + --fleet-soft: #a59cff; + --green: #7dd3a8; + --amber: #d4b88a; + --rose: #c08e7b; + --section-pad-x: 40px; +} + +[data-theme='light'] { + /* Light theme is intentionally a thin remap. Vojo is dark-default; the + * theme param exists so we don't fight an explicit user/host setting, + * not because we expect daily light-mode use. */ + --bg: #f5f5f7; + --bg2: #ffffff; + --surface: #f0f0f2; + --surface2: #e8e8ec; + --divider: rgba(0, 0, 0, 0.08); + --hairline: rgba(0, 0, 0, 0.1); + --text: #1a1a1d; + --muted: rgba(26, 26, 29, 0.62); + --faint: rgba(26, 26, 29, 0.4); +} + +@media (max-width: 600px) { + :root { + --section-pad-x: 20px; + } +} + +* { + box-sizing: border-box; + /* Kills the translucent grey overlay iOS/Android WebViews paint on top + * of any tapped element. Web browsers ignore this. */ + -webkit-tap-highlight-color: transparent; +} + +html, +body, +#app { + height: 100%; +} + +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.45 -apple-system, 'Segoe UI', 'Inter', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +.app { + display: flex; + flex-direction: column; + min-height: 100%; + max-width: 960px; + margin: 0 auto; +} + +/* The hero is OWNED BY THE HOST (BotShellHero). The widget body starts + * with the active-state section directly. */ + +/* ── Section ──────────────────────────────────────────────────────── */ + +.section { + padding: 24px var(--section-pad-x) 20px; +} + +.section + .section { + padding-top: 4px; +} + +/* Status pill — non-interactive (no cursor:pointer, no hover). The pill + * carries the section's identity for stateful sections. */ +.section-status { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 13px; + line-height: 20px; + color: var(--muted); + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 8px; + padding: 8px 14px; + margin: 0 0 14px; + user-select: none; + white-space: nowrap; +} + +.section-status .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--faint); + flex-shrink: 0; +} + +.section-status.connected { + color: var(--green); +} +.section-status.connected .dot { + background: var(--green); + box-shadow: 0 0 0 3px rgba(125, 211, 168, 0.16); +} + +.section-status.disconnected { + color: var(--rose); +} +.section-status.disconnected .dot { + background: var(--rose); +} + +.section-status.checking { + color: var(--amber); +} +.section-status.checking .dot { + background: var(--amber); +} + +/* Section row: status pill + recovery button (refresh / reconnect / + * cancel) when the state has no other affordance. Without this row, the + * user can stare at a «Проверка статуса…» pill forever if the first + * ping reply dropped on the wire. */ +.section-recovery-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 14px; +} +.section-recovery-row > .section-status { + margin-bottom: 0; +} + +.recovery-action { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 8px; + padding: 8px 14px; + font: inherit; + font-size: 13px; + line-height: 20px; + color: var(--muted); + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +:root[data-input='mouse'] .recovery-action:hover:not(:disabled) { + background: var(--surface); + color: var(--text); + border-color: var(--hairline); +} +.recovery-action:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.recovery-action svg { + width: 16px; + height: 16px; +} + +/* ── Command card (action card with name + desc + chevron) ──────── */ + +.command-card { + /* `appearance:none` strips native WebView focus paint that otherwise + * sits ON TOP of our explicit background — see telegram widget for + * the full debugging trail (Capacitor Android WebView holds native + * focus paint until focus moves elsewhere). */ + -webkit-appearance: none; + appearance: none; + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 10px; + padding: 14px 16px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + text-align: left; + font: inherit; + color: inherit; + transition: border-color 0.12s, background 0.12s; +} + +/* Hover scoped to mouse-mode sessions only — Capacitor Android WebView + * reports `(hover: hover)` as TRUE on a pure-touch device, so a media- + * query gate doesn't work. `[data-input]` is set in main.tsx from the + * actual `pointerdown.pointerType`. */ +:root[data-input='mouse'] .command-card:hover:not(:disabled) { + background: var(--surface); + border-color: var(--hairline); +} + +.command-card:focus { + outline: none; +} + +:root[data-input='mouse'] .command-card:focus-visible { + outline: 2px solid var(--fleet); + outline-offset: 2px; +} + +.command-card:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.command-card-body { + flex: 1; + min-width: 0; +} + +.command-card-name { + font-size: 15px; + color: var(--text); + font-weight: 600; + margin-bottom: 3px; +} + +.command-card-desc { + font-size: 14px; + color: var(--muted); + line-height: 19px; +} + +.command-card-chevron { + color: var(--muted); + font-size: 18px; + flex-shrink: 0; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.command-card-chevron svg { + width: 18px; + height: 18px; + display: block; +} + +.command-card.refreshing .command-card-chevron svg { + animation: command-card-spin 0.8s linear infinite; +} +@keyframes command-card-spin { + to { + transform: rotate(360deg); + } +} + +/* ── Transcript ──────────────────────────────────────────────────── */ + +.transcript { + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 10px; + padding: 12px 14px; + font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace; + font-size: 12.5px; + line-height: 1.55; + max-height: 360px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--surface2) transparent; +} + +.transcript::-webkit-scrollbar { + width: 8px; +} +.transcript::-webkit-scrollbar-track { + background: transparent; +} +.transcript::-webkit-scrollbar-thumb { + background: var(--surface2); + border-radius: 4px; + border: 2px solid var(--bg2); + background-clip: padding-box; +} +.transcript::-webkit-scrollbar-thumb:hover { + background: var(--surface); + border: 2px solid var(--bg2); + background-clip: padding-box; +} + +.transcript-line { + padding: 4px 0; + display: flex; + gap: 10px; + align-items: flex-start; + white-space: pre-wrap; + word-break: break-word; +} + +.transcript-line + .transcript-line { + border-top: 1px dashed var(--divider); +} + +.transcript-line .ts { + color: var(--faint); + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.transcript-line .body { + flex: 1; + min-width: 0; +} + +.transcript-line.from-bot .body { + color: var(--text); +} + +.transcript-line.from-user .body { + color: var(--fleet-soft); +} + +.transcript-line.diag .body { + color: var(--muted); +} + +.transcript-line.error .body { + color: var(--rose); +} + +.transcript-empty { + color: var(--faint); + text-align: center; + padding: 16px 0; + font-style: italic; +} + +/* Destructive card — red name marks logout as destructive vs the primary + * login card. */ +.command-card.danger .command-card-name { + color: var(--rose); +} +.command-card.danger:hover:not(:disabled) { + border-color: var(--rose); +} + +/* Inline confirm-in-place body for the destructive logout card. */ +.command-card-confirm { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + flex: 1; + min-width: 0; +} + +.command-card-confirm-prompt { + font-size: 14px; + color: var(--text); + flex: 1; + min-width: 0; +} + +.command-card-confirm-yes, +.command-card-confirm-no, +.btn-primary, +.btn-text { + font: inherit; + cursor: pointer; +} + +.command-card-confirm-yes { + background: var(--rose); + color: #0c0c0e; + border: none; + border-radius: 7px; + padding: 7px 14px; + font-size: 13px; + font-weight: 600; +} + +.command-card-confirm-no { + background: transparent; + color: var(--muted); + border: 1px solid var(--divider); + border-radius: 7px; + padding: 7px 14px; + font-size: 13px; +} + +.command-card-confirm-yes:disabled, +.command-card-confirm-no:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.command-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 10px; +} + +/* ── Auth card (QR panel chrome) ─────────────────────────────────── */ + +.auth-card { + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 10px; + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.auth-card.error { + border-color: var(--rose); +} + +.auth-card-title { + font-size: 13px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 1.4px; + font-weight: 600; +} + +.auth-card-hint { + font-size: 14px; + color: var(--muted); + line-height: 19px; +} + +.auth-card-row { + display: flex; + align-items: stretch; + gap: 10px; + flex-wrap: wrap; +} + +.btn-primary { + background: var(--fleet); + color: #0c0c0e; + border: none; + border-radius: 8px; + padding: 10px 18px; + font-size: 13px; + font-weight: 600; +} +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-text { + background: transparent; + border: none; + color: var(--muted); + padding: 10px 12px; + font-size: 13px; +} +.btn-text:hover:not(:disabled) { + color: var(--text); +} + +.auth-card-error { + font-size: 13px; + line-height: 18px; + color: var(--rose); +} + +.auth-card-warn { + font-size: 13px; + line-height: 18px; + color: var(--amber); +} + +.auth-card-countdown { + font-size: 13px; + color: var(--muted); + line-height: 18px; + font-variant-numeric: tabular-nums; + transition: color 0.2s ease-out; +} +.auth-card-countdown.expired { + color: var(--amber); +} + +/* ── QR-login panel ─────────────────────────────────────────────── */ + +/* Override the auth-card row layout — QR panel stacks vertically with the + * matrix as the visual anchor. */ +.auth-card-qr { + align-items: stretch; +} + +/* The QR matrix sits on a hard #fff plate regardless of theme — phone + * camera scanners need maximum contrast. */ +.auth-card-qr-frame { + align-self: center; + background: #fff; + border-radius: 12px; + padding: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + /* Lock the inner box to the SVG's rendered size so the placeholder + * variant doesn't collapse to zero height while the matrix is being + * computed. */ + min-width: 260px; + min-height: 260px; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06), 0 12px 24px rgba(0, 0, 0, 0.32); +} + +.auth-card-qr-placeholder { + display: inline-flex; + align-items: center; + gap: 8px; + color: rgba(26, 26, 29, 0.62); + font-size: 13px; + line-height: 20px; + padding: 96px 16px; +} +.auth-card-qr-placeholder .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--amber); + flex-shrink: 0; +} + +.auth-card-qr-steps { + margin: 0; + padding-left: 1.4em; + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + line-height: 19px; + color: var(--muted); +} +.auth-card-qr-steps li::marker { + color: var(--faint); +} + +@media (max-width: 600px) { + .auth-card-row { + flex-direction: column; + } + .btn-primary, + .btn-text { + width: 100%; + } + + .command-card { + padding: 12px 14px; + border-radius: 8px; + } + .command-card-name { + font-size: 14px; + margin-bottom: 2px; + } + .command-card-desc { + font-size: 13px; + line-height: 17px; + } + + .command-grid { + grid-template-columns: minmax(0, 1fr); + } + + .auth-card-qr-frame { + min-width: 232px; + min-height: 232px; + padding: 10px; + } + .auth-card-qr-placeholder { + padding: 80px 12px; + } +} + +/* ── Linkified transcript bodies ─────────────────────────────────── */ + +.transcript-line a { + color: var(--fleet-soft); + text-decoration: underline; +} +.transcript-line a:hover { + color: var(--text); +} + +/* ── Diagnostic banner (pre-bootstrap failure) ───────────────────── */ + +.error-banner { + margin: var(--section-pad-x); + padding: 14px 16px; + background: rgba(192, 142, 123, 0.08); + border: 1px solid var(--rose); + border-radius: 10px; + color: var(--rose); + font-size: 13px; + line-height: 19px; +} + +.error-banner strong { + display: block; + margin-bottom: 4px; + color: var(--rose); + font-weight: 600; +} + +.error-banner code { + background: var(--bg2); + padding: 1px 6px; + border-radius: 4px; + font-family: ui-monospace, 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--text); +} + +/* ── About modal ─────────────────────────────────────────────────── */ + +.about-overlay { + position: fixed; + inset: 0; + background: rgba(13, 14, 17, 0.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + animation: about-fade 0.15s ease-out; +} + +@keyframes about-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.about-panel { + background: var(--bg); + border: 1px solid var(--hairline); + border-radius: 14px; + width: 100%; + max-width: 520px; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.about-header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--divider); +} + +.about-title { + flex: 1; + font-size: 17px; + font-weight: 600; + color: var(--text); + margin: 0; + line-height: 1.3; +} + +.about-close-x { + background: transparent; + border: none; + color: var(--muted); + width: 32px; + height: 32px; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font: inherit; + font-size: 24px; + line-height: 1; + transition: background 0.12s, color 0.12s; +} +.about-close-x:hover { + background: var(--surface); + color: var(--text); +} + +.about-body { + padding: 16px 18px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; +} +.about-body p { + margin: 0; + font-size: 14px; + line-height: 1.55; + color: var(--text); +} +.about-body a { + color: var(--fleet-soft); + text-decoration: underline; + overflow-wrap: anywhere; +} +.about-body a:hover { + color: var(--text); +} + +.about-footer { + padding: 12px 18px 16px; + display: flex; + justify-content: flex-end; + border-top: 1px solid var(--divider); +} diff --git a/apps/widget-discord/src/vite-env.d.ts b/apps/widget-discord/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/apps/widget-discord/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/widget-discord/src/widget-api.ts b/apps/widget-discord/src/widget-api.ts new file mode 100644 index 00000000..8ac47aae --- /dev/null +++ b/apps/widget-discord/src/widget-api.ts @@ -0,0 +1,290 @@ +// Minimal matrix-widget-api transport implemented inline. Mirrors the +// Telegram widget's transport (apps/widget-telegram/src/widget-api.ts); +// the postMessage protocol is bot-agnostic and the host-side +// BotWidgetDriver / BotWidgetEmbed treat every bot identically. +// +// Protocol shapes match +// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts +// (in the host repo). Default request timeout on the host transport is +// 10 s — keep that in mind for bridge-bot replies that take time. + +import type { WidgetBootstrap } from './bootstrap'; + +export type RoomEvent = { + type: string; + event_id: string; + room_id: string; + sender: string; + origin_server_ts: number; + content: { msgtype?: string; body?: string; [k: string]: unknown }; + unsigned: Record; + // `m.room.redaction` events carry `redacts` at the top level (room v < 11) + // and/or inside `content.redacts` (v11+). The host driver mirrors at both + // for forward-compat; the widget-side parser reads either. + redacts?: string; +}; + +type ToWidgetMessage = { + api: 'toWidget'; + widgetId: string; + requestId: string; + action: string; + data: Record; + // Present when this message IS a reply to a prior toWidget request. + // Per matrix-widget-api PostmessageTransport: replies preserve the original + // `api` field and add `response`. Both directions follow the same shape. + response?: Record; +}; + +type FromWidgetMessage = { + api: 'fromWidget'; + widgetId: string; + requestId: string; + action: string; + data: Record; + response?: Record; +}; + +export type Capability = string; + +export type WidgetApiEvents = { + ready: () => void; + liveEvent: (ev: RoomEvent) => void; + themeChange: (name: 'light' | 'dark') => void; +}; + +const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000; + +export class WidgetApi { + private readonly listeners: { [K in keyof WidgetApiEvents]?: Array } = {}; + + private readonly pending = new Map< + string, + { resolve: (v: Record) => void; reject: (e: Error) => void } + >(); + + private requestSeq = 0; + + private isReady = false; + + public constructor( + private readonly bootstrap: WidgetBootstrap, + private readonly capabilities: Capability[] + ) { + window.addEventListener('message', this.onMessage); + } + + public dispose(): void { + window.removeEventListener('message', this.onMessage); + this.pending.forEach(({ reject }) => reject(new Error('disposed'))); + this.pending.clear(); + } + + public on(event: K, listener: WidgetApiEvents[K]): void { + const list = (this.listeners[event] ??= []) as Array; + list.push(listener); + // `ready` is a one-shot lifecycle signal. If the handshake completed + // before this listener attached (cached-bundle race: host fires the + // capabilities request on iframe `load`, the WidgetApi catches and + // resolves it during script init, then React's useEffect runs *after* + // that and attaches the `ready` listener), replay synchronously so + // App.tsx still flips `handshakeOk` and fires the initial probe. + if (event === 'ready' && this.isReady) { + (listener as () => void)(); + } + } + + public sendText(body: string): Promise<{ event_id: string }> { + return this.fromWidget('send_event', { + type: 'm.room.message', + content: { msgtype: 'm.text', body }, + }) as Promise<{ event_id: string }>; + } + + // Always prefix outbound commands with ` ` (trailing space). + // Legacy mautrix-discord routes management-room commands through the + // bridge.commands.Processor in mautrix/go bridge/commands; outside the + // management room the prefix is required, inside it's optional but stays + // unambiguous when other text is present. We always send the prefix — + // works in both cases, never wrong. + public sendCommand(rawBody: string): Promise<{ event_id: string }> { + const body = `${this.bootstrap.commandPrefix} ${rawBody}`; + return this.sendText(body); + } + + // Timeline-resume probe. Action name is MSC2876 (`read_events`); the + // capability is MSC2762 timeline (already requested at construction). We + // pass `room_ids: [bootstrap.roomId]` explicitly so the host's + // ClientWidgetApi takes the modern code path that calls our driver's + // `readRoomTimeline` (single-room cap-checked) rather than the deprecated + // `readRoomEvents` fallback. Driver returns events newest-first; reversing + // to chronological order is the caller's job. + // + // `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR + // post-scan cleanup events. `msgtype` is honoured only for m.room.message. + public async readTimeline(opts: { + limit: number; + type?: 'm.room.message' | 'm.room.redaction'; + msgtype?: 'm.text' | 'm.notice' | 'm.image'; + }): Promise { + const data: Record = { + type: opts.type ?? 'm.room.message', + limit: opts.limit, + room_ids: [this.bootstrap.roomId], + }; + if (opts.msgtype !== undefined) data.msgtype = opts.msgtype; + const res = await this.fromWidget('org.matrix.msc2876.read_events', data); + return (res.events as RoomEvent[] | undefined) ?? []; + } + + private emit( + event: K, + ...args: Parameters + ): void { + const list = this.listeners[event] as + | Array<(...a: Parameters) => void> + | undefined; + list?.forEach((fn) => fn(...args)); + } + + private nextRequestId(): string { + this.requestSeq += 1; + return `widget-discord-${Date.now()}-${this.requestSeq}`; + } + + private postToHost(msg: ToWidgetMessage | FromWidgetMessage): void { + window.parent.postMessage(msg, this.bootstrap.parentOrigin); + } + + private onMessage = (ev: MessageEvent): void => { + if (ev.origin !== this.bootstrap.parentOrigin) return; + // Source-window guard — see telegram widget for full rationale. + if (ev.source !== window.parent) return; + const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined; + if (!msg || typeof msg !== 'object') return; + if (msg.widgetId !== this.bootstrap.widgetId) return; + + if (msg.api === 'toWidget') { + this.handleToWidget(msg); + return; + } + + if (msg.api === 'fromWidget' && msg.response) { + const pending = this.pending.get(msg.requestId); + if (!pending) return; + this.pending.delete(msg.requestId); + const err = (msg.response as { error?: { message?: string } }).error; + if (err) pending.reject(new Error(err.message ?? 'request failed')); + else pending.resolve(msg.response); + } + }; + + private replyTo(msg: ToWidgetMessage, response: Record): void { + this.postToHost({ + api: msg.api, + widgetId: msg.widgetId, + requestId: msg.requestId, + action: msg.action, + data: msg.data, + response, + }); + } + + private handleToWidget(msg: ToWidgetMessage): void { + if (!msg.requestId || !msg.action) return; + switch (msg.action) { + case 'capabilities': { + this.replyTo(msg, { capabilities: this.capabilities }); + return; + } + case 'notify_capabilities': { + this.replyTo(msg, {}); + if (!this.isReady) { + this.isReady = true; + this.emit('ready'); + } + return; + } + case 'supported_api_versions': { + this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] }); + return; + } + case 'theme_change': { + const name = (msg.data?.name as string | undefined) ?? ''; + const themed: 'light' | 'dark' = name === 'dark' ? 'dark' : 'light'; + this.emit('themeChange', themed); + this.replyTo(msg, {}); + return; + } + case 'send_event': { + // Live event push from host. Forward `m.room.message` (carries the + // bot's notices / errors / `m.image` QR-login broadcasts) AND + // `m.room.redaction` (post-scan QR cleanup, see BotWidgetDriver + // `sanitizeBotWidgetRedactionEvent`). + const data = msg.data as Partial | undefined; + if ( + data && + data.event_id && + (data.type === 'm.room.message' || data.type === 'm.room.redaction') + ) { + this.emit('liveEvent', data as RoomEvent); + } + this.replyTo(msg, {}); + return; + } + case 'update_state': { + // Initial room state push from host (m.room.member members) — ignored. + this.replyTo(msg, {}); + return; + } + default: { + // Be liberal — reply empty so the host's request promise resolves. + this.replyTo(msg, {}); + } + } + } + + private fromWidget( + action: string, + data: Record + ): Promise> { + return new Promise((resolve, reject) => { + const requestId = this.nextRequestId(); + this.pending.set(requestId, { resolve, reject }); + this.postToHost({ + api: 'fromWidget', + widgetId: this.bootstrap.widgetId, + requestId, + action, + data, + }); + window.setTimeout(() => { + if (this.pending.has(requestId)) { + this.pending.delete(requestId); + reject(new Error(`${action} timed out after ${FROM_WIDGET_REQUEST_TIMEOUT_MS}ms`)); + } + }, FROM_WIDGET_REQUEST_TIMEOUT_MS); + }); + } +} + +// Capability set must match the host's BotWidgetDriver.getBotWidgetCapabilities. +// The driver is bot-agnostic — the same allowlist is applied for telegram +// and discord. Discord-specific additions would have to land in +// BotWidgetDriver first. +// +// `m.image` carries the QR login URL in `content.body` (the host sanitizer +// strips `url` / `file` / `info`, so only the URL string survives); we +// render the QR client-side from that URL via `qrcode-generator`. +// `m.room.redaction` is how the bridge signals «QR consumed by a successful +// scan» — see mautrix-discord/commands.go::fnLoginQR which redacts the QR +// event after the remoteauth websocket completes. +export const buildCapabilities = (roomId: string): Capability[] => [ + `org.matrix.msc2762.timeline:${roomId}`, + 'org.matrix.msc2762.send.event:m.room.message#m.text', + 'org.matrix.msc2762.receive.event:m.room.message#m.text', + 'org.matrix.msc2762.receive.event:m.room.message#m.notice', + 'org.matrix.msc2762.receive.event:m.room.message#m.image', + 'org.matrix.msc2762.receive.event:m.room.redaction', + 'org.matrix.msc2762.receive.state_event:m.room.member', +]; diff --git a/apps/widget-discord/tsconfig.json b/apps/widget-discord/tsconfig.json new file mode 100644 index 00000000..42f8fdbc --- /dev/null +++ b/apps/widget-discord/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "useDefineForClassFields": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/widget-discord/vite.config.ts b/apps/widget-discord/vite.config.ts new file mode 100644 index 00000000..b1712a1e --- /dev/null +++ b/apps/widget-discord/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import preact from '@preact/preset-vite'; + +// Build artefact lives at apps/widget-discord/dist/. The deploy step (out +// of repo) rsyncs this into ~/vojo/widgets/discord/ on the server, which +// Caddy serves from /var/www/widgets/discord via the widgets.vojo.chat +// block — same shape as the Telegram widget, different sub-path. +// +// `base: './'` keeps every generated asset path relative so the same +// build can sit under /discord/ on widgets.vojo.chat without rewrites. +export default defineConfig({ + base: './', + plugins: [preact()], + build: { + target: 'es2020', + sourcemap: true, + // Inline CSS for a single round-trip; the widget is small and the + // host's iframe handshake budget is already tight (10s default). + cssCodeSplit: false, + }, + server: { + // Port 8082 — telegram widget owns 8081, host SPA owns 8080. + // Both widget dev servers can run side by side without conflict. + port: 8082, + host: true, + }, +}); diff --git a/config.json b/config.json index 1ee27fd8..ffae9ad0 100644 --- a/config.json +++ b/config.json @@ -23,6 +23,16 @@ "type": "matrix-widget", "url": "https://widgets.vojo.chat/telegram/index.html" } + }, + { + "id": "discord", + "mxid": "@discordbot:vojo.chat", + "name": "Discord", + "experience": { + "type": "matrix-widget", + "url": "https://widgets.vojo.chat/discord/index.html", + "commandPrefix": "!discord" + } } ], "push": { diff --git a/public/locales/en.json b/public/locales/en.json index 32741363..6cb5ffbf 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -897,10 +897,12 @@ "retry_widget": "Retry robot", "more_options": "More", "description": { - "telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages." + "telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.", + "discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app." }, "description_short": { - "telegram": "Telegram chat connection" + "telegram": "Telegram chat connection", + "discord": "Discord chat connection" }, "unknown_title": "Robot not found", "unknown_description": "This robot is not in the Vojo catalog." diff --git a/public/locales/ru.json b/public/locales/ru.json index fe96b450..0c4d284a 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -901,10 +901,12 @@ "retry_widget": "Повторить", "more_options": "Ещё", "description": { - "telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения." + "telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.", + "discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord." }, "description_short": { - "telegram": "Подключение чатов Telegram" + "telegram": "Подключение чатов Telegram", + "discord": "Подключение чатов Discord" }, "unknown_title": "Робот не найден", "unknown_description": "Этого робота нет в каталоге Vojo."