diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index ee931bc4..b564d15f 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -16,7 +16,7 @@
{
"label": "Deploy widgets",
"type": "shell",
- "command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL",
+ "command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; (cd apps/widget-vojo-ai && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/vojo-ai/) & PID4=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; wait $PID4 || FAIL=1; exit $FAIL",
"group": "none",
"presentation": {
"reveal": "always",
diff --git a/apps/widget-vojo-ai/.gitignore b/apps/widget-vojo-ai/.gitignore
new file mode 100644
index 00000000..205a6bfb
--- /dev/null
+++ b/apps/widget-vojo-ai/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+.vite/
+*.local
diff --git a/apps/widget-vojo-ai/index.html b/apps/widget-vojo-ai/index.html
new file mode 100644
index 00000000..723f3c37
--- /dev/null
+++ b/apps/widget-vojo-ai/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Vojo AI
+
+
+
+
+
+
diff --git a/apps/widget-vojo-ai/package-lock.json b/apps/widget-vojo-ai/package-lock.json
new file mode 100644
index 00000000..8a5e7e60
--- /dev/null
+++ b/apps/widget-vojo-ai/package-lock.json
@@ -0,0 +1,1995 @@
+{
+ "name": "@vojo/widget-vojo-ai",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@vojo/widget-vojo-ai",
+ "version": "0.0.1",
+ "dependencies": {
+ "preact": "10.22.1"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "2.9.0",
+ "typescript": "5.4.5",
+ "vite": "5.4.19"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@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.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@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.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz",
+ "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "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.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz",
+ "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz",
+ "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/plugin-syntax-jsx": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-development": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz",
+ "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "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.10",
+ "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.10.tgz",
+ "integrity": "sha512-7yPTFbG56sutaFu8krp3B4a200KOFUvrtlllKWRuLjsYXo9UUucHOZRcer+gtgMkFTpv6ob8TGcTwA32bSwa1w==",
+ "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.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+ "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+ "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+ "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+ "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+ "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+ "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+ "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+ "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+ "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+ "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+ "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+ "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+ "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+ "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+ "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+ "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+ "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+ "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+ "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+ "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+ "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+ "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+ "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+ "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.33",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
+ "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
+ "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.30001793",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
+ "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
+ "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.364",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
+ "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==",
+ "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.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "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.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "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.46",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
+ "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "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.12",
+ "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/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.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
+ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+ "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.4",
+ "@rollup/rollup-android-arm64": "4.60.4",
+ "@rollup/rollup-darwin-arm64": "4.60.4",
+ "@rollup/rollup-darwin-x64": "4.60.4",
+ "@rollup/rollup-freebsd-arm64": "4.60.4",
+ "@rollup/rollup-freebsd-x64": "4.60.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+ "@rollup/rollup-linux-arm64-musl": "4.60.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+ "@rollup/rollup-linux-loong64-musl": "4.60.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-musl": "4.60.4",
+ "@rollup/rollup-openbsd-x64": "4.60.4",
+ "@rollup/rollup-openharmony-arm64": "4.60.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+ "@rollup/rollup-win32-x64-gnu": "4.60.4",
+ "@rollup/rollup-win32-x64-msvc": "4.60.4",
+ "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-vojo-ai/package.json b/apps/widget-vojo-ai/package.json
new file mode 100644
index 00000000..451d8e1b
--- /dev/null
+++ b/apps/widget-vojo-ai/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@vojo/widget-vojo-ai",
+ "version": "0.0.1",
+ "private": true,
+ "description": "Vojo AI bot widget — policy notice + «Add to chat», mounts inside /bots/vojo-ai",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "preact": "10.22.1"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "2.9.0",
+ "typescript": "5.4.5",
+ "vite": "5.4.19"
+ }
+}
diff --git a/apps/widget-vojo-ai/src/App.tsx b/apps/widget-vojo-ai/src/App.tsx
new file mode 100644
index 00000000..e1c180dd
--- /dev/null
+++ b/apps/widget-vojo-ai/src/App.tsx
@@ -0,0 +1,144 @@
+import { useEffect, useState } from 'preact/hooks';
+import type { WidgetBootstrap } from './bootstrap';
+import type { WidgetApi } from './widget-api';
+import { createT, type T } from './i18n';
+
+// Must match the host's capability string (catalog.ts BOT_CAP_ADD_TO_CHAT).
+const ADD_TO_CHAT_CAP = 'vojo.add_to_chat';
+
+// Lead glyph for the «Добавить в чат» card — a speech bubble with a «+».
+// Stroke-only so it picks up `currentColor` and matches the bridge widgets'
+// icon language (viewBox 20×20, stroke-width 1.6).
+const AddChatIcon = () => (
+
+
+
+
+
+);
+
+// Shield + check — leads the «Конфиденциальность и данные» card (mirrors the
+// Telegram widget's info-card-opens-modal pattern).
+const ShieldIcon = () => (
+
+
+
+
+);
+
+// Full privacy notice, behind a card → modal (Telegram «О боте» pattern). This
+// is where the Grok / xAI / 30-day / third-party detail lives — NOT on the
+// surface. Backdrop click + Escape close; no focus-trap (small surface).
+const AboutModal = ({ t, onClose }: { t: T; onClose: () => void }) => {
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [onClose]);
+
+ return (
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ >
+
+
+
+
{t('about.body-1')}
+
{t('about.body-2')}
+
{t('about.body-3')}
+
{t('about.body-4')}
+
{t('about.consent')}
+
+
+
+
+ );
+};
+
+type AppProps = {
+ bootstrap: WidgetBootstrap;
+ api: WidgetApi;
+};
+
+export function App({ bootstrap, api }: AppProps) {
+ const t = createT(bootstrap.clientLanguage);
+ const [aboutOpen, setAboutOpen] = useState(false);
+
+ // Render the action ONLY when the host advertised the capability. UI hint
+ // only — the host re-checks the capability against the trusted config before
+ // honouring the verb, so a forced render here cannot escalate anything.
+ const canAddToChat = bootstrap.capabilities.includes(ADD_TO_CHAT_CAP);
+
+ // Follow host theme changes (initial theme applied in main.tsx before paint).
+ useEffect(() => {
+ api.on('themeChange', (name) => {
+ document.documentElement.dataset.theme = name;
+ });
+ }, [api]);
+
+ return (
+
+
+ {t('section.label')}
+
+ {canAddToChat && (
+
api.addToChat()}>
+
+
+
+
+
{t('card.add.name')}
+
{t('card.add.desc')}
+
+
+ ›
+
+
+ )}
+
setAboutOpen(true)}>
+
+
+
+
+
{t('card.privacy.name')}
+
{t('card.privacy.desc')}
+
+
+ ›
+
+
+
+
+
+ {aboutOpen &&
setAboutOpen(false)} />}
+
+ );
+}
diff --git a/apps/widget-vojo-ai/src/bootstrap.ts b/apps/widget-vojo-ai/src/bootstrap.ts
new file mode 100644
index 00000000..412595b4
--- /dev/null
+++ b/apps/widget-vojo-ai/src/bootstrap.ts
@@ -0,0 +1,69 @@
+// Parse the URL params the 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.
+
+export type WidgetBootstrap = {
+ widgetId: string;
+ parentUrl: string;
+ parentOrigin: string;
+ roomId: string;
+ userId: string;
+ botId: string;
+ botMxid: string;
+ /** Elevated host verbs this bot is allowed to drive, forwarded by the host
+ * as a CSV render hint (NOT an authorization input — the host re-checks the
+ * capability against the trusted config before acting). Used here only to
+ * decide whether to draw the «Add to chat» button. Empty CSV ⇒ `[]` (F19). */
+ capabilities: string[];
+ theme: 'light' | 'dark';
+ clientLanguage: string;
+};
+
+export type BootstrapResult =
+ | { ok: true; bootstrap: WidgetBootstrap }
+ | { ok: false; missing: string[] };
+
+const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid'] 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'] };
+ }
+
+ // CSV → string[]. Empty string MUST become [] — `''.split(',')` yields `['']`
+ // which would make `capabilities.includes(...)` subtly wrong downstream (F19).
+ const capsRaw = get('capabilities');
+ const capabilities = capsRaw ? capsRaw.split(',').filter(Boolean) : [];
+
+ 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'),
+ capabilities,
+ theme,
+ clientLanguage: get('clientLanguage'),
+ },
+ };
+};
diff --git a/apps/widget-vojo-ai/src/i18n/en.ts b/apps/widget-vojo-ai/src/i18n/en.ts
new file mode 100644
index 00000000..c9301a03
--- /dev/null
+++ b/apps/widget-vojo-ai/src/i18n/en.ts
@@ -0,0 +1,27 @@
+// 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 = {
+ 'section.label': 'Robot in chats',
+ 'card.add.name': 'Add to chat',
+ 'card.add.desc': 'Invite Vojo AI into a room — it will reply to mentions there.',
+ 'card.privacy.name': 'Privacy & data',
+ 'card.privacy.desc': 'What is sent to the AI service and how it is stored',
+ 'about.title': 'Vojo AI privacy',
+ 'about.body-1': 'Vojo AI is an AI-powered virtual assistant. The model is provided by xAI (USA).',
+ 'about.body-2':
+ 'When you mention the robot in a chat or message it directly, the text of messages from that chat is sent to xAI to generate a reply and may be stored there for up to 30 days.',
+ 'about.body-3':
+ 'If the robot is added to a group chat, other participants’ messages may also reach xAI. Only add the robot where appropriate, and let the other participants know.',
+ 'about.body-4':
+ 'Do not send the robot personal, payment, or other confidential data. Replies are AI-generated and may contain errors.',
+ 'about.consent':
+ 'By adding the robot to a chat, you consent to sending that chat’s messages to xAI.',
+ 'about.close': 'Close',
+ 'about.aria-close': 'Close “Vojo AI privacy”',
+ '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-vojo-ai/src/i18n/index.ts b/apps/widget-vojo-ai/src/i18n/index.ts
new file mode 100644
index 00000000..686c299a
--- /dev/null
+++ b/apps/widget-vojo-ai/src/i18n/index.ts
@@ -0,0 +1,30 @@
+// 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).
+
+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-vojo-ai/src/i18n/ru.ts b/apps/widget-vojo-ai/src/i18n/ru.ts
new file mode 100644
index 00000000..98477859
--- /dev/null
+++ b/apps/widget-vojo-ai/src/i18n/ru.ts
@@ -0,0 +1,37 @@
+// 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' })`.
+// Interpolation uses `{name}` placeholders resolved against the second arg.
+//
+// The hero (name/avatar) is OWNED BY THE HOST (src/app/features/bots/BotShell).
+// The widget renders action cards + a privacy modal (Telegram «О боте» pattern).
+
+export const RU = {
+ 'section.label': 'Робот в чатах',
+ // Action card.
+ 'card.add.name': 'Добавить в чат',
+ 'card.add.desc': 'Пригласите Vojo AI в комнату — он будет отвечать на упоминания в ней.',
+ // Privacy card → opens the full policy modal.
+ 'card.privacy.name': 'Конфиденциальность и данные',
+ 'card.privacy.desc': 'Что отправляется в ИИ-сервис и как хранится',
+ // Full privacy notice (the «Политика» spelled out — see card.privacy).
+ 'about.title': 'Конфиденциальность Vojo AI',
+ 'about.body-1':
+ 'Vojo AI — виртуальный собеседник на базе искусственного интеллекта. Модель предоставляет компания xAI (США).',
+ 'about.body-2':
+ 'Когда вы упоминаете робота в чате или пишете ему напрямую, текст сообщений из этого чата передаётся в xAI для генерации ответа и может храниться там до 30 дней.',
+ 'about.body-3':
+ 'Если робот добавлен в групповой чат, в xAI могут попасть и сообщения других участников. Добавляйте робота только туда, где это уместно, и предупреждайте собеседников.',
+ 'about.body-4':
+ 'Не отправляйте роботу персональные, платёжные или иные конфиденциальные данные. Ответы генерирует ИИ — они могут содержать ошибки.',
+ 'about.consent': 'Добавляя робота в чат, вы соглашаетесь на передачу сообщений этого чата в xAI.',
+ 'about.close': 'Закрыть',
+ 'about.aria-close': 'Закрыть «Конфиденциальность Vojo AI»',
+ 'bootstrap.failed': 'Виджет не запустился',
+ 'bootstrap.missing-params': 'Не хватает параметров URL: {names}.',
+ 'bootstrap.embedded-only': 'Эта страница предназначена для встраивания в Vojo по адресу {route}.',
+} as const;
+
+export type StringKey = keyof typeof RU;
diff --git a/apps/widget-vojo-ai/src/main.tsx b/apps/widget-vojo-ai/src/main.tsx
new file mode 100644
index 00000000..48bd45c6
--- /dev/null
+++ b/apps/widget-vojo-ai/src/main.tsx
@@ -0,0 +1,61 @@
+import { render } from 'preact';
+import { readBootstrap } from './bootstrap';
+import { App } from './App';
+import { createT } from './i18n';
+import { WidgetApi } from './widget-api';
+import './styles.css';
+
+// Input-mode detector for hover styling (same rationale as widget-telegram):
+// Capacitor's Android WebView synthesises a sticky `:hover` on the tapped
+// element after a tap. CSS gates `:hover` on `:root[data-input="mouse"]`; truth
+// comes from `pointerdown.pointerType`. Default 'mouse' is no worse than any
+// interaction-media query, which mis-report on shipping devices.
+const setInputMode = (mode: 'touch' | 'mouse'): void => {
+ document.documentElement.dataset.input = mode;
+};
+setInputMode('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 the widget URL was opened directly (no host params) or a host bug
+ // failed to provide them. Render a self-contained diagnostic. Bootstrap failed
+ // before clientLanguage could be read, so createT falls 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/vojo-ai' })}
+
+
,
+ root
+ );
+} else {
+ // Apply the initial theme synchronously so the first paint isn't flashed
+ // through the wrong palette.
+ document.documentElement.dataset.theme = result.bootstrap.theme;
+
+ // Instantiate the WidgetApi BEFORE React render — its constructor attaches the
+ // `message` listener synchronously, so the host's capability request (fired on
+ // iframe `load`) is never missed on a warm/cached second mount. This widget
+ // requests NO matrix-widget-api capabilities (it neither reads nor sends
+ // events); the `add-to-chat` verb rides the separate io.vojo.bot-widget
+ // side-channel and is intentionally NOT a matrix-widget-api capability.
+ const api = new WidgetApi(result.bootstrap, []);
+ render( , root);
+}
diff --git a/apps/widget-vojo-ai/src/styles.css b/apps/widget-vojo-ai/src/styles.css
new file mode 100644
index 00000000..a437e494
--- /dev/null
+++ b/apps/widget-vojo-ai/src/styles.css
@@ -0,0 +1,360 @@
+/* Dawn palette + command-card / about-modal vocabulary — a faithful subset of
+ * apps/widget-telegram/src/styles.css so the Vojo AI widget reads as the same
+ * surface as the bridge widgets (same palette, sections, command cards, and
+ * the «about» modal pattern for detailed copy). */
+
+: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;
+ --rose: #c08e7b;
+ --section-pad-x: 40px;
+}
+
+[data-theme='light'] {
+ /* Light theme is intentionally a thin remap. Vojo is dark-default. */
+ --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 over a
+ * tapped element (read as «button stuck on grey»). Web browsers ignore it. */
+ -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 (avatar + name + handle + description + three-dots menu) is OWNED
+ * BY THE HOST — see src/app/features/bots/BotShell.tsx. The widget body starts
+ * with the action/privacy section directly. */
+
+/* ── Section ──────────────────────────────────────────────────────── */
+
+.section {
+ padding: 24px var(--section-pad-x) 20px;
+}
+
+.section + .section {
+ padding-top: 4px;
+}
+
+/* Section label — dark-bg pill, uppercase letter-spaced caption. */
+.section-label {
+ display: inline-flex;
+ align-items: center;
+ font-size: 13px;
+ line-height: 20px;
+ text-transform: uppercase;
+ letter-spacing: 1.4px;
+ font-weight: 600;
+ color: var(--muted);
+ background: var(--bg2);
+ border: 1px solid var(--divider);
+ border-radius: 8px;
+ padding: 8px 14px;
+ margin: 0 0 14px;
+ white-space: nowrap;
+ user-select: none;
+}
+
+/* ── Command card (action card with lead icon + name + desc + chevron) ─ */
+/* Lifted verbatim from the bridge widget so «Добавить в чат» / «Конфиденци-
+ * альность» are pixel-identical to the Telegram login/about cards. */
+
+.command-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-auto-rows: 1fr;
+ gap: 10px;
+}
+
+.command-card {
+ /* The widget runs in an iframe and does NOT inherit the host's
+ * `button { -webkit-appearance: button }` rule, so on iOS/Android WebView a
+ * draws a native focus/active overlay ON TOP of our background
+ * (the «greys out and doesn't snap back» bug). appearance:none makes our CSS
+ * the sole source of truth. Web browsers ignore appearance for . */
+ -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)` TRUE on pure-touch devices, so a media query alone would
+ * leave a sticky synthesised :hover after tap. `[data-input]` is set in
+ * main.tsx from the real `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-lead-icon {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--muted);
+}
+.command-card-lead-icon svg {
+ width: 20px;
+ height: 20px;
+ display: block;
+}
+
+.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;
+}
+
+@media (max-width: 600px) {
+ .command-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .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;
+ }
+}
+
+/* ── Buttons ─────────────────────────────────────────────────────────── */
+
+.btn-primary {
+ -webkit-appearance: none;
+ appearance: none;
+ background: var(--fleet);
+ color: #0c0c0e;
+ border: none;
+ border-radius: 8px;
+ padding: 10px 18px;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+}
+.btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ── About / policy modal ───────────────────────────────────────────── */
+/* Lightweight modal — fixed inside the widget iframe, not crossing into the
+ * host. Backdrop click + Escape close; no focus-trap library (small surface).
+ * Identical chrome to the Telegram widget's «О боте» 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);
+}
+
+/* ── 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;
+}
diff --git a/apps/widget-vojo-ai/src/vite-env.d.ts b/apps/widget-vojo-ai/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/apps/widget-vojo-ai/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/widget-vojo-ai/src/widget-api.ts b/apps/widget-vojo-ai/src/widget-api.ts
new file mode 100644
index 00000000..052fc1e8
--- /dev/null
+++ b/apps/widget-vojo-ai/src/widget-api.ts
@@ -0,0 +1,144 @@
+// Minimal matrix-widget-api transport, implemented inline (same approach as
+// apps/widget-telegram). This widget neither reads nor sends Matrix events — it
+// only needs to (a) complete the host's capability handshake so the host's
+// loading bar fades and `onReady` fires, (b) follow theme changes, and (c) post
+// two Vojo-extension side-channel verbs: `add-to-chat` and `open-external-url`.
+//
+// Protocol shapes match
+// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
+// in the host repo. Default host request timeout is 10s.
+
+import type { WidgetBootstrap } from './bootstrap';
+
+type ToWidgetMessage = {
+ api: 'toWidget';
+ widgetId: string;
+ requestId: string;
+ action: string;
+ data: Record;
+ response?: Record;
+};
+
+export type Capability = string;
+
+export type WidgetApiEvents = {
+ ready: () => void;
+ themeChange: (name: 'light' | 'dark') => void;
+};
+
+export class WidgetApi {
+ private readonly listeners: { [K in keyof WidgetApiEvents]?: Array } = {};
+
+ 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);
+ }
+
+ 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`, we resolve it during script init, then a
+ // post-render effect attaches the listener), replay synchronously.
+ if (event === 'ready' && this.isReady) {
+ (listener as () => void)();
+ }
+ }
+
+ // `add-to-chat` — ask the host to invite this bot into a room the USER picks.
+ // The host owns the picker and substitutes the invitee (`preset.mxid`); we
+ // send NO room and NO mxid. Distinct `io.vojo.bot-widget` channel — does not
+ // route through the matrix-widget-api request/response machinery.
+ public addToChat(): void {
+ window.parent.postMessage(
+ { api: 'io.vojo.bot-widget', action: 'add-to-chat', data: {} },
+ this.bootstrap.parentOrigin
+ );
+ }
+
+ // Open an external URL via the host (cross-origin iframes drop
+ // `` clicks inside Capacitor's Android WebView; the host
+ // routes this through `openExternalUrl`). https only — the host re-validates.
+ public openExternalUrl(url: string): void {
+ window.parent.postMessage(
+ { api: 'io.vojo.bot-widget', action: 'open-external-url', data: { url } },
+ this.bootstrap.parentOrigin
+ );
+ }
+
+ private emit(
+ event: K,
+ ...args: Parameters
+ ): void {
+ const list = this.listeners[event] as
+ | Array<(...a: Parameters) => void>
+ | undefined;
+ list?.forEach((fn) => fn(...args));
+ }
+
+ private replyTo(msg: ToWidgetMessage, response: Record): void {
+ window.parent.postMessage(
+ {
+ api: msg.api,
+ widgetId: msg.widgetId,
+ requestId: msg.requestId,
+ action: msg.action,
+ data: msg.data,
+ response,
+ },
+ this.bootstrap.parentOrigin
+ );
+ }
+
+ private onMessage = (ev: MessageEvent): void => {
+ if (ev.origin !== this.bootstrap.parentOrigin) return;
+ // Hard source guard: every legit widget-API message comes from the host
+ // window that embedded our iframe (window.parent). A foreign tab/frame on
+ // the same origin could otherwise forge a message that passes the origin
+ // check — `widgetId` below is a soft filter, this is the hard one.
+ if (ev.source !== window.parent) return;
+ const msg = ev.data as ToWidgetMessage | undefined;
+ if (!msg || typeof msg !== 'object') return;
+ if (msg.api !== 'toWidget') return;
+ if (msg.widgetId !== this.bootstrap.widgetId) return;
+ 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) ?? '';
+ this.emit('themeChange', name === 'dark' ? 'dark' : 'light');
+ this.replyTo(msg, {});
+ return;
+ }
+ default: {
+ // Be liberal — reply empty so the host's request promise resolves.
+ this.replyTo(msg, {});
+ }
+ }
+ };
+}
diff --git a/apps/widget-vojo-ai/tsconfig.json b/apps/widget-vojo-ai/tsconfig.json
new file mode 100644
index 00000000..42f8fdbc
--- /dev/null
+++ b/apps/widget-vojo-ai/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-vojo-ai/vite.config.ts b/apps/widget-vojo-ai/vite.config.ts
new file mode 100644
index 00000000..94360086
--- /dev/null
+++ b/apps/widget-vojo-ai/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite';
+import preact from '@preact/preset-vite';
+
+// Build artefact lives at apps/widget-vojo-ai/dist/. The «Deploy widgets» task
+// rsyncs it into ~/vojo/widgets/vojo-ai/ on the server, which Caddy serves from
+// the widgets.vojo.chat block at /vojo-ai/ (mirror of the telegram widget).
+//
+// `base: './'` keeps every generated asset path relative so the same build can
+// sit under /vojo-ai/ 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 tiny and the host's
+ // iframe handshake budget is already tight (10s default).
+ cssCodeSplit: false,
+ },
+ server: {
+ // 8081 telegram / 8082 discord / 8083 whatsapp / 8084 vojo-ai.
+ port: 8084,
+ host: true,
+ },
+});
diff --git a/config.json b/config.json
index 4fcb3392..484b8303 100644
--- a/config.json
+++ b/config.json
@@ -43,6 +43,16 @@
"url": "https://widgets.vojo.chat/whatsapp/index.html",
"commandPrefix": "!wa"
}
+ },
+ {
+ "id": "vojo-ai",
+ "mxid": "@ai:vojo.chat",
+ "name": "Vojo AI",
+ "experience": {
+ "type": "matrix-widget",
+ "url": "https://widgets.vojo.chat/vojo-ai/index.html",
+ "capabilities": ["vojo.add_to_chat"]
+ }
}
],
"push": {
diff --git a/public/locales/en.json b/public/locales/en.json
index 309329e1..c8945835 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -915,6 +915,14 @@
"not_connected_description": "Create a private chat with {{mxid}} to use this robot.",
"connect": "Connect",
"connect_error": "Failed to connect robot.",
+ "add_to_chat_title": "Add {{name}} to a chat",
+ "add_to_chat_subtitle": "Pick a room. {{name}} will be invited and can reply to mentions there.",
+ "add_to_chat_search_placeholder": "Search your rooms…",
+ "add_to_chat_empty": "No rooms where you can add {{name}}.",
+ "add_to_chat_no_match": "No rooms match your search.",
+ "add_to_chat_unavailable": "You can no longer add {{name}} to this room.",
+ "add_to_chat_error": "Couldn’t add {{name}}. Please try again.",
+ "encrypted_room_disabled": "Encrypted — {{name}} can’t read this room",
"pending_title": "{{name}} is connecting",
"pending_bot_invite_description": "The chat exists. Waiting for {{mxid}} to join.",
"pending_self_invite_description": "You have been invited to the chat with this robot. Accept the invite to continue.",
@@ -936,12 +944,14 @@
"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.",
"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.",
- "whatsapp": "Connect WhatsApp to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal WhatsApp messages. Sign-in uses a QR code or pairing code from the WhatsApp mobile app."
+ "whatsapp": "Connect WhatsApp to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal WhatsApp messages. Sign-in uses a QR code or pairing code from the WhatsApp mobile app.",
+ "vojo-ai": "Vojo’s AI assistant. Mention it in a chat and it replies."
},
"description_short": {
"telegram": "Telegram chat connection",
"discord": "Discord chat connection",
- "whatsapp": "WhatsApp chat connection"
+ "whatsapp": "WhatsApp chat connection",
+ "vojo-ai": "AI assistant"
},
"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 baa683d4..e86c9deb 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -933,6 +933,14 @@
"not_connected_description": "Создайте приватный чат с {{mxid}}, чтобы пользоваться роботом.",
"connect": "Подключить",
"connect_error": "Не удалось подключить робота.",
+ "add_to_chat_title": "Добавить {{name}} в чат",
+ "add_to_chat_subtitle": "Выберите комнату. {{name}} будет приглашён и сможет отвечать на упоминания в ней.",
+ "add_to_chat_search_placeholder": "Поиск по вашим комнатам…",
+ "add_to_chat_empty": "Нет комнат, куда можно добавить {{name}}.",
+ "add_to_chat_no_match": "Нет комнат по вашему запросу.",
+ "add_to_chat_unavailable": "Добавить {{name}} в эту комнату больше нельзя.",
+ "add_to_chat_error": "Не удалось добавить {{name}}. Попробуйте ещё раз.",
+ "encrypted_room_disabled": "Зашифрована — {{name}} не читает эту комнату",
"pending_title": "{{name}} подключается",
"pending_bot_invite_description": "Чат уже создан. Ждём, пока {{mxid}} присоединится.",
"pending_self_invite_description": "Вас пригласили в чат с роботом. Примите приглашение, чтобы продолжить.",
@@ -954,12 +962,14 @@
"description": {
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.",
- "whatsapp": "Подключите WhatsApp к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в WhatsApp как обычные сообщения. Вход — через QR-код или 8-символьный код из мобильного WhatsApp."
+ "whatsapp": "Подключите WhatsApp к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в WhatsApp как обычные сообщения. Вход — через QR-код или 8-символьный код из мобильного WhatsApp.",
+ "vojo-ai": "ИИ-ассистент Vojo. Упомяните его в чате — и он ответит."
},
"description_short": {
"telegram": "Подключение чатов Telegram",
"discord": "Подключение чатов Discord",
- "whatsapp": "Подключение чатов WhatsApp"
+ "whatsapp": "Подключение чатов WhatsApp",
+ "vojo-ai": "ИИ-ассистент"
},
"unknown_title": "Робот не найден",
"unknown_description": "Этого робота нет в каталоге Vojo."
diff --git a/src/app/features/bots/BotAddToChatPicker.tsx b/src/app/features/bots/BotAddToChatPicker.tsx
new file mode 100644
index 00000000..06f85baf
--- /dev/null
+++ b/src/app/features/bots/BotAddToChatPicker.tsx
@@ -0,0 +1,311 @@
+import React, {
+ ChangeEventHandler,
+ MouseEventHandler,
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import FocusTrap from 'focus-trap-react';
+import { Room } from 'matrix-js-sdk';
+import { useTranslation } from 'react-i18next';
+import {
+ Avatar,
+ Box,
+ Icon,
+ Icons,
+ Input,
+ Line,
+ MenuItem,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Scroll,
+ Spinner,
+ Text,
+ color,
+ config,
+ toRem,
+} from 'folds';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import {
+ SearchItemStrGetter,
+ UseAsyncSearchOptions,
+ useAsyncSearch,
+} from '../../hooks/useAsyncSearch';
+import { IPowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
+import { getStateEvent, isSpace } from '../../utils/room';
+import { getMxIdServer } from '../../utils/matrix';
+import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
+import { nameInitials } from '../../utils/common';
+import { Membership, StateEvent } from '../../../types/matrix/room';
+import type { BotPreset } from './catalog';
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+ matchOptions: { contain: true },
+ normalizeOptions: { ignoreWhitespace: false },
+};
+
+type BotAddToChatPickerProps = {
+ preset: BotPreset;
+ requestClose: () => void;
+};
+
+// Host-owned room picker for the elevated `add-to-chat` verb. This modal is
+// the SOLE authorization boundary for inviting the bot into a room outside its
+// control DM (the side-channel verb carries no room/mxid — the host substitutes
+// `preset.mxid` and the user picks the room here). NOT built on `useRoomSearch`,
+// which returns every room/direct/space with no predicates (F14); the candidate
+// set is filtered to the plan's predicates and every invite is re-checked
+// fail-closed immediately before `mx.invite` (F9).
+export function BotAddToChatPicker({ preset, requestClose }: BotAddToChatPickerProps) {
+ const { t } = useTranslation();
+ const mx = useMatrixClient();
+ const myUserId = mx.getSafeUserId();
+ const myServer = mx.getDomain();
+
+ // The bot may be added only to a personal chat — the «Личные»/Direct set
+ // (orphan ∪ m.direct rooms, already minus catalog bot control DMs and bridged
+ // portals), never a Channel/space room.
+ const directRoomIds = useDirectRooms();
+
+ // Plan's predicates (owner decision): joined, non-space, the bot isn't
+ // already a member, the user holds invite power, AND the room is hosted
+ // entirely on the user's own homeserver (no federated third parties — mirrors
+ // the bot's server-side «stays on allowed servers» rule). Encryption is handled
+ // separately at render (disabled row + note) and re-checked in the invite.
+ // The power read tolerates partial `m.room.power_levels` content —
+ // `readPowerLevel` fills Matrix defaults (users_default 0 / invite 0), so a
+ // default-open room correctly passes and a restricted room correctly fails.
+ const isListable = useCallback(
+ (room: Room): boolean => {
+ if (room.getMyMembership() !== Membership.Join) return false;
+ if (isSpace(room)) return false;
+ const botMembership = room.getMember(preset.mxid)?.membership;
+ if (botMembership === Membership.Join || botMembership === Membership.Invite) return false;
+ const pl =
+ getStateEvent(room, StateEvent.RoomPowerLevels, '')?.getContent() ?? {};
+ if (readPowerLevel.user(pl, myUserId) < readPowerLevel.action(pl, 'invite')) return false;
+ // Hosted entirely on the user's own homeserver: every active (joined or
+ // invited) member shares myServer, so the bot is never invited into a
+ // federated room (where it would leave anyway).
+ const active = room
+ .getMembers()
+ .filter(
+ (mem) => mem.membership === Membership.Join || mem.membership === Membership.Invite
+ );
+ if (!myServer || !active.every((mem) => getMxIdServer(mem.userId) === myServer)) return false;
+ return true;
+ },
+ [preset.mxid, myUserId, myServer]
+ );
+
+ // Snapshot at open — stable identity so `useAsyncSearch` doesn't reset its
+ // result every render. Restricted to the «Личные»/Direct set (no Channels);
+ // staleness is fine — the per-invite live re-check below is the authoritative
+ // gate, not this list.
+ const candidateRoomIds = useMemo(
+ () =>
+ directRoomIds.filter((roomId) => {
+ const room = mx.getRoom(roomId);
+ return !!room && isListable(room);
+ }),
+ [mx, directRoomIds, isListable]
+ );
+
+ const getItemStr: SearchItemStrGetter = useCallback(
+ (roomId) => mx.getRoom(roomId)?.name ?? roomId,
+ [mx]
+ );
+ const [result, search, resetSearch] = useAsyncSearch(
+ candidateRoomIds,
+ getItemStr,
+ SEARCH_OPTIONS
+ );
+ const roomsToRender = result ? result.items : candidateRoomIds;
+
+ const handleInputChange: ChangeEventHandler = useCallback(
+ (evt) => {
+ const value = evt.currentTarget.value.trim();
+ if (value === '') {
+ resetSearch();
+ return;
+ }
+ search(value);
+ },
+ [search, resetSearch]
+ );
+
+ const [pendingRoomId, setPendingRoomId] = useState(null);
+ const [inviteState, invite] = useAsyncCallback(
+ useCallback(
+ async (roomId: string) => {
+ const room = mx.getRoom(roomId);
+ // Fail-closed live re-check (F9). The snapshot above can be stale —
+ // membership lost, invite power revoked, the bot invited by another
+ // device, or encryption enabled since the modal opened. Re-verify the
+ // full predicate (incl. NOT encrypted) right before the privileged
+ // invite. try/catch upstream surfaces server races (e.g. M_FORBIDDEN
+ // re-inviting a banned bot, F13) as an inline error.
+ if (!room || !isListable(room) || room.hasEncryptionStateEvent()) {
+ throw new Error(t('Bots.add_to_chat_unavailable', { name: preset.name }));
+ }
+ await mx.invite(roomId, preset.mxid);
+ },
+ [mx, preset.mxid, preset.name, isListable, t]
+ )
+ );
+
+ const inviting = inviteState.status === AsyncStatus.Loading;
+
+ const handleRoomClick: MouseEventHandler = useCallback(
+ (evt) => {
+ if (inviting) return;
+ const roomId = evt.currentTarget.getAttribute('data-room-id');
+ if (!roomId) return;
+ setPendingRoomId(roomId);
+ invite(roomId)
+ .then(() => requestClose())
+ .catch(() => undefined); // error rendered inline via inviteState
+ },
+ [inviting, invite, requestClose]
+ );
+
+ return (
+ }>
+
+ {
+ evt.stopPropagation();
+ return true;
+ },
+ }}
+ >
+
+
+
+ {t('Bots.add_to_chat_title', { name: preset.name })}
+
+ {t('Bots.add_to_chat_subtitle', { name: preset.name })}
+
+
+ {/* Direct child of a Column Box → stretches to full width (same as
+ * the canonical Search modal). A wrapping Row Box collapsed it to
+ * content width, which read as a cramped «Поиск» pill. */}
+ }
+ onChange={handleInputChange}
+ />
+
+
+ {roomsToRender.length === 0 ? (
+
+
+ {result
+ ? t('Bots.add_to_chat_no_match')
+ : t('Bots.add_to_chat_empty', { name: preset.name })}
+
+
+ ) : (
+
+
+ {roomsToRender.map((roomId) => {
+ const room = mx.getRoom(roomId);
+ if (!room) return null;
+ const encrypted = room.hasEncryptionStateEvent();
+ const rowPending = pendingRoomId === roomId && inviting;
+ return (
+
+
+ {nameInitials(room.name)}
+
+
+ }
+ after={
+ // eslint-disable-next-line no-nested-ternary
+ rowPending ? (
+
+ ) : encrypted ? (
+
+ ) : undefined
+ }
+ >
+
+
+ {room.name}
+
+ {encrypted && (
+
+ {t('Bots.encrypted_room_disabled', { name: preset.name })}
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+ {inviteState.status === AsyncStatus.Error && (
+ <>
+
+
+
+
+ {inviteState.error.message ||
+ t('Bots.add_to_chat_error', { name: preset.name })}
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/features/bots/BotCard.tsx b/src/app/features/bots/BotCard.tsx
index db691f91..8b3c4821 100644
--- a/src/app/features/bots/BotCard.tsx
+++ b/src/app/features/bots/BotCard.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
import { Avatar, AvatarImage, Box, Text, toRem } from 'folds';
import { NavItem, NavItemContent, NavLink } from '../../components/nav';
import { getBotPath } from '../../pages/pathUtils';
@@ -33,6 +33,16 @@ export function BotCard({ preset, selected }: BotCardProps) {
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 56, 56, 'crop') ?? undefined
: undefined;
+ // Fall back to the letter square when the avatar image fails to load —
+ // mirrors the canonical UserAvatar/RoomAvatar pattern. Without this, a bot
+ // whose profile `avatar_url` points at media the server can't thumbnail
+ // (wrong format, missing thumbnail) renders the raw alt text ("Vojo
+ // AI" → "Vojo") instead of a clean initial. Reset on src change so a later
+ // good avatar (config refresh / late profile fetch) gets a fresh attempt.
+ const [imgError, setImgError] = useState(false);
+ useEffect(() => setImgError(false), [avatarUrl]);
+ const showImage = !!avatarUrl && !imgError;
+
return (
- {avatarUrl ? (
-
+ {showImage ? (
+ setImgError(true)} />
) : (
{initial}
diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts
index e59e3ae5..13acc0ba 100644
--- a/src/app/features/bots/BotWidgetEmbed.ts
+++ b/src/app/features/bots/BotWidgetEmbed.ts
@@ -19,7 +19,7 @@ import {
import { Theme } from '../../hooks/useTheme';
import { openExternalUrl } from '../../utils/capacitor';
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
-import type { BotPreset } from './catalog';
+import { BOT_CAP_ADD_TO_CHAT, type BotPreset } from './catalog';
import {
BotWidgetDriver,
sanitizeBotWidgetMessageEvent,
@@ -43,6 +43,12 @@ export type BotWidgetEmbedOptions = {
// bot-aware: any widget that delivers a matrix.to URL via the side-channel
// (`open-matrix-to` action) reaches the same handler.
onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
+ // Elevated `add-to-chat` verb (gated on the `vojo.add_to_chat` capability
+ // opt-in in config.json). The widget supplies NO room and NO mxid — the host
+ // substitutes the invitee from `preset.mxid` and the target room comes from
+ // the host's own picker. Plumbed from `BotWidgetMount` (via a ref-shim, like
+ // `onOpenMatrixToRoom`) where `mx` + navigation are available.
+ onAddToChat?: () => void;
};
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
@@ -65,6 +71,11 @@ const getBotWidgetUrl = (
url.searchParams.set('botId', preset.id);
url.searchParams.set('botMxid', preset.mxid);
url.searchParams.set('commandPrefix', preset.experience.commandPrefix);
+ // UI-only render hint: tells the widget whether to draw the «Add to chat»
+ // button. NOT an authorization input — the real gate is host-side in
+ // `onWidgetMessage` (the host re-reads `preset.experience.capabilities` from
+ // the trusted config). Empty CSV when no caps; the widget reads ''→[] (F19).
+ url.searchParams.set('capabilities', preset.experience.capabilities.join(','));
url.searchParams.set('theme', theme.kind);
url.searchParams.set('clientLanguage', language);
url.searchParams.set('baseUrl', mx.baseUrl);
@@ -229,7 +240,15 @@ export class BotWidgetEmbed {
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
// of our extension and avoids the «unknown action» reply path.
//
- // Two actions today:
+ // Three actions today:
+ //
+ // * `add-to-chat` — elevated verb, gated on the `vojo.add_to_chat`
+ // capability opt-in in config.json. Carries NO url and NO room/mxid:
+ // the host substitutes the invitee (`preset.mxid`) and the target room
+ // comes from the host's own picker (BotWidgetMount). A compromised
+ // bundle on a preset that didn't declare the cap is silently ignored —
+ // it cannot grant itself the capability (config.json is the trusted
+ // root). See the gate in the handler below.
//
// * `open-external-url` — forwards an https:// URL to the host's
// `openExternalUrl` (utils/capacitor.ts), which routes through
@@ -260,7 +279,9 @@ export class BotWidgetEmbed {
// sibling frame on the same origin in a future deployment —
// could otherwise pass the origin check).
//
- // Per-action URL validation (NOT shared, but each branch enforces):
+ // Per-action URL validation (NOT shared — each url-branch extracts and
+ // checks `msg.data.url` itself; `add-to-chat` carries no url at all, which is
+ // why the string-typeof gate must NOT be hoisted above the dispatch — see F1):
// * `open-external-url` — requires `https:` protocol, rejecting plain
// http, javascript:, data:, file:, etc. We tightened from http+https
// to https-only because no shipped widget content links over plain
@@ -278,10 +299,21 @@ export class BotWidgetEmbed {
| undefined;
if (!msg || typeof msg !== 'object') return;
if (msg.api !== 'io.vojo.bot-widget') return;
- const url = msg.data?.url;
- if (typeof url !== 'string') return;
+
+ // Elevated verb — must be dispatched BEFORE any url extraction. Its `data`
+ // is `{}` (no url), so a hoisted `typeof url === 'string'` gate would
+ // early-return and the verb would never fire (F1). The host gate reads the
+ // capability from the trusted config (`preset.experience.capabilities`),
+ // never from the widget message; the invitee is `preset.mxid`, host-set.
+ if (msg.action === 'add-to-chat') {
+ if (!this.options.preset.experience?.capabilities.includes(BOT_CAP_ADD_TO_CHAT)) return;
+ this.options.onAddToChat?.();
+ return;
+ }
if (msg.action === 'open-external-url') {
+ const url = msg.data?.url;
+ if (typeof url !== 'string') return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return;
@@ -306,6 +338,8 @@ export class BotWidgetEmbed {
// hop (`onOpenMatrixToRoom`) is the optional caller — embedded code
// paths that don't provide a callback (e.g. future test harness) get
// a silent drop, not a crash.
+ const url = msg.data?.url;
+ if (typeof url !== 'string') return;
const parsed = parseMatrixToRoom(url);
if (!parsed) return;
this.options.onOpenMatrixToRoom?.(parsed);
diff --git a/src/app/features/bots/BotWidgetMount.tsx b/src/app/features/bots/BotWidgetMount.tsx
index fe2e50ef..8b9aee67 100644
--- a/src/app/features/bots/BotWidgetMount.tsx
+++ b/src/app/features/bots/BotWidgetMount.tsx
@@ -12,6 +12,7 @@ import {
import { getChannelsSpacePath } from '../../pages/pathUtils';
import type { MatrixToRoom } from '../../plugins/matrix-to';
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
+import { BotAddToChatPicker } from './BotAddToChatPicker';
import * as css from './BotWidgetMount.css';
// Anti-flicker debounce — same rationale as `SyncIndicator`'s
@@ -67,12 +68,21 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
[mx, navigate]
);
+ // Host-owned room picker for the elevated `add-to-chat` verb. The widget
+ // only posts the verb (no room, no mxid) — opening the picker and choosing a
+ // room is entirely host-side, so this modal IS the authorization boundary.
+ // `pickerOpen` is the singleton open-state: re-firing the verb while it's
+ // already true is a no-op, so a hostile bundle can't stack modals.
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const handleAddToChat = useCallback(() => setPickerOpen(true), []);
+
const { ready } = useBotWidgetEmbed({
containerRef,
preset,
room,
onError,
onOpenMatrixToRoom: handleOpenMatrixToRoom,
+ onAddToChat: handleAddToChat,
});
// Track Matrix sync state so the bot loading bar yields to the global
@@ -194,6 +204,9 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
onAnimationIteration={handleIteration}
/>
+ {pickerOpen && (
+ setPickerOpen(false)} />
+ )}
>
);
}
diff --git a/src/app/features/bots/catalog.ts b/src/app/features/bots/catalog.ts
index 9f19fefd..8664e305 100644
--- a/src/app/features/bots/catalog.ts
+++ b/src/app/features/bots/catalog.ts
@@ -8,8 +8,21 @@ export type BotExperience = {
/** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
* Resolved with the bridgev2 default `!tg` when the operator omits it. */
commandPrefix: string;
+ /** Elevated host verbs this bot is allowed to drive via the
+ * `io.vojo.bot-widget` side-channel. Always an array (never `undefined`) so
+ * the runtime gate `capabilities.includes(...)` is well-defined — see
+ * `normalizeBotCaps`. Filtered against `KNOWN_BOT_CAPS` at load; absent ⇒ `[]`. */
+ capabilities: string[];
};
+/** The only elevated side-channel verb today: lets the widget ask the host to
+ * invite the bot (`preset.mxid`, host-substituted) into a user-picked room. The
+ * widget never supplies the room or the mxid. Privilege originates from the
+ * trusted config.json opt-in, NOT from the widget claiming it. */
+export const BOT_CAP_ADD_TO_CHAT = 'vojo.add_to_chat';
+
+const KNOWN_BOT_CAPS: ReadonlySet = new Set([BOT_CAP_ADD_TO_CHAT]);
+
export type BotPreset = {
/** Stable URL slug — `/bots/`. Never reuse across bots. */
id: string;
@@ -56,6 +69,21 @@ const normalizeCommandPrefix = (raw: unknown): string | undefined => {
return trimmed;
};
+// Validate `experience.capabilities` against the known allowlist. Unknown
+// strings are dropped (an operator typo or a future cap the host doesn't
+// implement must never reach the runtime gate). ALWAYS returns an array —
+// never `undefined` — so `preset.experience.capabilities.includes(...)` is a
+// live boolean rather than a no-op on `undefined?.includes` (F2: the three
+// return literals below have no spread, so the field must be set explicitly in
+// each, and a default of `undefined` would silently disarm the gate). Dedupes
+// so a doubled config entry can't skew anything downstream.
+const normalizeBotCaps = (raw: unknown): string[] => {
+ if (!Array.isArray(raw)) return [];
+ return [
+ ...new Set(raw.filter((c): c is string => typeof c === 'string' && KNOWN_BOT_CAPS.has(c))),
+ ];
+};
+
const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
typeof preset?.id === 'string' &&
BOT_ID_RE.test(preset.id) &&
@@ -76,6 +104,12 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
const commandPrefix = normalizeCommandPrefix(experience?.commandPrefix);
if (commandPrefix === undefined) return undefined;
+ // Allowlisted at load so `preset.experience.capabilities` only ever holds
+ // known caps; the host gate (BotWidgetEmbed) trusts this value, so the
+ // filtering MUST happen here, not at the UI layer. Added to every return
+ // literal below (no spread — F2).
+ const capabilities = normalizeBotCaps(experience?.capabilities);
+
if (url.startsWith('/')) {
// Resolve once so `/widgets/../admin` collapses before the prefix check —
// a relative `/widgets/...` survives `new URL(url, base)` only if it does
@@ -102,6 +136,7 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
type,
url: `${resolved.pathname}${resolved.search}${resolved.hash}`,
commandPrefix,
+ capabilities,
};
} catch {
return undefined;
@@ -117,12 +152,12 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
// collapses to a literal `false`), so it never relaxes the prod validator.
if (import.meta.env.DEV && parsed.protocol === 'http:' && parsed.hostname === 'localhost') {
if (parsed.username || parsed.password) return undefined;
- return { type, url: parsed.toString(), commandPrefix };
+ return { type, url: parsed.toString(), commandPrefix, capabilities };
}
if (parsed.protocol !== 'https:') return undefined;
if (parsed.username || parsed.password) return undefined;
if (!PROD_WIDGET_ORIGINS.has(parsed.origin)) return undefined;
- return { type, url: parsed.toString(), commandPrefix };
+ return { type, url: parsed.toString(), commandPrefix, capabilities };
} catch {
return undefined;
}
diff --git a/src/app/features/bots/useBotWidgetEmbed.ts b/src/app/features/bots/useBotWidgetEmbed.ts
index e61ffb63..ce7d7b52 100644
--- a/src/app/features/bots/useBotWidgetEmbed.ts
+++ b/src/app/features/bots/useBotWidgetEmbed.ts
@@ -15,6 +15,10 @@ type UseBotWidgetEmbedOptions = {
// Forwarded into the embed. Plumbed from `BotWidgetMount` where the
// react-router context is available — the hook stays unaware of routing.
onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
+ // Forwarded into the embed. Fires when the widget posts the elevated
+ // `add-to-chat` verb (host opens its own room picker). Plumbed from
+ // `BotWidgetMount` where `mx` is available.
+ onAddToChat?: () => void;
};
type UseBotWidgetEmbedResult = {
@@ -35,6 +39,7 @@ export const useBotWidgetEmbed = ({
room,
onError,
onOpenMatrixToRoom,
+ onAddToChat,
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
const { i18n } = useTranslation();
const mx = useMatrixClient();
@@ -54,6 +59,10 @@ export const useBotWidgetEmbed = ({
// embed only sees a stable shim that re-reads it.
const onOpenMatrixToRoomRef = useRef(onOpenMatrixToRoom);
onOpenMatrixToRoomRef.current = onOpenMatrixToRoom;
+ // Same ref indirection for `onAddToChat` (closes over `mx`/picker state per
+ // render) so the embed lifecycle effect doesn't remount the iframe.
+ const onAddToChatRef = useRef(onAddToChat);
+ onAddToChatRef.current = onAddToChat;
// Depend on primitive identity for the embed lifecycle — using `preset`
// directly would remount the iframe (and re-handshake with the widget)
@@ -86,6 +95,7 @@ export const useBotWidgetEmbed = ({
// Indirection so the embed lifecycle doesn't reset when the
// navigate-callback closes over a new render's `mx`/`navigate`.
onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target),
+ onAddToChat: () => onAddToChatRef.current?.(),
});
embedRef.current = embed;
} catch (error) {
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index 5d146cb4..bda26a41 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -23,6 +23,11 @@ export type BotConfig = {
type?: string;
url?: string;
commandPrefix?: string;
+ /** Declarative opt-in to elevated host verbs (e.g. `vojo.add_to_chat`).
+ * Validated against an allowlist at load (catalog.ts `normalizeBotCaps`);
+ * unknown entries are dropped, absent ⇒ `[]`. config.json is a trusted,
+ * operator-controlled root — the widget cannot grant itself a capability. */
+ capabilities?: string[];
};
};