Compare commits
138 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b3a4145a7 | |||
| 765445c091 | |||
| 408b9eefc3 | |||
| 8d8b39e897 | |||
| 055b7d3692 | |||
| 78504262d3 | |||
| cde50cff0f | |||
| 6ca6b69d48 | |||
| 240bb54c29 | |||
| 8fb885df1b | |||
| ab283e9788 | |||
| e866cd3830 | |||
| 7c5a1f2ee7 | |||
| 0422a9832f | |||
| 4a9d5f6384 | |||
| 870e13d895 | |||
| 727a53a776 | |||
| af97549e48 | |||
| 0c704aac38 | |||
| b26340fa7d | |||
| 5dbe83aa9d | |||
| 4b4454fa1d | |||
| de348eb4fc | |||
| 06778702b2 | |||
| 8a80194fe5 | |||
| 38d24e5527 | |||
| 408f165f60 | |||
| b9aad691b5 | |||
| 770609b964 | |||
| ebf2cfe07b | |||
| 2d101a40fc | |||
| f2ecca64da | |||
| 6982ec374e | |||
| 45c69317ff | |||
| c78984a6d8 | |||
| bfd72dc1ff | |||
| 0eb2e056c0 | |||
| 81d23be61f | |||
| 8e2db986b4 | |||
| 646cb7b124 | |||
| 8400ef54ee | |||
| a893e86d92 | |||
| 2d74848509 | |||
| c3a384b651 | |||
| 3c7c79fb6c | |||
| f5e992daad | |||
| 2dac76f9af | |||
| 1ee1d50c41 | |||
| d3e69e042f | |||
| 5d4a50f593 | |||
| 5c16649e0c | |||
| 30e477d2cd | |||
| 6a6b7acf15 | |||
| 3dee9f099f | |||
| 11c46d9250 | |||
| c27f8a7cc2 | |||
| 4e7ea405ab | |||
| ffb80bff88 | |||
| 663aece487 | |||
| 4654836092 | |||
| 635fb91022 | |||
| c6bb66958d | |||
| 149382299a | |||
| ce82d66883 | |||
| f38cb42344 | |||
| 785b679b61 | |||
| de2354f1da | |||
| 41a9af19e3 | |||
| 2337b05140 | |||
| ab6c65a4e0 | |||
| e3e61afd4c | |||
| 4d0b508ebb | |||
| 023a6a439c | |||
| c992e910ee | |||
| 2bbaf4dfcf | |||
| 117bb9fba4 | |||
| 626a7c2d1d | |||
| 9c204c1af6 | |||
| 4b39046c09 | |||
| 3fed5ff873 | |||
| d4b05619a8 | |||
| 0b31e6b930 | |||
| 896a2e2083 | |||
| e80453785e | |||
| 307af24d1e | |||
| 4632be30f7 | |||
| 851f3d30a3 | |||
| 075b6cf69c | |||
| efe58dc2e2 | |||
| c6eba1e935 | |||
| 3ea01a9c3f | |||
| 0d93a223d0 | |||
| 6560f0b424 | |||
| b30704dd96 | |||
| cd824e0c90 | |||
| f4292611cf | |||
| d58e69d49f | |||
| 997375b307 | |||
| ce308776cd | |||
| 97a50e29f9 | |||
| 17ba496b7e | |||
| 998813eff4 | |||
| ece9e922e3 | |||
| 7af69574f4 | |||
| e8865cec5f | |||
| 1d64275bae | |||
| 295dfbb796 | |||
| 526515dcde | |||
| 42d9ccfbf3 | |||
| bc360e84cc | |||
| 5eb12f888b | |||
| 156570826a | |||
| e46bba2f7d | |||
| b2f3b668c5 | |||
| 817dad383c | |||
| 949860bc1a | |||
| f102593081 | |||
| e547c466a8 | |||
| a1ff5db724 | |||
| 8f49124043 | |||
| ed1544dd5e | |||
| bae6761683 | |||
| 316c3eb9fd | |||
| 35ade7e941 | |||
| e43b0fb597 | |||
| d961dddfbc | |||
| 83e246da1f | |||
| 357a2024f4 | |||
| 96085ba6a1 | |||
| b5ea37d57a | |||
| 212d3e3482 | |||
| 3cd1611ee2 | |||
| d2c77496a7 | |||
| 103d6ad8a1 | |||
| 5bf0aeb00b | |||
| e230e688de | |||
| ed3e5c0640 | |||
| 0c89e9fda0 |
|
|
@ -1,2 +1,3 @@
|
||||||
experiment
|
experiment
|
||||||
node_modules
|
node_modules
|
||||||
|
*.css
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,15 @@ module.exports = {
|
||||||
es2021: true,
|
es2021: true,
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
"eslint:recommended",
|
'eslint:recommended',
|
||||||
"plugin:react/recommended",
|
'plugin:react/recommended',
|
||||||
"plugin:react-hooks/recommended",
|
'plugin:react-hooks/recommended',
|
||||||
"plugin:@typescript-eslint/eslint-recommended",
|
'plugin:@typescript-eslint/eslint-recommended',
|
||||||
"plugin:@typescript-eslint/recommended",
|
'plugin:@typescript-eslint/recommended',
|
||||||
'airbnb',
|
'airbnb',
|
||||||
'prettier',
|
'prettier',
|
||||||
],
|
],
|
||||||
parser: "@typescript-eslint/parser",
|
parser: '@typescript-eslint/parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaFeatures: {
|
ecmaFeatures: {
|
||||||
jsx: true,
|
jsx: true,
|
||||||
|
|
@ -20,53 +20,80 @@ module.exports = {
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
"globals": {
|
globals: {
|
||||||
JSX: "readonly"
|
JSX: 'readonly',
|
||||||
|
__APP_VERSION__: 'readonly',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: ['react', '@typescript-eslint'],
|
||||||
'react',
|
|
||||||
'@typescript-eslint'
|
|
||||||
],
|
|
||||||
rules: {
|
rules: {
|
||||||
'linebreak-style': 0,
|
'linebreak-style': 0,
|
||||||
'no-underscore-dangle': 0,
|
'no-underscore-dangle': 0,
|
||||||
"no-shadow": "off",
|
'no-shadow': 'off',
|
||||||
|
|
||||||
"import/prefer-default-export": "off",
|
'import/prefer-default-export': 'off',
|
||||||
"import/extensions": "off",
|
'import/extensions': 'off',
|
||||||
"import/no-unresolved": "off",
|
'import/no-unresolved': 'off',
|
||||||
"import/no-extraneous-dependencies": [
|
'import/no-extraneous-dependencies': [
|
||||||
"error",
|
'error',
|
||||||
{
|
{
|
||||||
devDependencies: true,
|
devDependencies: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
'react/no-unstable-nested-components': [
|
'react/no-unstable-nested-components': ['error', { allowAsProps: true }],
|
||||||
|
'react/jsx-filename-extension': [
|
||||||
'error',
|
'error',
|
||||||
{ allowAsProps: true },
|
|
||||||
],
|
|
||||||
"react/jsx-filename-extension": [
|
|
||||||
"error",
|
|
||||||
{
|
{
|
||||||
extensions: [".tsx", ".jsx"],
|
extensions: ['.tsx', '.jsx'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
"react/require-default-props": "off",
|
'react/require-default-props': 'off',
|
||||||
"react/jsx-props-no-spreading": "off",
|
'react/jsx-props-no-spreading': 'off',
|
||||||
"react-hooks/rules-of-hooks": "error",
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
"react-hooks/exhaustive-deps": "error",
|
'react-hooks/exhaustive-deps': 'error',
|
||||||
|
|
||||||
"@typescript-eslint/no-unused-vars": "error",
|
// Disable base rules in favour of their @typescript-eslint counterparts —
|
||||||
"@typescript-eslint/no-shadow": "error"
|
// the base rules can't see TS-specific constructs (interface members, type
|
||||||
|
// imports, etc.) and double-fire alongside the TS versions.
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-shadow': 'error',
|
||||||
|
|
||||||
|
// Policy: kept as `warn` at the rule level so editors / `eslint --fix` /
|
||||||
|
// ad-hoc runs surface them as warnings, but `npm run check:eslint` and
|
||||||
|
// `lint-staged` BOTH pass `--max-warnings 0`, so new occurrences block
|
||||||
|
// commit. When unavoidable (matrix-js-sdk boundary, generic helpers,
|
||||||
|
// third-party callback shapes), suppress on the line with
|
||||||
|
// `// eslint-disable-next-line` and a one-line justification.
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ['*.ts'],
|
files: ['*.ts', '*.tsx'],
|
||||||
rules: {
|
rules: {
|
||||||
'no-undef': 'off',
|
'no-undef': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Upstream-vendored binary parsing copied verbatim from matrix-react-sdk
|
||||||
|
// (src/util/cryptE2ERoomKeys.js header link). Bitwise ops, post-increment
|
||||||
|
// and string concatenation are correct for the domain — clean-up risks
|
||||||
|
// breaking E2E room-key import/export. Keep the body byte-identical to
|
||||||
|
// upstream and disable only the rules that fire on those idioms.
|
||||||
|
files: ['src/util/cryptE2ERoomKeys.js'],
|
||||||
|
rules: {
|
||||||
|
'no-bitwise': 'off',
|
||||||
|
'no-plusplus': 'off',
|
||||||
|
'prefer-template': 'off',
|
||||||
|
'no-param-reassign': 'off',
|
||||||
|
// `for (;;)` form upstream uses for the iter-loops trips eslint
|
||||||
|
// even though it's intentional — keep upstream control flow.
|
||||||
|
'no-constant-condition': 'off',
|
||||||
|
// Diagnostic `console.log` left as-is in vendor copy.
|
||||||
|
'no-console': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
21
.gitignore
vendored
|
|
@ -2,13 +2,26 @@ experiment
|
||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
devAssets
|
devAssets
|
||||||
|
config.local.json
|
||||||
|
|
||||||
|
electron/dist-electron
|
||||||
|
release
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode/*
|
||||||
|
!.vscode/tasks.json
|
||||||
.codex
|
.codex
|
||||||
.claude
|
.claude
|
||||||
docs/ai/desired_features.md
|
|
||||||
docs/ai/bugs.md
|
|
||||||
docs/plans
|
docs/plans
|
||||||
docs
|
docs/design
|
||||||
|
docs/ai/*
|
||||||
|
!docs/ai/README.md
|
||||||
|
!docs/ai/android.md
|
||||||
|
!docs/ai/architecture.md
|
||||||
|
!docs/ai/electron.md
|
||||||
|
!docs/ai/i18n.md
|
||||||
|
!docs/ai/overview.md
|
||||||
|
!docs/ai/server-side.md
|
||||||
|
|
||||||
|
vite.config.*.timestamp-*.mjs
|
||||||
|
|
|
||||||
5
.husky/pre-commit
Normal file → Executable file
|
|
@ -1,3 +1,2 @@
|
||||||
# These are commented until we enable lint and typecheck
|
npx tsc -p tsconfig.json --noEmit
|
||||||
# npx tsc -p tsconfig.json --noEmit
|
npx lint-staged
|
||||||
# npx lint-staged
|
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,39 @@ package.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|
||||||
|
# Generated by Capacitor / Gradle / AGP — never format these.
|
||||||
|
android/app/build/
|
||||||
|
android/build/
|
||||||
|
android/capacitor-cordova-android-plugins/build/
|
||||||
|
android/app/src/main/assets/public/
|
||||||
|
android/app/src/main/assets/capacitor.config.json
|
||||||
|
android/app/src/main/assets/capacitor.plugins.json
|
||||||
|
android/app/google-services.json
|
||||||
|
|
||||||
|
# Internal docs — hand-formatted markdown. Prettier reflows tables and
|
||||||
|
# fenced code blocks (e.g. YAML inside fences in server-side.md, tables in
|
||||||
|
# architecture.md) in ways that change document structure, not whitespace.
|
||||||
|
# Most paths under docs/ are gitignored anyway via top-level .gitignore.
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Upstream Cinny GitHub Actions / templates — leave as-is, format drift here
|
||||||
|
# is unrelated to our work.
|
||||||
|
.github/
|
||||||
|
|
||||||
|
# Minified third-party assets.
|
||||||
|
*.min.js
|
||||||
|
|
||||||
|
# Top-level docs / HTML inherited from upstream Cinny — not part of this
|
||||||
|
# infra cleanup's scope. They have minor pre-existing format drift; touching
|
||||||
|
# them would just add review noise.
|
||||||
|
CLAUDE.md
|
||||||
|
CODE_OF_CONDUCT.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
index.html
|
||||||
|
|
||||||
|
# Upstream-vendored files copied verbatim from external projects (links in
|
||||||
|
# their headers). Keep byte-identical to upstream to make future re-syncs
|
||||||
|
# trivially diffable. Same intent as the per-file ESLint override.
|
||||||
|
src/util/cryptE2ERoomKeys.js
|
||||||
|
src/util/colorMXID.js
|
||||||
|
|
|
||||||
104
.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Deploy to vojo.chat",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/cinny/",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Build Android APK",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm run build:android:debug",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Deploy to Android (ADB)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm run build:android:debug && adb install -r android/app/build/outputs/apk/debug/app-debug.apk",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Connect to Android device (ADB)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "adb connect 192.168.1.204:5555",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Start Electron (dev)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm run electron:dev",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Build Electron Windows",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm run build:electron:win",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Deploy Discord bridge",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker build -t vojo-mautrix-discord:custom . && docker save vojo-mautrix-discord:custom | gzip | ssh vojo-superuser@187.127.77.124 'gunzip | docker load'",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/../vojo-mautrix-discord"
|
||||||
|
},
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,29 @@
|
||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
def packageJson = new groovy.json.JsonSlurper().parseText(file('../../package.json').text)
|
// Mirror of resolveAppVersion() in ../../vite.config.js so the APK's
|
||||||
def semver = packageJson.version.split('\\.')
|
// versionName matches __APP_VERSION__ rendered in the About screen.
|
||||||
def computedVersionCode = semver[0].toInteger() * 1000000 + semver[1].toInteger() * 1000 + semver[2].toInteger()
|
// `git describe --tags --match 'v*'` against tag v0.2.0 yields
|
||||||
|
// `v0.2.0-<commits>-g<hash>`; patch = commit count since the tag.
|
||||||
|
// Falls back to package.json only when git is unavailable.
|
||||||
|
def gitDescribe = providers.exec {
|
||||||
|
it.commandLine 'git', 'describe', '--tags', '--match', 'v*', '--always'
|
||||||
|
it.workingDir rootDir.parentFile
|
||||||
|
it.ignoreExitValue = true
|
||||||
|
}
|
||||||
|
def appVersion = {
|
||||||
|
def fromGit = gitDescribe.result.get().exitValue == 0 ? gitDescribe.standardOutput.asText.get().trim() : null
|
||||||
|
def m = fromGit =~ /^v?(\d+)\.(\d+)\.(\d+)(?:-(\d+)-g[0-9a-f]+)?$/
|
||||||
|
if (fromGit && m.matches()) {
|
||||||
|
def major = m[0][1].toInteger()
|
||||||
|
def minor = m[0][2].toInteger()
|
||||||
|
def patch = (m[0][4] ?: m[0][3]).toInteger()
|
||||||
|
return [name: "${major}.${minor}.${patch}", major: major, minor: minor, patch: patch]
|
||||||
|
}
|
||||||
|
def pkg = new groovy.json.JsonSlurper().parseText(file('../../package.json').text)
|
||||||
|
def parts = pkg.version.split('\\.')
|
||||||
|
return [name: pkg.version, major: parts[0].toInteger(), minor: parts[1].toInteger(), patch: parts[2].toInteger()]
|
||||||
|
}()
|
||||||
|
def computedVersionCode = appVersion.major * 1000000 + appVersion.minor * 1000 + appVersion.patch
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "chat.vojo.app"
|
namespace = "chat.vojo.app"
|
||||||
|
|
@ -12,7 +33,7 @@ android {
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode computedVersionCode
|
versionCode computedVersionCode
|
||||||
versionName packageJson.version
|
versionName appVersion.name
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|
@ -20,12 +41,6 @@ android {
|
||||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
minifyEnabled false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// AGP 8+ requires explicit opt-in for BuildConfig generation. We rely on
|
// AGP 8+ requires explicit opt-in for BuildConfig generation. We rely on
|
||||||
// BuildConfig.DEBUG to gate Log.d calls that dump privacy-sensitive
|
// BuildConfig.DEBUG to gate Log.d calls that dump privacy-sensitive
|
||||||
// identifiers (roomId, eventId) so release builds don't leak them through
|
// identifiers (roomId, eventId) so release builds don't leak them through
|
||||||
|
|
@ -33,6 +48,26 @@ android {
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
if (project.hasProperty('VOJO_RELEASE_STORE_FILE')) {
|
||||||
|
storeFile file(VOJO_RELEASE_STORE_FILE)
|
||||||
|
storePassword VOJO_RELEASE_STORE_PASSWORD
|
||||||
|
keyAlias VOJO_RELEASE_KEY_ALIAS
|
||||||
|
keyPassword VOJO_RELEASE_KEY_PASSWORD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled true
|
||||||
|
shrinkResources true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
|
|
@ -52,6 +87,11 @@ dependencies {
|
||||||
// already depends on firebase-messaging but declares it `implementation`
|
// already depends on firebase-messaging but declares it `implementation`
|
||||||
// so classes aren't exposed at app-module compile time.
|
// so classes aren't exposed at app-module compile time.
|
||||||
implementation "com.google.firebase:firebase-messaging:25.0.1"
|
implementation "com.google.firebase:firebase-messaging:25.0.1"
|
||||||
|
// WorkManager hosts VojoPollWorker — periodic /notifications poll that
|
||||||
|
// delivers messages and missed-call surfaces on networks where FCM
|
||||||
|
// (mtalk.google.com:5228) is blocked. Library self-registers its scheduler
|
||||||
|
// in the merged manifest; we declare no permission for it.
|
||||||
|
implementation "androidx.work:work-runtime:2.10.0"
|
||||||
testImplementation "junit:junit:$junitVersion"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
|
|
||||||
24
android/app/proguard-rules.pro
vendored
|
|
@ -19,3 +19,27 @@
|
||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
# Keep custom app classes — entry points invoked by Android system (Intents,
|
||||||
|
# FCM, AndroidManifest references) or by JS bridge via reflection.
|
||||||
|
-keep class chat.vojo.app.MainActivity { *; }
|
||||||
|
-keep class chat.vojo.app.VojoFirebaseMessagingService { *; }
|
||||||
|
-keep class chat.vojo.app.CallForegroundPlugin { *; }
|
||||||
|
-keep class chat.vojo.app.CallForegroundService { *; }
|
||||||
|
-keep class chat.vojo.app.CallDeclineReceiver { *; }
|
||||||
|
-keep class chat.vojo.app.CallCancelReceiver { *; }
|
||||||
|
-keep class chat.vojo.app.FullScreenIntentPlugin { *; }
|
||||||
|
-keep class chat.vojo.app.LaunchSplashPlugin { *; }
|
||||||
|
|
||||||
|
# Firebase Messaging — receivers/services resolved by Android via manifest.
|
||||||
|
-keep public class * extends com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
-keep class com.google.firebase.iid.** { *; }
|
||||||
|
-keep class com.google.firebase.messaging.** { *; }
|
||||||
|
|
||||||
|
# Capacitor — plugins discovered by annotation/reflection.
|
||||||
|
-keep @com.getcapacitor.annotation.CapacitorPlugin class * { *; }
|
||||||
|
-keep class com.getcapacitor.** { *; }
|
||||||
|
-keep class com.getcapacitor.plugin.** { *; }
|
||||||
|
|
||||||
|
# AndroidX splashscreen — reflection paths.
|
||||||
|
-keep class androidx.core.splashscreen.** { *; }
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,30 @@
|
||||||
android:pathPrefix="/u/" />
|
android:pathPrefix="/u/" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- System share-sheet target. Three filters because Android's
|
||||||
|
sheet UI dedupes by activity but resolves by MIME match:
|
||||||
|
text/* gets its own filter so the Vojo icon shows up
|
||||||
|
alongside WhatsApp/Telegram for «share link/selection»; */*
|
||||||
|
covers single-file (image/video/audio/pdf/…) and
|
||||||
|
SEND_MULTIPLE picks up gallery multi-select.
|
||||||
|
Payload extraction lives in ShareTargetPlugin — MainActivity
|
||||||
|
only routes the Intent to the plugin via onNewIntent. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/*" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|
@ -85,6 +109,18 @@
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".CallDeclineReceiver"
|
android:name=".CallDeclineReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".MarkAsReadReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".NotificationDismissReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".ReplyReceiver"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.util.LruCache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory LRU cache of decoded avatar bitmaps keyed by MXC URL string.
|
||||||
|
*
|
||||||
|
* Sized as a process-singleton (~4 MB) so the FCM service, polling Worker
|
||||||
|
* and ReplyReceiver all share one pool. 96×96 ARGB_8888 bitmap is about
|
||||||
|
* 36 KB, so a 4 MB cache holds ~110 avatars — enough for the active
|
||||||
|
* conversation set on a typical user. LruCache evicts the least-recently-
|
||||||
|
* read entry when full; this is the right shape for "rooms the user is
|
||||||
|
* actively talking in stay warm, dormant rooms reload on demand".
|
||||||
|
*
|
||||||
|
* Thread-safety: LruCache itself is synchronized internally on every
|
||||||
|
* get/put/remove. We don't need an outer lock for normal operation. The
|
||||||
|
* AvatarLoader funnels all puts through this class.
|
||||||
|
*
|
||||||
|
* Process death: cache is in-memory only. After a kill, the first push
|
||||||
|
* to any room cold-renders without avatars and re-renders once the
|
||||||
|
* loader populates the cache (see AvatarLoader.loadAllWithTimeout).
|
||||||
|
*/
|
||||||
|
final class AvatarBitmapCache {
|
||||||
|
|
||||||
|
// Heap budget: bytes. 4 MB is generous against ARGB_8888 96×96 bitmaps
|
||||||
|
// (~36 KB each) and stays comfortably under the 1/8-of-heap Android
|
||||||
|
// recommendation on every device we ship to (minSdk 24 → at least
|
||||||
|
// 96 MB heap on a low-end phone).
|
||||||
|
private static final int MAX_SIZE_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
private static final LruCache<String, Bitmap> CACHE =
|
||||||
|
new LruCache<String, Bitmap>(MAX_SIZE_BYTES) {
|
||||||
|
@Override
|
||||||
|
protected int sizeOf(String key, Bitmap value) {
|
||||||
|
return value.getByteCount();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private AvatarBitmapCache() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached bitmap for an MXC URL, or null on miss.
|
||||||
|
*
|
||||||
|
* Bitmap references are NOT defensively copied — the cache hands out
|
||||||
|
* the same reference to every caller. This is safe because no code
|
||||||
|
* path in the app calls Bitmap.recycle() on a cached bitmap (the
|
||||||
|
* intermediate square / source bitmaps inside AvatarLoader.
|
||||||
|
* toCircularBitmap ARE recycled, but the circular output that lands
|
||||||
|
* here is held until LRU evicts it). LRU eviction simply drops the
|
||||||
|
* cache's reference, and the GC reclaims memory only after every
|
||||||
|
* Notification that referenced the bitmap is also released by the
|
||||||
|
* system. Adding a defensive copy here would halve the effective
|
||||||
|
* cache size for no real-world benefit.
|
||||||
|
*/
|
||||||
|
static Bitmap get(String mxc) {
|
||||||
|
if (mxc == null || mxc.isEmpty()) return null;
|
||||||
|
return CACHE.get(mxc);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put(String mxc, Bitmap bitmap) {
|
||||||
|
if (mxc == null || mxc.isEmpty() || bitmap == null) return;
|
||||||
|
CACHE.put(mxc, bitmap);
|
||||||
|
}
|
||||||
|
}
|
||||||
368
android/app/src/main/java/chat/vojo/app/AvatarLoader.java
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.graphics.BitmapShader;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Shader;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and decodes avatar bitmaps from MXC URLs, populating
|
||||||
|
* {@link AvatarBitmapCache}.
|
||||||
|
*
|
||||||
|
* URL resolution mirrors matrix-js-sdk's auth-media v1.11+ pattern:
|
||||||
|
* mxc://server/mediaId
|
||||||
|
* → <homeserver>/_matrix/client/v1/media/thumbnail/<server>/<mediaId>
|
||||||
|
* ?width=96&height=96&method=crop
|
||||||
|
* + Authorization: Bearer <accessToken>
|
||||||
|
*
|
||||||
|
* The legacy unauthenticated `/_matrix/media/v3/thumbnail/...` endpoint is
|
||||||
|
* NOT used — every Synapse the Vojo audience runs against (vanilla, v1.11+
|
||||||
|
* by deployment policy, see docs/ai/server-side.md) speaks auth media.
|
||||||
|
* Removing the legacy fallback keeps the loader off the deprecated path
|
||||||
|
* and avoids leaking the access token to a server route that doesn't
|
||||||
|
* require it.
|
||||||
|
*
|
||||||
|
* Concurrency: each MXC URL is fetched at most once concurrently — the
|
||||||
|
* `inFlight` set short-circuits duplicate requests from rapid
|
||||||
|
* append-rebuild cycles on the same conversation. Loads happen on a
|
||||||
|
* shared 4-thread pool; bigger than 1 so 5 senders in a group chat can
|
||||||
|
* load in parallel, capped to keep socket pressure under the typical
|
||||||
|
* mobile network budget.
|
||||||
|
*
|
||||||
|
* Two entry points:
|
||||||
|
* - {@link #loadAllWithTimeout}: synchronous wait, used by the render
|
||||||
|
* path to populate the cache before building the MessagingStyle so the
|
||||||
|
* first post already has avatars. Timeout-bounded to keep FCM thread
|
||||||
|
* responsive (Android budgets ~10s; we use 800 ms).
|
||||||
|
* - {@link #prefetch}: fire-and-forget, used for warm-up scenarios.
|
||||||
|
* Not currently called but kept for the room-metadata bridge to
|
||||||
|
* eventually warm the cache on visibility resume.
|
||||||
|
*/
|
||||||
|
final class AvatarLoader {
|
||||||
|
|
||||||
|
private static final String TAG = "AvatarLoader";
|
||||||
|
|
||||||
|
private static final int AVATAR_SIZE_PX = 96;
|
||||||
|
private static final int CONNECT_TIMEOUT_MS = 5_000;
|
||||||
|
private static final int READ_TIMEOUT_MS = 5_000;
|
||||||
|
private static final int RENDER_BLOCK_TIMEOUT_MS = 800;
|
||||||
|
// Cap decoded bitmap byte count — a malicious / huge avatar shouldn't
|
||||||
|
// OOM the FCM service. 96×96 ARGB_8888 is ~36 KB; we accept up to
|
||||||
|
// 4× that (~140 KB) to allow some downscaling slack on servers that
|
||||||
|
// return slightly oversized thumbnails.
|
||||||
|
private static final int MAX_DECODED_BYTES = 144 * 1024;
|
||||||
|
|
||||||
|
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);
|
||||||
|
|
||||||
|
// MXC URL → CountDownLatch that fires when the in-flight download
|
||||||
|
// completes (success or failure). A second caller observing an
|
||||||
|
// already-pending mxc waits on the SAME latch instead of either
|
||||||
|
// returning empty-handed or kicking off a duplicate fetch. Latches
|
||||||
|
// are removed by the worker task in its finally block; the same task
|
||||||
|
// that put the entry is the only one allowed to remove it, so a slow
|
||||||
|
// remove() race is harmless.
|
||||||
|
private static final ConcurrentHashMap<String, CountDownLatch> inFlight =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private AvatarLoader() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block the caller for up to {@link #RENDER_BLOCK_TIMEOUT_MS} while
|
||||||
|
* fetching any of the given MXC URLs that are not yet in
|
||||||
|
* {@link AvatarBitmapCache}. Cache hits are no-ops. Already-in-flight
|
||||||
|
* URLs are awaited via the shared latch — duplicate concurrent
|
||||||
|
* fetches do not happen.
|
||||||
|
*
|
||||||
|
* Designed to be called inline from the render path: after this
|
||||||
|
* returns, {@link AvatarBitmapCache#get} will be non-null for every
|
||||||
|
* MXC that loaded successfully within the budget. Failures are
|
||||||
|
* silent — the render then falls back to a Person without icon
|
||||||
|
* (Android renders initials/blank).
|
||||||
|
*
|
||||||
|
* Returns the count of avatars that landed in the cache during this
|
||||||
|
* call (purely informational — useful for logs).
|
||||||
|
*/
|
||||||
|
static int loadAllWithTimeout(Context ctx, Collection<String> mxcs) {
|
||||||
|
if (mxcs == null || mxcs.isEmpty()) {
|
||||||
|
Log.i(TAG, "loadAll: empty input, skip");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||||
|
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||||
|
String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
|
||||||
|
String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
|
||||||
|
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
|
||||||
|
// No credentials yet (fresh install + first push). We can't
|
||||||
|
// resolve MXC URLs without an access token. Falling back to
|
||||||
|
// no-icon Person renderer is the correct behaviour here.
|
||||||
|
Log.i(TAG, "loadAll: no credentials in prefs, skip"
|
||||||
|
+ " hasToken=" + (token != null && !token.isEmpty())
|
||||||
|
+ " hasHs=" + (homeserver != null && !homeserver.isEmpty()));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// De-duplicate and filter to misses only; if the cache already has
|
||||||
|
// an entry, no work is needed.
|
||||||
|
Set<String> toLoad = new LinkedHashSet<>();
|
||||||
|
for (String mxc : mxcs) {
|
||||||
|
if (mxc == null || mxc.isEmpty()) continue;
|
||||||
|
if (!mxc.startsWith("mxc://")) continue;
|
||||||
|
if (AvatarBitmapCache.get(mxc) != null) continue;
|
||||||
|
toLoad.add(mxc);
|
||||||
|
}
|
||||||
|
if (toLoad.isEmpty()) return 0;
|
||||||
|
|
||||||
|
// Per-mxc latches shared across concurrent callers — a second
|
||||||
|
// caller arriving while we're already mid-fetch waits on the
|
||||||
|
// SAME latch instead of forcing a duplicate HTTP or returning
|
||||||
|
// immediately empty-handed (which was the previous bug — see
|
||||||
|
// git blame for the race description).
|
||||||
|
java.util.List<CountDownLatch> waits = new java.util.ArrayList<>(toLoad.size());
|
||||||
|
for (String mxc : toLoad) {
|
||||||
|
CountDownLatch myLatch = new CountDownLatch(1);
|
||||||
|
CountDownLatch existing = inFlight.putIfAbsent(mxc, myLatch);
|
||||||
|
if (existing != null) {
|
||||||
|
// Already in flight — share the original latch.
|
||||||
|
waits.add(existing);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// We own this fetch; kick off the worker that will fire
|
||||||
|
// myLatch when done.
|
||||||
|
waits.add(myLatch);
|
||||||
|
final String capturedMxc = mxc;
|
||||||
|
final String capturedHomeserver = homeserver;
|
||||||
|
final String capturedToken = token;
|
||||||
|
EXECUTOR.execute(() -> {
|
||||||
|
try {
|
||||||
|
Bitmap bmp = fetchAndDecode(capturedMxc, capturedHomeserver, capturedToken);
|
||||||
|
if (bmp != null) AvatarBitmapCache.put(capturedMxc, bmp);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "fetch threw mxc=" + capturedMxc, t);
|
||||||
|
} finally {
|
||||||
|
// Remove BEFORE countDown so a freshly-arriving caller
|
||||||
|
// doesn't observe a stale latch for an already-loaded
|
||||||
|
// mxc (would block until the next call with no fetch
|
||||||
|
// actually pending). Cache.get() on the post-await
|
||||||
|
// side covers the race where remove+put-cache happens
|
||||||
|
// between two latch waits.
|
||||||
|
inFlight.remove(capturedMxc);
|
||||||
|
myLatch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Single budget for the whole batch — wait for all latches OR
|
||||||
|
// hit the timeout. Latches that fire early just return await()
|
||||||
|
// immediately; the slowest one consumes the remainder of the
|
||||||
|
// budget.
|
||||||
|
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RENDER_BLOCK_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
for (CountDownLatch latch : waits) {
|
||||||
|
long remaining = deadline - System.nanoTime();
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
latch.await(remaining, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
// Count how many actually landed in the cache during this call —
|
||||||
|
// includes both items we fetched and items that finished after our
|
||||||
|
// timeout (which won't be reflected in this count but are still
|
||||||
|
// usable on the next render).
|
||||||
|
int hits = 0;
|
||||||
|
for (String mxc : toLoad) {
|
||||||
|
if (AvatarBitmapCache.get(mxc) != null) hits += 1;
|
||||||
|
}
|
||||||
|
Log.i(TAG, "loadAll: requested=" + mxcs.size()
|
||||||
|
+ " toLoad=" + toLoad.size() + " hits=" + hits);
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an `mxc://server/mediaId` URL to a 96×96 thumbnail via the
|
||||||
|
* authenticated v1.11+ media endpoint and decode the response into a
|
||||||
|
* Bitmap. Returns null on any non-2xx, decode failure, or oversized
|
||||||
|
* payload (see {@link #MAX_DECODED_BYTES}).
|
||||||
|
*/
|
||||||
|
private static Bitmap fetchAndDecode(String mxc, String homeserver, String token)
|
||||||
|
throws IOException {
|
||||||
|
Parsed parsed = parseMxc(mxc);
|
||||||
|
if (parsed == null) {
|
||||||
|
Log.w(TAG, "fetch: malformed mxc=" + mxc);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server + mediaId are NOT URL-encoded — matches matrix-js-sdk's
|
||||||
|
// content-repo.ts (it concatenates verbatim via `new URL()`).
|
||||||
|
// URLEncoder would turn `example.com:8448` into `example.com%3A8448`,
|
||||||
|
// which Synapse's media router rejects as an unknown server.
|
||||||
|
// mediaId is base64-ish per spec (URL-safe alphabet) so no
|
||||||
|
// encoding is needed there either.
|
||||||
|
StringBuilder url = new StringBuilder(homeserver);
|
||||||
|
if (!homeserver.endsWith("/")) url.append('/');
|
||||||
|
url.append("_matrix/client/v1/media/thumbnail/")
|
||||||
|
.append(parsed.server)
|
||||||
|
.append('/')
|
||||||
|
.append(parsed.mediaId)
|
||||||
|
.append("?width=").append(AVATAR_SIZE_PX)
|
||||||
|
.append("&height=").append(AVATAR_SIZE_PX)
|
||||||
|
.append("&method=crop");
|
||||||
|
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection();
|
||||||
|
try {
|
||||||
|
conn.setRequestMethod("GET");
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
|
conn.setRequestProperty("Accept", "image/*");
|
||||||
|
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||||
|
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
Log.i(TAG, "fetch: mxc=" + mxc + " status=" + code);
|
||||||
|
if (code < 200 || code >= 300) return null;
|
||||||
|
int contentLength = conn.getContentLength();
|
||||||
|
if (contentLength > MAX_DECODED_BYTES) {
|
||||||
|
Log.w(TAG, "fetch: oversized contentLength=" + contentLength + " mxc=" + mxc);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try (InputStream in = conn.getInputStream()) {
|
||||||
|
BitmapFactory.Options opts = new BitmapFactory.Options();
|
||||||
|
// Stick with ARGB_8888 even on low-mem devices — RGB_565
|
||||||
|
// would lose alpha (group avatars often have a
|
||||||
|
// transparent corner) and the cache cap (4 MB) already
|
||||||
|
// bounds total memory. inJustDecodeBounds + sample-size
|
||||||
|
// dance is overkill at 96×96.
|
||||||
|
opts.inPreferredConfig = Bitmap.Config.ARGB_8888;
|
||||||
|
Bitmap bmp = BitmapFactory.decodeStream(in, null, opts);
|
||||||
|
if (bmp == null) {
|
||||||
|
Log.w(TAG, "fetch: decodeStream returned null mxc=" + mxc);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (bmp.getByteCount() > MAX_DECODED_BYTES) {
|
||||||
|
Log.w(TAG, "fetch: decoded oversized "
|
||||||
|
+ bmp.getByteCount() + " bytes mxc=" + mxc);
|
||||||
|
bmp.recycle();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Crop into a circle BEFORE caching — IconCompat.createWithBitmap
|
||||||
|
// renders the bitmap verbatim, with no shape mask, so a
|
||||||
|
// square thumbnail from the homeserver lands as a square
|
||||||
|
// tile in the shade (visible on Android 12+ where
|
||||||
|
// conversation Person icons used to be auto-rounded by the
|
||||||
|
// OS — this changed). Pre-cropping guarantees a round
|
||||||
|
// visual on every API level instead of relying on the
|
||||||
|
// SystemUI of the day. The original square bitmap is
|
||||||
|
// recycled once the circular copy is in hand.
|
||||||
|
return toCircularBitmap(bmp);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-encode a circular avatar as an adaptive-icon-shaped bitmap:
|
||||||
|
* embeds the avatar inside a transparent canvas whose total size is
|
||||||
|
* 1.5× the avatar so Android's adaptive-icon safe zone (66% of total)
|
||||||
|
* covers the entire avatar without clipping.
|
||||||
|
*
|
||||||
|
* Required for conversation-shortcut icons per docs at
|
||||||
|
* developer.android.com/develop/ui/views/notifications/conversations:
|
||||||
|
* *"To avoid unintentional clipping of your shortcut avatar, provide
|
||||||
|
* an AdaptiveIconDrawable for the shortcut's icon."*
|
||||||
|
*
|
||||||
|
* Without this padding, IconCompat.createWithAdaptiveBitmap would
|
||||||
|
* crop ~17% off every edge of the avatar to fit the safe zone — a
|
||||||
|
* visible mutilation. With it, the shortcut icon renders pixel-
|
||||||
|
* identical to the circular avatar inside the system shade's
|
||||||
|
* conversation slot.
|
||||||
|
*/
|
||||||
|
static Bitmap toAdaptivePaddedBitmap(Bitmap circularAvatar) {
|
||||||
|
int avatarSize = Math.min(circularAvatar.getWidth(), circularAvatar.getHeight());
|
||||||
|
// Pad to 150% so the adaptive safe-zone (66% of canvas = avatarSize)
|
||||||
|
// covers the full avatar. Rounded up to keep the canvas even.
|
||||||
|
int canvasSize = (int) Math.ceil(avatarSize / 0.66f);
|
||||||
|
if (canvasSize % 2 != 0) canvasSize += 1;
|
||||||
|
Bitmap output = Bitmap.createBitmap(canvasSize, canvasSize, Bitmap.Config.ARGB_8888);
|
||||||
|
Canvas canvas = new Canvas(output);
|
||||||
|
int offset = (canvasSize - avatarSize) / 2;
|
||||||
|
canvas.drawBitmap(circularAvatar, offset, offset, null);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a circular ARGB_8888 bitmap of the source — centre-cropped to
|
||||||
|
* a square if non-square, then masked with a circular path so the
|
||||||
|
* corners are transparent. The source bitmap is recycled.
|
||||||
|
*
|
||||||
|
* Anti-aliased edges via Paint.setAntiAlias on the circle draw — the
|
||||||
|
* BitmapShader copies the source's pixels into the circular region in
|
||||||
|
* a single drawCircle call, which keeps allocation to one output
|
||||||
|
* bitmap (vs the naive "decode → square crop → mask compose" path
|
||||||
|
* that touches three intermediate bitmaps).
|
||||||
|
*/
|
||||||
|
private static Bitmap toCircularBitmap(Bitmap source) {
|
||||||
|
int size = Math.min(source.getWidth(), source.getHeight());
|
||||||
|
Bitmap squareSource;
|
||||||
|
if (source.getWidth() == size && source.getHeight() == size) {
|
||||||
|
squareSource = source;
|
||||||
|
} else {
|
||||||
|
int x = (source.getWidth() - size) / 2;
|
||||||
|
int y = (source.getHeight() - size) / 2;
|
||||||
|
squareSource = Bitmap.createBitmap(source, x, y, size, size);
|
||||||
|
source.recycle();
|
||||||
|
}
|
||||||
|
Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
|
||||||
|
Canvas canvas = new Canvas(output);
|
||||||
|
Paint paint = new Paint();
|
||||||
|
paint.setAntiAlias(true);
|
||||||
|
paint.setShader(new BitmapShader(
|
||||||
|
squareSource, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
|
||||||
|
float radius = size / 2f;
|
||||||
|
canvas.drawCircle(radius, radius, radius, paint);
|
||||||
|
if (squareSource != source) {
|
||||||
|
squareSource.recycle();
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Parsed {
|
||||||
|
final String server;
|
||||||
|
final String mediaId;
|
||||||
|
|
||||||
|
Parsed(String server, String mediaId) {
|
||||||
|
this.server = server;
|
||||||
|
this.mediaId = mediaId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split an `mxc://server/mediaId` URL into its two components. Returns
|
||||||
|
* null on any malformed input — caller drops the avatar silently.
|
||||||
|
*/
|
||||||
|
private static Parsed parseMxc(String mxc) {
|
||||||
|
if (mxc == null) return null;
|
||||||
|
final String prefix = "mxc://";
|
||||||
|
if (!mxc.startsWith(prefix)) return null;
|
||||||
|
int slash = mxc.indexOf('/', prefix.length());
|
||||||
|
if (slash < 0 || slash == prefix.length()) return null;
|
||||||
|
String server = mxc.substring(prefix.length(), slash);
|
||||||
|
String mediaId = mxc.substring(slash + 1);
|
||||||
|
if (server.isEmpty() || mediaId.isEmpty()) return null;
|
||||||
|
return new Parsed(server, mediaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -121,7 +121,14 @@ public class CallForegroundPlugin extends Plugin {
|
||||||
// extras — Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
|
// extras — Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
|
||||||
// on containsKey. Empty string also satisfies the gate; we pass the
|
// on containsKey. Empty string also satisfies the gate; we pass the
|
||||||
// caller's value through verbatim.
|
// caller's value through verbatim.
|
||||||
VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
|
boolean seeded = VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
|
||||||
|
// Mark in NotificationDedup so a polling fire 15 minutes later
|
||||||
|
// doesn't post a "Missed call" notification for a ring the user
|
||||||
|
// already saw live via the in-app strip. Mirrors the FCM-arrival
|
||||||
|
// path in VojoFirebaseMessagingService.onMessageReceived.
|
||||||
|
if (seeded) {
|
||||||
|
NotificationDedup.markNotified(getContext(), eventId);
|
||||||
|
}
|
||||||
call.resolve();
|
call.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.core.content.LocusIdCompat;
|
||||||
|
import androidx.core.content.pm.ShortcutInfoCompat;
|
||||||
|
import androidx.core.content.pm.ShortcutManagerCompat;
|
||||||
|
import androidx.core.graphics.drawable.IconCompat;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a long-lived sharing shortcut for a Matrix room so the system
|
||||||
|
* treats per-room MessagingStyle notifications as conversations on
|
||||||
|
* Android 11+ (API 30+).
|
||||||
|
*
|
||||||
|
* Without a published shortcut whose id matches the notification's
|
||||||
|
* setShortcutId(), Android falls back to the app icon for the collapsed-
|
||||||
|
* preview avatar regardless of Person.setIcon / Builder.setLargeIcon —
|
||||||
|
* Person icons are only consulted by the Conversation styling layer,
|
||||||
|
* which activates exclusively for notifications backed by a real
|
||||||
|
* ShortcutInfoCompat marked Long Lived + the SHORTCUT_CATEGORY_CONVERSATION
|
||||||
|
* sharing category.
|
||||||
|
*
|
||||||
|
* Idempotent: republishing the same shortcut id is the documented "update"
|
||||||
|
* path; ShortcutManagerCompat handles dedup internally. Cheap to call
|
||||||
|
* from the render hot path (~ms on warm system, indistinguishable from a
|
||||||
|
* SharedPreferences write at our scale).
|
||||||
|
*/
|
||||||
|
final class ConversationShortcuts {
|
||||||
|
|
||||||
|
private static final String TAG = "ConvShortcuts";
|
||||||
|
|
||||||
|
private ConversationShortcuts() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish or refresh the shortcut backing a room's conversation
|
||||||
|
* notification. No-op on API < 30 — Conversation styling is an
|
||||||
|
* Android 11+ feature; older OS versions render the notification
|
||||||
|
* fine without the shortcut, and the largeIcon/Person.setIcon
|
||||||
|
* pipeline is the primary avatar source on them.
|
||||||
|
*
|
||||||
|
* @param ctx Context for the shortcut manager binding.
|
||||||
|
* @param roomId Matrix room id, used as the shortcut id so it
|
||||||
|
* matches NotificationCompat.Builder.setShortcutId.
|
||||||
|
* @param isDirect Whether the room is a DM; flips the shortcut
|
||||||
|
* category so launchers can group DMs separately.
|
||||||
|
* @param label Short visible label, typically the room name (or
|
||||||
|
* the peer's display name for a DM).
|
||||||
|
* @param avatar Optional cached avatar bitmap. Null falls through
|
||||||
|
* to the app launcher icon — still publishes the
|
||||||
|
* shortcut so the conversation styling activates.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Returns the published ShortcutInfoCompat so the caller can attach
|
||||||
|
* it directly to the notification via setShortcutInfo() — this is
|
||||||
|
* the documented "atomic publish + bind" path that avoids the race
|
||||||
|
* where the notification posts before the shortcut publish has
|
||||||
|
* settled and Android sees an orphan shortcut id. Null on API < 30,
|
||||||
|
* null on failure (notification still posts cleanly).
|
||||||
|
*/
|
||||||
|
static ShortcutInfoCompat publishForRoom(
|
||||||
|
Context ctx,
|
||||||
|
String roomId,
|
||||||
|
boolean isDirect,
|
||||||
|
String label,
|
||||||
|
Bitmap avatar
|
||||||
|
) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (roomId == null || roomId.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
// Conversation shortcut icon MUST be adaptive — official docs:
|
||||||
|
// "To avoid unintentional clipping of your shortcut avatar,
|
||||||
|
// provide an AdaptiveIconDrawable for the shortcut's icon."
|
||||||
|
// Without this, Android silently falls back to the app's
|
||||||
|
// launcher icon for the collapsed-shade conversation avatar
|
||||||
|
// slot, even though shortcut publish + bind succeed.
|
||||||
|
// Resource icons (mipmap.ic_launcher) already ship with
|
||||||
|
// adaptive layers in the manifest; bitmap avatars need padding
|
||||||
|
// so the safe zone doesn't crop them.
|
||||||
|
IconCompat icon;
|
||||||
|
if (avatar != null) {
|
||||||
|
Bitmap padded = AvatarLoader.toAdaptivePaddedBitmap(avatar);
|
||||||
|
icon = IconCompat.createWithAdaptiveBitmap(padded);
|
||||||
|
} else {
|
||||||
|
icon = IconCompat.createWithResource(ctx, R.mipmap.ic_launcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intent the shortcut launches when tapped from the launcher
|
||||||
|
// long-press menu or share sheet — opens MainActivity and
|
||||||
|
// delivers the same `room_id` extra the notification tap
|
||||||
|
// path uses, so the existing pushNotificationActionPerformed
|
||||||
|
// listener navigates correctly.
|
||||||
|
Intent launchIntent = new Intent(ctx, MainActivity.class)
|
||||||
|
.setAction(Intent.ACTION_VIEW)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
.putExtra("room_id", roomId)
|
||||||
|
// Capacitor PushNotificationsPlugin gates its action
|
||||||
|
// delivery on bundle.containsKey("google.message_id"); we
|
||||||
|
// attach an empty value so a launcher-initiated open
|
||||||
|
// takes the same path as a push-tap.
|
||||||
|
.putExtra("google.message_id", "");
|
||||||
|
|
||||||
|
// Constant value of androidx.core's
|
||||||
|
// ShortcutInfoCompat.SHORTCUT_CATEGORY_CONVERSATION. Hardcoded
|
||||||
|
// verbatim because older androidx.core in our dependency
|
||||||
|
// graph doesn't export the constant; the string itself is
|
||||||
|
// platform-stable per the Android shortcut category contract.
|
||||||
|
Set<String> categories =
|
||||||
|
Collections.singleton("android.shortcut.conversation");
|
||||||
|
|
||||||
|
ShortcutInfoCompat.Builder b = new ShortcutInfoCompat.Builder(ctx, roomId)
|
||||||
|
.setShortLabel(label != null && !label.isEmpty() ? label : "Vojo")
|
||||||
|
.setLongLabel(label != null && !label.isEmpty() ? label : "Vojo")
|
||||||
|
.setIntent(launchIntent)
|
||||||
|
.setIcon(icon)
|
||||||
|
.setLongLived(true)
|
||||||
|
.setCategories(categories)
|
||||||
|
// LocusId mirrors the shortcut id; the OS uses it to
|
||||||
|
// attribute the notification to a specific conversation
|
||||||
|
// for digital-wellbeing dashboards and bubble grouping.
|
||||||
|
.setLocusId(new LocusIdCompat(roomId))
|
||||||
|
// Marks isDirect so launchers / share sheet can present
|
||||||
|
// person-style affordances on DMs.
|
||||||
|
.setIsConversation();
|
||||||
|
// setPerson is only needed for one-on-one conversations to
|
||||||
|
// unlock direct-share suggestions, but for a DM we also want
|
||||||
|
// it to anchor the shortcut on the peer's identity. Skipped
|
||||||
|
// for groups (single Person doesn't represent the room).
|
||||||
|
if (isDirect) {
|
||||||
|
b.setPerson(new androidx.core.app.Person.Builder()
|
||||||
|
// setKey must match the Person.key used in the
|
||||||
|
// MessagingStyle so Android's conversation
|
||||||
|
// attribution matches the shortcut to the
|
||||||
|
// notification on the same identity.
|
||||||
|
.setKey(roomId)
|
||||||
|
.setName(label != null ? label : "")
|
||||||
|
.setIcon(icon)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutInfoCompat shortcut = b.build();
|
||||||
|
boolean ok = ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut);
|
||||||
|
Log.i(TAG, "publish room=" + roomId + " label=" + label
|
||||||
|
+ " hasAvatar=" + (avatar != null) + " ok=" + ok);
|
||||||
|
return shortcut;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
// Shortcut publish is best-effort UX — a failure must not
|
||||||
|
// sink the notification. Worst case: collapsed preview
|
||||||
|
// falls back to app icon (same as before the shortcut path
|
||||||
|
// existed at all).
|
||||||
|
Log.w(TAG, "publish failed room=" + roomId, t);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
@CapacitorPlugin(name = "LaunchSplash")
|
||||||
|
public class LaunchSplashPlugin extends Plugin {
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void ready(PluginCall call) {
|
||||||
|
MainActivity.releaseLaunchSplash();
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,26 @@ import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import androidx.activity.EdgeToEdge;
|
import androidx.activity.EdgeToEdge;
|
||||||
|
import androidx.core.splashscreen.SplashScreen;
|
||||||
|
import androidx.core.view.WindowCompat;
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
public static volatile boolean isInForeground = false;
|
public static volatile boolean isInForeground = false;
|
||||||
|
private static volatile boolean launchSplashReady = false;
|
||||||
|
|
||||||
|
// Safety net for setKeepOnScreenCondition: if JS never calls
|
||||||
|
// launchSplash.ready() (boot crash, exception during config load before
|
||||||
|
// AuthMascot mounts, network hang in useClientConfig, deep-link straight
|
||||||
|
// into AuthLayout where the centered AuthMascot variant doesn't render,
|
||||||
|
// …) the splash would otherwise hang indefinitely and the user can't
|
||||||
|
// interact with anything. 5s covers normal cold boots on mid-range
|
||||||
|
// Android (config + bundle parse + first paint typically lands inside
|
||||||
|
// 1-2s) with comfortable headroom; past it we drop the splash and let
|
||||||
|
// whatever the web side has rendered take over — including blank
|
||||||
|
// AuthLayout, which is at least recoverable.
|
||||||
|
private static final long SPLASH_SAFETY_TIMEOUT_MS = 5000L;
|
||||||
|
|
||||||
// Short debounce on the onPause→renderRegistry edge so an in-flight JS
|
// Short debounce on the onPause→renderRegistry edge so an in-flight JS
|
||||||
// removeIncomingRing bridge call (e.g. user accepted/declined, then
|
// removeIncomingRing bridge call (e.g. user accepted/declined, then
|
||||||
|
|
@ -31,15 +47,45 @@ public class MainActivity extends BridgeActivity {
|
||||||
private final Runnable cancelRunnable = () ->
|
private final Runnable cancelRunnable = () ->
|
||||||
VojoFirebaseMessagingService.cancelRenderedIncomingRings(this);
|
VojoFirebaseMessagingService.cancelRenderedIncomingRings(this);
|
||||||
|
|
||||||
|
public static void releaseLaunchSplash() {
|
||||||
|
launchSplashReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
launchSplashReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Custom plugins must be registered before super.onCreate so BridgeActivity
|
// Custom plugins must be registered before super.onCreate so BridgeActivity
|
||||||
// can wire them into the WebView bridge on load. Registering after
|
// can wire them into the WebView bridge on load. Registering after
|
||||||
// super.onCreate would make the plugin invisible to JS until the next relaunch.
|
// super.onCreate would make the plugin invisible to JS until the next relaunch.
|
||||||
registerPlugin(FullScreenIntentPlugin.class);
|
registerPlugin(FullScreenIntentPlugin.class);
|
||||||
registerPlugin(CallForegroundPlugin.class);
|
registerPlugin(CallForegroundPlugin.class);
|
||||||
|
registerPlugin(LaunchSplashPlugin.class);
|
||||||
|
registerPlugin(ShareTargetPlugin.class);
|
||||||
|
registerPlugin(PollingPlugin.class);
|
||||||
|
|
||||||
|
// AndroidX SplashScreen must be installed before super.onCreate().
|
||||||
|
// Keep it until the web splash confirms its first visible frame is
|
||||||
|
// ready, OR the safety timeout elapses (see SPLASH_SAFETY_TIMEOUT_MS).
|
||||||
|
final long splashStartMs = System.currentTimeMillis();
|
||||||
|
SplashScreen splashScreen = SplashScreen.installSplashScreen(this);
|
||||||
|
splashScreen.setKeepOnScreenCondition(() -> {
|
||||||
|
if (launchSplashReady) return false;
|
||||||
|
return System.currentTimeMillis() - splashStartMs < SPLASH_SAFETY_TIMEOUT_MS;
|
||||||
|
});
|
||||||
|
|
||||||
EdgeToEdge.enable(this);
|
EdgeToEdge.enable(this);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
// Force light icons on both system bars: our CSS is permanently dark
|
||||||
|
// (Dawn redesign), but EdgeToEdge.enable auto-detects icon tint from
|
||||||
|
// the device uiMode — on a light-mode device that gives dark icons
|
||||||
|
// over our dark bars and they vanish.
|
||||||
|
WindowInsetsControllerCompat controller =
|
||||||
|
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
|
||||||
|
controller.setAppearanceLightStatusBars(false);
|
||||||
|
controller.setAppearanceLightNavigationBars(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
147
android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the per-notification "Mark as read" action.
|
||||||
|
*
|
||||||
|
* Posts {@code POST /_matrix/client/v3/rooms/{roomId}/receipt/m.read/{eventId}}
|
||||||
|
* using the access token saved by the polling lifecycle in
|
||||||
|
* {@code vojo_poll_state} SharedPreferences (same storage VojoPollWorker uses;
|
||||||
|
* keeps the credential lifecycle single-sourced). After a successful 2xx the
|
||||||
|
* per-room MessagingStyle notification is dismissed and the
|
||||||
|
* {@link RoomMessageCache} is cleared so the next push to that room starts a
|
||||||
|
* fresh conversation rather than re-appending to the prior history.
|
||||||
|
*
|
||||||
|
* Dismiss policy: OPTIMISTIC. The per-room notification is dismissed
|
||||||
|
* synchronously in onReceive — before the HTTP receipt PUT is even
|
||||||
|
* attempted — so the user sees instant feedback. The async receipt POST
|
||||||
|
* happens on a worker thread afterwards. This mirrors element-android's
|
||||||
|
* NotificationBroadcastReceiver pattern and matches the user's mental
|
||||||
|
* model ("I tapped, it should disappear immediately").
|
||||||
|
*
|
||||||
|
* Failure mode: on any non-2xx or thrown exception we accept that the
|
||||||
|
* server-side read receipt did not land. We do NOT re-post the
|
||||||
|
* notification or implement a flusher because:
|
||||||
|
* - the next room open from the JS app issues a fresh read-receipt
|
||||||
|
* for the latest visible event, catching up the server state
|
||||||
|
* - the in-app read-marker logic is the authoritative path; this
|
||||||
|
* receiver is a convenience for the shade-tap shortcut
|
||||||
|
* - accumulating tombstones in prefs (the CallDeclineReceiver pattern)
|
||||||
|
* would risk leaking historical eventIds the JS side would re-issue
|
||||||
|
* on app resume anyway
|
||||||
|
*
|
||||||
|
* Null-credential edge case (fresh install + first push before any
|
||||||
|
* saveSession bridge): no token to use, we still dismiss the notification
|
||||||
|
* locally so the user isn't stuck looking at a "stuck" Mark-as-read
|
||||||
|
* button. The next room open from JS covers the server view.
|
||||||
|
*/
|
||||||
|
public class MarkAsReadReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
|
public static final String ACTION_MARK_AS_READ = "chat.vojo.app.MARK_AS_READ";
|
||||||
|
public static final String EXTRA_ROOM_ID = "room_id";
|
||||||
|
public static final String EXTRA_EVENT_ID = "event_id";
|
||||||
|
|
||||||
|
private static final int CONNECT_TIMEOUT_MS = 8_000;
|
||||||
|
private static final int READ_TIMEOUT_MS = 8_000;
|
||||||
|
private static final String TAG = "MarkAsReadRcvr";
|
||||||
|
|
||||||
|
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
if (intent == null) return;
|
||||||
|
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||||
|
final String eventId = intent.getStringExtra(EXTRA_EVENT_ID);
|
||||||
|
if (roomId == null || roomId.isEmpty()) {
|
||||||
|
Log.w(TAG, "onReceive: missing room_id, abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Context appContext = context.getApplicationContext();
|
||||||
|
// Dismiss first for instant UX feedback — HTTP latency is irrelevant
|
||||||
|
// to the perceived "marked as read" action.
|
||||||
|
VojoFirebaseMessagingService.dismissRoomNotification(appContext, roomId);
|
||||||
|
|
||||||
|
final SharedPreferences prefs = appContext.getSharedPreferences(
|
||||||
|
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||||
|
final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
|
||||||
|
final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
|
||||||
|
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
|
||||||
|
Log.w(TAG, "onReceive: no credentials in prefs, local dismiss only");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (eventId == null || eventId.isEmpty()) {
|
||||||
|
// Without an eventId we cannot issue a receipt PUT — the JS-side
|
||||||
|
// read-marker handler will catch this up on the next room open.
|
||||||
|
Log.w(TAG, "onReceive: no event_id, local dismiss only");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final PendingResult pendingResult = goAsync();
|
||||||
|
EXECUTOR.execute(() -> {
|
||||||
|
try {
|
||||||
|
int status = sendReceipt(homeserver, token, roomId, eventId);
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, "receipt ok status=" + status + " room=" + roomId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "receipt non-2xx status=" + status + " room=" + roomId);
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "receipt threw room=" + roomId, t);
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private int sendReceipt(
|
||||||
|
String baseUrl,
|
||||||
|
String accessToken,
|
||||||
|
String roomId,
|
||||||
|
String eventId
|
||||||
|
) throws IOException {
|
||||||
|
String url = trimTrailingSlash(baseUrl)
|
||||||
|
+ "/_matrix/client/v3/rooms/"
|
||||||
|
+ URLEncoder.encode(roomId, "UTF-8")
|
||||||
|
+ "/receipt/m.read/"
|
||||||
|
+ URLEncoder.encode(eventId, "UTF-8");
|
||||||
|
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||||
|
try {
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
|
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||||
|
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
// Empty JSON body per spec; setFixedLengthStreamingMode keeps the
|
||||||
|
// connection on the cached path instead of chunked-transfer fallback.
|
||||||
|
byte[] payload = "{}".getBytes("UTF-8");
|
||||||
|
conn.setFixedLengthStreamingMode(payload.length);
|
||||||
|
try (java.io.OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(payload);
|
||||||
|
}
|
||||||
|
return conn.getResponseCode();
|
||||||
|
} finally {
|
||||||
|
conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimTrailingSlash(String s) {
|
||||||
|
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
android/app/src/main/java/chat/vojo/app/NotificationDedup.java
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-source LRU dedup for rendered push event_ids.
|
||||||
|
*
|
||||||
|
* Both the FCM service (after a successful nm.notify) and the polling Worker
|
||||||
|
* write into the same bounded SharedPreferences-backed set. The Worker reads
|
||||||
|
* it to skip events FCM already delivered — which fixes the regression where
|
||||||
|
* a user who dismissed an FCM notification before polling fired would see
|
||||||
|
* the same event resurface up to 15 minutes later via the polling fallback.
|
||||||
|
*
|
||||||
|
* The native `eventId.hashCode()` notification-id slot is still the primary
|
||||||
|
* dedup for *concurrent* render (Android NotificationManager replace), but
|
||||||
|
* that only collapses surfaces while both notifications are still visible;
|
||||||
|
* once the user dismisses, the slot is empty and the second render would
|
||||||
|
* post fresh. This shared set covers that gap.
|
||||||
|
*
|
||||||
|
* Synchronisation: SharedPreferences read-modify-write is not atomic across
|
||||||
|
* threads/processes, and FCM service runs on a Firebase-managed background
|
||||||
|
* thread while the Worker runs on WorkManager's executor. We serialise all
|
||||||
|
* mutations through a static lock. Critical sections are short (string split
|
||||||
|
* + LinkedHashSet trim + putString) — no Binder calls.
|
||||||
|
*/
|
||||||
|
final class NotificationDedup {
|
||||||
|
|
||||||
|
// Capacity is intentionally larger than VojoPollWorker's worst-case per-run
|
||||||
|
// event count (MAX_PAGES_PER_RUN × PAGE_LIMIT = 250). If a single fire
|
||||||
|
// marks 250 events and the cap were 200, the 50 oldest of those would
|
||||||
|
// already be evicted by the time we finish writing — so a sibling poll
|
||||||
|
// resuming the same window would re-render them. 500 gives 2× headroom
|
||||||
|
// while staying ~12 KB in SharedPreferences (negligible).
|
||||||
|
private static final int MAX_TRACKED = 500;
|
||||||
|
private static final Object lock = new Object();
|
||||||
|
|
||||||
|
private NotificationDedup() {}
|
||||||
|
|
||||||
|
/** Returns true iff the given event_id has been notified in a recent cycle. */
|
||||||
|
static boolean wasNotified(Context ctx, String eventId) {
|
||||||
|
if (eventId == null || eventId.isEmpty()) return false;
|
||||||
|
synchronized (lock) {
|
||||||
|
return readSet(ctx).contains(eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append the event_id to the LRU set, trimming the oldest when full. */
|
||||||
|
static void markNotified(Context ctx, String eventId) {
|
||||||
|
if (eventId == null || eventId.isEmpty()) return;
|
||||||
|
synchronized (lock) {
|
||||||
|
Set<String> set = readSet(ctx);
|
||||||
|
// LinkedHashSet preserves insertion order — re-adding moves to tail
|
||||||
|
// only if we remove-then-add. The Set#add no-op on a present entry
|
||||||
|
// does NOT refresh position, but the simple "drop oldest" trim
|
||||||
|
// below is adequate for our scale and matches the Worker's
|
||||||
|
// existing semantics. Skip the disk write entirely when add()
|
||||||
|
// returned false — the event was already in the set, persistence
|
||||||
|
// would just churn SharedPreferences for no state change.
|
||||||
|
if (!set.add(eventId)) return;
|
||||||
|
if (set.size() > MAX_TRACKED) {
|
||||||
|
Iterator<String> it = set.iterator();
|
||||||
|
int drop = set.size() - MAX_TRACKED;
|
||||||
|
while (it.hasNext() && drop > 0) {
|
||||||
|
it.next();
|
||||||
|
it.remove();
|
||||||
|
drop -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeSet(ctx, set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller must hold {@link #lock}. */
|
||||||
|
private static Set<String> readSet(Context ctx) {
|
||||||
|
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||||
|
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||||
|
String raw = prefs.getString(VojoPollWorker.KEY_NOTIFIED_IDS, "");
|
||||||
|
Set<String> out = new LinkedHashSet<>();
|
||||||
|
if (raw.isEmpty()) return out;
|
||||||
|
for (String id : raw.split(",")) {
|
||||||
|
if (!id.isEmpty()) out.add(id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller must hold {@link #lock}. */
|
||||||
|
private static void writeSet(Context ctx, Set<String> set) {
|
||||||
|
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||||
|
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||||
|
StringBuilder sb = new StringBuilder(set.size() * 25);
|
||||||
|
boolean first = true;
|
||||||
|
for (String id : set) {
|
||||||
|
if (!first) sb.append(',');
|
||||||
|
sb.append(id);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
prefs.edit().putString(VojoPollWorker.KEY_NOTIFIED_IDS, sb.toString()).apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires when the user swipes a per-room MessagingStyle notification away.
|
||||||
|
*
|
||||||
|
* Without this hook, RoomMessageCache would still hold the prior messages
|
||||||
|
* for that room — and the next push would append onto that history and
|
||||||
|
* re-surface the messages the user just dismissed. With it, swipe clears
|
||||||
|
* the cache so the next push starts a fresh conversation for the room.
|
||||||
|
*
|
||||||
|
* NOTE: this only fires for user-driven dismissals — programmatic
|
||||||
|
* nm.cancel calls (mark-as-read, receipt-driven dismiss, channel migration)
|
||||||
|
* already call RoomMessageCache.clear themselves and do NOT fire the
|
||||||
|
* delete intent. There's no double-clear risk.
|
||||||
|
*/
|
||||||
|
public class NotificationDismissReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
|
public static final String ACTION_NOTIFICATION_DISMISSED =
|
||||||
|
"chat.vojo.app.NOTIFICATION_DISMISSED";
|
||||||
|
public static final String EXTRA_ROOM_ID = "room_id";
|
||||||
|
|
||||||
|
private static final String TAG = "DismissRcvr";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
if (intent == null) return;
|
||||||
|
String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||||
|
if (roomId == null || roomId.isEmpty()) return;
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "swipe clear cache room=" + roomId);
|
||||||
|
RoomMessageCache.clear(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
236
android/app/src/main/java/chat/vojo/app/PollingPlugin.java
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.work.Constraints;
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy;
|
||||||
|
import androidx.work.NetworkType;
|
||||||
|
import androidx.work.PeriodicWorkRequest;
|
||||||
|
import androidx.work.WorkManager;
|
||||||
|
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JS ↔ Android bridge for the WorkManager-based polling fallback.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* - JS calls saveSession({accessToken, homeserverUrl, userId}) on login,
|
||||||
|
* on push (re)enable, and on visibilitychange → visible (to recover a
|
||||||
|
* 401-cleared credentials slot without a full remount).
|
||||||
|
* - JS calls schedule({intervalMinutes}) once push is enabled. Idempotent:
|
||||||
|
* KEEP policy means a second schedule() call against an already-enqueued
|
||||||
|
* worker is a no-op (the running period continues unchanged).
|
||||||
|
* - JS calls saveRoomNames({names}) on mount + visibilitychange → visible
|
||||||
|
* so VojoPollWorker has a local cache to resolve room_id → display name
|
||||||
|
* without making N extra GET /rooms/{id}/state/m.room.name requests.
|
||||||
|
* Brand-new rooms created between visibility events fall back to
|
||||||
|
* sender_display_name in the renderer.
|
||||||
|
* - JS calls cancel() + clearSession() on logout / push disable.
|
||||||
|
*
|
||||||
|
* Worker tag: a single unique periodic worker named UNIQUE_WORK_NAME — KEEP
|
||||||
|
* policy prevents schedule churn from re-creating it. Cancel() removes it
|
||||||
|
* by the same name.
|
||||||
|
*/
|
||||||
|
@CapacitorPlugin(name = "Polling")
|
||||||
|
public class PollingPlugin extends Plugin {
|
||||||
|
|
||||||
|
private static final String TAG = "PollingPlugin";
|
||||||
|
private static final String UNIQUE_WORK_NAME = "vojo_push_poll";
|
||||||
|
|
||||||
|
// Android's hard floor for PeriodicWorkRequest. Requests with shorter
|
||||||
|
// intervals are silently clamped to 15 minutes. We accept the requested
|
||||||
|
// value from JS but enforce the floor here so misuse from JS doesn't
|
||||||
|
// produce a silently-different behavior.
|
||||||
|
private static final long MIN_INTERVAL_MINUTES = 15;
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void saveSession(PluginCall call) {
|
||||||
|
String accessToken = call.getString("accessToken");
|
||||||
|
String homeserverUrl = call.getString("homeserverUrl");
|
||||||
|
if (accessToken == null || accessToken.isEmpty()
|
||||||
|
|| homeserverUrl == null || homeserverUrl.isEmpty()) {
|
||||||
|
call.reject("missing_accessToken_or_homeserverUrl");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String userId = call.getString("userId");
|
||||||
|
SharedPreferences prefs = getContext()
|
||||||
|
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||||
|
SharedPreferences.Editor editor = prefs.edit()
|
||||||
|
.putString(VojoPollWorker.KEY_ACCESS_TOKEN, accessToken)
|
||||||
|
.putString(VojoPollWorker.KEY_HOMESERVER_URL, homeserverUrl);
|
||||||
|
if (userId != null && !userId.isEmpty()) {
|
||||||
|
editor.putString(VojoPollWorker.KEY_USER_ID, userId);
|
||||||
|
}
|
||||||
|
// Seed the watermark to "now minus a small clock-skew buffer" on the
|
||||||
|
// first saveSession after install / logout. Without seeding the
|
||||||
|
// Worker's first fire sees watermark=0 and renders every historical
|
||||||
|
// unread /notifications entry as a fresh push. The buffer covers the
|
||||||
|
// case where the device clock runs ahead of the homeserver's clock —
|
||||||
|
// event ts is server-side, so a too-fresh local seed would silently
|
||||||
|
// skip recently-arrived events as "older than watermark" forever.
|
||||||
|
// 60s tolerates typical NTP drift while still suppressing days-old
|
||||||
|
// backlog on first enable. We seed only when the key is absent so
|
||||||
|
// subsequent saveSession calls (token rotation, visibilitychange
|
||||||
|
// re-bridge) don't reset live state.
|
||||||
|
if (!prefs.contains(VojoPollWorker.KEY_LAST_SEEN_TS)) {
|
||||||
|
editor.putLong(
|
||||||
|
VojoPollWorker.KEY_LAST_SEEN_TS,
|
||||||
|
System.currentTimeMillis() - SEED_CLOCK_SKEW_BUFFER_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
editor.apply();
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final long SEED_CLOCK_SKEW_BUFFER_MS = 60_000L;
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void clearSession(PluginCall call) {
|
||||||
|
getContext()
|
||||||
|
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.remove(VojoPollWorker.KEY_ACCESS_TOKEN)
|
||||||
|
.remove(VojoPollWorker.KEY_HOMESERVER_URL)
|
||||||
|
.remove(VojoPollWorker.KEY_USER_ID)
|
||||||
|
.remove(VojoPollWorker.KEY_LAST_SEEN_TS)
|
||||||
|
.remove(VojoPollWorker.KEY_DRAIN_CURSOR)
|
||||||
|
.remove(VojoPollWorker.KEY_DRAIN_TARGET_TS)
|
||||||
|
.remove(VojoPollWorker.KEY_NOTIFIED_IDS)
|
||||||
|
.remove(VojoPollWorker.KEY_ROOM_NAMES)
|
||||||
|
.remove(VojoPollWorker.KEY_USER_AVATARS)
|
||||||
|
.apply();
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* user_id → MXC avatar URL snapshot. Mirrors {@link #saveRoomNames} —
|
||||||
|
* stored as a JSON blob in vojo_poll_state for the FCM service /
|
||||||
|
* polling Worker / ReplyReceiver to consult via
|
||||||
|
* VojoFirebaseMessagingService.lookupUserAvatarMxc. JS dumps on the
|
||||||
|
* same lifecycle triggers as room names (mount, visibility resume,
|
||||||
|
* m.direct change, m.room.encryption flip).
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
public void saveUserAvatars(PluginCall call) {
|
||||||
|
JSObject avatars = call.getObject("avatars");
|
||||||
|
if (avatars == null) {
|
||||||
|
call.reject("missing_avatars");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String serialized = avatars.toString();
|
||||||
|
getContext()
|
||||||
|
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(VojoPollWorker.KEY_USER_AVATARS, serialized)
|
||||||
|
.apply();
|
||||||
|
Log.i(TAG, "saveUserAvatars: " + avatars.length() + " entries, "
|
||||||
|
+ serialized.length() + " bytes");
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void saveRoomNames(PluginCall call) {
|
||||||
|
JSObject names = call.getObject("names");
|
||||||
|
if (names == null) {
|
||||||
|
// Empty map is also valid (user cleared all rooms) — JS passes
|
||||||
|
// {} explicitly in that case; missing key is a contract bug.
|
||||||
|
call.reject("missing_names");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `JSObject extends JSONObject`, so names.toString() is already a
|
||||||
|
// valid JSON serialisation of validated values — no need to re-parse
|
||||||
|
// it through `new JSONObject(...)` just to re-serialise. Persist
|
||||||
|
// verbatim.
|
||||||
|
String serialized = names.toString();
|
||||||
|
getContext()
|
||||||
|
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(VojoPollWorker.KEY_ROOM_NAMES, serialized)
|
||||||
|
.apply();
|
||||||
|
Log.i(TAG, "saveRoomNames: " + names.length() + " entries, "
|
||||||
|
+ serialized.length() + " bytes");
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void schedule(PluginCall call) {
|
||||||
|
Integer intervalMinutes = call.getInt("intervalMinutes", 15);
|
||||||
|
long interval = Math.max(MIN_INTERVAL_MINUTES, intervalMinutes != null ? intervalMinutes : 15);
|
||||||
|
|
||||||
|
Constraints constraints = new Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
PeriodicWorkRequest req = new PeriodicWorkRequest.Builder(
|
||||||
|
VojoPollWorker.class, interval, TimeUnit.MINUTES
|
||||||
|
)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.addTag("vojo_push_poll")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try {
|
||||||
|
WorkManager.getInstance(getContext())
|
||||||
|
.enqueueUniquePeriodicWork(
|
||||||
|
UNIQUE_WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
req
|
||||||
|
);
|
||||||
|
Log.d(TAG, "scheduled periodic poll every " + interval + " minutes");
|
||||||
|
call.resolve();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "schedule failed", t);
|
||||||
|
call.reject("schedule_failed: " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss the per-room MessagingStyle notification + clear the in-memory
|
||||||
|
* RoomMessageCache for the room. Called from the JS receipt listener when
|
||||||
|
* a server-side read receipt zeroes the unread count (the user read on
|
||||||
|
* another device / tab). No-op if the notification was never posted or
|
||||||
|
* has already been swiped away.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
public void dismissRoom(PluginCall call) {
|
||||||
|
String roomId = call.getString("roomId");
|
||||||
|
if (roomId == null || roomId.isEmpty()) {
|
||||||
|
call.reject("missing_roomId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
VojoFirebaseMessagingService.dismissRoomNotification(getContext(), roomId);
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void cancel(PluginCall call) {
|
||||||
|
try {
|
||||||
|
// Block on the Operation so callers awaiting cancel() see the
|
||||||
|
// cancel committed to WorkManager's database before we resolve.
|
||||||
|
// (NOTE: this does NOT interrupt a Worker that's already mid
|
||||||
|
// doWork(); cooperative cancellation via isStopped() is owned
|
||||||
|
// by VojoPollWorker itself.) Without this wait a fast
|
||||||
|
// disable→reenable sequence races with ExistingPeriodicWorkPolicy.KEEP
|
||||||
|
// — the second enqueueUniquePeriodicWork can land before the
|
||||||
|
// cancel is committed and become a no-op. We're already off
|
||||||
|
// the main thread (Capacitor dispatches plugin calls on its
|
||||||
|
// own executor), so the blocking get() is safe here.
|
||||||
|
WorkManager.getInstance(getContext())
|
||||||
|
.cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||||
|
.getResult()
|
||||||
|
.get();
|
||||||
|
Log.d(TAG, "cancelled periodic poll");
|
||||||
|
call.resolve();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "cancel failed", t);
|
||||||
|
call.reject("cancel_failed: " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,55 @@ final class PushStrings {
|
||||||
return forAppLocale(ctx).getString(R.string.push_invitation);
|
return forAppLocale(ctx).getString(R.string.push_invitation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String missedCallTitle(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_missed_call);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String missedCallBody(Context ctx, String caller) {
|
||||||
|
String safeCaller = caller == null ? "" : caller;
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String channelGroup(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_channel_group);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String channelDm(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_channel_dm);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String channelDmDescription(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_channel_dm_description);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String channelGroupRoom(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_channel_group_room);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String channelGroupRoomDescription(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_channel_group_room_description);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String selfName(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_self_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String markAsReadAction(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_action_mark_as_read);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String replyAction(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_action_reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String replyHint(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_reply_hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String replyFailed(Context ctx) {
|
||||||
|
return forAppLocale(ctx).getString(R.string.push_reply_failed);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the invite-notification body from inviter + room name, falling
|
* Build the invite-notification body from inviter + room name, falling
|
||||||
* back through four variants when one or both are absent. The res IDs
|
* back through four variants when one or both are absent. The res IDs
|
||||||
|
|
|
||||||
248
android/app/src/main/java/chat/vojo/app/ReplyReceiver.java
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
import androidx.core.app.RemoteInput;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
import org.json.JSONException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the inline-reply RemoteInput action on a per-room MessagingStyle
|
||||||
|
* notification.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. User taps reply, types text, presses send → broadcast fires here.
|
||||||
|
* 2. We immediately append the outgoing message to RoomMessageCache and
|
||||||
|
* re-post the notification (instant UX feedback — the message appears
|
||||||
|
* as a self-Person bubble in the conversation while the HTTP is in
|
||||||
|
* flight).
|
||||||
|
* 3. PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}
|
||||||
|
* with {msgtype: "m.text", body}. Uses the vojo_poll_state token (same
|
||||||
|
* storage as Worker / MarkAsReadReceiver — single credential lifecycle).
|
||||||
|
* 4. On 2xx: nothing further; the JS sync echo will eventually replace
|
||||||
|
* the local-echo bubble in-app.
|
||||||
|
* 5. On non-2xx or thrown: post a small error notification "Could not
|
||||||
|
* send your reply" so the user knows to retry from in-app — better
|
||||||
|
* than silently swallowing the message.
|
||||||
|
*
|
||||||
|
* E2EE rooms are guarded UP-STREAM in VojoFirebaseMessagingService.
|
||||||
|
* renderMessageNotification: we don't even attach the reply action when
|
||||||
|
* RoomMetadata.isEncrypted is true. So this receiver never has to encrypt.
|
||||||
|
* Defense in depth: if a stale notification with the action ever survives
|
||||||
|
* an encryption flip we still detect the failure as a non-2xx HTTP and
|
||||||
|
* surface the error notification rather than sending cleartext (which
|
||||||
|
* Synapse would in any case reject for an encrypted room).
|
||||||
|
*
|
||||||
|
* Null-credential edge case: post the error notification so the user
|
||||||
|
* notices and retries in-app. Same logic as a network failure.
|
||||||
|
*/
|
||||||
|
public class ReplyReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
|
public static final String ACTION_REPLY = "chat.vojo.app.REPLY";
|
||||||
|
public static final String EXTRA_ROOM_ID = "room_id";
|
||||||
|
public static final String KEY_TEXT_REPLY = "vojo.text_reply";
|
||||||
|
|
||||||
|
private static final int CONNECT_TIMEOUT_MS = 8_000;
|
||||||
|
private static final int READ_TIMEOUT_MS = 8_000;
|
||||||
|
private static final String TAG = "ReplyRcvr";
|
||||||
|
|
||||||
|
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
if (intent == null) return;
|
||||||
|
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||||
|
if (roomId == null || roomId.isEmpty()) {
|
||||||
|
Log.w(TAG, "onReceive: missing room_id, abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bundle remote = RemoteInput.getResultsFromIntent(intent);
|
||||||
|
if (remote == null) {
|
||||||
|
Log.w(TAG, "onReceive: no RemoteInput results");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CharSequence reply = remote.getCharSequence(KEY_TEXT_REPLY);
|
||||||
|
if (reply == null) {
|
||||||
|
Log.w(TAG, "onReceive: RemoteInput missing text");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String text = reply.toString().trim();
|
||||||
|
if (text.isEmpty()) return;
|
||||||
|
|
||||||
|
final Context appContext = context.getApplicationContext();
|
||||||
|
|
||||||
|
// Pre-flight validation BEFORE the optimistic echo. Posting a self
|
||||||
|
// bubble first and then immediately stacking an error notif on top
|
||||||
|
// is jarring UX; for predictable failures (logged out, freshly
|
||||||
|
// encrypted room) we'd rather skip the echo and only surface the
|
||||||
|
// error.
|
||||||
|
final SharedPreferences prefs = appContext.getSharedPreferences(
|
||||||
|
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||||
|
final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
|
||||||
|
final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
|
||||||
|
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
|
||||||
|
Log.w(TAG, "onReceive: no credentials in prefs, surfacing error notif");
|
||||||
|
postReplyError(appContext, roomId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Race guard for E2EE flip: the per-room metadata snapshot is
|
||||||
|
// refreshed by JS on m.room.encryption Timeline events, but a push
|
||||||
|
// delivered in the narrow window between the encryption state
|
||||||
|
// landing and the dump completing could still expose the reply
|
||||||
|
// action on a freshly-encrypted room. Re-read the snapshot
|
||||||
|
// synchronously here — Synapse does NOT enforce "no cleartext in
|
||||||
|
// encrypted rooms" at the spec level, so without this guard we'd
|
||||||
|
// leak the user's reply into an E2EE timeline as plaintext.
|
||||||
|
if (isRoomEncryptedAtSendTime(prefs, roomId)) {
|
||||||
|
Log.w(TAG, "onReceive: room flipped to encrypted between render and send, abort");
|
||||||
|
postReplyError(appContext, roomId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic local echo — appends a self-Person message to the
|
||||||
|
// conversation and re-posts, so the user sees their reply in the
|
||||||
|
// shade before the HTTP completes. Only happens after pre-flight
|
||||||
|
// checks pass so the user doesn't see an echo for a reply we know
|
||||||
|
// will fail.
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now);
|
||||||
|
|
||||||
|
final PendingResult pendingResult = goAsync();
|
||||||
|
final String txnId = "vojo-reply-" + UUID.randomUUID();
|
||||||
|
EXECUTOR.execute(() -> {
|
||||||
|
try {
|
||||||
|
int status = sendReply(homeserver, token, roomId, txnId, text);
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, "reply ok status=" + status + " room=" + roomId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "reply non-2xx status=" + status + " room=" + roomId);
|
||||||
|
postReplyError(appContext, roomId);
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "reply threw room=" + roomId, t);
|
||||||
|
postReplyError(appContext, roomId);
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private int sendReply(
|
||||||
|
String baseUrl,
|
||||||
|
String accessToken,
|
||||||
|
String roomId,
|
||||||
|
String txnId,
|
||||||
|
String text
|
||||||
|
) throws IOException {
|
||||||
|
String url = trimTrailingSlash(baseUrl)
|
||||||
|
+ "/_matrix/client/v3/rooms/"
|
||||||
|
+ URLEncoder.encode(roomId, "UTF-8")
|
||||||
|
+ "/send/m.room.message/"
|
||||||
|
+ URLEncoder.encode(txnId, "UTF-8");
|
||||||
|
|
||||||
|
JSONObject body;
|
||||||
|
try {
|
||||||
|
body = new JSONObject();
|
||||||
|
body.put("msgtype", "m.text");
|
||||||
|
body.put("body", text);
|
||||||
|
} catch (org.json.JSONException je) {
|
||||||
|
// JSONObject.put only throws on NaN/Inf doubles, neither of
|
||||||
|
// which we use — but keep the type contract honest.
|
||||||
|
throw new IOException("payload encode failed", je);
|
||||||
|
}
|
||||||
|
byte[] payload = body.toString().getBytes("UTF-8");
|
||||||
|
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||||
|
try {
|
||||||
|
conn.setRequestMethod("PUT");
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
|
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||||
|
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setFixedLengthStreamingMode(payload.length);
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(payload);
|
||||||
|
}
|
||||||
|
return conn.getResponseCode();
|
||||||
|
} finally {
|
||||||
|
conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface a short error notification when the reply HTTP fails so the
|
||||||
|
* user knows the message did NOT land server-side and can retry from
|
||||||
|
* within the app. Posted on the DM channel as a one-shot. Unique notif
|
||||||
|
* id per room so it can't clobber the room's conversation slot.
|
||||||
|
*/
|
||||||
|
private static void postReplyError(Context ctx, String roomId) {
|
||||||
|
NotificationManager nm = (NotificationManager)
|
||||||
|
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
if (nm == null) return;
|
||||||
|
try {
|
||||||
|
String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM;
|
||||||
|
NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, channel)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(PushStrings.replyFailed(ctx))
|
||||||
|
.setContentText(PushStrings.replyFailed(ctx))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
||||||
|
int errId = ("replyErr_" + roomId).hashCode();
|
||||||
|
nm.notify(errId, b.build());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "reply error notif failed", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimTrailingSlash(String s) {
|
||||||
|
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous re-check of the room's encryption flag at send time.
|
||||||
|
* Mirrors VojoFirebaseMessagingService.loadRoomMetadata's tolerant
|
||||||
|
* parse: legacy string-shape entries and missing flags both default
|
||||||
|
* to encrypted=true (privacy-first — refusing a reply on a falsely-
|
||||||
|
* flagged room is harmless; sending cleartext into a truly encrypted
|
||||||
|
* room is a privacy leak).
|
||||||
|
*/
|
||||||
|
private static boolean isRoomEncryptedAtSendTime(SharedPreferences prefs, String roomId) {
|
||||||
|
String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null);
|
||||||
|
if (raw == null || raw.isEmpty()) return true;
|
||||||
|
try {
|
||||||
|
JSONObject map = new JSONObject(raw);
|
||||||
|
if (!map.has(roomId) || map.isNull(roomId)) return true;
|
||||||
|
JSONObject obj = map.optJSONObject(roomId);
|
||||||
|
if (obj == null) {
|
||||||
|
// Legacy string-shape predates the encryption flag —
|
||||||
|
// assume encrypted to err on the side of privacy.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return obj.optBoolean("isEncrypted", true);
|
||||||
|
} catch (JSONException je) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
android/app/src/main/java/chat/vojo/app/RoomMessageCache.java
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
import androidx.core.app.Person;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-room MessagingStyle history cache.
|
||||||
|
*
|
||||||
|
* Stores the last N messages observed for each room so renderMessageNotification
|
||||||
|
* can rebuild a NotificationCompat.MessagingStyle with conversation context on
|
||||||
|
* every new event instead of posting a fresh single-message notification per
|
||||||
|
* event. Without this every 5-message DM produced 5 distinct entries in the
|
||||||
|
* shade; with it the user sees one expandable conversation per room — the
|
||||||
|
* WhatsApp/Telegram convention.
|
||||||
|
*
|
||||||
|
* Thread-safety: ConcurrentHashMap + per-key synchronized mutation via the
|
||||||
|
* compute() / get() pattern. Both VojoFirebaseMessagingService.onMessageReceived
|
||||||
|
* (Firebase-managed thread) and VojoPollWorker.doWork (WorkManager executor)
|
||||||
|
* mutate the cache; without serialization a same-room FCM + polling race could
|
||||||
|
* lose a message. Mutations are short — only deque append + bounded trim.
|
||||||
|
*
|
||||||
|
* Persistence: in-memory only. After process kill the cache is empty, and
|
||||||
|
* renderMessageNotification falls back to extractMessagingStyleFromNotification
|
||||||
|
* to recover history from the live system shade. If the user dismissed the
|
||||||
|
* notification too, the conversation legitimately starts fresh — no signal we
|
||||||
|
* could recover from there anyway.
|
||||||
|
*
|
||||||
|
* Eviction: bounded at MAX_MESSAGES_PER_ROOM per room, with FIFO eviction
|
||||||
|
* (oldest message at the head of the deque is dropped via pollFirst when the
|
||||||
|
* append would exceed the cap). Map itself is unbounded; in practice the
|
||||||
|
* dump from dismissRoom (when a server-side read receipt clears unread) keeps
|
||||||
|
* the room count proportional to active conversations. For safety against
|
||||||
|
* runaway growth from rooms the user never reads, we cap the map at MAX_ROOMS.
|
||||||
|
*/
|
||||||
|
final class RoomMessageCache {
|
||||||
|
|
||||||
|
// Element-android keeps a similar in-memory queue (NotificationEventQueue);
|
||||||
|
// 20 messages per room is generous enough for an active group chat while
|
||||||
|
// staying well under Android's MessagingStyle render budget — Android only
|
||||||
|
// shows the last ~7 messages in the shade anyway.
|
||||||
|
private static final int MAX_MESSAGES_PER_ROOM = 20;
|
||||||
|
|
||||||
|
// Hard cap on the map size so a long-running session that touches many
|
||||||
|
// rooms without ever clearing receipts can't slowly leak memory.
|
||||||
|
// Eviction is approximate (oldest-touched first via insertion order from
|
||||||
|
// ConcurrentHashMap is NOT guaranteed, so we just clear the oldest by
|
||||||
|
// arbitrary entry on overflow — acceptable for an LRU at this scale).
|
||||||
|
private static final int MAX_ROOMS = 200;
|
||||||
|
|
||||||
|
private static final ConcurrentHashMap<String, Deque<Entry>> store =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private RoomMessageCache() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of a single rendered message. We can't store
|
||||||
|
* NotificationCompat.MessagingStyle.Message directly because Person's
|
||||||
|
* Icon field is not safely shareable across threads / not cheap to
|
||||||
|
* rebuild on every poll. Building the Message at render time from this
|
||||||
|
* record matches element-android's RoomGroupMessageCreator pattern.
|
||||||
|
*/
|
||||||
|
static final class Entry {
|
||||||
|
// Matrix event_id when known (incoming pushes always carry one;
|
||||||
|
// outgoing optimistic-echo entries pass null). Used by append() to
|
||||||
|
// suppress duplicate appends when FCM retries / cross-source
|
||||||
|
// delivery hands the same event in twice — without this the
|
||||||
|
// MessagingStyle conversation would render the same message N
|
||||||
|
// times in the shade.
|
||||||
|
final String eventId;
|
||||||
|
final String body;
|
||||||
|
final long timestamp;
|
||||||
|
final String senderKey;
|
||||||
|
final String senderName;
|
||||||
|
final boolean fromSelf;
|
||||||
|
|
||||||
|
Entry(
|
||||||
|
String eventId,
|
||||||
|
String body,
|
||||||
|
long timestamp,
|
||||||
|
String senderKey,
|
||||||
|
String senderName,
|
||||||
|
boolean fromSelf
|
||||||
|
) {
|
||||||
|
this.eventId = eventId;
|
||||||
|
this.body = body;
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.senderKey = senderKey;
|
||||||
|
this.senderName = senderName;
|
||||||
|
this.fromSelf = fromSelf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a message to the room's history and return an ordered snapshot
|
||||||
|
* including the newly-added entry. Snapshot is taken INSIDE the atomic
|
||||||
|
* compute() so a concurrent append for the same room can't mutate the
|
||||||
|
* deque between our addLast and our copy. Returning the deque reference
|
||||||
|
* and copying outside is unsafe — ConcurrentHashMap.compute serialises
|
||||||
|
* only the lambda body per key, not subsequent reads of the value.
|
||||||
|
*/
|
||||||
|
static List<Entry> append(String roomId, Entry entry) {
|
||||||
|
if (roomId == null || roomId.isEmpty() || entry == null) {
|
||||||
|
return java.util.Collections.emptyList();
|
||||||
|
}
|
||||||
|
final List<Entry> snapshot = new ArrayList<>();
|
||||||
|
store.compute(roomId, (key, existing) -> {
|
||||||
|
Deque<Entry> d = (existing != null) ? existing : new ArrayDeque<>();
|
||||||
|
// Dedup by eventId — protects against FCM retry / cross-source
|
||||||
|
// (FCM + polling Worker) double-delivery that would otherwise
|
||||||
|
// append the same event twice. Only applies when both the new
|
||||||
|
// entry and a prior one carry a non-empty eventId; outgoing
|
||||||
|
// self-echo entries have null eventId by design and never
|
||||||
|
// collide.
|
||||||
|
boolean isDup = false;
|
||||||
|
if (entry.eventId != null && !entry.eventId.isEmpty()) {
|
||||||
|
for (Entry prior : d) {
|
||||||
|
if (entry.eventId.equals(prior.eventId)) {
|
||||||
|
isDup = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isDup) {
|
||||||
|
d.addLast(entry);
|
||||||
|
while (d.size() > MAX_MESSAGES_PER_ROOM) {
|
||||||
|
d.pollFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshot.addAll(d);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
// Bound the map. Iteration order of ConcurrentHashMap is unspecified
|
||||||
|
// and the size() check is racy with concurrent puts; we accept ±1
|
||||||
|
// eviction precision at the 200-room cap as an acceptable approximation
|
||||||
|
// of LRU (the alternative is a global lock on every append which is
|
||||||
|
// far more expensive than letting the cache drift by one).
|
||||||
|
if (store.size() > MAX_ROOMS) {
|
||||||
|
java.util.Iterator<String> it = store.keySet().iterator();
|
||||||
|
while (it.hasNext() && store.size() > MAX_ROOMS) {
|
||||||
|
String key = it.next();
|
||||||
|
if (!key.equals(roomId)) it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the room's history from an already-posted MessagingStyle (recovered
|
||||||
|
* via NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification
|
||||||
|
* after process kill). Idempotent: if the room already has cached entries
|
||||||
|
* we leave them alone — they are by construction at least as recent.
|
||||||
|
*/
|
||||||
|
static void seedIfAbsent(String roomId, List<Entry> entries) {
|
||||||
|
if (roomId == null || roomId.isEmpty() || entries == null || entries.isEmpty()) return;
|
||||||
|
store.computeIfAbsent(roomId, key -> {
|
||||||
|
Deque<Entry> d = new ArrayDeque<>();
|
||||||
|
for (Entry e : entries) {
|
||||||
|
d.addLast(e);
|
||||||
|
while (d.size() > MAX_MESSAGES_PER_ROOM) d.pollFirst();
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop all cached messages for a room (e.g. on receipt-driven dismiss). */
|
||||||
|
static void clear(String roomId) {
|
||||||
|
if (roomId == null || roomId.isEmpty()) return;
|
||||||
|
store.remove(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
273
android/app/src/main/java/chat/vojo/app/ShareTargetPlugin.java
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.content.ContentResolver;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.MimeTypeMap;
|
||||||
|
|
||||||
|
import com.getcapacitor.JSArray;
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives ACTION_SEND / ACTION_SEND_MULTIPLE intents from the system share-
|
||||||
|
* sheet and surfaces them to the WebView as a pending share that JS consumes
|
||||||
|
* via {@code pickPendingShare()} (or reacts to via the {@code shareReceived}
|
||||||
|
* event when the app was already in the foreground).
|
||||||
|
*
|
||||||
|
* Cold-start flow:
|
||||||
|
* 1. Share-sheet → Vojo → MainActivity.onCreate → super.onCreate runs
|
||||||
|
* BridgeActivity.load(), which itself calls bridge.onNewIntent(getIntent())
|
||||||
|
* and fans the intent out to every plugin's handleOnNewIntent. So
|
||||||
|
* cold-start and warm-start share the SAME entry point — we don't
|
||||||
|
* double-process via handleOnStart.
|
||||||
|
* 2. captureFromIntent copies payload bytes into the app cache and stashes
|
||||||
|
* the result in {@link #pendingShare}.
|
||||||
|
* 3. JS booting up (Matrix client ready, user logged in) calls
|
||||||
|
* pickPendingShare(); receives the JSON; opens the room-picker UI. The
|
||||||
|
* shareReceived event fired here is dropped silently because no JS
|
||||||
|
* listener is attached yet — that's fine, pickPendingShare drains the
|
||||||
|
* slot regardless.
|
||||||
|
*
|
||||||
|
* Warm flow (app already running):
|
||||||
|
* 1. Share-sheet → MainActivity.onNewIntent → BridgeActivity forwards to
|
||||||
|
* plugin.handleOnNewIntent(intent).
|
||||||
|
* 2. We re-capture the payload AND emit {@code shareReceived} so JS can
|
||||||
|
* open the picker without polling.
|
||||||
|
*
|
||||||
|
* Why we copy to cache instead of handing JS a content:// URI:
|
||||||
|
* - WebView fetch() rejects content:// schemes outright, and
|
||||||
|
* `Capacitor.convertFileSrc()` only works on file paths.
|
||||||
|
* - The originating app holds the read-grant only for the lifetime of the
|
||||||
|
* launching task; routing the URI through JS+picker+RoomInput would race
|
||||||
|
* that grant on Android 14+.
|
||||||
|
* - Copying into our own cache means the share is self-contained: even if
|
||||||
|
* the user backgrounds Vojo for hours before picking a chat, the bytes
|
||||||
|
* are still there. We schedule no cleanup of our own — Android's cache
|
||||||
|
* eviction handles long-tail garbage.
|
||||||
|
*/
|
||||||
|
@CapacitorPlugin(name = "ShareTarget")
|
||||||
|
public class ShareTargetPlugin extends Plugin {
|
||||||
|
|
||||||
|
private static final String TAG = "ShareTargetPlugin";
|
||||||
|
private static final String SHARE_CACHE_SUBDIR = "shared";
|
||||||
|
|
||||||
|
// Single-slot pending share. Multiple share-sheet invocations before JS
|
||||||
|
// drains the slot collapse — the latest wins. JS contract is "consume
|
||||||
|
// once, then it's gone" via pickPendingShare(consume=true). This matches
|
||||||
|
// user intent: tapping share twice on different photos clearly means
|
||||||
|
// "share THIS one now".
|
||||||
|
private volatile JSObject pendingShare = null;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleOnNewIntent(Intent intent) {
|
||||||
|
super.handleOnNewIntent(intent);
|
||||||
|
captureFromIntent(intent, /* notifyJs */ true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void pickPendingShare(PluginCall call) {
|
||||||
|
JSObject ret = new JSObject();
|
||||||
|
JSObject snapshot = pendingShare;
|
||||||
|
if (snapshot == null) {
|
||||||
|
ret.put("empty", true);
|
||||||
|
} else {
|
||||||
|
// Default: consume on read. Lets us treat the slot like a one-shot
|
||||||
|
// mailbox without an extra round-trip. Caller can pass consume=false
|
||||||
|
// to peek (not used today, but cheap to keep).
|
||||||
|
Boolean consume = call.getBoolean("consume", Boolean.TRUE);
|
||||||
|
ret = snapshot;
|
||||||
|
if (Boolean.TRUE.equals(consume)) {
|
||||||
|
pendingShare = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call.resolve(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureFromIntent(Intent intent, boolean notifyJs) {
|
||||||
|
if (intent == null) return;
|
||||||
|
String action = intent.getAction();
|
||||||
|
if (action == null) return;
|
||||||
|
|
||||||
|
// Capacitor's JSObject.put() silently swallows JSONException internally
|
||||||
|
// (it wraps org.json.JSONObject and returns `this` on failure) so no
|
||||||
|
// checked exception is thrown here — unlike the raw org.json API.
|
||||||
|
JSObject share = new JSObject();
|
||||||
|
share.put("empty", false);
|
||||||
|
|
||||||
|
String text = intent.getStringExtra(Intent.EXTRA_TEXT);
|
||||||
|
String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
|
||||||
|
if (text != null && !text.isEmpty()) share.put("text", text);
|
||||||
|
if (subject != null && !subject.isEmpty()) share.put("subject", subject);
|
||||||
|
|
||||||
|
JSArray items = new JSArray();
|
||||||
|
List<Uri> uris = new ArrayList<>();
|
||||||
|
if (Intent.ACTION_SEND.equals(action)) {
|
||||||
|
Uri uri;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
uri = intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class);
|
||||||
|
} else {
|
||||||
|
// Deprecated overload — required to read EXTRA_STREAM on
|
||||||
|
// API ≤32, where the typed variant doesn't exist.
|
||||||
|
//noinspection deprecation
|
||||||
|
uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||||
|
}
|
||||||
|
if (uri != null) uris.add(uri);
|
||||||
|
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
|
||||||
|
List<Uri> multi;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
multi = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri.class);
|
||||||
|
} else {
|
||||||
|
//noinspection deprecation
|
||||||
|
multi = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
||||||
|
}
|
||||||
|
if (multi != null) uris.addAll(multi);
|
||||||
|
}
|
||||||
|
|
||||||
|
String intentMime = intent.getType();
|
||||||
|
for (Uri uri : uris) {
|
||||||
|
JSObject item = copyUriToCache(uri, intentMime);
|
||||||
|
if (item != null) items.put(item);
|
||||||
|
}
|
||||||
|
share.put("items", items);
|
||||||
|
|
||||||
|
// Drop pure-noise intents — neither text nor a successfully
|
||||||
|
// copied file. Possible if a sender app handed us only a content://
|
||||||
|
// URI we can't read (permission revoked) or an EXTRA_STREAM with a
|
||||||
|
// null Uri. Keeps JS from showing an empty picker.
|
||||||
|
if (text == null && subject == null && items.length() == 0) {
|
||||||
|
Log.w(TAG, "Dropping share intent with no usable payload");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingShare = share;
|
||||||
|
if (notifyJs) {
|
||||||
|
notifyListeners("shareReceived", new JSObject());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the content of {@code uri} into a fresh file under
|
||||||
|
* cacheDir/shared/, then return {name, mimeType, size, path}. The path is
|
||||||
|
* an absolute filesystem path — JS wraps it with
|
||||||
|
* {@code Capacitor.convertFileSrc} before fetch().
|
||||||
|
*/
|
||||||
|
private JSObject copyUriToCache(Uri uri, String fallbackMime) {
|
||||||
|
if (uri == null) return null;
|
||||||
|
ContentResolver resolver = getContext().getContentResolver();
|
||||||
|
|
||||||
|
String name = queryDisplayName(resolver, uri);
|
||||||
|
String mimeType = resolver.getType(uri);
|
||||||
|
if (mimeType == null) mimeType = fallbackMime;
|
||||||
|
if (mimeType == null) mimeType = "application/octet-stream";
|
||||||
|
|
||||||
|
if (name == null || name.isEmpty()) {
|
||||||
|
String ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
|
||||||
|
name = "share-" + UUID.randomUUID() + (ext != null ? "." + ext : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
File dir = new File(getContext().getCacheDir(), SHARE_CACHE_SUBDIR);
|
||||||
|
// mkdirs returns false if the directory already exists — not an error.
|
||||||
|
// The real failure mode is the I/O exception below on FileOutputStream
|
||||||
|
// construction, which we surface.
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
Log.e(TAG, "Could not create share cache dir: " + dir);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Prefix with UUID so a repeated share of "IMG_1234.jpg" doesn't
|
||||||
|
// overwrite the previous payload while the user is still picking a
|
||||||
|
// chat for the older one (e.g. Gallery → Vojo, see room-picker open,
|
||||||
|
// background → Gallery → re-share same file → foreground Vojo). Both
|
||||||
|
// payloads stay independently addressable.
|
||||||
|
File out = new File(dir, UUID.randomUUID() + "_" + safeFileName(name));
|
||||||
|
|
||||||
|
// Open the input first; if the sender's provider hands us back
|
||||||
|
// null (revoked grant, gone-away ContentProvider, …) bail before
|
||||||
|
// creating any on-disk file — otherwise the FileOutputStream
|
||||||
|
// initializer below would create a zero-byte orphan we'd never
|
||||||
|
// clean up (catch arm doesn't fire when we early-return).
|
||||||
|
long size;
|
||||||
|
try (InputStream in = resolver.openInputStream(uri)) {
|
||||||
|
if (in == null) {
|
||||||
|
Log.w(TAG, "openInputStream returned null for " + uri);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(out)) {
|
||||||
|
byte[] buf = new byte[64 * 1024];
|
||||||
|
int n;
|
||||||
|
long total = 0;
|
||||||
|
while ((n = in.read(buf)) > 0) {
|
||||||
|
fos.write(buf, 0, n);
|
||||||
|
total += n;
|
||||||
|
}
|
||||||
|
size = total;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.e(TAG, "Failed to copy " + uri, e);
|
||||||
|
// Drop the partial file so we don't surface a truncated
|
||||||
|
// payload to JS as if it were valid.
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
out.delete();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSObject item = new JSObject();
|
||||||
|
item.put("name", name);
|
||||||
|
item.put("mimeType", mimeType);
|
||||||
|
item.put("size", size);
|
||||||
|
item.put("path", out.getAbsolutePath());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String queryDisplayName(ContentResolver resolver, Uri uri) {
|
||||||
|
// ContentResolver.query throws if the provider rejects the URI scheme
|
||||||
|
// (e.g. some senders pass a file:// directly — no provider involved).
|
||||||
|
// Wrap in try/catch and fall back to the URI's last path segment.
|
||||||
|
try (Cursor c = resolver.query(uri, new String[]{ OpenableColumns.DISPLAY_NAME }, null, null, null)) {
|
||||||
|
if (c != null && c.moveToFirst()) {
|
||||||
|
int idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||||
|
if (idx >= 0) {
|
||||||
|
String name = c.getString(idx);
|
||||||
|
if (name != null && !name.isEmpty()) return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.d(TAG, "queryDisplayName failed for " + uri + ": " + t.getMessage());
|
||||||
|
}
|
||||||
|
String last = uri.getLastPathSegment();
|
||||||
|
if (last != null && !last.isEmpty()) {
|
||||||
|
// Strip any directory traversal a malicious sender might encode.
|
||||||
|
int slash = last.lastIndexOf('/');
|
||||||
|
return slash >= 0 ? last.substring(slash + 1) : last;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeFileName(String name) {
|
||||||
|
// Strip path separators and trim length — the on-disk name is just an
|
||||||
|
// identifier; the display name we return to JS preserves the user's
|
||||||
|
// original filename verbatim. Trim from the tail so the recognisable
|
||||||
|
// head ("IMG_2025_05_16…") survives and the extension is the part
|
||||||
|
// that gets clipped on absurdly long names; the on-disk extension
|
||||||
|
// doesn't matter because nothing inside Vojo dispatches on it (the
|
||||||
|
// display name carries the real extension into JS).
|
||||||
|
String stripped = name.replaceAll("[/\\\\]", "_");
|
||||||
|
if (stripped.length() > 120) stripped = stripped.substring(0, 120);
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
}
|
||||||
675
android/app/src/main/java/chat/vojo/app/VojoPollWorker.java
Normal file
|
|
@ -0,0 +1,675 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.core.app.NotificationManagerCompat;
|
||||||
|
import androidx.work.Worker;
|
||||||
|
import androidx.work.WorkerParameters;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic poll of `/_matrix/client/v3/notifications` as a fallback delivery
|
||||||
|
* channel for users whose network blocks FCM (mtalk.google.com:5228) — the
|
||||||
|
* ~5% slice on whitelist intranets (corporate / school / government) that
|
||||||
|
* otherwise receive zero pushes.
|
||||||
|
*
|
||||||
|
* Scheduling: enqueued from PollingPlugin.schedule() with a 15-minute period
|
||||||
|
* (Android's minimum for PeriodicWorkRequest) and CONNECTED network constraint.
|
||||||
|
* Cancelled via PollingPlugin.cancel() on logout / push disable.
|
||||||
|
*
|
||||||
|
* Credentials: read from SharedPreferences (saved by the JS side through
|
||||||
|
* PollingPlugin.saveSession). Vanilla Synapse (no MAS/OIDC) issues
|
||||||
|
* non-expiring access tokens; we do not implement refresh-token flow here.
|
||||||
|
* If a 401 ever occurs, doWork returns Result.success() — the next foreground
|
||||||
|
* launch re-saves the credentials and polling resumes. Retrying with a stale
|
||||||
|
* token would just waste battery and amplify rate limits.
|
||||||
|
*
|
||||||
|
* Output: messages and invites route through VojoFirebaseMessagingService
|
||||||
|
* .renderMessageNotification (shared with FCM, same notif-id slots →
|
||||||
|
* Android dedupes by replace). RTC ring events route through
|
||||||
|
* .renderMissedCallNotification (always stale by the time we poll — 15-min
|
||||||
|
* cadence vs 30-second ring lifetime), so the user sees "Missed call" instead
|
||||||
|
* of a phantom incoming-call CallStyle for a long-dead ring.
|
||||||
|
*
|
||||||
|
* E2EE caveat: Synapse cannot decrypt event content, so for end-to-end
|
||||||
|
* encrypted rooms the response carries `content.algorithm`+`ciphertext`
|
||||||
|
* with no `body`. The renderer falls through to PushStrings.messageFallback
|
||||||
|
* (i18n "New message") with the room name as title — same UX as the web
|
||||||
|
* Service Worker on encrypted pushes. By design — no key access from the
|
||||||
|
* Worker.
|
||||||
|
*
|
||||||
|
* Dedup is two complementary mechanisms:
|
||||||
|
* 1) A per-poll high-watermark on the latest event ts we've notified.
|
||||||
|
* Stored as KEY_LAST_SEEN_TS; advances only after a successful render
|
||||||
|
* (or a foreground-skipped event the user already saw in-app). Worker
|
||||||
|
* stops walking within a run as soon as it hits ts strictly less than
|
||||||
|
* watermark — newest-first ordering guarantees the rest are also
|
||||||
|
* older. Same-ts events fall through to the secondary filters because
|
||||||
|
* multiple events can share a millisecond.
|
||||||
|
* 2) NotificationDedup — a shared cross-source bounded LRU written by
|
||||||
|
* every renderer (FCM service after successful nm.notify, this Worker
|
||||||
|
* after successful render, and the ring-upsert paths at seed time).
|
||||||
|
* Lets the Worker skip events FCM already delivered even after the
|
||||||
|
* user dismissed the FCM notification.
|
||||||
|
*
|
||||||
|
* Each fire starts from the HEAD of /notifications (no persistent
|
||||||
|
* pagination cursor — the spec's `next_token` walks BACKWARDS into
|
||||||
|
* history, so a persisted cursor silently drifts off the new events the
|
||||||
|
* next poll should see; see matrix-js-sdk client.ts:5040 for the
|
||||||
|
* reference traversal pattern). When a single fire's backlog exceeds
|
||||||
|
* MAX_PAGES_PER_RUN pages the leftover next_token is saved as
|
||||||
|
* KEY_DRAIN_CURSOR (with the head ts snapshotted in KEY_DRAIN_TARGET_TS)
|
||||||
|
* and resumed on the next run, so big backlogs (>250 events) drain over
|
||||||
|
* consecutive polls without being clipped.
|
||||||
|
*/
|
||||||
|
public class VojoPollWorker extends Worker {
|
||||||
|
|
||||||
|
private static final String TAG = "VojoPoll";
|
||||||
|
|
||||||
|
static final String PREFS = "vojo_poll_state";
|
||||||
|
static final String KEY_ACCESS_TOKEN = "access_token";
|
||||||
|
static final String KEY_HOMESERVER_URL = "homeserver_url";
|
||||||
|
static final String KEY_USER_ID = "user_id";
|
||||||
|
// High-watermark on the latest event ts we've already notified about.
|
||||||
|
// Stored as a long-millis string. Replaces an earlier `last_from` cursor
|
||||||
|
// experiment that misunderstood /notifications pagination direction.
|
||||||
|
static final String KEY_LAST_SEEN_TS = "last_seen_ts";
|
||||||
|
// Continuation cursor used when a single run hits MAX_PAGES_PER_RUN before
|
||||||
|
// reaching the watermark. Persists the next_token across runs so a >250
|
||||||
|
// event backlog drains over consecutive polls instead of being clipped
|
||||||
|
// forever by the page cap. Cleared once we either reach the watermark or
|
||||||
|
// exhaust pagination on a single run.
|
||||||
|
static final String KEY_DRAIN_CURSOR = "drain_cursor";
|
||||||
|
// The "head ts" we recorded when entering drain mode. After drain
|
||||||
|
// completes the watermark is jumped to THIS value rather than the
|
||||||
|
// (older) max ts seen during drain — otherwise the bounded LRU could
|
||||||
|
// evict events from the original head and let the next normal run
|
||||||
|
// re-render them. Set once on entering drain mode, untouched while
|
||||||
|
// draining, cleared when drain completes.
|
||||||
|
static final String KEY_DRAIN_TARGET_TS = "drain_target_ts";
|
||||||
|
static final String KEY_NOTIFIED_IDS = "notified_ids";
|
||||||
|
static final String KEY_ROOM_NAMES = "room_names";
|
||||||
|
// user_id → MXC avatar URL, JSON-encoded, bridged from JS via
|
||||||
|
// PollingPlugin.saveUserAvatars. Consumed by
|
||||||
|
// VojoFirebaseMessagingService.lookupUserAvatarMxc for per-sender
|
||||||
|
// Person.setIcon in MessagingStyle conversations. Bounded at 500
|
||||||
|
// entries on the JS side; read tolerantly here.
|
||||||
|
static final String KEY_USER_AVATARS = "user_avatars";
|
||||||
|
|
||||||
|
private static final int HTTP_TIMEOUT_MS = 30_000;
|
||||||
|
// Cap pages-per-fire so an unexpectedly large backlog (server-side bug,
|
||||||
|
// first run after a long offline window) cannot loop until Android's
|
||||||
|
// 10-minute Worker kill timer fires. 5 pages × 50 events = up to 250
|
||||||
|
// events per cycle — well above realistic 15-minute backlog for a single
|
||||||
|
// user. We also break as soon as we hit ts ≤ watermark, so most polls
|
||||||
|
// touch only a single page.
|
||||||
|
private static final int MAX_PAGES_PER_RUN = 5;
|
||||||
|
private static final int PAGE_LIMIT = 50;
|
||||||
|
|
||||||
|
private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification";
|
||||||
|
private static final String RTC_NOTIFICATION_TYPE_STABLE = "m.rtc.notification";
|
||||||
|
|
||||||
|
public VojoPollWorker(@NonNull Context context, @NonNull WorkerParameters params) {
|
||||||
|
super(context, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public Result doWork() {
|
||||||
|
Context ctx = getApplicationContext();
|
||||||
|
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
|
||||||
|
String token = prefs.getString(KEY_ACCESS_TOKEN, null);
|
||||||
|
String homeserver = prefs.getString(KEY_HOMESERVER_URL, null);
|
||||||
|
if (token == null || homeserver == null) {
|
||||||
|
// Not logged in (or JS hasn't bridged credentials yet). Return
|
||||||
|
// success so WorkManager keeps the periodic schedule alive —
|
||||||
|
// we'll pick up the credentials on the next fire.
|
||||||
|
Log.i(TAG, "poll: no credentials, bail");
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If POST_NOTIFICATIONS was revoked we'd fetch + parse + try to
|
||||||
|
// render and then watch every nm.notify fail with SecurityException
|
||||||
|
// — which leaves the LRU/watermark unadvanced (correctly so for a
|
||||||
|
// transient failure) and re-runs the same loop every 15 minutes
|
||||||
|
// forever. Bail early to avoid burning battery on a permanent
|
||||||
|
// user choice. The next visibility re-bridge inside the JS app
|
||||||
|
// will pick up a re-granted permission.
|
||||||
|
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
|
||||||
|
Log.i(TAG, "poll: notifications disabled, bail");
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
long watermark = prefs.getLong(KEY_LAST_SEEN_TS, 0L);
|
||||||
|
String drainCursor = prefs.getString(KEY_DRAIN_CURSOR, null);
|
||||||
|
long drainTargetTs = prefs.getLong(KEY_DRAIN_TARGET_TS, 0L);
|
||||||
|
boolean wasDraining = drainCursor != null;
|
||||||
|
Map<String, String> roomNames = loadRoomNamesMap(prefs);
|
||||||
|
// Mirror the FCM service's foreground gate: if the user is actively in
|
||||||
|
// the app, the live timeline owns the UX and a system notification for
|
||||||
|
// a backlog event would be both stale and visually noisy. We still
|
||||||
|
// consume state (LRU, watermark) so the same event doesn't surface
|
||||||
|
// when the user later backgrounds the app.
|
||||||
|
boolean inForeground = MainActivity.isInForeground;
|
||||||
|
|
||||||
|
Log.i(TAG, "poll: start fg=" + inForeground
|
||||||
|
+ " watermark=" + watermark
|
||||||
|
+ " draining=" + wasDraining);
|
||||||
|
|
||||||
|
int pagesFetched = 0;
|
||||||
|
int renderedCount = 0;
|
||||||
|
int skippedDedupCount = 0;
|
||||||
|
long highestTsSeen = watermark;
|
||||||
|
boolean reachedWatermark = false;
|
||||||
|
// The continuation cursor we'd save if this run is capped. Starts as
|
||||||
|
// the resumed drain cursor; advances with each successful page fetch
|
||||||
|
// so a transient mid-pagination error still preserves drain progress.
|
||||||
|
String pendingCursor = drainCursor;
|
||||||
|
boolean paginationExhausted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cursor strategy: drain cursor resumes from where a previous capped
|
||||||
|
// run stopped; otherwise we start from the HEAD. next_token from
|
||||||
|
// /notifications paginates BACKWARDS into history, so a stored
|
||||||
|
// cursor must be used as a drain-only continuation, NOT as an
|
||||||
|
// ongoing "since" mark (the latter would silently drift off new
|
||||||
|
// events). Within a single fire we stop as soon as ts < watermark
|
||||||
|
// (newest-first ordering means everything past that is covered).
|
||||||
|
String nextFrom = drainCursor;
|
||||||
|
for (int page = 0; page < MAX_PAGES_PER_RUN && !reachedWatermark; page += 1) {
|
||||||
|
// Cooperative cancellation. WorkManager.cancelUniqueWork (called
|
||||||
|
// from PollingPlugin.cancel during logout / push disable) only
|
||||||
|
// marks future scheduling — it does NOT interrupt this thread.
|
||||||
|
// Without these checks the Worker keeps fetching pages, posting
|
||||||
|
// notifications, and (worst of all) running the final
|
||||||
|
// editor.apply() with stale state written AFTER clearSession
|
||||||
|
// wiped prefs — leaking watermark / drain cursor from the
|
||||||
|
// logged-out account into the next login.
|
||||||
|
if (isStopped()) return Result.success();
|
||||||
|
|
||||||
|
JSONObject body = fetchNotifications(homeserver, token, nextFrom);
|
||||||
|
// fetchNotifications throws on every failure path; a null
|
||||||
|
// return is unreachable in current code. The early-break here
|
||||||
|
// is a defensive belt-and-suspenders — keep paginationExhausted
|
||||||
|
// consistent so the drain-bookkeeping below clears the cursor
|
||||||
|
// instead of replaying the same empty page forever.
|
||||||
|
if (body == null) {
|
||||||
|
paginationExhausted = true;
|
||||||
|
pendingCursor = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONArray notifications = body.optJSONArray("notifications");
|
||||||
|
if (notifications == null || notifications.length() == 0) {
|
||||||
|
// Server returned no entries for this page. Treat as
|
||||||
|
// end-of-pagination so a drain in progress can complete
|
||||||
|
// (otherwise pendingCursor would keep its old value and
|
||||||
|
// we'd re-fetch the same empty page next cycle forever).
|
||||||
|
paginationExhausted = true;
|
||||||
|
pendingCursor = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < notifications.length(); i += 1) {
|
||||||
|
if (isStopped()) return Result.success();
|
||||||
|
JSONObject entry = notifications.optJSONObject(i);
|
||||||
|
if (entry == null) continue;
|
||||||
|
String eventId = extractEventId(entry);
|
||||||
|
if (eventId == null) continue;
|
||||||
|
|
||||||
|
// ts gate: server returns newest-first, so once we hit
|
||||||
|
// ts STRICTLY less than the watermark we know the rest of
|
||||||
|
// the page (and every subsequent page) is already covered.
|
||||||
|
// Same-ts events fall through to the LRU/read filters
|
||||||
|
// below — multiple events can share a millisecond, and
|
||||||
|
// collapsing them at the ts boundary would silently drop
|
||||||
|
// a fresh sibling of a previously-rendered one.
|
||||||
|
long ts = entry.optLong("ts", 0L);
|
||||||
|
if (ts > 0 && ts < watermark) {
|
||||||
|
reachedWatermark = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip notifications the user already read on another
|
||||||
|
// client (web tab, Element, second device). Spec marks
|
||||||
|
// `read` as a required boolean on each entry.
|
||||||
|
if (entry.optBoolean("read", false)) {
|
||||||
|
if (ts > highestTsSeen) highestTsSeen = ts;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip events the push rules said don't notify (muted
|
||||||
|
// rooms, dont_notify overrides). Without this gate
|
||||||
|
// polling would re-surface events Sygnal already
|
||||||
|
// suppressed for the FCM path — the mute toggle
|
||||||
|
// wouldn't actually mute on whitelist networks.
|
||||||
|
if (!notifyAllowed(entry)) {
|
||||||
|
if (ts > highestTsSeen) highestTsSeen = ts;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-source dedup via NotificationDedup: FCM writes
|
||||||
|
// into this set after every successful render, so the
|
||||||
|
// Worker correctly skips events the FCM service already
|
||||||
|
// delivered — even if the user dismissed the FCM
|
||||||
|
// notification before this cycle fired.
|
||||||
|
if (NotificationDedup.wasNotified(ctx, eventId)) {
|
||||||
|
skippedDedupCount += 1;
|
||||||
|
if (ts > highestTsSeen) highestTsSeen = ts;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three outcomes for marking + watermark advance:
|
||||||
|
// foreground → mark + advance (skip render
|
||||||
|
// but consume state, otherwise
|
||||||
|
// next bg poll would replay)
|
||||||
|
// background + posted → mark + advance
|
||||||
|
// background + !posted → DON'T mark, DON'T advance
|
||||||
|
// (transient render failure
|
||||||
|
// should be retried next poll)
|
||||||
|
boolean posted = false;
|
||||||
|
boolean treatAsNotRenderable = false;
|
||||||
|
if (!inForeground) {
|
||||||
|
Map<String, String> flattened = flattenNotification(entry, roomNames);
|
||||||
|
String type = flattened.get("type");
|
||||||
|
boolean isRtcType = RTC_NOTIFICATION_TYPE.equals(type)
|
||||||
|
|| RTC_NOTIFICATION_TYPE_STABLE.equals(type);
|
||||||
|
boolean isRing = "ring".equals(flattened.get("content_notification_type"));
|
||||||
|
|
||||||
|
if (isRtcType && isRing) {
|
||||||
|
// Composite session dedup: if FCM already alerted
|
||||||
|
// for this call session (different ring event,
|
||||||
|
// same parent), skip posting a duplicate
|
||||||
|
// missed-call. Without this, a session with one
|
||||||
|
// FCM live-alert ring + one re-ring through
|
||||||
|
// polling would surface as both a CallStyle and
|
||||||
|
// a missed-call card. Helpers live in
|
||||||
|
// VojoFirebaseMessagingService so the key shape
|
||||||
|
// stays in lock-step across FCM and polling.
|
||||||
|
String roomIdField = flattened.get("room_id");
|
||||||
|
String sessionId = VojoFirebaseMessagingService
|
||||||
|
.extractCallSessionId(flattened);
|
||||||
|
String composite = null;
|
||||||
|
if (roomIdField != null && sessionId != null) {
|
||||||
|
composite = VojoFirebaseMessagingService
|
||||||
|
.compositeCallDedupKey(roomIdField, sessionId);
|
||||||
|
if (NotificationDedup.wasNotified(ctx, composite)) {
|
||||||
|
if (ts > highestTsSeen) highestTsSeen = ts;
|
||||||
|
treatAsNotRenderable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!treatAsNotRenderable) {
|
||||||
|
// Stale ring (call lifetime is 30 seconds; we
|
||||||
|
// poll every 15 minutes). Show "Missed call"
|
||||||
|
// so the user knows somebody tried, without
|
||||||
|
// phantom-ringing a long-dead call via
|
||||||
|
// CallStyle.
|
||||||
|
posted = VojoFirebaseMessagingService
|
||||||
|
.renderMissedCallNotification(ctx, flattened);
|
||||||
|
if (posted && composite != null) {
|
||||||
|
// Mark the composite so the next polling
|
||||||
|
// cycle observing a re-ring for the same
|
||||||
|
// session doesn't double-post.
|
||||||
|
NotificationDedup.markNotified(ctx, composite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isRtcType) {
|
||||||
|
// Non-ring RTC sub-type. MSC4075 defines at least
|
||||||
|
// "ring" and "notification" — the latter is the
|
||||||
|
// chat-style alert variant which doesn't make
|
||||||
|
// sense to surface as a stale "missed" entry from
|
||||||
|
// a 15-minute poll. Falling through to
|
||||||
|
// renderMessageNotification would post a generic
|
||||||
|
// "New message" with no body (no content.body on
|
||||||
|
// RTC events). Skip rendering but still mark seen
|
||||||
|
// so we don't re-walk it next poll.
|
||||||
|
treatAsNotRenderable = true;
|
||||||
|
} else {
|
||||||
|
posted = VojoFirebaseMessagingService
|
||||||
|
.renderMessageNotification(ctx, flattened, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mark + advance ts whenever we've consumed the event
|
||||||
|
// (foreground-skipped, non-ring-RTC skipped, or
|
||||||
|
// successfully rendered). Render-failure (bg branch where
|
||||||
|
// posted==false) is intentionally excluded so the next
|
||||||
|
// poll retries it.
|
||||||
|
if (inForeground || posted || treatAsNotRenderable) {
|
||||||
|
NotificationDedup.markNotified(ctx, eventId);
|
||||||
|
if (ts > highestTsSeen) highestTsSeen = ts;
|
||||||
|
if (posted) renderedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesFetched += 1;
|
||||||
|
// optString returns the fallback only when the key is absent;
|
||||||
|
// a literal JSON `null` becomes the string "null" — guard
|
||||||
|
// against the rare server quirk so we don't loop on it.
|
||||||
|
String rawNext = body.optString("next_token", null);
|
||||||
|
if (rawNext == null || rawNext.isEmpty() || "null".equals(rawNext)) {
|
||||||
|
nextFrom = null;
|
||||||
|
} else {
|
||||||
|
nextFrom = rawNext;
|
||||||
|
}
|
||||||
|
pendingCursor = nextFrom;
|
||||||
|
if (nextFrom == null) {
|
||||||
|
paginationExhausted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (UnauthorizedException e) {
|
||||||
|
Log.w(TAG, "poll: 401 — clearing credentials, awaiting next foreground re-bridge");
|
||||||
|
prefs.edit()
|
||||||
|
.remove(KEY_ACCESS_TOKEN)
|
||||||
|
.apply();
|
||||||
|
return Result.success();
|
||||||
|
} catch (ForbiddenException e) {
|
||||||
|
// 403 from Synapse is usually rate-limit or a transient server
|
||||||
|
// policy reject, not a dead token. Don't clear credentials —
|
||||||
|
// just let the next periodic fire retry. Avoid Result.retry()
|
||||||
|
// because we don't want an immediate accelerated retry that
|
||||||
|
// amplifies the rate-limit cause.
|
||||||
|
Log.w(TAG, "poll: 403/429 — skipping this cycle, will retry on next scheduled fire");
|
||||||
|
return Result.success();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "poll: failed at page " + pagesFetched, t);
|
||||||
|
return Result.retry();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final stopped-check before persisting state. If cancellation landed
|
||||||
|
// between the last in-loop check and here, do NOT apply: the
|
||||||
|
// accumulated editor writes would otherwise overwrite KEY_LAST_SEEN_TS
|
||||||
|
// and KEY_DRAIN_CURSOR AFTER JS clearSession wiped them, leaking
|
||||||
|
// stale state from the just-logged-out account into the next login.
|
||||||
|
if (isStopped()) return Result.success();
|
||||||
|
|
||||||
|
SharedPreferences.Editor editor = prefs.edit();
|
||||||
|
// Drain-mode bookkeeping. Three transitions:
|
||||||
|
// - normal → normal (cap not hit): advance watermark to highestTsSeen.
|
||||||
|
// - normal → drain (cap hit, no prior drain): save continuation
|
||||||
|
// cursor AND snapshot drainTargetTs = highestTsSeen. The current
|
||||||
|
// run's highest ts becomes the "fast-forward" target for when
|
||||||
|
// drain eventually completes — without this, the bounded LRU
|
||||||
|
// could evict the original head events and let the post-drain
|
||||||
|
// normal run re-render them.
|
||||||
|
// - drain → drain (still capped): keep cursor + target unchanged.
|
||||||
|
// Don't overwrite drainTargetTs with this run's highestTsSeen,
|
||||||
|
// because drain pages are always OLDER than the original head.
|
||||||
|
// - drain → normal (drain complete): clear cursor + target. Advance
|
||||||
|
// watermark to drainTargetTs — drain pages always walk backwards
|
||||||
|
// (older than the snapshotted head), so highestTsSeen accumulated
|
||||||
|
// during drain is by construction ≤ drainTargetTs.
|
||||||
|
boolean cappedWithMore = !reachedWatermark && !paginationExhausted && pendingCursor != null;
|
||||||
|
long newWatermark = watermark;
|
||||||
|
String drainState;
|
||||||
|
if (cappedWithMore) {
|
||||||
|
editor.putString(KEY_DRAIN_CURSOR, pendingCursor);
|
||||||
|
if (!wasDraining) {
|
||||||
|
// First run entering drain mode — snapshot the head ts.
|
||||||
|
editor.putLong(KEY_DRAIN_TARGET_TS, highestTsSeen);
|
||||||
|
drainState = "drain-entered";
|
||||||
|
} else {
|
||||||
|
drainState = "drain-continued";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editor.remove(KEY_DRAIN_CURSOR);
|
||||||
|
editor.remove(KEY_DRAIN_TARGET_TS);
|
||||||
|
long advanceTo = wasDraining ? drainTargetTs : highestTsSeen;
|
||||||
|
if (advanceTo > watermark) {
|
||||||
|
editor.putLong(KEY_LAST_SEEN_TS, advanceTo);
|
||||||
|
newWatermark = advanceTo;
|
||||||
|
}
|
||||||
|
drainState = wasDraining ? "drain-exited" : "normal";
|
||||||
|
}
|
||||||
|
editor.apply();
|
||||||
|
|
||||||
|
Log.i(TAG, "poll: done pages=" + pagesFetched
|
||||||
|
+ " rendered=" + renderedCount
|
||||||
|
+ " dedupSkipped=" + skippedDedupCount
|
||||||
|
+ " watermark=" + newWatermark
|
||||||
|
+ " state=" + drainState);
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true iff at least one element of entry.actions is the literal
|
||||||
|
// string "notify". Per Matrix spec §13.13.1, tweak objects
|
||||||
|
// (`{set_tweak: ...}`) only MODIFY a notification produced by a separate
|
||||||
|
// `"notify"` action — they do not by themselves imply notify. "dont_notify"
|
||||||
|
// or an empty actions array means the push rule explicitly suppressed
|
||||||
|
// this event (most commonly: a muted room).
|
||||||
|
private static boolean notifyAllowed(JSONObject entry) {
|
||||||
|
JSONArray actions = entry.optJSONArray("actions");
|
||||||
|
if (actions == null || actions.length() == 0) return false;
|
||||||
|
for (int i = 0; i < actions.length(); i += 1) {
|
||||||
|
Object a = actions.opt(i);
|
||||||
|
if ((a instanceof String) && "notify".equals(a)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// HTTP
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static final class UnauthorizedException extends IOException {
|
||||||
|
UnauthorizedException() {
|
||||||
|
super("401 Unauthorized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 from Synapse is most commonly a rate-limit or a transient policy
|
||||||
|
// reject (M_LIMIT_EXCEEDED, M_FORBIDDEN). It is NOT "token died" — we
|
||||||
|
// surface it as a distinct exception so doWork can skip this cycle
|
||||||
|
// without clearing credentials and without an accelerated Result.retry()
|
||||||
|
// that would amplify the rate-limit cause.
|
||||||
|
private static final class ForbiddenException extends IOException {
|
||||||
|
ForbiddenException() {
|
||||||
|
super("403 Forbidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject fetchNotifications(String homeserverUrl, String token, String fromCursor)
|
||||||
|
throws IOException {
|
||||||
|
StringBuilder url = new StringBuilder(homeserverUrl);
|
||||||
|
if (!homeserverUrl.endsWith("/")) url.append('/');
|
||||||
|
url.append("_matrix/client/v3/notifications?limit=").append(PAGE_LIMIT);
|
||||||
|
if (fromCursor != null && !fromCursor.isEmpty()) {
|
||||||
|
url.append("&from=").append(java.net.URLEncoder.encode(fromCursor, "UTF-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection();
|
||||||
|
try {
|
||||||
|
conn.setRequestMethod("GET");
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
|
conn.setRequestProperty("Accept", "application/json");
|
||||||
|
// Identifiable UA so server logs can attribute polling traffic
|
||||||
|
// (some WAFs also flag bare "Java/<version>" as suspicious).
|
||||||
|
conn.setRequestProperty("User-Agent", "Vojo-Android-Poll/" + BuildConfig.VERSION_NAME);
|
||||||
|
conn.setConnectTimeout(HTTP_TIMEOUT_MS);
|
||||||
|
conn.setReadTimeout(HTTP_TIMEOUT_MS);
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
if (code == 401) throw new UnauthorizedException();
|
||||||
|
// Treat 429 (rate limited) and 403 (Synapse policy reject) the
|
||||||
|
// same: skip this cycle, don't retry-storm. Result.retry()'s 30s
|
||||||
|
// backoff would amplify the rate-limit cause; the next periodic
|
||||||
|
// fire in 15 minutes is well past any realistic Retry-After
|
||||||
|
// window from a Matrix homeserver.
|
||||||
|
if (code == 403 || code == 429) throw new ForbiddenException();
|
||||||
|
if (code < 200 || code >= 300) {
|
||||||
|
throw new IOException("HTTP " + code);
|
||||||
|
}
|
||||||
|
try (InputStream in = conn.getInputStream()) {
|
||||||
|
return new JSONObject(readAll(in));
|
||||||
|
} catch (org.json.JSONException je) {
|
||||||
|
throw new IOException("malformed JSON", je);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readAll(InputStream in) throws IOException {
|
||||||
|
// Accumulate raw bytes, then decode the whole buffer as a single UTF-8
|
||||||
|
// string. Decoding each 8 KB chunk separately would corrupt multi-byte
|
||||||
|
// sequences that straddle a chunk boundary — for a Russian-content
|
||||||
|
// notification body that crosses ~8 KB, the result is U+FFFD in place
|
||||||
|
// of a Cyrillic character. Also use != -1 rather than > 0 for the
|
||||||
|
// read loop: InputStream.read(byte[]) is contractually allowed to
|
||||||
|
// return 0 without indicating EOF.
|
||||||
|
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||||
|
byte[] buf = new byte[8 * 1024];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) != -1) {
|
||||||
|
if (n > 0) out.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
return out.toString("UTF-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Payload shaping
|
||||||
|
//
|
||||||
|
// The /notifications response shape is structured (event{type,sender,
|
||||||
|
// content{}}, room_id, ts, read, actions) — different from Sygnal's
|
||||||
|
// flattened FCM payload. We flatten into the Sygnal-shape Map<String,
|
||||||
|
// String> so the shared renderer in VojoFirebaseMessagingService can
|
||||||
|
// stay source-agnostic. Keys we set: event_id, room_id, sender, type,
|
||||||
|
// content_membership, content_body, content_notification_type,
|
||||||
|
// content_sender_ts, content_lifetime, room_name (from local cache).
|
||||||
|
//
|
||||||
|
// NOTE: sender_display_name is NOT set here — /notifications returns the
|
||||||
|
// raw event without the Sygnal-side profile resolution that gives FCM
|
||||||
|
// its `sender_display_name`. The renderer's title-fallback chain
|
||||||
|
// (room_name → sender_display_name → sender → "Vojo") therefore lands
|
||||||
|
// on `sender` (a raw MXID) when the room name isn't cached. The renderer
|
||||||
|
// strips the MXID to its local-part as a final cosmetic guard so users
|
||||||
|
// see "alice" instead of "@alice:hs.tld".
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Map<String, String> flattenNotification(
|
||||||
|
JSONObject entry, Map<String, String> roomNames
|
||||||
|
) {
|
||||||
|
Map<String, String> out = new HashMap<>();
|
||||||
|
String roomId = entry.optString("room_id", null);
|
||||||
|
if (roomId != null) out.put("room_id", roomId);
|
||||||
|
|
||||||
|
JSONObject event = entry.optJSONObject("event");
|
||||||
|
if (event != null) {
|
||||||
|
putIfPresent(out, event, "event_id", "event_id");
|
||||||
|
putIfPresent(out, event, "sender", "sender");
|
||||||
|
putIfPresent(out, event, "type", "type");
|
||||||
|
JSONObject content = event.optJSONObject("content");
|
||||||
|
if (content != null) {
|
||||||
|
putIfPresent(out, content, "membership", "content_membership");
|
||||||
|
putIfPresent(out, content, "body", "content_body");
|
||||||
|
putIfPresent(out, content, "notification_type", "content_notification_type");
|
||||||
|
if (content.has("sender_ts")) {
|
||||||
|
out.put("content_sender_ts", String.valueOf(content.optLong("sender_ts")));
|
||||||
|
}
|
||||||
|
if (content.has("lifetime")) {
|
||||||
|
out.put("content_lifetime", String.valueOf(content.optLong("lifetime")));
|
||||||
|
}
|
||||||
|
// Parent call event_id for session-level dedup. The shared
|
||||||
|
// FCM renderer reads this from the flattened key
|
||||||
|
// `content_m.relates_to_event_id` (mirroring one of Sygnal's
|
||||||
|
// flatten shapes); writing the literal-dot variant here keeps
|
||||||
|
// FCM and polling on the same key.
|
||||||
|
JSONObject relates = content.optJSONObject("m.relates_to");
|
||||||
|
if (relates != null) {
|
||||||
|
String parentEventId = relates.optString("event_id", null);
|
||||||
|
if (parentEventId != null && !parentEventId.isEmpty()) {
|
||||||
|
out.put("content_m.relates_to_event_id", parentEventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy MSC2746 call_id fallback. Modern MSC4075 sessions
|
||||||
|
// surface via m.relates_to above; this branch is a no-op for
|
||||||
|
// them but keeps the shape symmetric for older deployments.
|
||||||
|
if (content.has("call_id")) {
|
||||||
|
String callId = content.optString("call_id", null);
|
||||||
|
if (callId != null && !callId.isEmpty()) {
|
||||||
|
out.put("content_call_id", callId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room name from the snapshot the JS side pushes through
|
||||||
|
// PollingPlugin.saveRoomNames, parsed once at the start of doWork().
|
||||||
|
// Brand-new rooms (not yet observed by JS at last bridge time) miss
|
||||||
|
// the cache — the renderer falls back to sender / "Vojo".
|
||||||
|
if (roomId != null) {
|
||||||
|
String roomName = roomNames.get(roomId);
|
||||||
|
if (roomName != null && !roomName.isEmpty()) out.put("room_name", roomName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the SharedPreferences-stored room-name JSON snapshot once per
|
||||||
|
// doWork() so we don't redo the parse for every event in the page (up to
|
||||||
|
// PAGE_LIMIT × MAX_PAGES_PER_RUN = 250 events).
|
||||||
|
//
|
||||||
|
// The snapshot shape evolved: legacy was {roomId: "Display name"}, current
|
||||||
|
// is {roomId: {name, isDirect, isEncrypted, avatarMxc?}}. We parse both
|
||||||
|
// tolerantly — for the structured shape we extract `name`, for the legacy
|
||||||
|
// shape we use the string verbatim. A naive optString on the structured
|
||||||
|
// entry serialises the whole object as JSON ("{name:Alice,...}") and that
|
||||||
|
// string leaked into the missed-call / message title on the polling
|
||||||
|
// path — visible bug.
|
||||||
|
private static Map<String, String> loadRoomNamesMap(SharedPreferences prefs) {
|
||||||
|
Map<String, String> out = new HashMap<>();
|
||||||
|
String raw = prefs.getString(KEY_ROOM_NAMES, null);
|
||||||
|
if (raw == null || raw.isEmpty()) return out;
|
||||||
|
try {
|
||||||
|
JSONObject map = new JSONObject(raw);
|
||||||
|
for (Iterator<String> it = map.keys(); it.hasNext(); ) {
|
||||||
|
String roomId = it.next();
|
||||||
|
if (map.isNull(roomId)) continue;
|
||||||
|
JSONObject obj = map.optJSONObject(roomId);
|
||||||
|
String name = obj != null
|
||||||
|
? obj.optString("name", null)
|
||||||
|
: map.optString(roomId, null);
|
||||||
|
if (name != null && !name.isEmpty()) out.put(roomId, name);
|
||||||
|
}
|
||||||
|
} catch (org.json.JSONException je) {
|
||||||
|
// Corrupt blob — return empty map. Renderer falls back to sender.
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void putIfPresent(
|
||||||
|
Map<String, String> out, JSONObject src, String srcKey, String dstKey
|
||||||
|
) {
|
||||||
|
// Guard against a literal JSON null at the key: JSONObject.optString
|
||||||
|
// returns the *fallback* only when the key is absent, but on a
|
||||||
|
// present-but-null key it coerces JSONObject.NULL to the four-char
|
||||||
|
// string "null", which would leak as "null" into a notification body.
|
||||||
|
if (!src.has(srcKey) || src.isNull(srcKey)) return;
|
||||||
|
String v = src.optString(srcKey, null);
|
||||||
|
if (v != null && !v.isEmpty()) out.put(dstKey, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractEventId(JSONObject entry) {
|
||||||
|
JSONObject event = entry.optJSONObject("event");
|
||||||
|
if (event == null) return null;
|
||||||
|
if (!event.has("event_id") || event.isNull("event_id")) return null;
|
||||||
|
String eventId = event.optString("event_id", null);
|
||||||
|
if (eventId == null || eventId.isEmpty()) return null;
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/drawable/vojo_mascot_splash.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
7
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Matches web safe-area / DM 1:1 chat background (DAWN.bg2) so the
|
||||||
|
native splash, the WebView body, and the in-app AuthSplashScreen all
|
||||||
|
share a single backdrop and read as one continuous splash. -->
|
||||||
|
<color name="splash_bg">#0d0e11</color>
|
||||||
|
</resources>
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
|
@ -13,12 +15,39 @@
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
<!-- Bridges the gap between native splash exit and the WebView's first
|
||||||
|
body paint: without this the window paints transparent/black for
|
||||||
|
~200ms while the bundle hydrates, producing a visible black flash
|
||||||
|
between the native and the in-app splash. Matches splash_bg so
|
||||||
|
cold start reads as one continuous backdrop. -->
|
||||||
|
<item name="android:windowBackground">@color/splash_bg</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<!-- Launch theme: Android 12+ system splash (Theme.SplashScreen via
|
||||||
|
androidx.core.splashscreen). Renders the mascot centered on the same
|
||||||
|
#0d0e11 backdrop the web AuthSplashScreen uses, so cold start reads
|
||||||
|
as one continuous splash (native → WebView mount → web splash) instead
|
||||||
|
of three visual jumps. MainActivity installs AndroidX SplashScreen
|
||||||
|
before super.onCreate() and keeps it visible until Capacitor's local
|
||||||
|
WebView has loaded the app shell. -->
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<!-- Theme.SplashScreen only sets the native android:windowActionBar /
|
||||||
|
android:windowNoTitle attrs. Capacitor's BridgeActivity extends
|
||||||
|
AppCompatActivity, whose ActionBar delegate reads the un-prefixed
|
||||||
|
AppCompat attrs — without these two overrides, AppCompat keeps
|
||||||
|
its ActionBar enabled, paints the activity label ("Vojo" from
|
||||||
|
strings.xml/title_activity_main) at the top of the WebView, and
|
||||||
|
persists past the splash exit. -->
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:windowBackground">@android:color/black</item>
|
<item name="windowSplashScreenBackground">@color/splash_bg</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@drawable/vojo_mascot_splash</item>
|
||||||
|
<!-- Intentionally NO windowSplashScreenIconBackgroundColor: setting it
|
||||||
|
switches the system to the "with-background" canvas, which is
|
||||||
|
actually 240dp (vs 288dp without) — the colored ring would just
|
||||||
|
shrink the visible icon zone. Background already matches via
|
||||||
|
windowSplashScreenBackground above. -->
|
||||||
|
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
60
apps/.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// Per-package ESLint config for the Preact widget apps under `apps/`.
|
||||||
|
//
|
||||||
|
// `root: true` stops ESLint from walking up to the host's
|
||||||
|
// `cinny/.eslintrc.cjs`, which extends airbnb + the React plugin. Those
|
||||||
|
// rule sets are tuned for the React host and flag legitimate Preact /
|
||||||
|
// small-widget patterns as errors (`class=` attributes, arrow-fn
|
||||||
|
// components, inline icon sub-components, for-of loops, etc.). Keeping
|
||||||
|
// the hierarchy open would force every widget file to fight host style
|
||||||
|
// for no real win.
|
||||||
|
//
|
||||||
|
// Widgets keep a minimal but real lint pass via the rule sets below:
|
||||||
|
//
|
||||||
|
// * `eslint:recommended` — catches genuine bugs (no-undef, no-dupe-*,
|
||||||
|
// no-redeclare, no-unused-vars, …) without enforcing style.
|
||||||
|
// * `@typescript-eslint/recommended` — TS-aware variants of the above
|
||||||
|
// plus type-level checks the recommended set ships.
|
||||||
|
//
|
||||||
|
// We deliberately DON'T extend `plugin:react/recommended` —
|
||||||
|
// `react/react-in-jsx-scope` and `react/no-unknown-property` both flag
|
||||||
|
// Preact-correct code as errors, and disabling them one by one creates
|
||||||
|
// a long suppression list. Widget JSX is type-checked by each app's
|
||||||
|
// `tsc --noEmit` (run by `vite build`), which is the better signal for
|
||||||
|
// JSX correctness anyway.
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
// `node` covers `module.exports` in this very file (CommonJS config);
|
||||||
|
// `browser` is the runtime widget code itself sees.
|
||||||
|
env: { browser: true, es2021: true, node: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
// preact/hooks has the same dep-array semantics as react/hooks, and
|
||||||
|
// the widget code already carries `// eslint-disable-next-line
|
||||||
|
// react-hooks/exhaustive-deps` directives at the relevant sites;
|
||||||
|
// loading the plugin (a) keeps those directives meaningful (without
|
||||||
|
// it ESLint errors on the «unknown rule» referenced by the comment)
|
||||||
|
// and (b) catches the real exhaustive-deps mistakes in widget hooks
|
||||||
|
// for free.
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint', 'react-hooks'],
|
||||||
|
rules: {
|
||||||
|
// Underscore-prefixed args are intentionally unused (Preact event
|
||||||
|
// handlers receive args the body doesn't need); match the host's
|
||||||
|
// convention so lint reads consistently across both trees.
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
// Widget bridge-protocol regexes occasionally escape `-` inside
|
||||||
|
// character classes for visual clarity (e.g. `[0-9\-]`). The escape
|
||||||
|
// is harmless and pre-existing across all three widgets — keeping
|
||||||
|
// the rule on would force a churn-y diff in code that's been stable
|
||||||
|
// since the v0.7.6 bridge dialect work.
|
||||||
|
'no-useless-escape': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
3
apps/widget-discord/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
193
apps/widget-discord/README.md
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
# @vojo/widget-discord
|
||||||
|
|
||||||
|
Vojo Discord bridge management widget — mounts inside `/bots/discord`
|
||||||
|
in the Vojo client. Mirrors the Telegram widget contract; protocol
|
||||||
|
specifics differ because mautrix-discord runs on the **legacy** mautrix
|
||||||
|
command framework, not bridgev2 (the Discord bridge had not yet been
|
||||||
|
ported to v2 as of January 2026 — see
|
||||||
|
https://mau.fi/blog/2026-01-mautrix-release/).
|
||||||
|
|
||||||
|
This is **not** a Discord client. It's a small panel that drives the
|
||||||
|
mautrix-discord bridge bot (`@discordbot:vojo.chat`) by sending text
|
||||||
|
commands in the control DM and rendering the bot's text replies. It
|
||||||
|
ships QR-only login (the Discord token-login flow stays accessible via
|
||||||
|
chat-fallback for power users).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── bootstrap.ts Parse URL params (matches BotWidgetEmbed.ts)
|
||||||
|
├── widget-api.ts Inline matrix-widget-api postMessage transport
|
||||||
|
├── App.tsx UI: status pill, QR panel, logout / reconnect cards, transcript
|
||||||
|
├── main.tsx Entry: bootstrap + render
|
||||||
|
├── state.ts LoginState reducer + hydrate-from-timeline
|
||||||
|
├── styles.css Theme-aware CSS variables (Dawn palette)
|
||||||
|
├── i18n/ Tiny RU/EN dictionary harness
|
||||||
|
└── bridge-protocol/
|
||||||
|
├── types.ts LoginEvent + ParsableEvent types
|
||||||
|
├── parser.ts Dialect dispatch shim
|
||||||
|
└── dialects/
|
||||||
|
└── legacy_v076.ts mautrix-discord v0.7.6 wording
|
||||||
|
```
|
||||||
|
|
||||||
|
## Login flow (QR only)
|
||||||
|
|
||||||
|
1. Widget sends `!discord login-qr`.
|
||||||
|
2. Bridge replies with an `m.image` event whose `body` is a Discord
|
||||||
|
remoteauth URL (`https://discord.com/ra/<token>`). The host driver
|
||||||
|
strips `url`/`file`/`info` so the widget never touches the uploaded
|
||||||
|
PNG bytes — it re-encodes the URL into an SVG QR matrix client-side
|
||||||
|
via `qrcode-generator`.
|
||||||
|
3. The user scans the QR with the **Discord mobile app** (Settings →
|
||||||
|
Devices → Scan QR Code). Discord's remoteauth gateway requires the
|
||||||
|
mobile app — desktop Discord and the browser cannot scan.
|
||||||
|
4. Bridge redacts the `m.image` event after a successful scan and sends
|
||||||
|
`Successfully logged in as @<username>`.
|
||||||
|
5. Widget fires `!discord ping` to pick up the discord snowflake for
|
||||||
|
the connected pill.
|
||||||
|
|
||||||
|
If Discord asks for a CAPTCHA, the bridge replies with the standard
|
||||||
|
error line plus a hint about token-login. The widget surfaces an amber
|
||||||
|
warning suggesting the user retry later or use chat-fallback.
|
||||||
|
|
||||||
|
## Status probe
|
||||||
|
|
||||||
|
Discord's legacy command system has no `list-logins` API; status is
|
||||||
|
queried via `!discord ping`. The four reply variants map to four UI
|
||||||
|
states:
|
||||||
|
|
||||||
|
- `You're not logged in` → disconnected
|
||||||
|
- `You're logged in as @x (\`<id>\`)` → connected
|
||||||
|
- `You have a Discord token stored, but are not connected for some reason 🤔` → connected_dead (token_stored)
|
||||||
|
- `You're logged in, but the Discord connection seems to be dead 💥` → connected_dead (connection_dead)
|
||||||
|
|
||||||
|
`connected_dead` exposes a «Переподключиться» card that sends
|
||||||
|
`!discord reconnect`. `disconnect` is recognised for chat-fallback
|
||||||
|
typists but never sent by the widget.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
Same overlay mechanism as the Telegram widget — create
|
||||||
|
`config.local.json` at the project root (gitignored) with a `bots[]`
|
||||||
|
entry overriding the discord widget's `experience.url` to your local
|
||||||
|
dev server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# one-time: install widget deps
|
||||||
|
cd apps/widget-discord && npm install
|
||||||
|
|
||||||
|
# config.local.json (gitignored) at the project root
|
||||||
|
cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON'
|
||||||
|
{
|
||||||
|
"bots": [
|
||||||
|
{
|
||||||
|
"id": "discord",
|
||||||
|
"experience": {
|
||||||
|
"type": "matrix-widget",
|
||||||
|
"url": "http://localhost:8082/",
|
||||||
|
"commandPrefix": "!discord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
`http://localhost:*` URLs pass the host's URL validator only in dev
|
||||||
|
builds — see `src/app/features/bots/catalog.ts` `import.meta.env.DEV`
|
||||||
|
branch. Production builds drop the branch via Vite's dead-code
|
||||||
|
elimination AND enforce an origin allowlist (`PROD_WIDGET_ORIGINS`).
|
||||||
|
|
||||||
|
Run both servers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal 1 — widget on :8082 with HMR
|
||||||
|
cd apps/widget-discord && npm run dev
|
||||||
|
|
||||||
|
# terminal 2 — host SPA on :8080
|
||||||
|
cd /home/ubuntu/projects/vojo/cinny && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8080/bots/discord`. The Telegram widget on :8081
|
||||||
|
can run in parallel with no port conflict.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs to `apps/widget-discord/dist/`. Deploy by rsyncing `dist/*` into
|
||||||
|
`~/vojo/widgets/discord/` on the production host (Caddy serves this via
|
||||||
|
the `widgets.vojo.chat` block).
|
||||||
|
|
||||||
|
## Hosting (server-side, runbook)
|
||||||
|
|
||||||
|
Pre-requisite: `widgets.vojo.chat` already exists for the Telegram
|
||||||
|
widget — only the Caddy `widgets.vojo.chat` block needs a new
|
||||||
|
`handle_path` and the docker host needs a new directory.
|
||||||
|
|
||||||
|
1. `~/vojo/caddy/Caddyfile` — append to the existing
|
||||||
|
`widgets.vojo.chat { … }` block, beside the Telegram `handle_path`:
|
||||||
|
```
|
||||||
|
handle_path /discord/* {
|
||||||
|
root * /var/www/widgets/discord
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2. `mkdir -p ~/vojo/widgets/discord` (placeholder so the bind-mount has
|
||||||
|
something to serve), then `docker compose up -d caddy` (or `reload`).
|
||||||
|
3. Verify directly:
|
||||||
|
`curl -I https://widgets.vojo.chat/discord/index.html` should
|
||||||
|
return 200 and the `Content-Security-Policy` header.
|
||||||
|
|
||||||
|
## Adding the discord bridge to docker-compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
discord-bridge:
|
||||||
|
image: dock.mau.dev/mautrix/discord:v0.7.6
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./mautrix-discord:/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `~/vojo/synapse/homeserver.yaml` needs the discord registration
|
||||||
|
file added to `app_service_config_files`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
app_service_config_files:
|
||||||
|
- /data/telegram-registration.yaml
|
||||||
|
- /data/discord-registration.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The bridge's `command_prefix` defaults to `!discord` — keep it that
|
||||||
|
way so it matches the widget's `experience.commandPrefix`. If you
|
||||||
|
override it in `mautrix-discord/config.yaml`, mirror the override in
|
||||||
|
`/config.json`.
|
||||||
|
|
||||||
|
## Capacitor (Android)
|
||||||
|
|
||||||
|
`capacitor.config.ts` already allow-navigates `widgets.vojo.chat` for
|
||||||
|
the Telegram widget; no further change needed.
|
||||||
|
|
||||||
|
## Capability contract
|
||||||
|
|
||||||
|
The widget requests EXACTLY this set (matches the host's
|
||||||
|
`BotWidgetDriver.getBotWidgetCapabilities`):
|
||||||
|
|
||||||
|
```
|
||||||
|
org.matrix.msc2762.timeline:<roomId>
|
||||||
|
org.matrix.msc2762.send.event:m.room.message#m.text
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.text
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.notice
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.image
|
||||||
|
org.matrix.msc2762.receive.event:m.room.redaction
|
||||||
|
org.matrix.msc2762.receive.state_event:m.room.member
|
||||||
|
```
|
||||||
|
|
||||||
|
`m.image` is the QR carrier; `m.room.redaction` signals the bridge
|
||||||
|
consumed the QR after a successful scan. The host sanitizer strips
|
||||||
|
`url`/`file`/`info` from `m.image` content, so only the QR URL string
|
||||||
|
inside `body` survives the boundary.
|
||||||
12
apps/widget-discord/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>Discord bridge — Vojo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1999
apps/widget-discord/package-lock.json
generated
Normal file
21
apps/widget-discord/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "@vojo/widget-discord",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Vojo Discord bridge management widget — mounts inside /bots/discord",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "10.22.1",
|
||||||
|
"qrcode-generator": "1.4.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "2.9.0",
|
||||||
|
"typescript": "5.4.5",
|
||||||
|
"vite": "5.4.19"
|
||||||
|
}
|
||||||
|
}
|
||||||
1489
apps/widget-discord/src/App.tsx
Normal file
69
apps/widget-discord/src/bootstrap.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// Parse the URL params the Phase 2 bot widget host appends when loading
|
||||||
|
// experience.url. Source of truth on the host side:
|
||||||
|
// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl).
|
||||||
|
// Keep this in sync if the host adds params.
|
||||||
|
//
|
||||||
|
// Identical shape to apps/widget-telegram/src/bootstrap.ts on purpose —
|
||||||
|
// the host emits the same param set for every bot. Differences between
|
||||||
|
// telegram and discord live in the bridge protocol, not in bootstrap.
|
||||||
|
|
||||||
|
export type WidgetBootstrap = {
|
||||||
|
widgetId: string;
|
||||||
|
parentUrl: string;
|
||||||
|
parentOrigin: string;
|
||||||
|
roomId: string;
|
||||||
|
userId: string;
|
||||||
|
botId: string;
|
||||||
|
botMxid: string;
|
||||||
|
/** Bridge command prefix (e.g. `!discord`). Always non-empty — the host
|
||||||
|
* validator (catalog.ts) defaults missing values to `!tg` and rejects
|
||||||
|
* malformed overrides, so the discord bot's /config.json entry MUST set
|
||||||
|
* `experience.commandPrefix: "!discord"` to override the default. The
|
||||||
|
* widget prepends `<commandPrefix> ` to every outbound command. */
|
||||||
|
commandPrefix: string;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
clientLanguage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BootstrapResult =
|
||||||
|
| { ok: true; bootstrap: WidgetBootstrap }
|
||||||
|
| { ok: false; missing: string[] };
|
||||||
|
|
||||||
|
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const;
|
||||||
|
|
||||||
|
export const readBootstrap = (search: string): BootstrapResult => {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const get = (k: string) => params.get(k) ?? '';
|
||||||
|
|
||||||
|
const missing = REQUIRED.filter((k) => !params.get(k));
|
||||||
|
if (missing.length > 0) return { ok: false, missing: [...missing] };
|
||||||
|
|
||||||
|
// Origin is what the widget validates against on incoming postMessage —
|
||||||
|
// see widget-api.ts. Falling back to '*' would defeat the security
|
||||||
|
// boundary, so a malformed parentUrl bails out as a missing-param error.
|
||||||
|
let parentOrigin: string;
|
||||||
|
try {
|
||||||
|
parentOrigin = new URL(get('parentUrl')).origin;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, missing: ['parentUrl'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeRaw = get('theme');
|
||||||
|
const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
bootstrap: {
|
||||||
|
widgetId: get('widgetId'),
|
||||||
|
parentUrl: get('parentUrl'),
|
||||||
|
parentOrigin,
|
||||||
|
roomId: get('roomId'),
|
||||||
|
userId: get('userId'),
|
||||||
|
botId: get('botId'),
|
||||||
|
botMxid: get('botMxid'),
|
||||||
|
commandPrefix: get('commandPrefix'),
|
||||||
|
theme,
|
||||||
|
clientLanguage: get('clientLanguage'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
554
apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts
Normal file
|
|
@ -0,0 +1,554 @@
|
||||||
|
// Dialect: mautrix-discord v0.7.6 (16 Feb 2026). The bridge runs on the
|
||||||
|
// LEGACY mautrix command framework — `maunium.net/go/mautrix/bridge/commands`,
|
||||||
|
// NOT bridgev2. As of January 2026 the mautrix maintainers flagged Discord
|
||||||
|
// as «not yet migrated to bridgev2» (mau.fi/blog/2026-01-mautrix-release/),
|
||||||
|
// so this dialect is the canonical one until the v2 migration lands.
|
||||||
|
//
|
||||||
|
// Each regex below is paired with its upstream source line in
|
||||||
|
// github.com/mautrix/discord/blob/v0.7.6/commands.go. If wording drifts in
|
||||||
|
// a future patch, replace this file with a sibling `legacy_v077.ts`
|
||||||
|
// (or whatever) and switch the import in ../parser.ts.
|
||||||
|
//
|
||||||
|
// Body encoding note: legacy mautrix commands use `ce.Reply(...)` which
|
||||||
|
// renders through `format.RenderMarkdown` in the bridge framework. Our
|
||||||
|
// host driver strips `formatted_body` (Phase 2 contract), so the widget
|
||||||
|
// only sees the markdown source — backticks, asterisks, escaped angle-
|
||||||
|
// brackets stay literal.
|
||||||
|
|
||||||
|
import type { LoginEvent, ParsableEvent } from '../types';
|
||||||
|
|
||||||
|
// --- Regex table ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Ping replies — commands.go:fnPing (l.297-310 in v0.7.6). All four are
|
||||||
|
// distinct phrasings; we capture each separately so the state machine can
|
||||||
|
// route them to different status pills.
|
||||||
|
//
|
||||||
|
// «You're logged in as @<username> (`<id>`)» — the trailing parens hold the
|
||||||
|
// numeric Discord snowflake wrapped in markdown backticks. Both are useful
|
||||||
|
// for surfacing in the UI.
|
||||||
|
const PING_LOGGED_IN_RE = /^you'?re logged in as\s+@?(.+?)\s+\(`?(\d+)`?\)\.?$/i;
|
||||||
|
// «You're not logged in» — exact match, no period. The legacy framework
|
||||||
|
// doesn't append punctuation here.
|
||||||
|
const PING_NOT_LOGGED_IN_RE = /^you'?re not logged in\.?$/i;
|
||||||
|
// «You have a Discord token stored, but are not connected for some reason 🤔»
|
||||||
|
// — the emoji is part of the literal upstream string and we tolerate optional
|
||||||
|
// trailing whitespace / period.
|
||||||
|
const PING_TOKEN_STORED_RE = /^you have a discord token stored, but are not connected/i;
|
||||||
|
// «You're logged in, but the Discord connection seems to be dead 💥»
|
||||||
|
const PING_CONNECTION_DEAD_RE = /^you'?re logged in, but the discord connection seems to be dead/i;
|
||||||
|
|
||||||
|
// login-token / login-qr success — commands.go:fnLoginToken (l.156) and
|
||||||
|
// fnLoginQR (l.220). Format: `Successfully logged in as @<username>`. The
|
||||||
|
// QR-login path doesn't include the snowflake; the token-login path has
|
||||||
|
// «Connecting to Discord as user ID %d» BEFORE the success line, but we
|
||||||
|
// only need the success terminator. Capturing the handle is enough — App
|
||||||
|
// fires `ping` after to pick up the snowflake.
|
||||||
|
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;
|
||||||
|
|
||||||
|
// login-qr CAPTCHA path — Vojo-patched bridge sends a single-line
|
||||||
|
// `VOJO-CAPTCHA-CHALLENGE-V1 {json}` m.notice carrying the hCaptcha
|
||||||
|
// sitekey, session_id, rqdata, rqtoken. The sentinel is markdown-inert by
|
||||||
|
// design (no `_`, `*`, `` ` ``, `[`, `<` characters): even if a future
|
||||||
|
// patch routes the bridge reply through goldmark again, the prefix
|
||||||
|
// survives intact. The bridge currently sends the notice via
|
||||||
|
// SendMessageEvent directly to bypass the framework's markdown round-trip.
|
||||||
|
// See bridge `commands_captcha.go` for the producer side.
|
||||||
|
const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
|
||||||
|
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
|
||||||
|
|
||||||
|
// Vojo-patched bridge emits this sentinel right after «Successfully logged
|
||||||
|
// in as @user» (commands_login_space.go::sendLoginSpaceNotice). Carries the
|
||||||
|
// matrix.to URL of the user's personal Discord space so the widget can
|
||||||
|
// render a CTA. Same markdown-inert + structured-JSON discipline as the
|
||||||
|
// captcha sentinel above; the bridge sends this via SendMessageEvent to
|
||||||
|
// bypass goldmark round-trip.
|
||||||
|
const LOGIN_SPACE_SENTINEL_PREFIX = 'VOJO-LOGIN-SPACE-V1';
|
||||||
|
const LOGIN_SPACE_SENTINEL_RE = /^VOJO-LOGIN-SPACE-V1\s+(\{[\s\S]*\})\s*$/;
|
||||||
|
|
||||||
|
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
|
||||||
|
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
|
||||||
|
// instead». Kept so a deployment running unpatched bridge still produces a
|
||||||
|
// useful hint rather than a generic Go-error tail.
|
||||||
|
const CAPTCHA_REQUIRED_RE = /captchas? are currently not supported/i;
|
||||||
|
|
||||||
|
// Generic «Error logging in: %v» — fnLoginQR l.205 / 211 (different
|
||||||
|
// branches funnel through the same Reply call). Capture the Go-error tail
|
||||||
|
// as `reason`. Order matters: CAPTCHA_REQUIRED must be checked BEFORE this
|
||||||
|
// trap because the captcha case is a more specific subset.
|
||||||
|
const LOGIN_FAILED_RE = /^error logging in:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// «Error connecting to login websocket: %v» — fnLoginQR l.184. Pre-QR
|
||||||
|
// failure (couldn't reach Discord's remoteauth gateway). Distinguish from
|
||||||
|
// LOGIN_FAILED so the App can surface a more accurate message.
|
||||||
|
const LOGIN_WEBSOCKET_FAILED_RE = /^error connecting to login websocket:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// «Error connecting after login: %v» — fnLoginQR l.213. Post-QR rare path:
|
||||||
|
// remoteauth handed us a token but the immediate Discord connect failed.
|
||||||
|
const CONNECT_AFTER_LOGIN_FAILED_RE = /^error connecting after login:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// «Failed to prepare login: %v» — fnLoginQR l.176. Pre-QR initialisation
|
||||||
|
// failure (remoteauth couldn't even start). Routes back to disconnected.
|
||||||
|
const PREPARE_LOGIN_FAILED_RE = /^failed to prepare login:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// «You're already logged in» — both fnLoginToken (l.117) and fnLoginQR
|
||||||
|
// (l.171). Replied when the user clicks login but ping would have shown
|
||||||
|
// connected. We dispatch a re-ping to reconcile.
|
||||||
|
const ALREADY_LOGGED_IN_RE = /^you'?re already logged in\.?$/i;
|
||||||
|
|
||||||
|
// Logout — commands.go:fnLogout (l.275-280).
|
||||||
|
const LOGOUT_OK_RE = /^logged out successfully\.?$/i;
|
||||||
|
const LOGOUT_NO_OP_RE = /^you weren'?t logged in, but data was re-cleared/i;
|
||||||
|
|
||||||
|
// Disconnect — commands.go:fnDisconnect (l.318-326). User-typed-only path
|
||||||
|
// (the widget never sends `disconnect`), but recognising the replies keeps
|
||||||
|
// chat-fallback typists from confusing the state machine.
|
||||||
|
const DISCONNECT_OK_RE = /^successfully disconnected\.?$/i;
|
||||||
|
const DISCONNECT_NO_OP_RE = /^you'?re already not connected\.?$/i;
|
||||||
|
const DISCONNECT_FAILED_RE = /^error while disconnecting:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// Reconnect — commands.go:fnReconnect (l.339-347). Used as recovery from
|
||||||
|
// `connection_dead` / `token_stored_not_connected` ping replies.
|
||||||
|
const RECONNECT_OK_RE = /^successfully reconnected\.?$/i;
|
||||||
|
const RECONNECT_NO_OP_RE = /^you'?re already connected\.?$/i;
|
||||||
|
const RECONNECT_FAILED_RE = /^error while reconnecting:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// Unknown command — bridge/commands/processor.go (legacy framework). The
|
||||||
|
// exact wording differs between framework versions; this regex tolerates
|
||||||
|
// the canonical «Unknown command. Try `help`.» phrasing.
|
||||||
|
const UNKNOWN_COMMAND_RE = /^unknown command\.?\s*(?:try\s+)?`?help`?/i;
|
||||||
|
|
||||||
|
// --- Body parser ----------------------------------------------------------
|
||||||
|
|
||||||
|
const trimReplyBody = (raw: string): string => raw.trim();
|
||||||
|
|
||||||
|
export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
|
||||||
|
const body = trimReplyBody(rawBody);
|
||||||
|
if (body.length === 0) return { kind: 'unknown' };
|
||||||
|
|
||||||
|
// ORDER MATTERS:
|
||||||
|
// 1. CAPTCHA must be checked before the generic LOGIN_FAILED — captcha
|
||||||
|
// bodies match LOGIN_FAILED_RE but carry the more-specific suffix.
|
||||||
|
// 2. LOGIN_SUCCESS_RE has a permissive `(.+?)` capture; we keep it AFTER
|
||||||
|
// explicit ping replies so a future ping wording drift can't swallow
|
||||||
|
// a success line.
|
||||||
|
|
||||||
|
// Ping replies (most common) — try first.
|
||||||
|
if (PING_NOT_LOGGED_IN_RE.test(body)) return { kind: 'not_logged_in' };
|
||||||
|
if (PING_TOKEN_STORED_RE.test(body)) return { kind: 'token_stored_not_connected' };
|
||||||
|
if (PING_CONNECTION_DEAD_RE.test(body)) return { kind: 'connection_dead' };
|
||||||
|
const pingLoggedInMatch = PING_LOGGED_IN_RE.exec(body);
|
||||||
|
if (pingLoggedInMatch) {
|
||||||
|
return {
|
||||||
|
kind: 'logged_in',
|
||||||
|
handle: pingLoggedInMatch[1].trim(),
|
||||||
|
discordId: pingLoggedInMatch[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login lifecycle.
|
||||||
|
// Vojo-patched bridge: structured hCaptcha challenge wins over every
|
||||||
|
// legacy regex — checked first so the JSON payload survives.
|
||||||
|
if (body.startsWith(CAPTCHA_CHALLENGE_PREFIX)) {
|
||||||
|
const match = CAPTCHA_CHALLENGE_RE.exec(body);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(match[1]) as Record<string, unknown>;
|
||||||
|
const service = typeof payload.service === 'string' ? payload.service : '';
|
||||||
|
const sitekey = typeof payload.sitekey === 'string' ? payload.sitekey : '';
|
||||||
|
const sessionId = typeof payload.session_id === 'string' ? payload.session_id : '';
|
||||||
|
const rqdata = typeof payload.rqdata === 'string' ? payload.rqdata : '';
|
||||||
|
const rqtoken = typeof payload.rqtoken === 'string' ? payload.rqtoken : '';
|
||||||
|
if (sitekey && rqtoken) {
|
||||||
|
return { kind: 'captcha_challenge', service, sitekey, sessionId, rqdata, rqtoken };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through — malformed payload is treated as unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: 'unknown' };
|
||||||
|
}
|
||||||
|
if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };
|
||||||
|
|
||||||
|
// Vojo login-space sentinel: structured JSON with the personal Discord
|
||||||
|
// space's matrix.to URL. Checked alongside the captcha sentinel —
|
||||||
|
// markdown-inert prefix means it lands verbatim from the bridge, parsed
|
||||||
|
// into a `space_ready` event for the reducer to attach to connected state.
|
||||||
|
// Malformed payload (missing/empty `matrix_to_url`, JSON parse failure) is
|
||||||
|
// silently dropped as `unknown` rather than surfacing a stale CTA.
|
||||||
|
if (body.startsWith(LOGIN_SPACE_SENTINEL_PREFIX)) {
|
||||||
|
const match = LOGIN_SPACE_SENTINEL_RE.exec(body);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(match[1]) as Record<string, unknown>;
|
||||||
|
const matrixToUrl = typeof payload.matrix_to_url === 'string' ? payload.matrix_to_url : '';
|
||||||
|
if (matrixToUrl) {
|
||||||
|
return { kind: 'space_ready', matrixToUrl };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through — malformed payload is treated as unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
|
||||||
|
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
|
||||||
|
|
||||||
|
const connectAfterMatch = CONNECT_AFTER_LOGIN_FAILED_RE.exec(body);
|
||||||
|
if (connectAfterMatch)
|
||||||
|
return { kind: 'connect_after_login_failed', reason: connectAfterMatch[1].trim() };
|
||||||
|
|
||||||
|
const prepareMatch = PREPARE_LOGIN_FAILED_RE.exec(body);
|
||||||
|
if (prepareMatch) return { kind: 'prepare_login_failed', reason: prepareMatch[1].trim() };
|
||||||
|
|
||||||
|
const loginFailedMatch = LOGIN_FAILED_RE.exec(body);
|
||||||
|
if (loginFailedMatch) return { kind: 'login_failed', reason: loginFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
if (ALREADY_LOGGED_IN_RE.test(body)) return { kind: 'already_logged_in' };
|
||||||
|
|
||||||
|
// Login success — capture the handle. Discord usernames may include `.`
|
||||||
|
// and other ASCII punctuation; the regex's `(.+?)` is greedy-enough.
|
||||||
|
const successMatch = LOGIN_SUCCESS_RE.exec(body);
|
||||||
|
if (successMatch) {
|
||||||
|
const handleRaw = successMatch[1].trim();
|
||||||
|
return { kind: 'login_success', handle: handleRaw };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout / disconnect / reconnect lifecycle.
|
||||||
|
if (LOGOUT_OK_RE.test(body)) return { kind: 'logout_ok' };
|
||||||
|
if (LOGOUT_NO_OP_RE.test(body)) return { kind: 'logout_no_op' };
|
||||||
|
|
||||||
|
if (DISCONNECT_OK_RE.test(body)) return { kind: 'disconnect_ok' };
|
||||||
|
if (DISCONNECT_NO_OP_RE.test(body)) return { kind: 'disconnect_no_op' };
|
||||||
|
const disconnectFailedMatch = DISCONNECT_FAILED_RE.exec(body);
|
||||||
|
if (disconnectFailedMatch)
|
||||||
|
return { kind: 'disconnect_failed', reason: disconnectFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
if (RECONNECT_OK_RE.test(body)) return { kind: 'reconnect_ok' };
|
||||||
|
if (RECONNECT_NO_OP_RE.test(body)) return { kind: 'reconnect_no_op' };
|
||||||
|
const reconnectFailedMatch = RECONNECT_FAILED_RE.exec(body);
|
||||||
|
if (reconnectFailedMatch)
|
||||||
|
return { kind: 'reconnect_failed', reason: reconnectFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
if (UNKNOWN_COMMAND_RE.test(body)) return { kind: 'unknown_command' };
|
||||||
|
|
||||||
|
return { kind: 'unknown' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Full-event parser ----------------------------------------------------
|
||||||
|
//
|
||||||
|
// `parseEventLegacyV076` dispatches on `event.type`:
|
||||||
|
//
|
||||||
|
// * `m.room.redaction` → `qr_redacted`. The state machine pairs the
|
||||||
|
// redaction's `redacts` against the active QR event id; an unrelated
|
||||||
|
// redaction is dropped silently.
|
||||||
|
//
|
||||||
|
// * `m.room.message` + `msgtype=m.image` → `qr_displayed` when the body
|
||||||
|
// contains a Discord remoteauth URL. Discord doesn't rotate the QR (no
|
||||||
|
// m.replace edits), but we still honour `m.relates_to.rel_type=m.replace`
|
||||||
|
// for forward-compat with a hypothetical future bridge that does.
|
||||||
|
//
|
||||||
|
// * `m.room.message` + `msgtype=m.text|m.notice` → existing
|
||||||
|
// `parseLegacyV076Body(body)` path.
|
||||||
|
|
||||||
|
// Discord remoteauth URLs encode the auth handshake in a path on
|
||||||
|
// `discordapp.com` (the OLD Discord domain — Discord still uses it as
|
||||||
|
// the canonical remoteauth host because the URL is consumed by the
|
||||||
|
// mobile app's deep-link handler, not by browser routing).
|
||||||
|
//
|
||||||
|
// Verified upstream: mautrix/discord/remoteauth/serverpackets.go at v0.7.6
|
||||||
|
// builds the QR string as `"https://discordapp.com/ra/" + Fingerprint` —
|
||||||
|
// see https://github.com/mautrix/discord/blob/v0.7.6/remoteauth/serverpackets.go.
|
||||||
|
//
|
||||||
|
// We accept both `discordapp.com` (canonical) AND `discord.com` because
|
||||||
|
// Discord has been gradually consolidating onto discord.com over years
|
||||||
|
// and a future bridge release could flip — keeping both means the
|
||||||
|
// widget survives the transition without a co-ordinated push.
|
||||||
|
// Subdomains (`canary.`, `ptb.`) aren't expected here (bridge talks to
|
||||||
|
// production remoteauth) but we tolerate them as belt-and-suspenders.
|
||||||
|
const DISCORD_REMOTEAUTH_URL_RE =
|
||||||
|
/https:\/\/(?:[a-z0-9-]+\.)?(?:discordapp|discord)\.com\/[A-Za-z0-9/_\-+=.~?&]+/i;
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => {
|
||||||
|
if (event.type === 'm.room.redaction') {
|
||||||
|
const target =
|
||||||
|
typeof event.redacts === 'string'
|
||||||
|
? event.redacts
|
||||||
|
: isObject(event.content) && typeof event.content.redacts === 'string'
|
||||||
|
? event.content.redacts
|
||||||
|
: undefined;
|
||||||
|
if (!target) return { kind: 'unknown' };
|
||||||
|
return { kind: 'qr_redacted', redactsEventId: target };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type !== 'm.room.message') return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const msgtype = event.content?.msgtype;
|
||||||
|
|
||||||
|
if (msgtype === 'm.image') {
|
||||||
|
// Edits replace `body` by spec; bridges typically also mirror the new
|
||||||
|
// body into `m.new_content.body`. Discord's bridge doesn't edit QRs in
|
||||||
|
// the v0.7.6 timeline, but we read both spots so a future change
|
||||||
|
// doesn't quietly break the parser.
|
||||||
|
const newContent = isObject(event.content['m.new_content'])
|
||||||
|
? (event.content['m.new_content'] as { body?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const editedBody = typeof newContent?.body === 'string' ? newContent.body : undefined;
|
||||||
|
const directBody = typeof event.content.body === 'string' ? event.content.body : '';
|
||||||
|
const body = editedBody ?? directBody;
|
||||||
|
|
||||||
|
const match = body.match(DISCORD_REMOTEAUTH_URL_RE);
|
||||||
|
if (!match) return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const relatesTo = isObject(event.content['m.relates_to'])
|
||||||
|
? (event.content['m.relates_to'] as { rel_type?: unknown; event_id?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const replacesEventId =
|
||||||
|
relatesTo?.rel_type === 'm.replace' && typeof relatesTo.event_id === 'string'
|
||||||
|
? relatesTo.event_id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
discordUrl: match[0],
|
||||||
|
eventId: event.event_id,
|
||||||
|
replacesEventId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgtype !== 'm.text' && msgtype !== 'm.notice') return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const body = typeof event.content.body === 'string' ? event.content.body : '';
|
||||||
|
return parseLegacyV076Body(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DEV sanity assertions ------------------------------------------------
|
||||||
|
// Vite tree-shakes this branch in production builds: `import.meta.env.DEV`
|
||||||
|
// is replaced with the literal `false` and the call site collapses, so the
|
||||||
|
// fixture array never ships. Failure throws — HMR/dev-overlay surfaces the
|
||||||
|
// first regression on reload.
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
runSanityChecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSanityChecks(): void {
|
||||||
|
// Body-only cases (`parseLegacyV076Body`).
|
||||||
|
const cases: Array<[string, LoginEvent]> = [
|
||||||
|
// Ping replies.
|
||||||
|
["You're not logged in", { kind: 'not_logged_in' }],
|
||||||
|
["You're not logged in.", { kind: 'not_logged_in' }],
|
||||||
|
[
|
||||||
|
'You have a Discord token stored, but are not connected for some reason 🤔',
|
||||||
|
{ kind: 'token_stored_not_connected' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"You're logged in, but the Discord connection seems to be dead 💥",
|
||||||
|
{ kind: 'connection_dead' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"You're logged in as @example (`123456789`)",
|
||||||
|
{ kind: 'logged_in', handle: 'example', discordId: '123456789' },
|
||||||
|
],
|
||||||
|
// Discord usernames support `.` since the 2026 username migration.
|
||||||
|
[
|
||||||
|
"You're logged in as @user.name (`987654321`)",
|
||||||
|
{ kind: 'logged_in', handle: 'user.name', discordId: '987654321' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Login success (post-QR scan). No snowflake in this line; App fires
|
||||||
|
// `ping` afterwards to pick up the discordId.
|
||||||
|
['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }],
|
||||||
|
['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }],
|
||||||
|
|
||||||
|
// Login failure paths.
|
||||||
|
['Error logging in: rate limited 429', { kind: 'login_failed', reason: 'rate limited 429' }],
|
||||||
|
// CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
|
||||||
|
// unpatched upstream v0.7.6.
|
||||||
|
[
|
||||||
|
'Error logging in: captcha-required 400\n\nCAPTCHAs are currently not supported - use token login instead',
|
||||||
|
{ kind: 'captcha_required' },
|
||||||
|
],
|
||||||
|
// Vojo-patched bridge — structured hCaptcha challenge. Sentinel prefix
|
||||||
|
// is checked before any regex so the JSON body is never misclassified.
|
||||||
|
// Use a realistic-shape rqtoken (JWT-style segments separated by `.`,
|
||||||
|
// base64url payload with `_`/`-`/`=`) so a regression where the regex
|
||||||
|
// accidentally trips on those characters is caught in CI.
|
||||||
|
[
|
||||||
|
'VOJO-CAPTCHA-CHALLENGE-V1 {"service":"hcaptcha","sitekey":"a9b5fb07-92ff-493f-86fe-352a2803b3df","session_id":"e971514e-4a6e-4a45-a869-01e61421327c","rqdata":"fgpS6hRTe96TX5qXD7QZgLQwgbQal50jmYsSPyZHqdY+UdfpECcH9gAZESHuGwi0k3n2aCUDs/32bITzKBYGSYjRUbKJqsN0gSo3JHr7SzyPB4bMLcwkOT15yro6f2ax","rqtoken":"IlRGQ3BWQVdGLzJhZUxHMDUrWkV3OE9TNjF6MjNCOS9zOWx2Nk1idzBOdlVvTy9abmZqUnZoZDNnZ2lBUm80Ull1NVRPL0E9PXhFTVRpOW5QeXFmaGF1MFEi.afpL4w.vi8MtSRKgUhesyHDNy4uWwpft1A"}',
|
||||||
|
{
|
||||||
|
kind: 'captcha_challenge',
|
||||||
|
service: 'hcaptcha',
|
||||||
|
sitekey: 'a9b5fb07-92ff-493f-86fe-352a2803b3df',
|
||||||
|
sessionId: 'e971514e-4a6e-4a45-a869-01e61421327c',
|
||||||
|
rqdata:
|
||||||
|
'fgpS6hRTe96TX5qXD7QZgLQwgbQal50jmYsSPyZHqdY+UdfpECcH9gAZESHuGwi0k3n2aCUDs/32bITzKBYGSYjRUbKJqsN0gSo3JHr7SzyPB4bMLcwkOT15yro6f2ax',
|
||||||
|
rqtoken:
|
||||||
|
'IlRGQ3BWQVdGLzJhZUxHMDUrWkV3OE9TNjF6MjNCOS9zOWx2Nk1idzBOdlVvTy9abmZqUnZoZDNnZ2lBUm80Ull1NVRPL0E9PXhFTVRpOW5QeXFmaGF1MFEi.afpL4w.vi8MtSRKgUhesyHDNy4uWwpft1A',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Malformed challenge JSON falls through to `unknown` so a corrupted
|
||||||
|
// bridge build doesn't crash the parser.
|
||||||
|
['VOJO-CAPTCHA-CHALLENGE-V1 {not-json', { kind: 'unknown' }],
|
||||||
|
[
|
||||||
|
'Error connecting to login websocket: dial tcp i/o timeout',
|
||||||
|
{ kind: 'login_websocket_failed', reason: 'dial tcp i/o timeout' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Error connecting after login: gateway timeout',
|
||||||
|
{ kind: 'connect_after_login_failed', reason: 'gateway timeout' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Failed to prepare login: remoteauth init failed',
|
||||||
|
{ kind: 'prepare_login_failed', reason: 'remoteauth init failed' },
|
||||||
|
],
|
||||||
|
["You're already logged in", { kind: 'already_logged_in' }],
|
||||||
|
|
||||||
|
// Logout.
|
||||||
|
['Logged out successfully.', { kind: 'logout_ok' }],
|
||||||
|
["You weren't logged in, but data was re-cleared just to be safe.", { kind: 'logout_no_op' }],
|
||||||
|
|
||||||
|
// Disconnect / reconnect.
|
||||||
|
['Successfully disconnected', { kind: 'disconnect_ok' }],
|
||||||
|
["You're already not connected", { kind: 'disconnect_no_op' }],
|
||||||
|
[
|
||||||
|
'Error while disconnecting: connection already closed',
|
||||||
|
{ kind: 'disconnect_failed', reason: 'connection already closed' },
|
||||||
|
],
|
||||||
|
['Successfully reconnected', { kind: 'reconnect_ok' }],
|
||||||
|
["You're already connected", { kind: 'reconnect_no_op' }],
|
||||||
|
[
|
||||||
|
'Error while reconnecting: dial tcp connection refused',
|
||||||
|
{ kind: 'reconnect_failed', reason: 'dial tcp connection refused' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Unknown command — the bridge framework's wording.
|
||||||
|
['Unknown command. Try `help`.', { kind: 'unknown_command' }],
|
||||||
|
|
||||||
|
// Catch-all.
|
||||||
|
['Some completely unknown bridge reply that does not match any anchor', { kind: 'unknown' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [body, expected] of cases) {
|
||||||
|
const actual = parseLegacyV076Body(body);
|
||||||
|
if (!sameEvent(actual, expected)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[legacy_v076 sanity] mismatch', { body, actual, expected });
|
||||||
|
throw new Error(
|
||||||
|
`legacy_v076 parser sanity failed for body ${JSON.stringify(body)} — see console for diff`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-event cases — m.image / m.room.redaction / m.notice fall-through.
|
||||||
|
const eventCases: Array<[ParsableEvent, LoginEvent]> = [
|
||||||
|
[
|
||||||
|
// Canonical upstream form — `discordapp.com` (verified at v0.7.6
|
||||||
|
// serverpackets.go). The legacy domain is what the Discord mobile
|
||||||
|
// app's deep-link handler accepts.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$qr1',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.image', body: 'https://discordapp.com/ra/ABCDEF' },
|
||||||
|
},
|
||||||
|
{ kind: 'qr_displayed', discordUrl: 'https://discordapp.com/ra/ABCDEF', eventId: '$qr1' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Forward-compat: a future bridge release could flip to
|
||||||
|
// `discord.com`. The regex tolerates both.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$qr1b',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.image', body: 'https://discord.com/ra/ABCDEF' },
|
||||||
|
},
|
||||||
|
{ kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/ABCDEF', eventId: '$qr1b' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Bare m.image without a discord URL — bridge has no business sending
|
||||||
|
// these here, but the parser declines to invent state.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$rand',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.image', body: 'random non-discord image caption' },
|
||||||
|
},
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Forward-compat: hypothetical future edit. Verifies the rotation
|
||||||
|
// path works even though Discord doesn't currently rotate.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$qr2',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.image',
|
||||||
|
body: 'https://discordapp.com/ra/OLD',
|
||||||
|
'm.relates_to': { rel_type: 'm.replace', event_id: '$qr1' },
|
||||||
|
'm.new_content': { msgtype: 'm.image', body: 'https://discordapp.com/ra/ROTATED' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
discordUrl: 'https://discordapp.com/ra/ROTATED',
|
||||||
|
eventId: '$qr2',
|
||||||
|
replacesEventId: '$qr1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Redaction — top-level `redacts` (host sanitizer mirrors there).
|
||||||
|
{
|
||||||
|
type: 'm.room.redaction',
|
||||||
|
event_id: '$red1',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: { redacts: '$qr1' },
|
||||||
|
redacts: '$qr1',
|
||||||
|
},
|
||||||
|
{ kind: 'qr_redacted', redactsEventId: '$qr1' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Redaction missing target — sanitizer should already reject; defence
|
||||||
|
// in depth.
|
||||||
|
{
|
||||||
|
type: 'm.room.redaction',
|
||||||
|
event_id: '$red2',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: {},
|
||||||
|
},
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// m.notice fall-through — preserves the body-side parser path.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$n1',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.notice', body: "You're not logged in" },
|
||||||
|
},
|
||||||
|
{ kind: 'not_logged_in' },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [event, expected] of eventCases) {
|
||||||
|
const actual = parseEventLegacyV076(event);
|
||||||
|
if (!sameEvent(actual, expected)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
|
||||||
|
throw new Error(
|
||||||
|
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${
|
||||||
|
event.content?.msgtype ?? '<none>'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameEvent(a: LoginEvent, b: LoginEvent): boolean {
|
||||||
|
if (a.kind !== b.kind) return false;
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
18
apps/widget-discord/src/bridge-protocol/parser.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// Parser shim. The widget consumes a single `parseEvent(rawEvent)` and
|
||||||
|
// the dialect handles the full event surface — m.text, m.notice, m.image
|
||||||
|
// (QR broadcasts), m.room.redaction (post-scan cleanup). M-discord ships
|
||||||
|
// one dialect, `legacy_v076`, for the operator's current bridge image.
|
||||||
|
// When mautrix-discord eventually migrates to bridgev2 (the team flagged
|
||||||
|
// this as «not yet» as of 2026-01), add a sibling dialect file and
|
||||||
|
// switch the import below.
|
||||||
|
//
|
||||||
|
// The dialects/ subdirectory is kept as a seam for that swap; we don't
|
||||||
|
// implement runtime autodetect (the operator owns one bridge image at a
|
||||||
|
// time and a parser pin is honest about that).
|
||||||
|
|
||||||
|
import type { LoginEvent, ParsableEvent } from './types';
|
||||||
|
import { parseEventLegacyV076 } from './dialects/legacy_v076';
|
||||||
|
|
||||||
|
export type { ParsableEvent };
|
||||||
|
|
||||||
|
export const parseEvent = (event: ParsableEvent): LoginEvent => parseEventLegacyV076(event);
|
||||||
130
apps/widget-discord/src/bridge-protocol/types.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// LoginEvent — discriminated union the parser emits and the state machine
|
||||||
|
// consumes. One LoginEvent per inbound m.room.message / m.room.redaction
|
||||||
|
// from the bridge bot.
|
||||||
|
//
|
||||||
|
// Source-of-truth for every kind below is mautrix/discord legacy command
|
||||||
|
// system (commands.go), tag v0.7.6 — see ./dialects/legacy_v076.ts for the
|
||||||
|
// per-string upstream pointers. Discord uses the OLDER mautrix command
|
||||||
|
// processor (`maunium.net/go/mautrix/bridge/commands`), NOT bridgev2 — so
|
||||||
|
// the wording differs from mautrix-telegram and there's no list-logins
|
||||||
|
// API; status is queried via `ping`, and there's no per-account login id.
|
||||||
|
|
||||||
|
// `ping` reply variants — the bridge's only status-probe surface for the
|
||||||
|
// legacy command system. Each variant maps to a different LoginEvent so
|
||||||
|
// the state machine can render distinct status pills.
|
||||||
|
export type PingResult =
|
||||||
|
| { kind: 'not_logged_in' }
|
||||||
|
| { kind: 'token_stored_not_connected' }
|
||||||
|
| { kind: 'connection_dead' }
|
||||||
|
| { kind: 'logged_in'; handle: string; discordId?: string };
|
||||||
|
|
||||||
|
// Shape of an inbound event the dialect parser needs to look at. Matches
|
||||||
|
// the wire shape produced by the host's BotWidgetDriver sanitizer; declared
|
||||||
|
// here (not in widget-api.ts) so the dialect doesn't import from the
|
||||||
|
// transport layer.
|
||||||
|
export type ParsableEvent = {
|
||||||
|
type: string;
|
||||||
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
origin_server_ts?: number;
|
||||||
|
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
||||||
|
redacts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginEvent =
|
||||||
|
// --- ping reply ----------------------------------------------------------
|
||||||
|
| { kind: 'not_logged_in' }
|
||||||
|
| { kind: 'token_stored_not_connected' }
|
||||||
|
| { kind: 'connection_dead' }
|
||||||
|
// `ping` says we're live; handle parsed from `You're logged in as @x (`id`)`.
|
||||||
|
// Same shape as `login_success` — App routes both into the connected state.
|
||||||
|
| { kind: 'logged_in'; handle: string; discordId?: string }
|
||||||
|
|
||||||
|
// --- login-qr lifecycle --------------------------------------------------
|
||||||
|
// `m.image` carrying the remoteauth URL inside `content.body`. The widget
|
||||||
|
// renders the QR client-side from that URL and never touches the uploaded
|
||||||
|
// PNG. Discord's remoteauth does NOT rotate the URL (unlike Telegram
|
||||||
|
// MTProto): the bridge sends one m.image per login attempt and either
|
||||||
|
// redacts it on success or leaves it (and replies with an error) on
|
||||||
|
// failure. `replacesEventId` is here for forward-compat / paranoia — if a
|
||||||
|
// future bridge ever does an edit, the state machine handles it gracefully.
|
||||||
|
| { kind: 'qr_displayed'; discordUrl: string; eventId: string; replacesEventId?: string }
|
||||||
|
// Bridge redacted the QR event after a successful scan. NOT terminal — the
|
||||||
|
// success line («Successfully logged in as @x») typically lands in the same
|
||||||
|
// breath; the state machine moves us into a `qr_verifying` interstitial
|
||||||
|
// until it does.
|
||||||
|
| { kind: 'qr_redacted'; redactsEventId: string }
|
||||||
|
|
||||||
|
// Successful login (after QR scan). Captures handle and optional snowflake.
|
||||||
|
| { kind: 'login_success'; handle: string; discordId?: string }
|
||||||
|
// Generic login failure (wraps gotd / remoteauth Go errors). Most common
|
||||||
|
// bodies: «Error logging in: ...» — we surface the verbatim Go-error tail
|
||||||
|
// as a yellow warning on the QR panel.
|
||||||
|
| { kind: 'login_failed'; reason?: string }
|
||||||
|
// Vojo-patched bridge replies with a sentinel-prefixed JSON line
|
||||||
|
// («VOJO-CAPTCHA-CHALLENGE-V1 {...}») when Discord demands an hCaptcha
|
||||||
|
// before completing remote-auth. The widget renders a hCaptcha iframe
|
||||||
|
// with the supplied sitekey + rqdata, then sends the solved token back
|
||||||
|
// via `<commandPrefix> login-captcha <token>` to retry the login.
|
||||||
|
| {
|
||||||
|
kind: 'captcha_challenge';
|
||||||
|
service: string;
|
||||||
|
sitekey: string;
|
||||||
|
sessionId: string;
|
||||||
|
rqdata: string;
|
||||||
|
rqtoken: string;
|
||||||
|
}
|
||||||
|
// Legacy «CAPTCHAs are currently not supported - use token login instead»
|
||||||
|
// path — only fires against an UNPATCHED upstream v0.7.6 bridge. Kept so
|
||||||
|
// a deployment that forgets to ship the Vojo bridge image still surfaces
|
||||||
|
// a useful hint instead of a generic Go-error tail.
|
||||||
|
| { kind: 'captcha_required' }
|
||||||
|
// bridge sets up a websocket against Discord's remoteauth gateway; this is
|
||||||
|
// the «we couldn't even reach Discord» error — different from
|
||||||
|
// login_failed, which lands AFTER the websocket is up.
|
||||||
|
| { kind: 'login_websocket_failed'; reason?: string }
|
||||||
|
// Surfaces when QR-login starts but the bridge is already logged in.
|
||||||
|
// Race against ping/status — the App fires `ping` to reconcile.
|
||||||
|
| { kind: 'already_logged_in' }
|
||||||
|
// bridge couldn't initialise remoteauth at all (rare, indicates bridge-
|
||||||
|
// image misconfiguration). Routes back to disconnected with a warn line.
|
||||||
|
| { kind: 'prepare_login_failed'; reason?: string }
|
||||||
|
// bridge received the token but couldn't connect to Discord with it (rare
|
||||||
|
// post-scan failure; remoteauth can return a stale token if the gateway
|
||||||
|
// race trips). Surfaces as «Signed in, but couldn't connect: <reason>»
|
||||||
|
// and routes back to disconnected.
|
||||||
|
| { kind: 'connect_after_login_failed'; reason?: string }
|
||||||
|
|
||||||
|
// --- logout --------------------------------------------------------------
|
||||||
|
| { kind: 'logout_ok' }
|
||||||
|
// Bridge says the user wasn't logged in but cleared state defensively.
|
||||||
|
// Idempotent confirmation that we're now disconnected.
|
||||||
|
| { kind: 'logout_no_op' }
|
||||||
|
|
||||||
|
// --- disconnect / reconnect ---------------------------------------------
|
||||||
|
// Used as recovery from `connection_dead` / `token_stored_not_connected`.
|
||||||
|
// The widget never SENDS `disconnect` — that's an admin-only state op —
|
||||||
|
// but if the user typed it manually in chat-fallback, the parser still
|
||||||
|
// recognises the reply.
|
||||||
|
| { kind: 'disconnect_ok' }
|
||||||
|
| { kind: 'disconnect_no_op' }
|
||||||
|
| { kind: 'disconnect_failed'; reason?: string }
|
||||||
|
| { kind: 'reconnect_ok' }
|
||||||
|
| { kind: 'reconnect_no_op' }
|
||||||
|
| { kind: 'reconnect_failed'; reason?: string }
|
||||||
|
|
||||||
|
// --- Vojo: bridge-managed personal space ---------------------------------
|
||||||
|
// Vojo-patched bridge emits the sentinel `VOJO-LOGIN-SPACE-V1 {...}` as a
|
||||||
|
// separate m.notice right after the «Successfully logged in» line. Carries
|
||||||
|
// a `matrix.to` URL pointing at the user's auto-created Discord space
|
||||||
|
// (user.go::GetSpaceRoom on the bridge side). The widget surfaces this as
|
||||||
|
// an «Open in Channels» card; click → host navigates cinny to the space.
|
||||||
|
// See vojo-mautrix-discord/commands_login_space.go for the wire format.
|
||||||
|
| { kind: 'space_ready'; matrixToUrl: string }
|
||||||
|
|
||||||
|
// --- bridge-side errors --------------------------------------------------
|
||||||
|
// Generic «I don't know that command» — should not happen since we only
|
||||||
|
// ship known commands, but visible if the bridge image is misconfigured
|
||||||
|
// or the prefix in /config.json drifted from the bridge's command_prefix.
|
||||||
|
| { kind: 'unknown_command' }
|
||||||
|
| { kind: 'unknown' };
|
||||||
89
apps/widget-discord/src/i18n/en.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// English fallback. Mirror the RU key set; `Record<StringKey, string>` enforces
|
||||||
|
// that every RU key has an EN counterpart at compile time.
|
||||||
|
|
||||||
|
import type { StringKey } from './ru';
|
||||||
|
|
||||||
|
export const EN: Record<StringKey, string> = {
|
||||||
|
'status.unknown': 'Checking status…',
|
||||||
|
'status.disconnected': 'Discord not linked',
|
||||||
|
'status.connected': 'Discord linked',
|
||||||
|
'status.connected-as': 'Discord linked as {handle}',
|
||||||
|
'status.connection-dead': 'Discord connection lost',
|
||||||
|
'status.token-stored': 'Discord session is not active',
|
||||||
|
'status.qr-verifying': 'Verifying sign-in…',
|
||||||
|
'status.logging-out': 'Signing out…',
|
||||||
|
'status.reconnecting': 'Reconnecting to Discord…',
|
||||||
|
'card.login-qr.name': 'Sign in with QR code',
|
||||||
|
'card.login-qr.desc': 'Scan a QR code from the Discord mobile app',
|
||||||
|
'card.refresh.aria': 'Refresh status',
|
||||||
|
'card.refresh.label': 'Refresh status',
|
||||||
|
'card.refresh.name': 'Refresh status',
|
||||||
|
'card.refresh.desc': 'Re-check whether Discord is linked',
|
||||||
|
'card.refresh.in-flight': 'Checking…',
|
||||||
|
'card.about.name': 'How the Discord bot works',
|
||||||
|
'card.about.desc': 'Sign-in, safety, and source code',
|
||||||
|
'about.title': 'About the Discord bot',
|
||||||
|
'about.body-1':
|
||||||
|
'This bot connects Discord to Vojo. After sign-in, your DMs and servers from Discord will appear in Vojo’s chat list, and replies from the Vojo app will be sent to your contacts as normal Discord messages.',
|
||||||
|
'about.body-2':
|
||||||
|
'Sign-in requires the Discord mobile app — scan the QR code via Settings → Scan QR Code. Desktop Discord and the browser cannot be used: the bridge uses Discord’s “remoteauth” mechanism, available only in the mobile app.',
|
||||||
|
'about.body-3':
|
||||||
|
'The connection runs through the open-source mautrix-discord bridge. It creates a Discord session on the Vojo server and uses it to connect Discord with your Vojo account: receive messages from Discord and send your replies back.',
|
||||||
|
'about.github-label': 'The bridge source code is public on GitHub:',
|
||||||
|
'about.github-url': 'https://github.com/mautrix/discord',
|
||||||
|
'about.body-4':
|
||||||
|
'You can revoke access at any time — either with the “Sign out of Discord” button here, or inside Discord itself under Settings → Devices → Log out of Vojo.',
|
||||||
|
'about.close': 'Close',
|
||||||
|
'about.aria-close': 'Close “About this bot”',
|
||||||
|
'auth-card.qr.title': 'QR code sign-in',
|
||||||
|
'auth-card.qr.hint': 'Open the Discord mobile app and scan this QR code.',
|
||||||
|
'auth-card.qr.preparing': 'Preparing QR code…',
|
||||||
|
'auth-card.qr.aria': 'QR code for Discord sign-in. Scan it with your phone.',
|
||||||
|
'auth-card.qr.countdown': 'Time left to scan: {minutes}:{seconds}',
|
||||||
|
'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
||||||
|
'auth-card.qr.step-1': 'Open the Discord mobile app.',
|
||||||
|
'auth-card.qr.step-2': 'Open Settings → Scan QR Code.',
|
||||||
|
'auth-card.qr.step-3': 'Scan the QR and confirm sign-in on your phone.',
|
||||||
|
'auth-card.captcha.title': 'Confirm you’re not a robot',
|
||||||
|
'auth-card.captcha.hint':
|
||||||
|
'Discord asked for a CAPTCHA. Solve it below — sign-in will continue automatically once you’re done.',
|
||||||
|
'auth-card.captcha.load-error':
|
||||||
|
'Could not load the CAPTCHA. Check your network, tap Cancel and try signing in again.',
|
||||||
|
'auth-card.cancel': 'Cancel',
|
||||||
|
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.',
|
||||||
|
'auth-error.captcha-required':
|
||||||
|
'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.',
|
||||||
|
'auth-error.captcha-send-failed':
|
||||||
|
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
|
||||||
|
'auth-error.captcha-expired': 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
|
||||||
|
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
||||||
|
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
|
||||||
|
'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}',
|
||||||
|
'auth-error.connect-after-login-failed': 'Signed in, but could not connect to Discord: {reason}',
|
||||||
|
'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.',
|
||||||
|
'auth-error.unknown-command':
|
||||||
|
'The bot does not recognise this command — check the prefix in config.json.',
|
||||||
|
'auth-error.disconnect-failed': 'Disconnect failed: {reason}',
|
||||||
|
'card.reconnect.name': 'Reconnect',
|
||||||
|
'card.reconnect.desc': 'Restore the Discord connection without signing in again',
|
||||||
|
'card.logout.name': 'Sign out of Discord',
|
||||||
|
'card.logout.desc': 'End the session for this account',
|
||||||
|
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||||
|
'card.logout.confirm-yes': 'Sign out',
|
||||||
|
'card.logout.confirm-no': 'Cancel',
|
||||||
|
'card.open-space.name': 'Open in Channels',
|
||||||
|
'card.open-space.desc': 'Jump to your Discord space with all chats and servers',
|
||||||
|
'diag.space-ready': 'Discord space ready to open.',
|
||||||
|
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
|
||||||
|
'diag.ready': 'Ready to send commands.',
|
||||||
|
'diag.checking-status': 'Checking connection status…',
|
||||||
|
'diag.send-failed': 'send failed: {message}',
|
||||||
|
'diag.history-marker': '─── history ───',
|
||||||
|
'diag.history-unavailable': 'Could not read history — re-checking status.',
|
||||||
|
'diag.qr-issued': 'QR code issued.',
|
||||||
|
'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.',
|
||||||
|
'diag.captcha-issued': 'Discord requested a CAPTCHA — solve it in the form above.',
|
||||||
|
'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}.',
|
||||||
|
};
|
||||||
34
apps/widget-discord/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Tiny i18n harness — Russian primary, English fallback (BCP-47 prefix
|
||||||
|
// match — any `en` variant). Bootstrap forwards `clientLanguage` from the
|
||||||
|
// host; main.tsx can also call `createT()` without args before bootstrap
|
||||||
|
// completes (falls back to navigator.language, then RU).
|
||||||
|
//
|
||||||
|
// Identical mechanics to apps/widget-telegram/src/i18n/index.ts; the
|
||||||
|
// Discord widget keeps its own dictionary file because the copy differs —
|
||||||
|
// QR-only flow, no SMS, no 2FA password form.
|
||||||
|
|
||||||
|
import { RU, type StringKey } from './ru';
|
||||||
|
import { EN } from './en';
|
||||||
|
|
||||||
|
const interpolate = (s: string, vars?: Record<string, string>): string => {
|
||||||
|
if (!vars) return s;
|
||||||
|
return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickDict = (clientLanguage: string | undefined): Record<StringKey, string> => {
|
||||||
|
const lang = (
|
||||||
|
clientLanguage ||
|
||||||
|
(typeof navigator !== 'undefined' ? navigator.language : '') ||
|
||||||
|
'ru'
|
||||||
|
).toLowerCase();
|
||||||
|
return lang.startsWith('en') ? EN : RU;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type T = (key: StringKey, vars?: Record<string, string>) => string;
|
||||||
|
|
||||||
|
export const createT = (clientLanguage?: string): T => {
|
||||||
|
const dict = pickDict(clientLanguage);
|
||||||
|
return (key, vars) => interpolate(dict[key], vars);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { StringKey };
|
||||||
132
apps/widget-discord/src/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// Russian primary copy. To add a string:
|
||||||
|
// 1. add the key + RU value here (this file is the canonical key list — `en.ts`
|
||||||
|
// and the `StringKey` type derive from it),
|
||||||
|
// 2. add the same key + EN value in `en.ts`,
|
||||||
|
// 3. consume via `t('key', { var: 'x' })` in components.
|
||||||
|
// Interpolation uses `{name}` placeholders resolved against the second arg.
|
||||||
|
//
|
||||||
|
// The widget no longer renders a hero (avatar/name/handle/description) —
|
||||||
|
// that block lives in the host's BotShellHero. Status is surfaced inline
|
||||||
|
// inside the relevant section.
|
||||||
|
|
||||||
|
export const RU = {
|
||||||
|
// --- Inline section status ---------------------------------------------
|
||||||
|
'status.unknown': 'Проверка статуса…',
|
||||||
|
'status.disconnected': 'Discord не привязан',
|
||||||
|
'status.connected': 'Discord привязан',
|
||||||
|
'status.connected-as': 'Discord привязан как {handle}',
|
||||||
|
'status.connection-dead': 'Соединение с Discord потеряно',
|
||||||
|
'status.token-stored': 'Сессия Discord не активна',
|
||||||
|
'status.qr-verifying': 'Проверяем вход…',
|
||||||
|
'status.logging-out': 'Завершение сеанса…',
|
||||||
|
'status.reconnecting': 'Переподключаюсь к Discord…',
|
||||||
|
// --- Section headers ---------------------------------------------------
|
||||||
|
'card.login-qr.name': 'Войти по QR-коду',
|
||||||
|
// Discord QR требует МОБИЛЬНОЕ приложение Discord (legacy remoteauth
|
||||||
|
// не работает с десктопным клиентом) — это важная подсказка, чтобы у
|
||||||
|
// пользователя без мобильного клиента не возникло тупика «попробовал
|
||||||
|
// и не работает».
|
||||||
|
'card.login-qr.desc': 'Отсканировать QR из мобильного приложения Discord',
|
||||||
|
'card.refresh.aria': 'Обновить статус',
|
||||||
|
'card.refresh.label': 'Обновить статус',
|
||||||
|
'card.refresh.name': 'Обновить статус',
|
||||||
|
'card.refresh.desc': 'Перепроверить, привязан ли Discord',
|
||||||
|
'card.refresh.in-flight': 'Проверяю…',
|
||||||
|
// --- About panel -------------------------------------------------------
|
||||||
|
'card.about.name': 'Как работает Discord-бот',
|
||||||
|
'card.about.desc': 'Вход, безопасность и исходный код',
|
||||||
|
'about.title': 'О боте Discord',
|
||||||
|
'about.body-1':
|
||||||
|
'Этот бот подключает Discord к Vojo. После входа личные чаты и серверы Discord появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в Discord.',
|
||||||
|
'about.body-2':
|
||||||
|
'Для входа нужен мобильный клиент Discord — отсканируйте QR-код через «Настройки → Сканировать QR-код». Десктопный Discord или браузер для входа не подходят: используется механизм remoteauth, который доступен только в мобильном приложении.',
|
||||||
|
'about.body-3':
|
||||||
|
'Подключение работает через open-source мост mautrix-discord. Он создаёт Discord-сессию на сервере Vojo и использует её для связи Discord с вашим аккаунтом Vojo: получает сообщения из Discord и отправляет ваши ответы обратно.',
|
||||||
|
'about.github-label': 'Исходный код моста открыт на GitHub:',
|
||||||
|
'about.github-url': 'https://github.com/mautrix/discord',
|
||||||
|
'about.body-4':
|
||||||
|
'Отозвать доступ можно в любой момент — кнопкой «Выйти из Discord» здесь, либо в самом Discord через «Настройки → Устройства → Выйти из Vojo».',
|
||||||
|
'about.close': 'Закрыть',
|
||||||
|
'about.aria-close': 'Закрыть «О боте»',
|
||||||
|
// --- QR form -----------------------------------------------------------
|
||||||
|
// Discord QR не ротируется в отличие от Telegram MTProto — мост держит
|
||||||
|
// одну сессию remoteauth до успеха, ошибки или таймаута. Поэтому в
|
||||||
|
// тексте говорим про «отсканируйте этот QR-код», без указаний на
|
||||||
|
// обновление, и таймаут показываем «всего окна» одной строкой.
|
||||||
|
'auth-card.qr.title': 'Вход по QR-коду',
|
||||||
|
'auth-card.qr.hint': 'Откройте мобильный Discord и отсканируйте этот QR-код.',
|
||||||
|
'auth-card.qr.preparing': 'Готовим QR-код…',
|
||||||
|
'auth-card.qr.aria': 'QR-код для входа в Discord. Отсканируйте его телефоном.',
|
||||||
|
'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}',
|
||||||
|
'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
||||||
|
'auth-card.qr.step-1': 'Откройте мобильное приложение Discord.',
|
||||||
|
'auth-card.qr.step-2': 'Откройте «Настройки → Сканировать QR-код».',
|
||||||
|
'auth-card.qr.step-3': 'Отсканируйте QR-код и подтвердите вход на телефоне.',
|
||||||
|
// --- hCaptcha challenge -----------------------------------------------
|
||||||
|
// Discord иногда требует решить hCaptcha перед завершением remoteauth —
|
||||||
|
// anti-abuse-сигнал, не зависящий от конкретного юзера. Vojo-патч
|
||||||
|
// отдаёт челлендж сюда в виджет; пользователь решает, токен уходит
|
||||||
|
// обратно на мост и логин завершается. Текст не упоминает «бан»: это
|
||||||
|
// обычный механизм Discord, а не санкция.
|
||||||
|
'auth-card.captcha.title': 'Подтвердите, что вы не робот',
|
||||||
|
'auth-card.captcha.hint':
|
||||||
|
'Discord попросил решить капчу. Решите её ниже — после этого вход продолжится автоматически.',
|
||||||
|
'auth-card.captcha.load-error':
|
||||||
|
'Не удалось загрузить капчу. Проверьте сеть и нажмите «Отмена», затем войдите снова.',
|
||||||
|
// --- Shared form chrome ------------------------------------------------
|
||||||
|
// Cancel в Discord-flow ЛОКАЛЬНЫЙ: legacy-мост не имеет команды отмены
|
||||||
|
// активного login-qr, поэтому кнопка просто возвращает виджет в
|
||||||
|
// disconnected, а серверная сторона сама истекает по таймауту remoteauth
|
||||||
|
// (~2 минуты по умолчанию). Это написано в about.body-2, и пользователь,
|
||||||
|
// увидев «Окно входа истекло», понимает, что стало с QR.
|
||||||
|
'auth-card.cancel': 'Отмена',
|
||||||
|
'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.',
|
||||||
|
// --- Inline errors -----------------------------------------------------
|
||||||
|
'auth-error.captcha-required':
|
||||||
|
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
||||||
|
'auth-error.captcha-send-failed':
|
||||||
|
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
|
||||||
|
'auth-error.captcha-expired': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
||||||
|
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
||||||
|
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||||
|
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
||||||
|
'auth-error.connect-after-login-failed':
|
||||||
|
'Вход прошёл, но соединиться с Discord не получилось: {reason}',
|
||||||
|
'auth-error.already-logged-in': 'Вы уже вошли в Discord — обновите статус.',
|
||||||
|
'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.',
|
||||||
|
'auth-error.disconnect-failed': 'Не удалось отключиться: {reason}',
|
||||||
|
// --- Logout / Reconnect ------------------------------------------------
|
||||||
|
// Reconnect-action нужен только в connection_dead / token_stored —
|
||||||
|
// здоровая сессия не показывает кнопку. Текст глагольный, без префиксов.
|
||||||
|
'card.reconnect.name': 'Переподключиться',
|
||||||
|
'card.reconnect.desc': 'Восстановить соединение с Discord без повторного входа',
|
||||||
|
'card.logout.name': 'Выйти из Discord',
|
||||||
|
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
|
||||||
|
'card.logout.confirm-prompt': 'Точно выйти?',
|
||||||
|
'card.logout.confirm-yes': 'Выйти',
|
||||||
|
'card.logout.confirm-no': 'Отмена',
|
||||||
|
// --- Open Discord space (Vojo bridge sentinel) ------------------------
|
||||||
|
'card.open-space.name': 'Открыть в Каналах',
|
||||||
|
'card.open-space.desc': 'Перейти в спейс Discord со списком чатов и серверов',
|
||||||
|
// --- Diagnostics in transcript ----------------------------------------
|
||||||
|
'diag.space-ready': 'Discord-спейс готов к открытию.',
|
||||||
|
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
||||||
|
'diag.ready': 'Готов отправлять команды.',
|
||||||
|
'diag.checking-status': 'Проверяю статус подключения…',
|
||||||
|
'diag.send-failed': 'ошибка отправки: {message}',
|
||||||
|
'diag.history-marker': '─── история ───',
|
||||||
|
'diag.history-unavailable': 'Не удалось прочитать историю — проверяю статус заново.',
|
||||||
|
// QR-сообщения никогда не выводятся целиком в transcript — body содержит
|
||||||
|
// токен `https://discord.com/ra/…`, который мост стирает после скана;
|
||||||
|
// сохранять его в DOM-логе виджета означало бы пережить эту защиту.
|
||||||
|
// Поэтому в логе только нейтральные диагностические строки.
|
||||||
|
'diag.qr-issued': 'QR-код выдан.',
|
||||||
|
'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.',
|
||||||
|
'diag.captcha-issued': 'Discord прислал CAPTCHA — решите её на форме выше.',
|
||||||
|
// --- Bootstrap failure -------------------------------------------------
|
||||||
|
'bootstrap.failed': 'Widget не запустился',
|
||||||
|
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
|
||||||
|
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StringKey = keyof typeof RU;
|
||||||
62
apps/widget-discord/src/main.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { readBootstrap } from './bootstrap';
|
||||||
|
import { App } from './App';
|
||||||
|
import { createT } from './i18n';
|
||||||
|
import { WidgetApi, buildCapabilities } from './widget-api';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
// Input-mode detector — see apps/widget-telegram/src/main.tsx for the
|
||||||
|
// full rationale. Default to 'mouse'; the capture-phase pointerdown
|
||||||
|
// listener flips to 'touch' on the first non-mouse pointerType.
|
||||||
|
// matchMedia guessing was dropped — every variant
|
||||||
|
// (`any-pointer: coarse|fine`, `hover: hover`, `pointer: fine|coarse`)
|
||||||
|
// is mis-reported on at least one shipping device.
|
||||||
|
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 someone opened the widget URL directly (no host params), or a
|
||||||
|
// host bug failed to provide them. Render a self-contained diagnostic
|
||||||
|
// instead of going silent. Bootstrap failed before we could read
|
||||||
|
// clientLanguage, so let createT fall back to navigator.language.
|
||||||
|
const t = createT();
|
||||||
|
render(
|
||||||
|
<div class="app">
|
||||||
|
<div class="error-banner">
|
||||||
|
<strong>{t('bootstrap.failed')}</strong>
|
||||||
|
{t('bootstrap.missing-params', { names: result.missing.join(', ') })}{' '}
|
||||||
|
{t('bootstrap.embedded-only', { route: '/bots/discord' })}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
root
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Apply initial theme synchronously so the first paint isn't flashed
|
||||||
|
// through the wrong palette.
|
||||||
|
document.documentElement.dataset.theme = result.bootstrap.theme;
|
||||||
|
|
||||||
|
// Instantiate WidgetApi BEFORE React render. The constructor attaches
|
||||||
|
// the message listener synchronously, so by the time the host's
|
||||||
|
// ClientWidgetApi fires its capabilities request on iframe `load`,
|
||||||
|
// we're already listening. Constructing inside a useEffect would race
|
||||||
|
// with the cached-bundle remount path. See widget-telegram for full
|
||||||
|
// rationale.
|
||||||
|
const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId));
|
||||||
|
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||||
|
}
|
||||||
939
apps/widget-discord/src/state.ts
Normal file
|
|
@ -0,0 +1,939 @@
|
||||||
|
// Login state machine — consumes LoginEvent (one per inbound bridge bot
|
||||||
|
// reply) and emits a typed UI state. The widget renders the QR panel and
|
||||||
|
// the status pill from this state, never from raw reply strings.
|
||||||
|
//
|
||||||
|
// Discord vs Telegram differences:
|
||||||
|
// - QR-only: there's no phone/code/password ladder, so the state space
|
||||||
|
// is much smaller than the Telegram reducer.
|
||||||
|
// - status comes from `ping` (legacy mautrix command system), not
|
||||||
|
// `list-logins` (bridgev2). The four ping replies map to four states:
|
||||||
|
// disconnected / connected / connection_dead / token_stored.
|
||||||
|
// - no list-logins-derived `loginId`; logout is the bare `logout` verb,
|
||||||
|
// so the connected state doesn't need to gate on a login id.
|
||||||
|
// - the QR is NOT rotated by Discord remoteauth (single image per
|
||||||
|
// login attempt). The state machine still tracks `qrEventId` so the
|
||||||
|
// redaction handler can match against it and ignore unrelated cleanup.
|
||||||
|
//
|
||||||
|
// State-gating policy: late-arriving replies from cancelled flows must
|
||||||
|
// not resurrect dead state. The `cancel_pending` action ALWAYS lands us
|
||||||
|
// in `disconnected` immediately; later bridge events arriving after
|
||||||
|
// cancel are filtered by the live reducer.
|
||||||
|
|
||||||
|
import type { LoginEvent } from './bridge-protocol/types';
|
||||||
|
|
||||||
|
export type LoginErrorFlag =
|
||||||
|
| { kind: 'login_failed'; reason?: string }
|
||||||
|
| { kind: 'captcha_required' }
|
||||||
|
// Local rollback flag — fired when the widget couldn't deliver
|
||||||
|
// `login-captcha <token>` to the bridge (transport / Matrix-API failure
|
||||||
|
// before the bridge even saw the command). Distinct from
|
||||||
|
// `login_failed` (which IS a bridge reply) so the UX can read «sign-in
|
||||||
|
// didn't reach the bot, retry» instead of hinting at a Discord-side
|
||||||
|
// problem the user can't act on.
|
||||||
|
| { kind: 'captcha_send_failed' }
|
||||||
|
// The captcha challenge timed out (rqtoken expiry on Discord's side)
|
||||||
|
// before the user solved it. Surfaced as a soft warning to retry
|
||||||
|
// login-qr from scratch.
|
||||||
|
| { kind: 'captcha_expired' }
|
||||||
|
| { kind: 'login_websocket_failed'; reason?: string }
|
||||||
|
| { kind: 'connect_after_login_failed'; reason?: string }
|
||||||
|
| { kind: 'prepare_login_failed'; reason?: string }
|
||||||
|
| { kind: 'already_logged_in' }
|
||||||
|
| { kind: 'unknown_command' };
|
||||||
|
// `reconnect_failed` is intentionally NOT a LoginErrorFlag arm: the live
|
||||||
|
// reducer routes that event back to `connected_dead` (no error surface
|
||||||
|
// there — the connected-dead pill IS the error indicator) without
|
||||||
|
// staging a reason for `localizeError`. If a future UI change wants to
|
||||||
|
// surface the reason, add `lastError?: ...` to the connected_dead state
|
||||||
|
// shape and route `reconnect_failed` through it.
|
||||||
|
|
||||||
|
// A live form is open and waiting for user action. M-discord ships with
|
||||||
|
// only one: the QR panel. Hydrate's restorable shape collapses to this
|
||||||
|
// single variant + the `qr_verifying` interstitial.
|
||||||
|
export type PendingFormState = {
|
||||||
|
kind: 'awaiting_qr_scan';
|
||||||
|
discordUrl: string;
|
||||||
|
qrEventId: string;
|
||||||
|
firstShownAt: number;
|
||||||
|
lastError?: LoginErrorFlag;
|
||||||
|
};
|
||||||
|
|
||||||
|
// hCaptcha challenge surfaced by the Vojo-patched bridge after Discord
|
||||||
|
// returned 400+captcha-required. The widget renders the hCaptcha iframe
|
||||||
|
// from `sitekey` + `rqdata`; on solve, the App sends `login-captcha
|
||||||
|
// <token>` and we transition to `qr_verifying` until the bridge replies.
|
||||||
|
export type CaptchaSolveState = {
|
||||||
|
kind: 'awaiting_captcha_solve';
|
||||||
|
service: string;
|
||||||
|
sitekey: string;
|
||||||
|
sessionId: string;
|
||||||
|
rqdata: string;
|
||||||
|
rqtoken: string;
|
||||||
|
firstShownAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginState =
|
||||||
|
// Pre-handshake / pre-ping. Status pill: --faint.
|
||||||
|
| { kind: 'unknown' }
|
||||||
|
// ping returned `not_logged_in`, OR logout completed. Status pill:
|
||||||
|
// --rose. The card grid offers the QR-login affordance.
|
||||||
|
| { kind: 'disconnected'; lastError?: LoginErrorFlag }
|
||||||
|
// QR-login in progress. Optimistically transitioned by `start_qr_login`;
|
||||||
|
// overwritten with real discordUrl/qrEventId by the live `qr_displayed`
|
||||||
|
// event. Status pill: --amber.
|
||||||
|
| PendingFormState
|
||||||
|
// hCaptcha challenge from Discord — Vojo-patched bridge surfaced the
|
||||||
|
// sitekey + rqdata via `VOJO-CAPTCHA-CHALLENGE-V1` notice. The widget
|
||||||
|
// renders the hCaptcha iframe; on solve we send `login-captcha <token>`
|
||||||
|
// and transition to `qr_verifying`. Status pill: --amber.
|
||||||
|
| CaptchaSolveState
|
||||||
|
// QR was redacted (i.e. the bridge accepted a scan), but we don't yet
|
||||||
|
// know whether login succeeded. Held as an intermediate spinner until
|
||||||
|
// the next bridge signal arrives. Status pill: --amber.
|
||||||
|
| { kind: 'qr_verifying' }
|
||||||
|
// logout in flight — waiting for `Logged out successfully.`. Status
|
||||||
|
// pill: --amber.
|
||||||
|
| { kind: 'logging_out' }
|
||||||
|
// reconnect in flight (recovery from connection_dead / token_stored).
|
||||||
|
// Waiting for `Successfully reconnected` or `You're already connected`.
|
||||||
|
// Status pill: --amber. `handle` is carried through from the
|
||||||
|
// connected_dead state so a successful reconnect can flip directly to
|
||||||
|
// `connected{handle}` without bouncing through a transient `unknown`
|
||||||
|
// (which would briefly paint a faint «Проверка статуса…» pill — bad
|
||||||
|
// UX immediately after the user took an action).
|
||||||
|
| { kind: 'reconnecting'; handle?: string }
|
||||||
|
// Live session — ping or login_success confirmed. Discord legacy bridge
|
||||||
|
// doesn't have a per-account loginId concept (single Discord account
|
||||||
|
// per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl`
|
||||||
|
// is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands
|
||||||
|
// right after login_success; it survives the post-login re-ping and the
|
||||||
|
// reconnect-ok transitions so the «Open in Channels» card stays visible
|
||||||
|
// until logout. Absent until the sentinel arrives (and absent forever
|
||||||
|
// against an UNPATCHED bridge — the card simply never appears).
|
||||||
|
| { kind: 'connected'; handle: string; discordId?: string; spaceMatrixToUrl?: string }
|
||||||
|
// ping says we have a token but the connection's down. Status pill:
|
||||||
|
// green-ish but with a Reconnect recovery action exposed. The reducer
|
||||||
|
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
|
||||||
|
// (we have the token but never got far enough to connect), but the UI
|
||||||
|
// collapses both into the same shape — they share the recovery path.
|
||||||
|
| { kind: 'connected_dead'; reason: 'connection_dead' | 'token_stored'; handle?: string };
|
||||||
|
|
||||||
|
// States that the hydrate path can restore after a reload. The QR panel
|
||||||
|
// (`awaiting_qr_scan`) survives reloads via the m.image / m.room.redaction
|
||||||
|
// timeline; `qr_verifying` covers the post-scan pre-success interstitial;
|
||||||
|
// `awaiting_captcha_solve` covers the case where the user reloads while
|
||||||
|
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
|
||||||
|
// often valid for a couple of minutes — fresh enough to reuse). Other
|
||||||
|
// transient states (logging_out, reconnecting) deliberately don't survive.
|
||||||
|
export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' };
|
||||||
|
|
||||||
|
// Outbound user actions the App dispatches. Form-submit actions clear any
|
||||||
|
// pending lastError; structural transitions optimistically advance state —
|
||||||
|
// the App rolls them back on send-failure where the bot would otherwise
|
||||||
|
// leave us stuck.
|
||||||
|
export type LoginAction =
|
||||||
|
| { kind: 'event'; event: LoginEvent }
|
||||||
|
| { kind: 'start_qr_login' } // user clicked «Войти по QR»
|
||||||
|
| { kind: 'request_logout' } // user clicked «Выйти из Discord»
|
||||||
|
// user clicked «Переподключиться» — App passes the current handle
|
||||||
|
// (from `connected_dead.handle` or `connected.handle`) so the
|
||||||
|
// transient `reconnecting` state carries it forward; without this the
|
||||||
|
// post-reconnect_ok branch can't paint the connected pill until the
|
||||||
|
// follow-up ping resolves.
|
||||||
|
| { kind: 'request_reconnect'; handle?: string }
|
||||||
|
// Discord legacy mautrix has no `cancel` command. Cancel is LOCAL —
|
||||||
|
// returns the widget to disconnected immediately; the bridge's
|
||||||
|
// remoteauth websocket eventually times out on its own. The action is
|
||||||
|
// kept symmetrical with TG's reducer for shape consistency, but
|
||||||
|
// dispatching it doesn't trigger any send.
|
||||||
|
| { kind: 'cancel_pending' }
|
||||||
|
// User finished an hCaptcha challenge — token is non-empty. Optimistic
|
||||||
|
// transition to `qr_verifying`; the App fires `login-captcha <token>`
|
||||||
|
// and the bridge's reply (`login_success` / chained `captcha_challenge`
|
||||||
|
// / `login_failed`) lands via the live event stream.
|
||||||
|
| { kind: 'submit_captcha_token' }
|
||||||
|
// Rollback for `submit_captcha_token` — the App couldn't deliver the
|
||||||
|
// command to the bridge. Routes back to disconnected with a localized
|
||||||
|
// error so the user sees what happened.
|
||||||
|
| { kind: 'captcha_send_failed' }
|
||||||
|
// hCaptcha challenge expired (server rqtoken TTL or local 90s timer).
|
||||||
|
// Routes to disconnected with a localized warn.
|
||||||
|
| { kind: 'captcha_expired' }
|
||||||
|
| { kind: 'hydrate'; state: HydrateRestoredState };
|
||||||
|
|
||||||
|
export const initialLoginState: LoginState = { kind: 'unknown' };
|
||||||
|
|
||||||
|
const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_qr_scan';
|
||||||
|
|
||||||
|
// States that a fresh captcha challenge can clobber: the QR scan has
|
||||||
|
// landed (or is mid-flight), or we're already showing a previous
|
||||||
|
// challenge that Discord chained on top.
|
||||||
|
const isCaptchaAcceptingState = (
|
||||||
|
s: LoginState
|
||||||
|
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
|
||||||
|
s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve';
|
||||||
|
|
||||||
|
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
||||||
|
if (action.kind === 'hydrate') {
|
||||||
|
// hydrate is a one-shot mount-time seed. If a live event already
|
||||||
|
// moved us off `unknown`, the live truth wins; the cached timeline
|
||||||
|
// snapshot is by definition older than what the live event just told
|
||||||
|
// us. Without this gate, a stale `awaiting_qr_scan` from a previous
|
||||||
|
// session could overwrite a legitimate `connected` that arrived
|
||||||
|
// during the readTimeline await.
|
||||||
|
if (state.kind !== 'unknown') return state;
|
||||||
|
return action.state;
|
||||||
|
}
|
||||||
|
if (action.kind === 'start_qr_login') {
|
||||||
|
// Optimistic placeholder; the live `qr_displayed` event overwrites
|
||||||
|
// discordUrl + qrEventId + firstShownAt. If the `!discord login-qr`
|
||||||
|
// send fails, the App rolls back to `disconnected`.
|
||||||
|
return {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: '',
|
||||||
|
qrEventId: '',
|
||||||
|
firstShownAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action.kind === 'request_logout') {
|
||||||
|
return { kind: 'logging_out' };
|
||||||
|
}
|
||||||
|
if (action.kind === 'request_reconnect') {
|
||||||
|
return { kind: 'reconnecting', handle: action.handle };
|
||||||
|
}
|
||||||
|
if (action.kind === 'cancel_pending') {
|
||||||
|
// Optimistic: drop straight back to disconnected. Discord legacy mautrix
|
||||||
|
// has no `cancel` command — the bridge's remoteauth websocket continues
|
||||||
|
// until it succeeds or times out internally. From the user's POV the
|
||||||
|
// widget returns to disconnected, and any later QR redaction / login
|
||||||
|
// success / login failure event from the abandoned flow is filtered
|
||||||
|
// by the per-event gates below (qr_redacted gated on awaiting_qr_scan,
|
||||||
|
// login_success / login_failed gated on awaiting_qr_scan|qr_verifying).
|
||||||
|
return { kind: 'disconnected' };
|
||||||
|
}
|
||||||
|
if (action.kind === 'submit_captcha_token') {
|
||||||
|
// User solved hCaptcha and we're about to fire login-captcha. Hold
|
||||||
|
// `qr_verifying` until the bridge replies (success / chained challenge
|
||||||
|
// / generic login_failed). Only honour from the captcha state — this
|
||||||
|
// action is App-emitted right after the hCaptcha callback, so it
|
||||||
|
// shouldn't ever fire from anywhere else, but defensive: a stale send
|
||||||
|
// shouldn't suddenly paint a verifying spinner over a connected pill.
|
||||||
|
if (state.kind !== 'awaiting_captcha_solve') return state;
|
||||||
|
return { kind: 'qr_verifying' };
|
||||||
|
}
|
||||||
|
if (action.kind === 'captcha_send_failed') {
|
||||||
|
// App couldn't ship the `login-captcha` command (transport / Matrix
|
||||||
|
// API failure before the bridge saw it). Roll the optimistic
|
||||||
|
// `qr_verifying` back to disconnected with a localized error.
|
||||||
|
//
|
||||||
|
// Honour ONLY from `qr_verifying` — narrowed from also accepting
|
||||||
|
// `awaiting_captcha_solve` to avoid clobbering a fresh chained
|
||||||
|
// captcha that may have arrived between the optimistic dispatch and
|
||||||
|
// the failed-send rollback. If the live state already moved to a
|
||||||
|
// newer challenge, the stale send-failure should be silently dropped.
|
||||||
|
if (state.kind !== 'qr_verifying') return state;
|
||||||
|
return { kind: 'disconnected', lastError: { kind: 'captcha_send_failed' } };
|
||||||
|
}
|
||||||
|
if (action.kind === 'captcha_expired') {
|
||||||
|
// Local 90s timer or hCaptcha's expired-callback fired — the rqtoken
|
||||||
|
// is dead, the user has nothing to solve. Route to disconnected with
|
||||||
|
// a localized warn so they retry login-qr from scratch.
|
||||||
|
if (state.kind !== 'awaiting_captcha_solve') return state;
|
||||||
|
return { kind: 'disconnected', lastError: { kind: 'captcha_expired' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = action.event;
|
||||||
|
switch (event.kind) {
|
||||||
|
// --- ping replies ----------------------------------------------------
|
||||||
|
|
||||||
|
case 'not_logged_in':
|
||||||
|
// Accept from states where flipping to disconnected is correct.
|
||||||
|
// Late-arriving `not_logged_in` MUST NOT clobber an active QR-scan
|
||||||
|
// (which was started after the ping was fired but before the reply
|
||||||
|
// landed) — that's the same race the TG reducer guards against.
|
||||||
|
if (
|
||||||
|
state.kind === 'unknown' ||
|
||||||
|
state.kind === 'disconnected' ||
|
||||||
|
state.kind === 'logging_out' ||
|
||||||
|
state.kind === 'qr_verifying' ||
|
||||||
|
state.kind === 'reconnecting' ||
|
||||||
|
state.kind === 'connected_dead'
|
||||||
|
) {
|
||||||
|
return { kind: 'disconnected' };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
case 'logged_in':
|
||||||
|
// Authoritative source — accept from any state. Used by both the
|
||||||
|
// initial ping AND the post-`login_success` re-ping that picks up
|
||||||
|
// the discordId snowflake. Preserve `spaceMatrixToUrl` from a prior
|
||||||
|
// `connected` so the post-login_success re-ping doesn't blank the
|
||||||
|
// CTA before the user gets a chance to click it.
|
||||||
|
return {
|
||||||
|
kind: 'connected',
|
||||||
|
handle: event.handle,
|
||||||
|
discordId: event.discordId,
|
||||||
|
spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'connection_dead':
|
||||||
|
// ping says token's good but the WS is down. Show the connected
|
||||||
|
// chrome with a Reconnect recovery action.
|
||||||
|
return {
|
||||||
|
kind: 'connected_dead',
|
||||||
|
reason: 'connection_dead',
|
||||||
|
handle: state.kind === 'connected' ? state.handle : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'token_stored_not_connected':
|
||||||
|
return {
|
||||||
|
kind: 'connected_dead',
|
||||||
|
reason: 'token_stored',
|
||||||
|
handle: state.kind === 'connected' ? state.handle : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- QR lifecycle ----------------------------------------------------
|
||||||
|
|
||||||
|
case 'qr_displayed': {
|
||||||
|
// Defence-in-depth: an inbound qr_displayed MUST carry a non-empty
|
||||||
|
// event id (the host driver rejects empty event_id at the sanitizer;
|
||||||
|
// this is a redundant guard).
|
||||||
|
if (event.eventId.length === 0) return state;
|
||||||
|
|
||||||
|
// Initial QR from a fresh login attempt — accept from:
|
||||||
|
// * `unknown` — cold-start before ping resolves;
|
||||||
|
// * placeholder `awaiting_qr_scan{qrEventId=''}` from start_qr_login.
|
||||||
|
//
|
||||||
|
// We DO NOT accept from `disconnected`. Discord legacy mautrix has
|
||||||
|
// no cancel command, so when the user clicks Cancel locally the
|
||||||
|
// bridge's remoteauth goroutine continues until success / failure
|
||||||
|
// / internal timeout. The widget transitions to `disconnected`
|
||||||
|
// immediately, but the bridge eventually emits the m.image. If we
|
||||||
|
// accepted that here, the user would see a QR they didn't ask for
|
||||||
|
// — the bridge has no way to know the user moved on. Drop it
|
||||||
|
// silently; the user has to click «Войти по QR» again to express
|
||||||
|
// intent (which resets the placeholder and lets the next m.image
|
||||||
|
// land).
|
||||||
|
if (
|
||||||
|
state.kind === 'unknown' ||
|
||||||
|
(state.kind === 'awaiting_qr_scan' && state.qrEventId === '')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: event.discordUrl,
|
||||||
|
qrEventId: event.eventId,
|
||||||
|
firstShownAt:
|
||||||
|
state.kind === 'awaiting_qr_scan' && state.firstShownAt
|
||||||
|
? state.firstShownAt
|
||||||
|
: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind !== 'awaiting_qr_scan') return state;
|
||||||
|
|
||||||
|
// Hypothetical edit pointing at our anchor — repaint URL, keep id.
|
||||||
|
// Discord doesn't currently edit QRs but the path stays for
|
||||||
|
// forward-compat (cheaper to keep than to reconstruct).
|
||||||
|
if (event.replacesEventId === state.qrEventId) {
|
||||||
|
return { ...state, discordUrl: event.discordUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh non-edit qr_displayed while we're already tracking one —
|
||||||
|
// could be a bridge-side restart (rare). Adopt as new anchor.
|
||||||
|
if (!event.replacesEventId) {
|
||||||
|
return {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: event.discordUrl,
|
||||||
|
qrEventId: event.eventId,
|
||||||
|
firstShownAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit pointing at something we don't track — ignore.
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'qr_redacted': {
|
||||||
|
// Bridge cleaned up the QR after a successful scan (commands.go
|
||||||
|
// l.197: `_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)`
|
||||||
|
// — only fires on the success path). Held as `qr_verifying` until
|
||||||
|
// the success line lands. Only honour from awaiting_qr_scan with a
|
||||||
|
// matching event id.
|
||||||
|
if (state.kind !== 'awaiting_qr_scan') return state;
|
||||||
|
if (state.qrEventId !== event.redactsEventId) return state;
|
||||||
|
return { kind: 'qr_verifying' };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'login_success':
|
||||||
|
// Honour from any non-terminal state. The bridge's success line
|
||||||
|
// doesn't include the discordId; the App fires `ping` afterwards
|
||||||
|
// to upgrade to the full `connected{handle, discordId}` shape.
|
||||||
|
return { kind: 'connected', handle: event.handle };
|
||||||
|
|
||||||
|
case 'login_failed':
|
||||||
|
// Generic Discord-side login failure — bridge replies «Error logging
|
||||||
|
// in: <go-error>». Routes back to disconnected with the verbatim
|
||||||
|
// reason as a warn line. Only honour when a QR flow is in flight,
|
||||||
|
// OR while the user is solving a captcha (the Vojo-patched bridge
|
||||||
|
// can also reply «Error logging in: …» AFTER `login-captcha` if
|
||||||
|
// Discord rejects the post-solve replay). Otherwise it's stale
|
||||||
|
// (e.g. an old failure replaying after page reload while the user
|
||||||
|
// is already connected).
|
||||||
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
|
return {
|
||||||
|
kind: 'disconnected',
|
||||||
|
lastError: { kind: 'login_failed', reason: event.reason },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'captcha_required':
|
||||||
|
// UNPATCHED bridge fallback: «CAPTCHAs are currently not supported».
|
||||||
|
// Surface as a hint suggesting token-login (chat-fallback only). On
|
||||||
|
// a Vojo-patched bridge this branch never fires — see captcha_challenge.
|
||||||
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
|
return { kind: 'disconnected', lastError: { kind: 'captcha_required' } };
|
||||||
|
|
||||||
|
case 'captcha_challenge':
|
||||||
|
// Vojo-patched bridge surfaced an hCaptcha challenge — pivot the
|
||||||
|
// widget to the captcha screen. Accept from awaiting_qr_scan
|
||||||
|
// (challenge landed before the QR was redacted), qr_verifying
|
||||||
|
// (challenge landed while we were still in the post-redact spinner)
|
||||||
|
// or awaiting_captcha_solve (Discord chained another challenge after
|
||||||
|
// the previous solve). Other states drop the event silently — a
|
||||||
|
// stale challenge from an abandoned flow shouldn't repaint UI.
|
||||||
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
|
return {
|
||||||
|
kind: 'awaiting_captcha_solve',
|
||||||
|
service: event.service,
|
||||||
|
sitekey: event.sitekey,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
rqdata: event.rqdata,
|
||||||
|
rqtoken: event.rqtoken,
|
||||||
|
firstShownAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'login_websocket_failed':
|
||||||
|
// Pre-QR failure: couldn't reach Discord remoteauth. The QR was
|
||||||
|
// never displayed in the first place. State `awaiting_qr_scan` with
|
||||||
|
// empty discordUrl is the placeholder set by `start_qr_login`;
|
||||||
|
// this fires before the first qr_displayed lands.
|
||||||
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
|
return {
|
||||||
|
kind: 'disconnected',
|
||||||
|
lastError: { kind: 'login_websocket_failed', reason: event.reason },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'connect_after_login_failed':
|
||||||
|
// Post-scan rare: remoteauth gave us a token, but the bridge couldn't
|
||||||
|
// connect to Discord with it. The bridge has the token cached and
|
||||||
|
// might recover on next ping; we still route to disconnected so the
|
||||||
|
// user can retry.
|
||||||
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
|
return {
|
||||||
|
kind: 'disconnected',
|
||||||
|
lastError: { kind: 'connect_after_login_failed', reason: event.reason },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'prepare_login_failed':
|
||||||
|
if (!isCaptchaAcceptingState(state)) return state;
|
||||||
|
return {
|
||||||
|
kind: 'disconnected',
|
||||||
|
lastError: { kind: 'prepare_login_failed', reason: event.reason },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'already_logged_in':
|
||||||
|
// The user clicked «Войти по QR» but the bridge is already logged
|
||||||
|
// in — race against ping. Surface a soft warning and let the App's
|
||||||
|
// re-ping reconcile to the connected state.
|
||||||
|
if (isFormState(state)) {
|
||||||
|
return { ...state, lastError: { kind: 'already_logged_in' } };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
// --- logout ----------------------------------------------------------
|
||||||
|
|
||||||
|
case 'logout_ok':
|
||||||
|
case 'logout_no_op':
|
||||||
|
// Late `Logged out` from a previous session can arrive while the
|
||||||
|
// user is mid-new-flow. Only honour from logging_out; other states
|
||||||
|
// keep their flow.
|
||||||
|
if (state.kind !== 'logging_out') return state;
|
||||||
|
return { kind: 'disconnected' };
|
||||||
|
|
||||||
|
// --- disconnect (read-only, never sent by widget) -------------------
|
||||||
|
|
||||||
|
case 'disconnect_ok':
|
||||||
|
case 'disconnect_no_op':
|
||||||
|
// User typed `disconnect` manually in chat-fallback while the widget
|
||||||
|
// was open. Reflect the bridge's truth: no token-loss, but no live
|
||||||
|
// connection either — same shape as `token_stored`. Both
|
||||||
|
// `connected` (string handle) and `connected_dead` (handle?:
|
||||||
|
// string) expose `handle` on the same key, so a single read works.
|
||||||
|
if (state.kind === 'connected' || state.kind === 'connected_dead') {
|
||||||
|
return {
|
||||||
|
kind: 'connected_dead',
|
||||||
|
reason: 'token_stored',
|
||||||
|
handle: state.handle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
case 'disconnect_failed':
|
||||||
|
// Manual disconnect attempt failed — keep current state, the widget
|
||||||
|
// doesn't surface a UI for this since it never sent the command.
|
||||||
|
return state;
|
||||||
|
|
||||||
|
// --- reconnect -------------------------------------------------------
|
||||||
|
|
||||||
|
case 'reconnect_ok':
|
||||||
|
case 'reconnect_no_op':
|
||||||
|
// After a successful reconnect, ping is the source of truth for the
|
||||||
|
// handle. The App fires `ping` after this event lands to refresh.
|
||||||
|
// We flip to `connected` immediately so the user sees an immediate
|
||||||
|
// green pill confirming their click; the post-event ping refreshes
|
||||||
|
// the handle / discordId within ~100ms. Both `reconnecting` and
|
||||||
|
// `connected_dead` carry `handle?` — a missing handle still flips
|
||||||
|
// green with an empty handle, which the UI's
|
||||||
|
// `state.handle ? connected-as : connected` ternary tolerates.
|
||||||
|
// This avoids the `unknown` flap that the previous draft would
|
||||||
|
// produce when no handle was stashed. spaceMatrixToUrl is not
|
||||||
|
// restorable from connected_dead (the dead state never carried it),
|
||||||
|
// so the CTA stays hidden until a fresh sentinel arrives — bridge
|
||||||
|
// does NOT re-emit on reconnect, but the card returns once the user
|
||||||
|
// explicitly re-logs in.
|
||||||
|
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
|
||||||
|
return { kind: 'connected', handle: state.handle ?? '' };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
case 'space_ready':
|
||||||
|
// Vojo-patched bridge surfaced the personal Discord space — attach
|
||||||
|
// its matrix.to URL to the connected state so the «Open in Channels»
|
||||||
|
// card renders. Late-arriving sentinels from an abandoned flow drop
|
||||||
|
// here silently (e.g. a sentinel that lands during `logging_out`
|
||||||
|
// mustn't resurrect a connected state). Honour only from the
|
||||||
|
// canonical alive states.
|
||||||
|
if (state.kind === 'connected') {
|
||||||
|
return { ...state, spaceMatrixToUrl: event.matrixToUrl };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
case 'reconnect_failed':
|
||||||
|
if (state.kind !== 'reconnecting') return state;
|
||||||
|
// Roll back to connected_dead carrying the previous handle. The
|
||||||
|
// user can hit Reconnect again or refresh. We don't surface the
|
||||||
|
// error reason here — the connected_dead pill itself reads as
|
||||||
|
// «something is wrong, try Reconnect» — adding a transient red
|
||||||
|
// banner adjacent to a recovery affordance is overkill.
|
||||||
|
return {
|
||||||
|
kind: 'connected_dead',
|
||||||
|
reason: 'connection_dead',
|
||||||
|
handle: state.handle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- bridge-side errors ---------------------------------------------
|
||||||
|
|
||||||
|
case 'unknown_command':
|
||||||
|
// Shouldn't happen — we only send commands the bridge knows. Visible
|
||||||
|
// when /config.json's commandPrefix drifts from the bridge's actual
|
||||||
|
// command_prefix. Surface loudly on disconnected.
|
||||||
|
return { kind: 'disconnected', lastError: { kind: 'unknown_command' } };
|
||||||
|
|
||||||
|
case 'unknown':
|
||||||
|
return state;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Exhaustiveness check — TS flags this if a new LoginEvent kind is
|
||||||
|
// added without a case here.
|
||||||
|
const exhaustive: never = event;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- hydrate-from-timeline -----------------------------------------------
|
||||||
|
//
|
||||||
|
// Discord's hydrate is simpler than Telegram's because the QR flow has
|
||||||
|
// fewer states. We walk past→present and let each event freely transition
|
||||||
|
// the state — like the TG hydrate, this is permissive (no out-of-thin-air
|
||||||
|
// rejection) because we trust the bridge's durable timeline.
|
||||||
|
|
||||||
|
// 3 minutes — Discord remoteauth's server-side timeout sits around 2
|
||||||
|
// minutes (verified empirically against v0.7.6's remoteauth/client.go;
|
||||||
|
// no explicit constant in the lib, the server-side gateway closes the
|
||||||
|
// websocket on inactivity). We use 3 min as a slight safety margin so
|
||||||
|
// reload-after-success grace still works while the panel is still
|
||||||
|
// fresh enough to scan. Telegram's QR rotates internally and lives ~10
|
||||||
|
// min, which is why the TG widget uses 10 min — Discord's single-shot
|
||||||
|
// remoteauth needs the tighter window.
|
||||||
|
const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000;
|
||||||
|
// hCaptcha rqtoken is even more short-lived than the QR ticket — Discord
|
||||||
|
// invalidates after ~90s in practice. If the user reloads while staring
|
||||||
|
// at a captcha challenge older than this, restoring the captcha screen
|
||||||
|
// only sets them up for a server-side rejection on solve. Drop the state
|
||||||
|
// instead and let live ping reconcile.
|
||||||
|
const CAPTCHA_HYDRATE_FRESHNESS_MS = 90 * 1000;
|
||||||
|
|
||||||
|
export type HydrateInput = {
|
||||||
|
ev: LoginEvent;
|
||||||
|
ts: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HydrateAccumulator = {
|
||||||
|
state: LoginState;
|
||||||
|
pendingTs: number | null;
|
||||||
|
terminated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => {
|
||||||
|
const { ev, ts } = input;
|
||||||
|
|
||||||
|
// After a terminal event we normally stop — except if a fresh
|
||||||
|
// `qr_displayed` shows up, that's the bridge signature of a NEW login
|
||||||
|
// flow. The user cancelled (or finished) and is now logging in again;
|
||||||
|
// the chain should resume tracking from the new start. Without this
|
||||||
|
// re-entry, `[qr_displayed, login_success, qr_displayed]` (logout-then-
|
||||||
|
// re-login-mid-QR) would return null.
|
||||||
|
if (prevAcc.terminated && ev.kind !== 'qr_displayed') {
|
||||||
|
return prevAcc;
|
||||||
|
}
|
||||||
|
// Restart-on-re-entry: clear the terminated bit AND any prior tracked
|
||||||
|
// state so the new flow's first event becomes the new anchor without
|
||||||
|
// inheriting the old QR's eventId.
|
||||||
|
const acc: HydrateAccumulator = prevAcc.terminated
|
||||||
|
? { state: { kind: 'unknown' }, pendingTs: null, terminated: false }
|
||||||
|
: prevAcc;
|
||||||
|
|
||||||
|
switch (ev.kind) {
|
||||||
|
case 'qr_displayed': {
|
||||||
|
// Same anchor logic as the live reducer.
|
||||||
|
if (acc.state.kind !== 'awaiting_qr_scan') {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: ev.discordUrl,
|
||||||
|
qrEventId: ev.eventId,
|
||||||
|
firstShownAt: ts,
|
||||||
|
},
|
||||||
|
pendingTs: ts,
|
||||||
|
terminated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ev.replacesEventId === acc.state.qrEventId) {
|
||||||
|
return {
|
||||||
|
state: { ...acc.state, discordUrl: ev.discordUrl },
|
||||||
|
pendingTs: ts,
|
||||||
|
terminated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!ev.replacesEventId) {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: ev.discordUrl,
|
||||||
|
qrEventId: ev.eventId,
|
||||||
|
firstShownAt: ts,
|
||||||
|
},
|
||||||
|
pendingTs: ts,
|
||||||
|
terminated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'qr_redacted': {
|
||||||
|
if (acc.state.kind !== 'awaiting_qr_scan') return acc;
|
||||||
|
if (acc.state.qrEventId !== ev.redactsEventId) return acc;
|
||||||
|
// Move into qr_verifying and keep the chain open — the success line
|
||||||
|
// typically follows in the same scan window.
|
||||||
|
return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'captcha_challenge': {
|
||||||
|
// SECURITY: only accept a captcha challenge if the same chain has
|
||||||
|
// already seen a `qr_displayed` (i.e. WE initiated the login).
|
||||||
|
// Otherwise a malicious / compromised homeserver could craft an
|
||||||
|
// m.notice with the sentinel JSON pointing at an attacker-controlled
|
||||||
|
// sitekey, the user solves it on reload, and the resulting hCaptcha
|
||||||
|
// token is sent verbatim to the bridge — useful free captcha-solving
|
||||||
|
// labour for the attacker, and a phishing surface. The live reducer
|
||||||
|
// already gates on `isCaptchaAcceptingState` (which requires we're
|
||||||
|
// mid-flow), but the hydrate path replays raw timeline events
|
||||||
|
// without the live state — drop unsolicited challenges here.
|
||||||
|
if (acc.state.kind !== 'awaiting_qr_scan' && acc.state.kind !== 'qr_verifying') {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
// Vojo-patched bridge surfaced an hCaptcha — keep the chain open so a
|
||||||
|
// later `login_success` / `login_failed` still lands as terminal.
|
||||||
|
// The rqdata/rqtoken are short-lived on Discord's side (~2 min);
|
||||||
|
// the captcha-specific freshness gate in `hydrateFromTimeline`
|
||||||
|
// (CAPTCHA_HYDRATE_FRESHNESS_MS) drops stale states before they
|
||||||
|
// surface to the user.
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
kind: 'awaiting_captcha_solve',
|
||||||
|
service: ev.service,
|
||||||
|
sitekey: ev.sitekey,
|
||||||
|
sessionId: ev.sessionId,
|
||||||
|
rqdata: ev.rqdata,
|
||||||
|
rqtoken: ev.rqtoken,
|
||||||
|
firstShownAt: ts,
|
||||||
|
},
|
||||||
|
pendingTs: ts,
|
||||||
|
terminated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal events — collapse the chain. State becomes whatever the
|
||||||
|
// bot confirmed last; the caller returns null and lets live `ping`
|
||||||
|
// reconcile.
|
||||||
|
case 'login_success':
|
||||||
|
case 'logged_in':
|
||||||
|
case 'logout_ok':
|
||||||
|
case 'logout_no_op':
|
||||||
|
case 'not_logged_in':
|
||||||
|
case 'connection_dead':
|
||||||
|
case 'token_stored_not_connected':
|
||||||
|
case 'reconnect_ok':
|
||||||
|
case 'reconnect_no_op':
|
||||||
|
case 'reconnect_failed':
|
||||||
|
case 'disconnect_ok':
|
||||||
|
case 'disconnect_no_op':
|
||||||
|
case 'disconnect_failed':
|
||||||
|
case 'login_failed':
|
||||||
|
case 'captcha_required':
|
||||||
|
case 'login_websocket_failed':
|
||||||
|
case 'connect_after_login_failed':
|
||||||
|
case 'prepare_login_failed':
|
||||||
|
case 'unknown_command':
|
||||||
|
return { state: acc.state, pendingTs: null, terminated: true };
|
||||||
|
|
||||||
|
case 'already_logged_in':
|
||||||
|
case 'unknown':
|
||||||
|
case 'space_ready':
|
||||||
|
// Soft no-op for hydrate. already_logged_in is a live-flow warning
|
||||||
|
// that doesn't reflect persistent state; unknown is a wording-drift
|
||||||
|
// catch-all; space_ready is a post-terminal sentinel — hydrate
|
||||||
|
// terminates on login_success and lets live ping reconcile, so
|
||||||
|
// the URL gets attached on the live path, not here.
|
||||||
|
return acc;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = ev;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hydrateFromTimeline = (
|
||||||
|
inputs: ReadonlyArray<HydrateInput>,
|
||||||
|
now: number = Date.now()
|
||||||
|
): HydrateRestoredState | null => {
|
||||||
|
const acc = inputs.reduce<HydrateAccumulator>(stepHydrate, {
|
||||||
|
state: { kind: 'unknown' },
|
||||||
|
pendingTs: null,
|
||||||
|
terminated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (acc.terminated) return null;
|
||||||
|
if (acc.pendingTs === null) return null;
|
||||||
|
// Tighter freshness gate for captcha state — rqtoken expires faster
|
||||||
|
// than the QR ticket. This protects the user from a "solved captcha,
|
||||||
|
// bridge rejects, user confused" UX after a slow reload.
|
||||||
|
const freshness =
|
||||||
|
acc.state.kind === 'awaiting_captcha_solve'
|
||||||
|
? CAPTCHA_HYDRATE_FRESHNESS_MS
|
||||||
|
: HYDRATE_FRESHNESS_MS;
|
||||||
|
if (now - acc.pendingTs > freshness) return null;
|
||||||
|
if (acc.state.kind === 'qr_verifying') return acc.state;
|
||||||
|
if (acc.state.kind === 'awaiting_captcha_solve') return acc.state;
|
||||||
|
if (!isFormState(acc.state)) return null;
|
||||||
|
return acc.state;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DEV sanity assertions ------------------------------------------------
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
runHydrateSanity();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runHydrateSanity(): void {
|
||||||
|
const t0 = 1_700_000_000_000;
|
||||||
|
const recent = (offset: number) => t0 + offset;
|
||||||
|
const now = t0 + 60 * 1000;
|
||||||
|
|
||||||
|
const cases: Array<{
|
||||||
|
name: string;
|
||||||
|
inputs: HydrateInput[];
|
||||||
|
expected: LoginState | null;
|
||||||
|
nowOverride?: number;
|
||||||
|
}> = [
|
||||||
|
{ name: 'empty timeline → null', inputs: [], expected: null },
|
||||||
|
{
|
||||||
|
name: 'lone qr_displayed → awaiting_qr_scan',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: 'https://discord.com/ra/A',
|
||||||
|
qrEventId: '$qrA',
|
||||||
|
firstShownAt: recent(0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qr_redacted with mismatched target → ignored',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) },
|
||||||
|
],
|
||||||
|
expected: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: 'https://discord.com/ra/A',
|
||||||
|
qrEventId: '$qrA',
|
||||||
|
firstShownAt: recent(0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qr scan → no follow-up → qr_verifying (reload during the gap)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) },
|
||||||
|
],
|
||||||
|
expected: { kind: 'qr_verifying' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qr scan → login_success → null (terminal — let ping reconcile)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) },
|
||||||
|
{ ev: { kind: 'login_success', handle: 'example' }, ts: recent(31000) },
|
||||||
|
],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'login_failed after qr → null (terminal)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'login_failed', reason: 'rate limited' }, ts: recent(15000) },
|
||||||
|
],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'captcha_required after qr → null (terminal)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'captcha_required' }, ts: recent(10000) },
|
||||||
|
],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'logout-then-relogin-mid-qr → awaiting_qr_scan (resume tracking)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/OLD', eventId: '$qrOld' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrOld' }, ts: recent(15000) },
|
||||||
|
{ ev: { kind: 'login_success', handle: 'old' }, ts: recent(16000) },
|
||||||
|
{ ev: { kind: 'logout_ok' }, ts: recent(20000) },
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/NEW', eventId: '$qrNew' },
|
||||||
|
ts: recent(25000),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: 'https://discord.com/ra/NEW',
|
||||||
|
qrEventId: '$qrNew',
|
||||||
|
firstShownAt: recent(25000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pending too old (5 min) → null (freshness guard, 3-min window)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: {
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
discordUrl: 'https://discordapp.com/ra/A',
|
||||||
|
eventId: '$qrA',
|
||||||
|
},
|
||||||
|
ts: t0 - 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: null,
|
||||||
|
nowOverride: t0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pending just inside window (2 min) → state',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: {
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
discordUrl: 'https://discordapp.com/ra/A',
|
||||||
|
eventId: '$qrA',
|
||||||
|
},
|
||||||
|
ts: t0 - 2 * 60 * 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: 'https://discordapp.com/ra/A',
|
||||||
|
qrEventId: '$qrA',
|
||||||
|
firstShownAt: t0 - 2 * 60 * 1000,
|
||||||
|
},
|
||||||
|
nowOverride: t0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'connection_dead alone → null (terminal — let live ping reconcile)',
|
||||||
|
inputs: [{ ev: { kind: 'connection_dead' }, ts: recent(0) }],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'token_stored_not_connected alone → null (terminal — let live ping reconcile)',
|
||||||
|
inputs: [{ ev: { kind: 'token_stored_not_connected' }, ts: recent(0) }],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'logged_in alone → null (terminal — let live ping reconcile)',
|
||||||
|
inputs: [{ ev: { kind: 'logged_in', handle: 'x' }, ts: recent(0) }],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'unknown alone → null',
|
||||||
|
inputs: [{ ev: { kind: 'unknown' }, ts: recent(0) }],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of cases) {
|
||||||
|
const actual = hydrateFromTimeline(c.inputs, c.nowOverride ?? now);
|
||||||
|
if (!sameLoginState(actual, c.expected)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[hydrate sanity] mismatch', { case: c.name, actual, expected: c.expected });
|
||||||
|
throw new Error(`hydrate sanity failed: ${c.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameLoginState(a: LoginState | null, b: LoginState | null): boolean {
|
||||||
|
if (a === null || b === null) return a === b;
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
794
apps/widget-discord/src/styles.css
Normal file
|
|
@ -0,0 +1,794 @@
|
||||||
|
/* Dawn palette — must stay in sync with
|
||||||
|
* docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
|
||||||
|
* (DAWN const, lines 4-23). The widget renders inside the Vojo chat slot
|
||||||
|
* which is itself a Dawn surface; the iframe inherits the same visual
|
||||||
|
* canon to feel like a continuation of the host.
|
||||||
|
*
|
||||||
|
* Identical visual vocabulary to apps/widget-telegram/src/styles.css —
|
||||||
|
* the Discord widget keeps fleet-violet (Vojo accent) rather than
|
||||||
|
* adopting Discord blurple, per product decision: «used Vojo style». */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #181a20;
|
||||||
|
--bg2: #0d0e11;
|
||||||
|
--surface: #21232b;
|
||||||
|
--surface2: #2a2d36;
|
||||||
|
--divider: rgba(255, 255, 255, 0.06);
|
||||||
|
--hairline: rgba(255, 255, 255, 0.08);
|
||||||
|
--text: #e6e6e9;
|
||||||
|
--muted: rgba(230, 230, 233, 0.55);
|
||||||
|
--faint: rgba(230, 230, 233, 0.32);
|
||||||
|
--fleet: #9580ff;
|
||||||
|
--fleet-soft: #a59cff;
|
||||||
|
--green: #7dd3a8;
|
||||||
|
--amber: #d4b88a;
|
||||||
|
--rose: #c08e7b;
|
||||||
|
--section-pad-x: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] {
|
||||||
|
/* Light theme is intentionally a thin remap. Vojo is dark-default; the
|
||||||
|
* theme param exists so we don't fight an explicit user/host setting,
|
||||||
|
* not because we expect daily light-mode use. */
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--bg2: #ffffff;
|
||||||
|
--surface: #f0f0f2;
|
||||||
|
--surface2: #e8e8ec;
|
||||||
|
--divider: rgba(0, 0, 0, 0.08);
|
||||||
|
--hairline: rgba(0, 0, 0, 0.1);
|
||||||
|
--text: #1a1a1d;
|
||||||
|
--muted: rgba(26, 26, 29, 0.62);
|
||||||
|
--faint: rgba(26, 26, 29, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:root {
|
||||||
|
--section-pad-x: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Kills the translucent grey overlay iOS/Android WebViews paint on top
|
||||||
|
* of any tapped element. Web browsers ignore this. */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.45 -apple-system, 'Segoe UI', 'Inter', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hero is OWNED BY THE HOST (BotShellHero). The widget body starts
|
||||||
|
* with the active-state section directly. */
|
||||||
|
|
||||||
|
/* ── Section ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 24px var(--section-pad-x) 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section + .section {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status pill — non-interactive (no cursor:pointer, no hover). The pill
|
||||||
|
* carries the section's identity for stateful sections. */
|
||||||
|
.section-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status.connected {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.section-status.connected .dot {
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 0 3px rgba(125, 211, 168, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status.disconnected {
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
.section-status.disconnected .dot {
|
||||||
|
background: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status.checking {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.section-status.checking .dot {
|
||||||
|
background: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section row: status pill + recovery button (refresh / reconnect /
|
||||||
|
* cancel) when the state has no other affordance. Without this row, the
|
||||||
|
* user can stare at a «Проверка статуса…» pill forever if the first
|
||||||
|
* ping reply dropped on the wire. */
|
||||||
|
.section-recovery-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.section-recovery-row > .section-status {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
:root[data-input='mouse'] .recovery-action:hover:not(:disabled) {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--hairline);
|
||||||
|
}
|
||||||
|
.recovery-action:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.recovery-action svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Command card (action card with name + desc + chevron) ──────── */
|
||||||
|
|
||||||
|
.command-card {
|
||||||
|
/* `appearance:none` strips native WebView focus paint that otherwise
|
||||||
|
* sits ON TOP of our explicit background — see telegram widget for
|
||||||
|
* the full debugging trail (Capacitor Android WebView holds native
|
||||||
|
* focus paint until focus moves elsewhere). */
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
transition: border-color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover scoped to mouse-mode sessions only — Capacitor Android WebView
|
||||||
|
* reports `(hover: hover)` as TRUE on a pure-touch device, so a media-
|
||||||
|
* query gate doesn't work. `[data-input]` is set in main.tsx from the
|
||||||
|
* actual `pointerdown.pointerType`. */
|
||||||
|
:root[data-input='mouse'] .command-card:hover:not(:disabled) {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-input='mouse'] .command-card:focus-visible {
|
||||||
|
outline: 2px solid var(--fleet);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-name {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-chevron {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-chevron svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic leading-icon slot — every command-card carries a semantic
|
||||||
|
* left-side glyph (mirror of the right-side chevron). Picks up
|
||||||
|
* `currentColor` from the parent and stays muted by default;
|
||||||
|
* `.danger` deliberately does NOT colour the lead icon so the rose
|
||||||
|
* accent stays reserved for the title. */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spin the leading refresh icon while the card is in its `refreshing`
|
||||||
|
* in-flight state. The selector targets the lead slot since the
|
||||||
|
* refresh card moved its glyph from the chevron (right) to the lead
|
||||||
|
* slot (left) for parity with every other card. */
|
||||||
|
.command-card.refreshing .command-card-lead-icon svg {
|
||||||
|
animation: command-card-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes command-card-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Transcript ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.transcript {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--surface2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.transcript::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.transcript::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--surface2);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid var(--bg2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
.transcript::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 2px solid var(--bg2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line {
|
||||||
|
padding: 4px 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line + .transcript-line {
|
||||||
|
border-top: 1px dashed var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line .ts {
|
||||||
|
color: var(--faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line .body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.from-bot .body {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.from-user .body {
|
||||||
|
color: var(--fleet-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.diag .body {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.error .body {
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-empty {
|
||||||
|
color: var(--faint);
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Destructive card — red name marks logout as destructive vs the primary
|
||||||
|
* login card. The hover border stays on the generic
|
||||||
|
* `:root[data-input='mouse'] .command-card:hover` rule (hairline) so the
|
||||||
|
* accent is consistent across touch and mouse modes — a previous
|
||||||
|
* `.command-card.danger:hover { border-color: var(--rose) }` override
|
||||||
|
* was dead in mouse mode (lower specificity than the input-gated rule)
|
||||||
|
* and only fired in the pre-first-pointerdown touch stub, leaking a
|
||||||
|
* rose tint that looked amber on the dark surface. */
|
||||||
|
.command-card.danger .command-card-name {
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline confirm-in-place body for the destructive logout card. */
|
||||||
|
.command-card-confirm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-prompt {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-yes,
|
||||||
|
.command-card-confirm-no,
|
||||||
|
.btn-primary,
|
||||||
|
.btn-text {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-yes {
|
||||||
|
background: var(--rose);
|
||||||
|
color: #0c0c0e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-no {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-yes:disabled,
|
||||||
|
.command-card-confirm-no:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Auth card (QR panel chrome) ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card.error {
|
||||||
|
border-color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-hint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--fleet);
|
||||||
|
color: #0c0c0e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn-text:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-error {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-warn {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-countdown {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 18px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
transition: color 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.auth-card-countdown.expired {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── QR-login panel ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Override the auth-card row layout — QR panel stacks vertically with the
|
||||||
|
* matrix as the visual anchor. */
|
||||||
|
.auth-card-qr {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The QR matrix sits on a hard #fff plate regardless of theme — phone
|
||||||
|
* camera scanners need maximum contrast. */
|
||||||
|
.auth-card-qr-frame {
|
||||||
|
align-self: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* Lock the inner box to the SVG's rendered size so the placeholder
|
||||||
|
* variant doesn't collapse to zero height while the matrix is being
|
||||||
|
* computed. */
|
||||||
|
min-width: 260px;
|
||||||
|
min-height: 260px;
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06), 0 12px 24px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-qr-placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: rgba(26, 26, 29, 0.62);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
padding: 96px 16px;
|
||||||
|
}
|
||||||
|
.auth-card-qr-placeholder .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--amber);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-qr-steps {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.4em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 19px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.auth-card-qr-steps li::marker {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── hCaptcha challenge panel ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.auth-card-captcha {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hCaptcha renders its own iframe inside this host. We just provide a
|
||||||
|
* minimum frame height so the layout doesn't collapse during script load,
|
||||||
|
* and centre the iframe horizontally. The hCaptcha widget is a fixed-
|
||||||
|
* width 304px element by default; on narrow viewports we let it overflow
|
||||||
|
* its container's flex line rather than try to scale the iframe (the
|
||||||
|
* upstream SDK handles its own responsive variants). */
|
||||||
|
.auth-card-captcha-frame {
|
||||||
|
align-self: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 88px;
|
||||||
|
}
|
||||||
|
.auth-card-captcha-host {
|
||||||
|
/* hCaptcha injects a 304x78 (normal) or 156x144 (compact) iframe. The
|
||||||
|
* default rendering is 'normal' — we size accordingly and let the SDK
|
||||||
|
* place its own iframe inside. */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.auth-card-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.btn-primary,
|
||||||
|
.btn-text {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.command-card-name {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.command-card-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-qr-frame {
|
||||||
|
min-width: 232px;
|
||||||
|
min-height: 232px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.auth-card-qr-placeholder {
|
||||||
|
padding: 80px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Linkified transcript bodies ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.transcript-line a {
|
||||||
|
color: var(--fleet-soft);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.transcript-line a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Diagnostic banner (pre-bootstrap failure) ───────────────────── */
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
margin: var(--section-pad-x);
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(192, 142, 123, 0.08);
|
||||||
|
border: 1px solid var(--rose);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--rose);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--rose);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner code {
|
||||||
|
background: var(--bg2);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── About modal ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.about-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(13, 14, 17, 0.72);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 20px;
|
||||||
|
animation: about-fade 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes about-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-panel {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-close-x {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.about-close-x:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-body {
|
||||||
|
padding: 16px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.about-body p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.about-body a {
|
||||||
|
color: var(--fleet-soft);
|
||||||
|
text-decoration: underline;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.about-body a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-footer {
|
||||||
|
padding: 12px 18px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
border-top: 1px solid var(--divider);
|
||||||
|
}
|
||||||
1
apps/widget-discord/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
335
apps/widget-discord/src/widget-api.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
// Minimal matrix-widget-api transport implemented inline. Mirrors the
|
||||||
|
// Telegram widget's transport (apps/widget-telegram/src/widget-api.ts);
|
||||||
|
// the postMessage protocol is bot-agnostic and the host-side
|
||||||
|
// BotWidgetDriver / BotWidgetEmbed treat every bot identically.
|
||||||
|
//
|
||||||
|
// Protocol shapes match
|
||||||
|
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
|
||||||
|
// (in the host repo). Default request timeout on the host transport is
|
||||||
|
// 10 s — keep that in mind for bridge-bot replies that take time.
|
||||||
|
|
||||||
|
import type { WidgetBootstrap } from './bootstrap';
|
||||||
|
|
||||||
|
export type RoomEvent = {
|
||||||
|
type: string;
|
||||||
|
event_id: string;
|
||||||
|
room_id: string;
|
||||||
|
sender: string;
|
||||||
|
origin_server_ts: number;
|
||||||
|
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
||||||
|
unsigned: Record<string, unknown>;
|
||||||
|
// `m.room.redaction` events carry `redacts` at the top level (room v < 11)
|
||||||
|
// and/or inside `content.redacts` (v11+). The host driver mirrors at both
|
||||||
|
// for forward-compat; the widget-side parser reads either.
|
||||||
|
redacts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToWidgetMessage = {
|
||||||
|
api: 'toWidget';
|
||||||
|
widgetId: string;
|
||||||
|
requestId: string;
|
||||||
|
action: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
// Present when this message IS a reply to a prior toWidget request.
|
||||||
|
// Per matrix-widget-api PostmessageTransport: replies preserve the original
|
||||||
|
// `api` field and add `response`. Both directions follow the same shape.
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FromWidgetMessage = {
|
||||||
|
api: 'fromWidget';
|
||||||
|
widgetId: string;
|
||||||
|
requestId: string;
|
||||||
|
action: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Capability = string;
|
||||||
|
|
||||||
|
export type WidgetApiEvents = {
|
||||||
|
ready: () => void;
|
||||||
|
liveEvent: (ev: RoomEvent) => void;
|
||||||
|
themeChange: (name: 'light' | 'dark') => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export class WidgetApi {
|
||||||
|
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
|
||||||
|
|
||||||
|
private readonly pending = new Map<
|
||||||
|
string,
|
||||||
|
{ resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
|
||||||
|
>();
|
||||||
|
|
||||||
|
private requestSeq = 0;
|
||||||
|
|
||||||
|
private isReady = false;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly bootstrap: WidgetBootstrap,
|
||||||
|
private readonly capabilities: Capability[]
|
||||||
|
) {
|
||||||
|
window.addEventListener('message', this.onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
window.removeEventListener('message', this.onMessage);
|
||||||
|
this.pending.forEach(({ reject }) => reject(new Error('disposed')));
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
|
||||||
|
const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
|
||||||
|
list.push(listener);
|
||||||
|
// `ready` is a one-shot lifecycle signal. If the handshake completed
|
||||||
|
// before this listener attached (cached-bundle race: host fires the
|
||||||
|
// capabilities request on iframe `load`, the WidgetApi catches and
|
||||||
|
// resolves it during script init, then React's useEffect runs *after*
|
||||||
|
// that and attaches the `ready` listener), replay synchronously so
|
||||||
|
// App.tsx still flips `handshakeOk` and fires the initial probe.
|
||||||
|
if (event === 'ready' && this.isReady) {
|
||||||
|
(listener as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendText(body: string): Promise<{ event_id: string }> {
|
||||||
|
return this.fromWidget('send_event', {
|
||||||
|
type: 'm.room.message',
|
||||||
|
content: { msgtype: 'm.text', body },
|
||||||
|
}) as Promise<{ event_id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open an external URL via the host. The host receives this on a
|
||||||
|
// SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from
|
||||||
|
// matrix-widget-api's `fromWidget` so it doesn't route through
|
||||||
|
// ClientWidgetApi's request/response machinery.
|
||||||
|
//
|
||||||
|
// Why this exists: cross-origin iframes inside Capacitor's Android
|
||||||
|
// WebView silently drop `<a target="_blank">` clicks — the WebView
|
||||||
|
// doesn't have a multi-window concept, and the host's global
|
||||||
|
// `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks
|
||||||
|
// inside the host document, not inside the iframe (cross-origin
|
||||||
|
// events don't bubble across the frame boundary). The widget posts
|
||||||
|
// this message instead; the host calls `openExternalUrl(url)` which
|
||||||
|
// routes to `Browser.open` on native and `window.open` on web.
|
||||||
|
public openExternalUrl(url: string): void {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
api: 'io.vojo.bot-widget',
|
||||||
|
action: 'open-external-url',
|
||||||
|
data: { url },
|
||||||
|
},
|
||||||
|
this.bootstrap.parentOrigin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask the host to navigate to a matrix.to URL inside the cinny app
|
||||||
|
// (room or space). Same side-channel pattern as `openExternalUrl` —
|
||||||
|
// distinct from matrix-widget-api's `fromWidget` so the SDK stays
|
||||||
|
// ignorant of this Vojo extension. The host validates the URL via
|
||||||
|
// `parseMatrixToRoom` (rejecting non-room URLs, javascript:/data:, etc.)
|
||||||
|
// BEFORE routing into the react-router; sending anything that isn't a
|
||||||
|
// matrix.to/#/!roomId or matrix.to/#/#alias URL silently no-ops on the
|
||||||
|
// host side. The widget is responsible for only invoking this when it
|
||||||
|
// genuinely has a matrix.to room URL (e.g. parsed from a bridge
|
||||||
|
// sentinel).
|
||||||
|
public openMatrixToUrl(url: string): void {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
api: 'io.vojo.bot-widget',
|
||||||
|
action: 'open-matrix-to',
|
||||||
|
data: { url },
|
||||||
|
},
|
||||||
|
this.bootstrap.parentOrigin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always prefix outbound commands with `<commandPrefix> ` (trailing space).
|
||||||
|
// Legacy mautrix-discord routes management-room commands through the
|
||||||
|
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
||||||
|
// management room the prefix is required, inside it's optional but stays
|
||||||
|
// unambiguous when other text is present. We always send the prefix —
|
||||||
|
// works in both cases, never wrong.
|
||||||
|
public sendCommand(rawBody: string): Promise<{ event_id: string }> {
|
||||||
|
const body = `${this.bootstrap.commandPrefix} ${rawBody}`;
|
||||||
|
return this.sendText(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline-resume probe. Action name is MSC2876 (`read_events`); the
|
||||||
|
// capability is MSC2762 timeline (already requested at construction). We
|
||||||
|
// pass `room_ids: [bootstrap.roomId]` explicitly so the host's
|
||||||
|
// ClientWidgetApi takes the modern code path that calls our driver's
|
||||||
|
// `readRoomTimeline` (single-room cap-checked) rather than the deprecated
|
||||||
|
// `readRoomEvents` fallback. Driver returns events newest-first; reversing
|
||||||
|
// to chronological order is the caller's job.
|
||||||
|
//
|
||||||
|
// `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR
|
||||||
|
// post-scan cleanup events. `msgtype` is honoured only for m.room.message.
|
||||||
|
public async readTimeline(opts: {
|
||||||
|
limit: number;
|
||||||
|
type?: 'm.room.message' | 'm.room.redaction';
|
||||||
|
msgtype?: 'm.text' | 'm.notice' | 'm.image';
|
||||||
|
}): Promise<RoomEvent[]> {
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
type: opts.type ?? 'm.room.message',
|
||||||
|
limit: opts.limit,
|
||||||
|
room_ids: [this.bootstrap.roomId],
|
||||||
|
};
|
||||||
|
if (opts.msgtype !== undefined) data.msgtype = opts.msgtype;
|
||||||
|
const res = await this.fromWidget('org.matrix.msc2876.read_events', data);
|
||||||
|
return (res.events as RoomEvent[] | undefined) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit<K extends keyof WidgetApiEvents>(
|
||||||
|
event: K,
|
||||||
|
...args: Parameters<WidgetApiEvents[K]>
|
||||||
|
): void {
|
||||||
|
const list = this.listeners[event] as
|
||||||
|
| Array<(...a: Parameters<WidgetApiEvents[K]>) => void>
|
||||||
|
| undefined;
|
||||||
|
list?.forEach((fn) => fn(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextRequestId(): string {
|
||||||
|
this.requestSeq += 1;
|
||||||
|
return `widget-discord-${Date.now()}-${this.requestSeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private postToHost(msg: ToWidgetMessage | FromWidgetMessage): void {
|
||||||
|
window.parent.postMessage(msg, this.bootstrap.parentOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage = (ev: MessageEvent): void => {
|
||||||
|
if (ev.origin !== this.bootstrap.parentOrigin) return;
|
||||||
|
// Source-window guard — see telegram widget for full rationale.
|
||||||
|
if (ev.source !== window.parent) return;
|
||||||
|
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
|
||||||
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
if (msg.widgetId !== this.bootstrap.widgetId) return;
|
||||||
|
|
||||||
|
if (msg.api === 'toWidget') {
|
||||||
|
this.handleToWidget(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.api === 'fromWidget' && msg.response) {
|
||||||
|
const pending = this.pending.get(msg.requestId);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(msg.requestId);
|
||||||
|
const err = (msg.response as { error?: { message?: string } }).error;
|
||||||
|
if (err) pending.reject(new Error(err.message ?? 'request failed'));
|
||||||
|
else pending.resolve(msg.response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private replyTo(msg: ToWidgetMessage, response: Record<string, unknown>): void {
|
||||||
|
this.postToHost({
|
||||||
|
api: msg.api,
|
||||||
|
widgetId: msg.widgetId,
|
||||||
|
requestId: msg.requestId,
|
||||||
|
action: msg.action,
|
||||||
|
data: msg.data,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleToWidget(msg: ToWidgetMessage): void {
|
||||||
|
if (!msg.requestId || !msg.action) return;
|
||||||
|
switch (msg.action) {
|
||||||
|
case 'capabilities': {
|
||||||
|
this.replyTo(msg, { capabilities: this.capabilities });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'notify_capabilities': {
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
if (!this.isReady) {
|
||||||
|
this.isReady = true;
|
||||||
|
this.emit('ready');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'supported_api_versions': {
|
||||||
|
this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'theme_change': {
|
||||||
|
const name = (msg.data?.name as string | undefined) ?? '';
|
||||||
|
const themed: 'light' | 'dark' = name === 'dark' ? 'dark' : 'light';
|
||||||
|
this.emit('themeChange', themed);
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'send_event': {
|
||||||
|
// Live event push from host. Forward `m.room.message` (carries the
|
||||||
|
// bot's notices / errors / `m.image` QR-login broadcasts) AND
|
||||||
|
// `m.room.redaction` (post-scan QR cleanup, see BotWidgetDriver
|
||||||
|
// `sanitizeBotWidgetRedactionEvent`).
|
||||||
|
const data = msg.data as Partial<RoomEvent> | undefined;
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
data.event_id &&
|
||||||
|
(data.type === 'm.room.message' || data.type === 'm.room.redaction')
|
||||||
|
) {
|
||||||
|
this.emit('liveEvent', data as RoomEvent);
|
||||||
|
}
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'update_state': {
|
||||||
|
// Initial room state push from host (m.room.member members) — ignored.
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// Be liberal — reply empty so the host's request promise resolves.
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fromWidget(
|
||||||
|
action: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const requestId = this.nextRequestId();
|
||||||
|
this.pending.set(requestId, { resolve, reject });
|
||||||
|
this.postToHost({
|
||||||
|
api: 'fromWidget',
|
||||||
|
widgetId: this.bootstrap.widgetId,
|
||||||
|
requestId,
|
||||||
|
action,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (this.pending.has(requestId)) {
|
||||||
|
this.pending.delete(requestId);
|
||||||
|
reject(new Error(`${action} timed out after ${FROM_WIDGET_REQUEST_TIMEOUT_MS}ms`));
|
||||||
|
}
|
||||||
|
}, FROM_WIDGET_REQUEST_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability set must match the host's BotWidgetDriver.getBotWidgetCapabilities.
|
||||||
|
// The driver is bot-agnostic — the same allowlist is applied for telegram
|
||||||
|
// and discord. Discord-specific additions would have to land in
|
||||||
|
// BotWidgetDriver first.
|
||||||
|
//
|
||||||
|
// `m.image` carries the QR login URL in `content.body` (the host sanitizer
|
||||||
|
// strips `url` / `file` / `info`, so only the URL string survives); we
|
||||||
|
// render the QR client-side from that URL via `qrcode-generator`.
|
||||||
|
// `m.room.redaction` is how the bridge signals «QR consumed by a successful
|
||||||
|
// scan» — see mautrix-discord/commands.go::fnLoginQR which redacts the QR
|
||||||
|
// event after the remoteauth websocket completes.
|
||||||
|
export const buildCapabilities = (roomId: string): Capability[] => [
|
||||||
|
`org.matrix.msc2762.timeline:${roomId}`,
|
||||||
|
'org.matrix.msc2762.send.event:m.room.message#m.text',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.text',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.notice',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.image',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.redaction',
|
||||||
|
'org.matrix.msc2762.receive.state_event:m.room.member',
|
||||||
|
];
|
||||||
21
apps/widget-discord/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
27
apps/widget-discord/vite.config.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
// Build artefact lives at apps/widget-discord/dist/. The deploy step (out
|
||||||
|
// of repo) rsyncs this into ~/vojo/widgets/discord/ on the server, which
|
||||||
|
// Caddy serves from /var/www/widgets/discord via the widgets.vojo.chat
|
||||||
|
// block — same shape as the Telegram widget, different sub-path.
|
||||||
|
//
|
||||||
|
// `base: './'` keeps every generated asset path relative so the same
|
||||||
|
// build can sit under /discord/ on widgets.vojo.chat without rewrites.
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
plugins: [preact()],
|
||||||
|
build: {
|
||||||
|
target: 'es2020',
|
||||||
|
sourcemap: true,
|
||||||
|
// Inline CSS for a single round-trip; the widget is small and the
|
||||||
|
// host's iframe handshake budget is already tight (10s default).
|
||||||
|
cssCodeSplit: false,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// Port 8082 — telegram widget owns 8081, host SPA owns 8080.
|
||||||
|
// Both widget dev servers can run side by side without conflict.
|
||||||
|
port: 8082,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
4
apps/widget-telegram/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
*.local
|
||||||
176
apps/widget-telegram/README.md
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
# @vojo/widget-telegram
|
||||||
|
|
||||||
|
Vojo Telegram bridge management widget — mounts inside `/bots/telegram`
|
||||||
|
in the Vojo client. See [`docs/plans/bots_tab.md`](../../docs/plans/bots_tab.md)
|
||||||
|
Phase 3 for product context and the matrix-widget-api contract.
|
||||||
|
|
||||||
|
This is **not** a Telegram client. It's a small panel that drives the
|
||||||
|
mautrix-telegram bridge bot (`@telegrambot:vojo.chat`) by sending text
|
||||||
|
commands in the control DM and rendering the bot's text replies. M11
|
||||||
|
ships only the bootstrap + a `ping` button to verify the host handshake.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── bootstrap.ts Parse URL params the host appends (matches BotWidgetEmbed.ts)
|
||||||
|
├── widget-api.ts Inline matrix-widget-api postMessage transport (no SDK)
|
||||||
|
├── App.tsx UI: bootstrap card, action buttons, transcript pane
|
||||||
|
├── main.tsx Entry: init bootstrap, render App or diagnostic
|
||||||
|
└── styles.css Theme-aware CSS variables
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
**Don't touch the committed `config.json`.** Create `config.local.json` at
|
||||||
|
the project root once — gitignored, never deployed. The host's Vite dev
|
||||||
|
server overlays it on top of `/config.json` responses (see
|
||||||
|
`serveLocalConfigOverlay` in `vite.config.js`); prod builds ignore the
|
||||||
|
overlay entirely.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# one-time: install widget deps
|
||||||
|
cd apps/widget-telegram && npm install
|
||||||
|
|
||||||
|
# one-time: create config.local.json (gitignored) at the project root
|
||||||
|
cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON'
|
||||||
|
{
|
||||||
|
"bots": [
|
||||||
|
{
|
||||||
|
"id": "telegram",
|
||||||
|
"experience": {
|
||||||
|
"type": "matrix-widget",
|
||||||
|
"url": "http://localhost:8081/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
The overlay merges `bots[]` by `id`, so just `{ id, experience }` is
|
||||||
|
enough — base bot's `mxid` and `name` are preserved. Top-level fields
|
||||||
|
not present in `config.local.json` are inherited from `config.json`.
|
||||||
|
|
||||||
|
Run both servers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal 1 — widget on :8081 with HMR
|
||||||
|
cd apps/widget-telegram && npm run dev
|
||||||
|
|
||||||
|
# terminal 2 — host SPA on :8080
|
||||||
|
cd /home/ubuntu/projects/vojo/cinny && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8080/bots/telegram`. Iframe loads cross-origin
|
||||||
|
from the widget dev server, HMR works, no proxy.
|
||||||
|
|
||||||
|
`http://localhost:*` URLs are accepted by the host's URL validator only
|
||||||
|
in dev builds (`import.meta.env.DEV` branch in
|
||||||
|
`src/app/features/bots/catalog.ts`); production builds drop the branch
|
||||||
|
via Vite's dead-code elimination, AND production-only enforces an origin
|
||||||
|
allowlist (`PROD_WIDGET_ORIGINS`) so prod can never embed `localhost` even
|
||||||
|
if config.json is poisoned.
|
||||||
|
|
||||||
|
Deploy is unchanged. `config.local.json` is gitignored, never shipped.
|
||||||
|
You don't need to revert anything before `Deploy to vojo.chat` — there
|
||||||
|
is nothing in tracked files that points at localhost.
|
||||||
|
|
||||||
|
Standalone preview of the widget bundle (no host, useful for visual
|
||||||
|
iteration):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/widget-telegram
|
||||||
|
npm run dev # vite dev server on :8081 — shows missing-params banner
|
||||||
|
# without host, expected.
|
||||||
|
npm run preview # serves the production build from dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs to `apps/widget-telegram/dist/`. Deploy by rsyncing `dist/*`
|
||||||
|
into `~/vojo/widgets/telegram/` on the production host (Caddy serves
|
||||||
|
this via the `widgets.vojo.chat` block). One parent `~/vojo/widgets/`
|
||||||
|
directory hosts every bot widget — adding a second one is `mkdir
|
||||||
|
~/vojo/widgets/<slug>/` plus a Caddy block, no docker-compose edit.
|
||||||
|
|
||||||
|
## Hosting (server-side, runbook)
|
||||||
|
|
||||||
|
1. DNS: `widgets.vojo.chat` A/AAAA → server. Verify with `dig`.
|
||||||
|
2. `~/vojo/docker-compose.yml` — Caddy `volumes:` adds (one parent mount,
|
||||||
|
future widgets reuse it):
|
||||||
|
```yaml
|
||||||
|
- ./widgets:/var/www/widgets
|
||||||
|
```
|
||||||
|
3. `~/vojo/caddy/Caddyfile` — append:
|
||||||
|
```
|
||||||
|
widgets.vojo.chat {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
Content-Security-Policy "frame-ancestors https://vojo.chat https://localhost"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_path /telegram/* {
|
||||||
|
root * /var/www/widgets/telegram
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
respond "Not Found" 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. `mkdir -p ~/vojo/widgets/telegram` (placeholder so cert provisioning
|
||||||
|
has something to serve), then `docker compose up -d caddy` to apply.
|
||||||
|
5. Verify directly: `curl -I https://widgets.vojo.chat/telegram/index.html`
|
||||||
|
should return 200 and the `Content-Security-Policy` header.
|
||||||
|
|
||||||
|
## Updating the production /config.json
|
||||||
|
|
||||||
|
Once the widget is live at `https://widgets.vojo.chat/telegram/index.html`,
|
||||||
|
add to the host repo's `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"experience": {
|
||||||
|
"type": "matrix-widget",
|
||||||
|
"url": "https://widgets.vojo.chat/telegram/index.html"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Capacitor (Android)
|
||||||
|
|
||||||
|
`capacitor.config.ts` already has a placeholder. Uncomment and set:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
server: { allowNavigation: ['widgets.vojo.chat'] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this, Android's WebView hijacks the cross-origin iframe URL into
|
||||||
|
`Intent.ACTION_VIEW` and the iframe stays blank. Rebuild the APK after.
|
||||||
|
|
||||||
|
## Capability contract
|
||||||
|
|
||||||
|
The widget requests EXACTLY this set (matches the host's
|
||||||
|
`BotWidgetDriver.getBotWidgetCapabilities`):
|
||||||
|
|
||||||
|
```
|
||||||
|
org.matrix.msc2762.timeline:<roomId>
|
||||||
|
org.matrix.msc2762.send.event:m.room.message#m.text
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.text
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.notice
|
||||||
|
org.matrix.msc2762.receive.state_event:m.room.member
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything else is silently dropped by the host. To extend the surface,
|
||||||
|
update `BotWidgetDriver.ts` upstream — that requires a security review
|
||||||
|
per Phase 2 plan §M9.
|
||||||
12
apps/widget-telegram/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>Telegram bridge — Vojo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1999
apps/widget-telegram/package-lock.json
generated
Normal file
21
apps/widget-telegram/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "@vojo/widget-telegram",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Vojo Telegram bridge management widget — mounts inside /bots/telegram",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "10.22.1",
|
||||||
|
"qrcode-generator": "^1.4.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "2.9.0",
|
||||||
|
"typescript": "5.4.5",
|
||||||
|
"vite": "5.4.19"
|
||||||
|
}
|
||||||
|
}
|
||||||
1591
apps/widget-telegram/src/App.tsx
Normal file
65
apps/widget-telegram/src/bootstrap.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// Parse the URL params the Phase 2 bot widget host appends when loading
|
||||||
|
// experience.url. Source of truth on the host side:
|
||||||
|
// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl).
|
||||||
|
// Keep this in sync if the host adds params.
|
||||||
|
|
||||||
|
export type WidgetBootstrap = {
|
||||||
|
widgetId: string;
|
||||||
|
parentUrl: string;
|
||||||
|
parentOrigin: string;
|
||||||
|
roomId: string;
|
||||||
|
userId: string;
|
||||||
|
botId: string;
|
||||||
|
botMxid: string;
|
||||||
|
/** Bridge command prefix (e.g. `!tg`). Always non-empty — the host
|
||||||
|
* validator (catalog.ts) defaults missing values to `!tg` and rejects
|
||||||
|
* malformed overrides. The widget prepends `<commandPrefix> ` to every
|
||||||
|
* outbound command and form-field value (bridgev2/queue.go:118 strips
|
||||||
|
* exactly `prefix+" "`). */
|
||||||
|
commandPrefix: string;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
clientLanguage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BootstrapResult =
|
||||||
|
| { ok: true; bootstrap: WidgetBootstrap }
|
||||||
|
| { ok: false; missing: string[] };
|
||||||
|
|
||||||
|
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const;
|
||||||
|
|
||||||
|
export const readBootstrap = (search: string): BootstrapResult => {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const get = (k: string) => params.get(k) ?? '';
|
||||||
|
|
||||||
|
const missing = REQUIRED.filter((k) => !params.get(k));
|
||||||
|
if (missing.length > 0) return { ok: false, missing: [...missing] };
|
||||||
|
|
||||||
|
// Origin is what the widget validates against on incoming postMessage —
|
||||||
|
// see widget-api.ts. Falling back to '*' would defeat the security
|
||||||
|
// boundary, so a malformed parentUrl bails out as a missing-param error.
|
||||||
|
let parentOrigin: string;
|
||||||
|
try {
|
||||||
|
parentOrigin = new URL(get('parentUrl')).origin;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, missing: ['parentUrl'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeRaw = get('theme');
|
||||||
|
const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
bootstrap: {
|
||||||
|
widgetId: get('widgetId'),
|
||||||
|
parentUrl: get('parentUrl'),
|
||||||
|
parentOrigin,
|
||||||
|
roomId: get('roomId'),
|
||||||
|
userId: get('userId'),
|
||||||
|
botId: get('botId'),
|
||||||
|
botMxid: get('botMxid'),
|
||||||
|
commandPrefix: get('commandPrefix'),
|
||||||
|
theme,
|
||||||
|
clientLanguage: get('clientLanguage'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
507
apps/widget-telegram/src/bridge-protocol/dialects/go_v2604.ts
Normal file
|
|
@ -0,0 +1,507 @@
|
||||||
|
// Dialect: mautrix-telegram Go rewrite v0.2604.0 + mautrix/go bridgev2.
|
||||||
|
// Generated against tag v0.2604.0 (commit b9f09628, 26 Apr 2026).
|
||||||
|
//
|
||||||
|
// Each regex is paired with its upstream source; if bridgev2 wording drifts
|
||||||
|
// in a future patch, replace this file with a sibling go_v2607.ts (or
|
||||||
|
// whatever) and switch the import in ../parser.ts.
|
||||||
|
//
|
||||||
|
// Body encoding note: bridgev2 routes replies through `format.RenderMarkdown`
|
||||||
|
// (bridgev2/commands/event.go:58) which sets `formatted_body` to HTML and
|
||||||
|
// `body` to the markdown source. Our host driver strips `formatted_body`
|
||||||
|
// (Phase 2 contract), so the widget only ever sees the markdown source —
|
||||||
|
// backticks, asterisks, escaped angle-brackets stay literal.
|
||||||
|
|
||||||
|
import type { LoginEvent, ListedLogin, ParsableEvent } from '../types';
|
||||||
|
|
||||||
|
// --- Regex table ----------------------------------------------------------
|
||||||
|
|
||||||
|
// list-logins, empty: bridgev2/commands/login.go:564 → `You're not logged in`
|
||||||
|
// Note: NO trailing period. The Python v0.15.3 dialect ended with one — this
|
||||||
|
// is a stable structural fingerprint between dialects.
|
||||||
|
const NOT_LOGGED_IN_RE = /^you'?re not logged in\.?$/i;
|
||||||
|
|
||||||
|
// list-logins, non-empty: bridgev2/user.go:185-190 ships a leading `\n` due
|
||||||
|
// to a `make([]string, N) + append` bug. Each row is
|
||||||
|
// `* `<id>` (<RemoteName>) - `<state>``.
|
||||||
|
// Tolerate both leading-whitespace and a future fix that removes the bug.
|
||||||
|
//
|
||||||
|
// Name capture uses greedy `(.+)` (not `[^)]*`) because Telegram display
|
||||||
|
// names commonly contain literal `)` — e.g. «Example (Work)», «Имя
|
||||||
|
// (Личный)». The trailing anchor `\)\s+-\s+`<state>`` forces the regex
|
||||||
|
// engine to backtrack to the LAST `)` before ` - `<…>``, so nested
|
||||||
|
// parens parse correctly.
|
||||||
|
const LOGIN_LIST_ROW_RE = /^\s*\*\s+`([^`]+)`\s+\((.+)\)\s+-\s+`([^`]+)`\s*$/gm;
|
||||||
|
|
||||||
|
// Phone prompt — bridgev2/commands/login.go:207 + connector loginphone.go:74.
|
||||||
|
// Composed: `Please enter your <field.Name>\n<field.Description>`. Phone step
|
||||||
|
// has no Instructions, so this is the only reply.
|
||||||
|
const PHONE_PROMPT_RE = /^please enter your phone number\b/i;
|
||||||
|
|
||||||
|
// Code prompt — bridgev2/commands/login.go:207 + connector loginphone.go:98.
|
||||||
|
// Same composition; sent on initial code request.
|
||||||
|
const CODE_PROMPT_RE = /^please enter your code\b/i;
|
||||||
|
|
||||||
|
// 2fa Instructions — connector login.go:170. First of TWO replies; the second
|
||||||
|
// is `Please enter your Password` which falls into PASSWORD_REPROMPT_RE.
|
||||||
|
const TWOFA_INSTRUCTIONS_RE = /^you have two-factor authentication enabled\.?$/i;
|
||||||
|
|
||||||
|
// Password re-prompt — bridgev2/commands/login.go:207. Emitted both after
|
||||||
|
// the 2fa instructions and after a wrong-password re-prompt.
|
||||||
|
const PASSWORD_REPROMPT_RE = /^please enter your password\s*$/i;
|
||||||
|
|
||||||
|
// Code incorrect Instructions — connector loginphone.go:107. First of two.
|
||||||
|
const CODE_INCORRECT_RE = /^incorrect code\.?$/i;
|
||||||
|
|
||||||
|
// Password incorrect Instructions — connector login.go:183. First of two.
|
||||||
|
const PASSWORD_INCORRECT_RE = /^incorrect password,/i;
|
||||||
|
|
||||||
|
// Login success — connector login.go:290. Format string is
|
||||||
|
// `Successfully logged in as %s (\`%d\`)` — the numeric id is wrapped in
|
||||||
|
// markdown backticks which survive into `body`. Capture both for UI use.
|
||||||
|
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+(.+?)\s+\(`?(\d+)`?\)\.?$/i;
|
||||||
|
|
||||||
|
// Logout — bridgev2/commands/login.go:591 → `Logged out` (no period).
|
||||||
|
const LOGOUT_OK_RE = /^logged out\.?$/i;
|
||||||
|
|
||||||
|
// Cancel — bridgev2/commands/processor.go:198 / 200. Action for our
|
||||||
|
// flow is always `Login` (set by userInputLoginCommandState at login.go:218).
|
||||||
|
const CANCEL_OK_RE = /^login cancelled\.?$/i;
|
||||||
|
const CANCEL_NO_OP_RE = /^no ongoing command\.?$/i;
|
||||||
|
|
||||||
|
// Login already in progress — bridgev2/commands/login.go:83.
|
||||||
|
const LOGIN_IN_PROGRESS_RE = /^you already have an ongoing login\b/i;
|
||||||
|
|
||||||
|
// Max logins — bridgev2/commands/login.go:74-79. Captures the limit.
|
||||||
|
const MAX_LOGINS_RE = /^you have reached the maximum number of logins \((\d+)\)/i;
|
||||||
|
|
||||||
|
// Login id not found — bridgev2/commands/login.go:587 (logout) and 68
|
||||||
|
// (relogin). Single backtick-wrapped id capture.
|
||||||
|
const LOGIN_NOT_FOUND_RE = /^login `([^`]+)` not found\b/i;
|
||||||
|
|
||||||
|
// Flow selector errors — bridgev2/commands/login.go:107 / 98.
|
||||||
|
const FLOW_REQUIRED_RE = /^please specify a login flow\b/i;
|
||||||
|
const FLOW_INVALID_RE = /^invalid login flow `([^`]+)`/i;
|
||||||
|
|
||||||
|
// Unknown command — bridgev2/commands/processor.go:163.
|
||||||
|
const UNKNOWN_COMMAND_RE = /^unknown command, use the `help` command/i;
|
||||||
|
|
||||||
|
// Generic error traps. Each anchors on a distinct prefix, so order between
|
||||||
|
// them is incidental — kept ordered for readability.
|
||||||
|
const INVALID_VALUE_RE = /^invalid value:\s*(.*)$/i;
|
||||||
|
const SUBMIT_FAILED_RE = /^failed to submit input:\s*(.*)$/i;
|
||||||
|
const PREPARE_FAILED_RE = /^failed to prepare login process:\s*(.*)$/i;
|
||||||
|
const START_FAILED_RE = /^failed to start login:\s*(.*)$/i;
|
||||||
|
// bridgev2/commands/login.go:366 — `Login failed: %v` from
|
||||||
|
// doLoginDisplayAndWait Wait error path. Captures both the 10-minute
|
||||||
|
// LoginTimeout (`login process timed out`) and post-cancel
|
||||||
|
// (`context canceled`) cases.
|
||||||
|
const LOGIN_FAILED_RE = /^login failed:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// --- Parser ---------------------------------------------------------------
|
||||||
|
|
||||||
|
const trimReplyBody = (raw: string): string => {
|
||||||
|
// Bridge sometimes emits a leading `\n` (login-list bug, user.go:185).
|
||||||
|
// Trim outer whitespace before matching to keep regexes anchored on `^`.
|
||||||
|
return raw.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseLoginList = (body: string): ListedLogin[] => {
|
||||||
|
const logins: ListedLogin[] = [];
|
||||||
|
// matchAll requires the global flag — preserve LOGIN_LIST_ROW_RE's lastIndex
|
||||||
|
// by rebuilding it for each call (RegExp instances are stateful with /g).
|
||||||
|
const re = new RegExp(LOGIN_LIST_ROW_RE.source, LOGIN_LIST_ROW_RE.flags);
|
||||||
|
for (const match of body.matchAll(re)) {
|
||||||
|
const [, id, name, state] = match;
|
||||||
|
logins.push({ id, name, state });
|
||||||
|
}
|
||||||
|
return logins;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseGoV2604 = (rawBody: string): LoginEvent => {
|
||||||
|
const body = trimReplyBody(rawBody);
|
||||||
|
if (body.length === 0) return { kind: 'unknown' };
|
||||||
|
|
||||||
|
// Order: highly-specific terminal/transitional matches first, generic
|
||||||
|
// error traps last. The login-list parser comes early because its anchor
|
||||||
|
// (` * `<id>` `) wouldn't false-match anything else, and the alternative
|
||||||
|
// — `not_logged_in` — covers the empty-list case explicitly.
|
||||||
|
|
||||||
|
if (NOT_LOGGED_IN_RE.test(body)) return { kind: 'not_logged_in' };
|
||||||
|
|
||||||
|
const successMatch = LOGIN_SUCCESS_RE.exec(body);
|
||||||
|
if (successMatch) {
|
||||||
|
return {
|
||||||
|
kind: 'login_success',
|
||||||
|
handle: successMatch[1].trim(),
|
||||||
|
numericId: successMatch[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TWOFA_INSTRUCTIONS_RE.test(body)) return { kind: 'twofa_required' };
|
||||||
|
if (CODE_INCORRECT_RE.test(body)) return { kind: 'invalid_code' };
|
||||||
|
if (PASSWORD_INCORRECT_RE.test(body)) return { kind: 'wrong_password' };
|
||||||
|
|
||||||
|
if (PHONE_PROMPT_RE.test(body)) return { kind: 'awaiting_phone' };
|
||||||
|
if (CODE_PROMPT_RE.test(body)) return { kind: 'awaiting_code' };
|
||||||
|
if (PASSWORD_REPROMPT_RE.test(body)) return { kind: 'awaiting_password' };
|
||||||
|
|
||||||
|
if (LOGOUT_OK_RE.test(body)) return { kind: 'logout_ok' };
|
||||||
|
if (CANCEL_OK_RE.test(body)) return { kind: 'cancel_ok' };
|
||||||
|
if (CANCEL_NO_OP_RE.test(body)) return { kind: 'cancel_no_op' };
|
||||||
|
if (LOGIN_IN_PROGRESS_RE.test(body)) return { kind: 'login_in_progress' };
|
||||||
|
if (UNKNOWN_COMMAND_RE.test(body)) return { kind: 'unknown_command' };
|
||||||
|
if (FLOW_REQUIRED_RE.test(body)) return { kind: 'flow_required' };
|
||||||
|
|
||||||
|
const maxMatch = MAX_LOGINS_RE.exec(body);
|
||||||
|
if (maxMatch) {
|
||||||
|
const limit = Number(maxMatch[1]);
|
||||||
|
return { kind: 'max_logins', limit: Number.isFinite(limit) ? limit : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const notFoundMatch = LOGIN_NOT_FOUND_RE.exec(body);
|
||||||
|
if (notFoundMatch) return { kind: 'login_not_found', loginId: notFoundMatch[1] };
|
||||||
|
|
||||||
|
const flowInvalidMatch = FLOW_INVALID_RE.exec(body);
|
||||||
|
if (flowInvalidMatch) return { kind: 'flow_invalid', flowId: flowInvalidMatch[1] };
|
||||||
|
|
||||||
|
const invalidValueMatch = INVALID_VALUE_RE.exec(body);
|
||||||
|
if (invalidValueMatch) return { kind: 'invalid_value', reason: invalidValueMatch[1].trim() };
|
||||||
|
|
||||||
|
const submitFailedMatch = SUBMIT_FAILED_RE.exec(body);
|
||||||
|
if (submitFailedMatch) return { kind: 'submit_failed', reason: submitFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
const prepareFailedMatch = PREPARE_FAILED_RE.exec(body);
|
||||||
|
if (prepareFailedMatch) return { kind: 'prepare_failed', reason: prepareFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
const startFailedMatch = START_FAILED_RE.exec(body);
|
||||||
|
if (startFailedMatch) return { kind: 'start_failed', reason: startFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
const loginFailedMatch = LOGIN_FAILED_RE.exec(body);
|
||||||
|
if (loginFailedMatch) return { kind: 'login_failed', reason: loginFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
// Fall-through to login-list AFTER the error traps so a row that happens to
|
||||||
|
// start with `* ` mid-error-message doesn't get mistaken for a login list.
|
||||||
|
const logins = parseLoginList(body);
|
||||||
|
if (logins.length > 0) return { kind: 'logins_listed', logins };
|
||||||
|
|
||||||
|
return { kind: 'unknown' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Full-event parser ----------------------------------------------------
|
||||||
|
//
|
||||||
|
// `parseEventGoV2604` dispatches on `event.type` and routes:
|
||||||
|
//
|
||||||
|
// * `m.room.redaction` → `qr_redacted`. We don't need to verify the redacted
|
||||||
|
// target here; the state machine pairs the redaction's `redacts` against
|
||||||
|
// the active QR event id and decides whether it's a meaningful signal or
|
||||||
|
// an unrelated cleanup.
|
||||||
|
//
|
||||||
|
// * `m.room.message` + `msgtype=m.image` → `qr_displayed` when the body
|
||||||
|
// contains a `tg://login?token=...` URL. The bridge sets that as the
|
||||||
|
// image's text body explicitly (mautrix/go bridgev2 commands/login.go
|
||||||
|
// sendQR sets `Body: qr` where `qr` is the token URL string). Anything
|
||||||
|
// else on m.image we don't recognise — fall through to `unknown` so the
|
||||||
|
// transcript still surfaces the line as a diag.
|
||||||
|
//
|
||||||
|
// * `m.room.message` + `msgtype=m.text|m.notice` → existing
|
||||||
|
// `parseGoV2604(body)` path.
|
||||||
|
|
||||||
|
// Telegram QR-login URLs encode the token in `tg://login?token=...`. The
|
||||||
|
// bridge wraps it in markdown backticks inside `formatted_body` (we never
|
||||||
|
// see formatted_body — driver strips it), but `body` carries the raw URL
|
||||||
|
// per upstream `bridgev2/commands/login.go::sendQR` line 297 (`Body: qr`).
|
||||||
|
// The regex tolerates surrounding whitespace and a possible markdown
|
||||||
|
// backtick wrap on either side as defence-in-depth, even though the
|
||||||
|
// current wire shape doesn't include backticks in the plain body.
|
||||||
|
const TG_LOGIN_URL_RE = /tg:\/\/login\?[^\s`<>]+/i;
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
export const parseEventGoV2604 = (event: ParsableEvent): LoginEvent => {
|
||||||
|
if (event.type === 'm.room.redaction') {
|
||||||
|
// `redacts` is mirrored at the top level by the host sanitizer (see
|
||||||
|
// `sanitizeBotWidgetRedactionEvent` in BotWidgetDriver.ts), but check
|
||||||
|
// both spots for forward-compat with future drivers / SDK shapes.
|
||||||
|
const target =
|
||||||
|
typeof event.redacts === 'string'
|
||||||
|
? event.redacts
|
||||||
|
: isObject(event.content) && typeof event.content.redacts === 'string'
|
||||||
|
? event.content.redacts
|
||||||
|
: undefined;
|
||||||
|
if (!target) return { kind: 'unknown' };
|
||||||
|
return { kind: 'qr_redacted', redactsEventId: target };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type !== 'm.room.message') return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const msgtype = event.content?.msgtype;
|
||||||
|
|
||||||
|
if (msgtype === 'm.image') {
|
||||||
|
// Edits replace `body` by spec; bridgev2 ALSO mirrors the new URL into
|
||||||
|
// `m.new_content.body`. Prefer `m.new_content.body` when present (so an
|
||||||
|
// older SDK pre-flattening edit content still lets us extract the new
|
||||||
|
// token) and fall back to `body`.
|
||||||
|
const newContent = isObject(event.content['m.new_content'])
|
||||||
|
? (event.content['m.new_content'] as { body?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const editedBody =
|
||||||
|
typeof newContent?.body === 'string' ? newContent.body : undefined;
|
||||||
|
const directBody = typeof event.content.body === 'string' ? event.content.body : '';
|
||||||
|
const body = editedBody ?? directBody;
|
||||||
|
|
||||||
|
const match = body.match(TG_LOGIN_URL_RE);
|
||||||
|
if (!match) return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const relatesTo = isObject(event.content['m.relates_to'])
|
||||||
|
? (event.content['m.relates_to'] as { rel_type?: unknown; event_id?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const replacesEventId =
|
||||||
|
relatesTo?.rel_type === 'm.replace' && typeof relatesTo.event_id === 'string'
|
||||||
|
? relatesTo.event_id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
tgUrl: match[0],
|
||||||
|
eventId: event.event_id,
|
||||||
|
replacesEventId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgtype !== 'm.text' && msgtype !== 'm.notice') return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const body = typeof event.content.body === 'string' ? event.content.body : '';
|
||||||
|
return parseGoV2604(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DEV sanity assertions ------------------------------------------------
|
||||||
|
// Vite tree-shakes this branch in production builds: `import.meta.env.DEV`
|
||||||
|
// is replaced with the literal `false` and the call site collapses, so the
|
||||||
|
// fixture array never ships. Failure throws — HMR/dev-overlay surfaces the
|
||||||
|
// first regression on reload.
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
runSanityChecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSanityChecks(): void {
|
||||||
|
const cases: Array<[string, LoginEvent]> = [
|
||||||
|
["You're not logged in", { kind: 'not_logged_in' }],
|
||||||
|
["You're not logged in.", { kind: 'not_logged_in' }],
|
||||||
|
['Please enter your Phone number\nInclude the country code with +', { kind: 'awaiting_phone' }],
|
||||||
|
[
|
||||||
|
'Please enter your Code\nThe code was sent to the Telegram app on your phone',
|
||||||
|
{ kind: 'awaiting_code' },
|
||||||
|
],
|
||||||
|
['You have two-factor authentication enabled.', { kind: 'twofa_required' }],
|
||||||
|
['Please enter your Password', { kind: 'awaiting_password' }],
|
||||||
|
['Incorrect code', { kind: 'invalid_code' }],
|
||||||
|
[
|
||||||
|
"Incorrect password, please try again. Use the official Telegram app to reset your password if you've forgotten it.",
|
||||||
|
{ kind: 'wrong_password' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Successfully logged in as @example (`123456789`)',
|
||||||
|
{ kind: 'login_success', handle: '@example', numericId: '123456789' },
|
||||||
|
],
|
||||||
|
['Logged out', { kind: 'logout_ok' }],
|
||||||
|
['Login cancelled.', { kind: 'cancel_ok' }],
|
||||||
|
['No ongoing command.', { kind: 'cancel_no_op' }],
|
||||||
|
[
|
||||||
|
'You already have an ongoing login. You can use `!tg cancel` to cancel it.',
|
||||||
|
{ kind: 'login_in_progress' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'You have reached the maximum number of logins (1). Please logout from an existing login before creating a new one. If you want to re-authenticate an existing login, use the `!tg relogin` command.',
|
||||||
|
{ kind: 'max_logins', limit: 1 },
|
||||||
|
],
|
||||||
|
['Login `abc123` not found', { kind: 'login_not_found', loginId: 'abc123' }],
|
||||||
|
['Unknown command, use the `help` command for help.', { kind: 'unknown_command' }],
|
||||||
|
[
|
||||||
|
'Failed to submit input: rpc error: PHONE_NUMBER_BANNED (400)',
|
||||||
|
{ kind: 'submit_failed', reason: 'rpc error: PHONE_NUMBER_BANNED (400)' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Failed to prepare login process: connector unavailable',
|
||||||
|
{ kind: 'prepare_failed', reason: 'connector unavailable' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Failed to start login: telegram connect timeout',
|
||||||
|
{ kind: 'start_failed', reason: 'telegram connect timeout' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Login failed: login process timed out',
|
||||||
|
{ kind: 'login_failed', reason: 'login process timed out' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Login failed: context canceled',
|
||||||
|
{ kind: 'login_failed', reason: 'context canceled' },
|
||||||
|
],
|
||||||
|
['Invalid value: must start with +', { kind: 'invalid_value', reason: 'must start with +' }],
|
||||||
|
[
|
||||||
|
'Please specify a login flow, e.g. `login phone`.\n\n* `phone` - Login using your Telegram phone number\n* `qr` - Login by scanning a QR code from your phone\n* `bot` - Log in as a bot using the bot token provided by BotFather.\n',
|
||||||
|
{ kind: 'flow_required' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Invalid login flow `wat`. Available options:\n\n* `phone` - …',
|
||||||
|
{ kind: 'flow_invalid', flowId: 'wat' },
|
||||||
|
],
|
||||||
|
// Truly unrecognised body — the catch-all kind keeps the transcript
|
||||||
|
// usable even when bridgev2 wording drifts.
|
||||||
|
['Some completely unknown bridge reply that does not match any anchor', { kind: 'unknown' }],
|
||||||
|
// Login list with the leading-newline bug present in v0.2604.0.
|
||||||
|
[
|
||||||
|
'\n* `42` (Example User) - `CONNECTED`',
|
||||||
|
{
|
||||||
|
kind: 'logins_listed',
|
||||||
|
logins: [{ id: '42', name: 'Example User', state: 'CONNECTED' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Same row without the bug — must keep matching after upstream fix.
|
||||||
|
[
|
||||||
|
'* `42` (Example User) - `CONNECTED`',
|
||||||
|
{
|
||||||
|
kind: 'logins_listed',
|
||||||
|
logins: [{ id: '42', name: 'Example User', state: 'CONNECTED' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Telegram display name with literal `)` inside — common case
|
||||||
|
// («Иван (Работа)», «Pavel (Beta)»). The greedy capture must
|
||||||
|
// backtrack to the LAST `)` before ` - `<state>``, not stop at
|
||||||
|
// the first one.
|
||||||
|
[
|
||||||
|
'* `42` (Example (Work)) - `CONNECTED`',
|
||||||
|
{
|
||||||
|
kind: 'logins_listed',
|
||||||
|
logins: [{ id: '42', name: 'Example (Work)', state: 'CONNECTED' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Two rows in one reply (multi-login user) with leading-newline bug.
|
||||||
|
[
|
||||||
|
'\n* `42` (Alice) - `CONNECTED`\n* `43` (Bob) - `CONNECTED`',
|
||||||
|
{
|
||||||
|
kind: 'logins_listed',
|
||||||
|
logins: [
|
||||||
|
{ id: '42', name: 'Alice', state: 'CONNECTED' },
|
||||||
|
{ id: '43', name: 'Bob', state: 'CONNECTED' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [body, expected] of cases) {
|
||||||
|
const actual = parseGoV2604(body);
|
||||||
|
if (!sameEvent(actual, expected)) {
|
||||||
|
// Surface the diff loudly — dev overlay shows the throw, and the
|
||||||
|
// console error gives the inputs side-by-side for debugging.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[go_v2604 sanity] mismatch', { body, actual, expected });
|
||||||
|
throw new Error(
|
||||||
|
`go_v2604 parser sanity failed for body ${JSON.stringify(body)} — see console for diff`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEventGoV2604 — exercises the full-event dispatch (m.image,
|
||||||
|
// m.room.redaction, m.notice fall-through). Same throw-on-mismatch
|
||||||
|
// pattern as the body-only parser cases above.
|
||||||
|
const eventCases: Array<[ParsableEvent, LoginEvent]> = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$qr1',
|
||||||
|
sender: '@telegrambot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.image', body: 'tg://login?token=ABCDEF' },
|
||||||
|
},
|
||||||
|
{ kind: 'qr_displayed', tgUrl: 'tg://login?token=ABCDEF', eventId: '$qr1' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// QR rotation edit — `m.relates_to.rel_type=m.replace` + new body
|
||||||
|
// inside `m.new_content.body`. The edited token must take precedence
|
||||||
|
// over the literal `body` (which the sender SDK may keep as the
|
||||||
|
// original to satisfy clients that don't render edits).
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$qr2',
|
||||||
|
sender: '@telegrambot:vojo.chat',
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.image',
|
||||||
|
body: 'tg://login?token=OLD',
|
||||||
|
'm.relates_to': { rel_type: 'm.replace', event_id: '$qr1' },
|
||||||
|
'm.new_content': { msgtype: 'm.image', body: 'tg://login?token=ROTATED' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
tgUrl: 'tg://login?token=ROTATED',
|
||||||
|
eventId: '$qr2',
|
||||||
|
replacesEventId: '$qr1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Bare m.image without a tg URL — the bridge has no business sending
|
||||||
|
// these to the control DM, but if it does we keep the line as
|
||||||
|
// unknown (transcript surfaces a diag, no QR-state mutation).
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$rand',
|
||||||
|
sender: '@telegrambot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.image', body: 'random non-tg image caption' },
|
||||||
|
},
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Redaction — top-level `redacts` (host sanitizer mirrors at top-level).
|
||||||
|
{
|
||||||
|
type: 'm.room.redaction',
|
||||||
|
event_id: '$red1',
|
||||||
|
sender: '@telegrambot:vojo.chat',
|
||||||
|
content: { redacts: '$qr1' },
|
||||||
|
redacts: '$qr1',
|
||||||
|
},
|
||||||
|
{ kind: 'qr_redacted', redactsEventId: '$qr1' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Redaction missing target — the sanitizer should already reject this,
|
||||||
|
// but defence-in-depth: parser declines to invent a target.
|
||||||
|
{
|
||||||
|
type: 'm.room.redaction',
|
||||||
|
event_id: '$red2',
|
||||||
|
sender: '@telegrambot:vojo.chat',
|
||||||
|
content: {},
|
||||||
|
},
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// m.notice fall-through — preserves existing behaviour for plain
|
||||||
|
// text replies that already had body-side parser coverage.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$n1',
|
||||||
|
sender: '@telegrambot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.notice', body: "You're not logged in" },
|
||||||
|
},
|
||||||
|
{ kind: 'not_logged_in' },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [event, expected] of eventCases) {
|
||||||
|
const actual = parseEventGoV2604(event);
|
||||||
|
if (!sameEvent(actual, expected)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[go_v2604 event sanity] mismatch', { event, actual, expected });
|
||||||
|
throw new Error(
|
||||||
|
`go_v2604 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameEvent(a: LoginEvent, b: LoginEvent): boolean {
|
||||||
|
if (a.kind !== b.kind) return false;
|
||||||
|
// Shallow-compare the discriminated payload. Good enough for the small
|
||||||
|
// set of structures we emit; deeper equality would only matter if we
|
||||||
|
// returned arbitrary nested data.
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
17
apps/widget-telegram/src/bridge-protocol/parser.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Parser shim. The widget consumes a single `parseEvent(rawEvent)` and
|
||||||
|
// the dialect handles the full event surface — m.text, m.notice, m.image
|
||||||
|
// (QR broadcasts), m.room.redaction (post-scan cleanup). M13 ships one
|
||||||
|
// dialect, `go_v2604`, for the operator's current bridge image. When
|
||||||
|
// bridgev2 strings drift in a future Go release, add a sibling dialect
|
||||||
|
// file and switch the import below.
|
||||||
|
//
|
||||||
|
// The dialects/ subdirectory is kept as a seam for that swap; we don't
|
||||||
|
// implement runtime autodetect (the operator owns one bridge image at a
|
||||||
|
// time and a parser pin is honest about that).
|
||||||
|
|
||||||
|
import type { LoginEvent, ParsableEvent } from './types';
|
||||||
|
import { parseEventGoV2604 } from './dialects/go_v2604';
|
||||||
|
|
||||||
|
export type { ParsableEvent };
|
||||||
|
|
||||||
|
export const parseEvent = (event: ParsableEvent): LoginEvent => parseEventGoV2604(event);
|
||||||
83
apps/widget-telegram/src/bridge-protocol/types.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// LoginEvent — discriminated union the parser emits and the state machine
|
||||||
|
// consumes. One LoginEvent per inbound m.notice from the bridge bot.
|
||||||
|
//
|
||||||
|
// Multi-reply collapse rule: bridgev2 emits TWO replies for steps that have
|
||||||
|
// non-empty Instructions (2FA prompt, invalid code, wrong password) — the
|
||||||
|
// Instructions text first, then a `Please enter your <field.Name>` re-prompt.
|
||||||
|
// The parser returns one event per notice; the state machine collapses the
|
||||||
|
// re-prompt into a no-op when the state already matches.
|
||||||
|
//
|
||||||
|
// Source-of-truth for every kind below is the Go-dialect wording table in
|
||||||
|
// docs/plans/bots_tab.md (Phase 3 → Research outcomes → R3 → Bridge response
|
||||||
|
// wording (Go v0.2604.0 snapshot)).
|
||||||
|
|
||||||
|
export type ListedLogin = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shape of an inbound event the dialect parser needs to look at. Matches
|
||||||
|
// the wire shape produced by the host's BotWidgetDriver sanitizer; declared
|
||||||
|
// here (not in widget-api.ts) so the dialect doesn't import from the
|
||||||
|
// transport layer.
|
||||||
|
export type ParsableEvent = {
|
||||||
|
type: string;
|
||||||
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
origin_server_ts?: number;
|
||||||
|
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
||||||
|
redacts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginEvent =
|
||||||
|
| { kind: 'logins_listed'; logins: ListedLogin[] }
|
||||||
|
| { kind: 'not_logged_in' }
|
||||||
|
| { kind: 'awaiting_phone' }
|
||||||
|
| { kind: 'awaiting_code' }
|
||||||
|
| { kind: 'awaiting_password' }
|
||||||
|
| { kind: 'twofa_required' }
|
||||||
|
| { kind: 'invalid_code' }
|
||||||
|
| { kind: 'wrong_password' }
|
||||||
|
| { kind: 'login_success'; handle: string; numericId: string }
|
||||||
|
| { kind: 'logout_ok' }
|
||||||
|
| { kind: 'cancel_ok' }
|
||||||
|
| { kind: 'cancel_no_op' }
|
||||||
|
| { kind: 'login_in_progress' }
|
||||||
|
| { kind: 'max_logins'; limit?: number }
|
||||||
|
| { kind: 'login_not_found'; loginId?: string }
|
||||||
|
| { kind: 'flow_required' }
|
||||||
|
| { kind: 'flow_invalid'; flowId?: string }
|
||||||
|
| { kind: 'unknown_command' }
|
||||||
|
| { kind: 'invalid_value'; reason?: string }
|
||||||
|
// Catch-all for Telegram-side errors leaking through bridgev2's commands
|
||||||
|
// layer as `Failed to submit input: <go-error>`. Surfaced to the user as a
|
||||||
|
// yellow inline warning with the verbatim Go error tail (no sub-code parse
|
||||||
|
// — gotd error format is unstable across patches).
|
||||||
|
| { kind: 'submit_failed'; reason?: string }
|
||||||
|
| { kind: 'prepare_failed'; reason?: string }
|
||||||
|
| { kind: 'start_failed'; reason?: string }
|
||||||
|
// bridgev2/commands/login.go:366 — `Login failed: <go-error>` after a
|
||||||
|
// display-and-wait branch returns an error from `login.Wait()`. Most
|
||||||
|
// common reasons: server-side `login process timed out` (10-min
|
||||||
|
// LoginTimeout in pkg/connector/loginqr.go:43) and `context canceled`
|
||||||
|
// when the user cancelled mid-QR (we've usually already moved to
|
||||||
|
// disconnected via cancel_pending in that case — see reducer).
|
||||||
|
| { kind: 'login_failed'; reason?: string }
|
||||||
|
// QR-login lifecycle (M13). The bridge ships `m.image` events whose
|
||||||
|
// `body` carries the raw `tg://login?token=...` URL; the widget renders
|
||||||
|
// the QR client-side from that URL and never touches the uploaded PNG.
|
||||||
|
// `replacesEventId` is set when this event is an `m.replace` edit of a
|
||||||
|
// prior QR event — the bridge rotates the token roughly every 30 s
|
||||||
|
// (anti-replay per Telegram MTProto spec) and edits the original event
|
||||||
|
// each time, so subsequent rotations carry the original event_id in
|
||||||
|
// `m.relates_to.event_id`. The widget treats that as «same QR-flow,
|
||||||
|
// updated payload» and just repaints; without it, every rotation would
|
||||||
|
// re-issue the «awaiting_qr_scan» state and reset transient form state.
|
||||||
|
| { kind: 'qr_displayed'; tgUrl: string; eventId: string; replacesEventId?: string }
|
||||||
|
// Bridge redacted the QR event after a successful scan. NOT terminal —
|
||||||
|
// a 2FA prompt or login success line typically follows; the state
|
||||||
|
// machine moves us into a `qr_verifying` interstitial until the next
|
||||||
|
// signal lands.
|
||||||
|
| { kind: 'qr_redacted'; redactsEventId: string }
|
||||||
|
| { kind: 'unknown' };
|
||||||
97
apps/widget-telegram/src/i18n/en.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// English fallback. Mirror the RU key set; `Record<StringKey, string>` enforces
|
||||||
|
// that every RU key has an EN counterpart at compile time.
|
||||||
|
|
||||||
|
import type { StringKey } from './ru';
|
||||||
|
|
||||||
|
export const EN: Record<StringKey, string> = {
|
||||||
|
'status.unknown': 'Checking status…',
|
||||||
|
'status.disconnected': 'Telegram not linked',
|
||||||
|
'status.connected': 'Telegram linked',
|
||||||
|
'status.connected-as': 'Telegram linked as {handle}',
|
||||||
|
'status.logging-out': 'Signing out…',
|
||||||
|
'status.qr-verifying': 'Verifying sign-in…',
|
||||||
|
'card.login.name': 'Sign in by phone number',
|
||||||
|
'card.login.desc': 'Code arrives in Telegram or via SMS',
|
||||||
|
'card.login-qr.name': 'Sign in with QR code',
|
||||||
|
'card.login-qr.desc': 'Scan a QR code from the Telegram app on your phone',
|
||||||
|
'card.refresh.aria': 'Refresh status',
|
||||||
|
'card.refresh.label': 'Refresh status',
|
||||||
|
'card.refresh.name': 'Refresh status',
|
||||||
|
'card.refresh.desc': 'Re-check whether Telegram is linked',
|
||||||
|
'card.refresh.in-flight': 'Checking…',
|
||||||
|
'card.about.name': 'How the Telegram bot works',
|
||||||
|
'card.about.desc': 'Sign-in, safety, and source code',
|
||||||
|
'about.title': 'About the Telegram bot',
|
||||||
|
'about.body-1':
|
||||||
|
'This bot connects Telegram to Vojo. After sign-in, your private chats and groups from Telegram will appear in Vojo’s chat list, and replies from the Vojo app will be sent to your contacts as normal Telegram messages.',
|
||||||
|
'about.body-2':
|
||||||
|
'Sign-in uses your phone number and the code from Telegram, just like signing in on a new device. If you have two-step verification enabled, Telegram will also ask for your cloud password.',
|
||||||
|
'about.body-3':
|
||||||
|
'The connection runs through the open-source mautrix-telegram bridge. It creates a Telegram session on the Vojo server and uses it to connect Telegram with your Vojo account: receive messages from Telegram and send your replies back.',
|
||||||
|
'about.github-label': 'The bridge source code is public on GitHub:',
|
||||||
|
'about.github-url': 'https://github.com/mautrix/telegram',
|
||||||
|
'about.body-4':
|
||||||
|
'You can revoke access at any time — either with the “Sign out of Telegram” button here, or inside Telegram itself under Settings → Devices.',
|
||||||
|
'about.close': 'Close',
|
||||||
|
'about.aria-close': 'Close “About this bot”',
|
||||||
|
'auth-card.phone.title': 'Phone login',
|
||||||
|
'auth-card.phone.label': 'Phone number',
|
||||||
|
'auth-card.phone.placeholder': '+15551234567',
|
||||||
|
'auth-card.phone.hint': 'SMS may take up to 30 seconds.',
|
||||||
|
'auth-card.phone.submit': 'Send code',
|
||||||
|
'auth-card.phone.cooldown': 'Retry in {seconds}s',
|
||||||
|
'auth-card.code.title': 'Verification code',
|
||||||
|
'auth-card.code.label': 'SMS code',
|
||||||
|
'auth-card.code.placeholder': '123456',
|
||||||
|
'auth-card.code.submit': 'Confirm',
|
||||||
|
'auth-card.code.privacy-hint':
|
||||||
|
'The Telegram code is visible in the room history — you can clear it manually.',
|
||||||
|
'auth-card.password.title': 'Telegram cloud password',
|
||||||
|
'auth-card.password.hint':
|
||||||
|
'Your account has two-factor authentication enabled. Enter your Telegram cloud password — this is not your Vojo password.',
|
||||||
|
'auth-card.password.label': 'Password',
|
||||||
|
'auth-card.password.submit': 'Confirm',
|
||||||
|
'auth-card.password.show': 'Show',
|
||||||
|
'auth-card.password.hide': 'Hide',
|
||||||
|
'auth-card.cancel': 'Cancel',
|
||||||
|
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.',
|
||||||
|
'auth-card.code.countdown': 'Code arriving in {seconds}s',
|
||||||
|
'auth-card.code.countdown-done': 'No code yet — tap Cancel and try again.',
|
||||||
|
'auth-card.qr.title': 'QR code sign-in',
|
||||||
|
'auth-card.qr.hint': 'Open Telegram on your phone and scan this QR code.',
|
||||||
|
'auth-card.qr.preparing': 'Preparing QR code…',
|
||||||
|
'auth-card.qr.aria': 'QR code for Telegram sign-in. Scan it with your phone.',
|
||||||
|
'auth-card.qr.countdown': 'Time left to scan: {minutes}:{seconds}',
|
||||||
|
'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
||||||
|
'auth-card.qr.step-1': 'Open Settings → Devices in the Telegram app.',
|
||||||
|
'auth-card.qr.step-2': 'Tap “Link Device” and scan this QR code.',
|
||||||
|
'auth-card.qr.step-3': 'If two-step verification is on, enter your cloud password on the next step.',
|
||||||
|
'auth-error.invalid-code': 'Code is invalid. Please try again.',
|
||||||
|
'auth-error.wrong-password': 'Password is incorrect. Please try again.',
|
||||||
|
'auth-error.invalid-value': 'Value not accepted: {reason}',
|
||||||
|
'auth-error.submit-failed': 'Telegram refused the input: {reason}',
|
||||||
|
'auth-error.login-in-progress':
|
||||||
|
'The bot already has another login flow open. Click Cancel and retry.',
|
||||||
|
'auth-error.max-logins': 'Login limit reached ({limit}). Log out of an existing account first.',
|
||||||
|
'auth-error.unknown-command':
|
||||||
|
'The bot does not recognise this command — check the prefix in config.json.',
|
||||||
|
'auth-error.start-failed': 'Failed to start login: {reason}',
|
||||||
|
'auth-error.prepare-failed': 'Failed to prepare login: {reason}',
|
||||||
|
'card.logout.name': 'Sign out of Telegram',
|
||||||
|
'card.logout.desc': 'End the session for this account',
|
||||||
|
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||||
|
'card.logout.confirm-yes': 'Sign out',
|
||||||
|
'card.logout.confirm-no': 'Cancel',
|
||||||
|
'card.logout.gated': 'Session identifier still loading — give it a moment.',
|
||||||
|
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
|
||||||
|
'diag.ready': 'Ready to send commands.',
|
||||||
|
'diag.checking-status': 'Checking connection status…',
|
||||||
|
'diag.send-failed': 'send failed: {message}',
|
||||||
|
'diag.history-marker': '─── history ───',
|
||||||
|
'diag.history-unavailable': 'Could not read history — re-checking status.',
|
||||||
|
'diag.qr-issued': 'QR code refreshed.',
|
||||||
|
'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.',
|
||||||
|
'bootstrap.failed': 'Widget failed to start',
|
||||||
|
'bootstrap.missing-params': 'Missing required URL params: {names}.',
|
||||||
|
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
|
||||||
|
};
|
||||||
30
apps/widget-telegram/src/i18n/index.ts
Normal file
|
|
@ -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, string>): string => {
|
||||||
|
if (!vars) return s;
|
||||||
|
return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickDict = (clientLanguage: string | undefined): Record<StringKey, string> => {
|
||||||
|
const lang = (
|
||||||
|
clientLanguage ||
|
||||||
|
(typeof navigator !== 'undefined' ? navigator.language : '') ||
|
||||||
|
'ru'
|
||||||
|
).toLowerCase();
|
||||||
|
return lang.startsWith('en') ? EN : RU;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type T = (key: StringKey, vars?: Record<string, string>) => string;
|
||||||
|
|
||||||
|
export const createT = (clientLanguage?: string): T => {
|
||||||
|
const dict = pickDict(clientLanguage);
|
||||||
|
return (key, vars) => interpolate(dict[key], vars);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { StringKey };
|
||||||
154
apps/widget-telegram/src/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
// Russian primary copy. To add a string:
|
||||||
|
// 1. add the key + RU value here (this file is the canonical key list — `en.ts`
|
||||||
|
// and the `StringKey` type derive from it),
|
||||||
|
// 2. add the same key + EN value in `en.ts`,
|
||||||
|
// 3. consume via `t('key', { var: 'x' })` in components.
|
||||||
|
// Interpolation uses `{name}` placeholders resolved against the second arg.
|
||||||
|
//
|
||||||
|
// The widget no longer renders a hero (avatar/name/handle/description) —
|
||||||
|
// that block lives in the host's BotShellHero. Status is surfaced inline
|
||||||
|
// inside the relevant section, with active labels («Войдите в Telegram»
|
||||||
|
// instead of passive «Не подключён»). Mid-flow states (awaiting_*) don't
|
||||||
|
// have status labels because the open form is itself the indicator.
|
||||||
|
|
||||||
|
export const RU = {
|
||||||
|
// --- Inline section status ---------------------------------------------
|
||||||
|
// Status pill mirrors the connected pill («Telegram привязан»). Earlier
|
||||||
|
// copy used «Войдите в Telegram», which read as a duplicate of the login
|
||||||
|
// card sitting directly below — the pill should describe state, the
|
||||||
|
// card should carry the action.
|
||||||
|
'status.unknown': 'Проверка статуса…',
|
||||||
|
'status.disconnected': 'Telegram не привязан',
|
||||||
|
'status.connected': 'Telegram привязан',
|
||||||
|
'status.connected-as': 'Telegram привязан как {handle}',
|
||||||
|
'status.logging-out': 'Завершение сеанса…',
|
||||||
|
// QR-вход: после успешного скана мост стирает QR и переходит к 2FA или
|
||||||
|
// подтверждению логина. Это короткий промежуточный pill между скан-моментом
|
||||||
|
// и реальным результатом — обычно секунды.
|
||||||
|
'status.qr-verifying': 'Проверяем вход…',
|
||||||
|
// --- Section headers ---------------------------------------------------
|
||||||
|
// Human-readable name; bridgev2's `!tg login` is sent under the hood, but
|
||||||
|
// surfacing «/login» on the button makes the UI read like a CLI.
|
||||||
|
'card.login.name': 'Войти по номеру',
|
||||||
|
// Card desc is descriptive (noun-style), not a third call-to-action — the
|
||||||
|
// section status carries state, the card carries action + how-to. The
|
||||||
|
// mention of «приложение или SMS» reflects Telegram's actual delivery:
|
||||||
|
// for users already logged in on another device the OTP arrives as a
|
||||||
|
// Telegram-app push first, only falling back to SMS if no other session.
|
||||||
|
'card.login.desc': 'Код придёт в Telegram или по SMS',
|
||||||
|
'card.login-qr.name': 'Войти по QR-коду',
|
||||||
|
'card.login-qr.desc': 'Отсканировать QR из приложения Telegram на телефоне',
|
||||||
|
'card.refresh.aria': 'Обновить статус',
|
||||||
|
'card.refresh.label': 'Обновить статус',
|
||||||
|
// Refresh-as-card variant for the disconnected state where it sits in
|
||||||
|
// the same `command-grid` as login. Same vocabulary as login card.
|
||||||
|
'card.refresh.name': 'Обновить статус',
|
||||||
|
'card.refresh.desc': 'Перепроверить, привязан ли Telegram',
|
||||||
|
// Shown in the desc slot while a refresh request is in flight (button
|
||||||
|
// also goes :disabled + spinning icon). Without this the click has no
|
||||||
|
// visible acknowledgement until the bot replies.
|
||||||
|
'card.refresh.in-flight': 'Проверяю…',
|
||||||
|
// --- About panel -------------------------------------------------------
|
||||||
|
'card.about.name': 'Как работает Telegram-бот',
|
||||||
|
'card.about.desc': 'Вход, безопасность и исходный код',
|
||||||
|
'about.title': 'О боте Telegram',
|
||||||
|
'about.body-1':
|
||||||
|
'Этот бот подключает Telegram к Vojo. После входа личные чаты и группы из Telegram появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в Telegram.',
|
||||||
|
'about.body-2':
|
||||||
|
'Для входа нужен номер телефона и код из Telegram — как при входе на новом устройстве. Если у вас включена двухэтапная проверка, Telegram дополнительно попросит облачный пароль.',
|
||||||
|
'about.body-3':
|
||||||
|
'Подключение работает через open-source мост mautrix-telegram. Он создаёт Telegram-сессию на сервере Vojo и использует её для связи Telegram с вашим аккаунтом Vojo: получает сообщения из Telegram и отправляет ваши ответы обратно.',
|
||||||
|
'about.github-label': 'Исходный код моста открыт на GitHub:',
|
||||||
|
'about.github-url': 'https://github.com/mautrix/telegram',
|
||||||
|
'about.body-4':
|
||||||
|
'Отозвать доступ можно в любой момент — кнопкой «Выйти из Telegram» здесь, либо в самом Telegram через «Настройки → Устройства».',
|
||||||
|
'about.close': 'Закрыть',
|
||||||
|
'about.aria-close': 'Закрыть «О боте»',
|
||||||
|
// --- Phone form --------------------------------------------------------
|
||||||
|
'auth-card.phone.title': 'Вход по номеру',
|
||||||
|
'auth-card.phone.label': 'Номер телефона',
|
||||||
|
'auth-card.phone.placeholder': '+79991234567',
|
||||||
|
'auth-card.phone.hint': 'SMS может идти до 30 секунд.',
|
||||||
|
'auth-card.phone.submit': 'Отправить код',
|
||||||
|
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
||||||
|
// --- Code form ---------------------------------------------------------
|
||||||
|
'auth-card.code.title': 'Код подтверждения',
|
||||||
|
'auth-card.code.label': 'Код из SMS',
|
||||||
|
'auth-card.code.placeholder': '123456',
|
||||||
|
'auth-card.code.submit': 'Подтвердить',
|
||||||
|
'auth-card.code.privacy-hint': 'Telegram-код виден в истории комнаты — можно очистить вручную.',
|
||||||
|
// --- 2FA password form -------------------------------------------------
|
||||||
|
'auth-card.password.title': 'Облачный пароль Telegram',
|
||||||
|
'auth-card.password.hint':
|
||||||
|
'У вашего аккаунта включена двухэтапная проверка. Введите облачный пароль Telegram — это не пароль от Vojo.',
|
||||||
|
'auth-card.password.label': 'Пароль',
|
||||||
|
'auth-card.password.submit': 'Подтвердить',
|
||||||
|
'auth-card.password.show': 'Показать',
|
||||||
|
'auth-card.password.hide': 'Скрыть',
|
||||||
|
// --- Shared form chrome ------------------------------------------------
|
||||||
|
'auth-card.cancel': 'Отмена',
|
||||||
|
'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.',
|
||||||
|
'auth-card.code.countdown': 'Код придёт через {seconds} сек',
|
||||||
|
'auth-card.code.countdown-done': 'Не пришло — нажмите «Отмена» и попробуйте снова.',
|
||||||
|
// --- QR form -----------------------------------------------------------
|
||||||
|
// Заголовок и подсказка над самим QR. Шаги ниже расписывают, где открыть
|
||||||
|
// сканер в приложении Telegram — без этого у пользователя без опыта
|
||||||
|
// обычно теряется минута на поиски пункта меню.
|
||||||
|
'auth-card.qr.title': 'Вход по QR-коду',
|
||||||
|
'auth-card.qr.hint': 'Откройте Telegram на телефоне и отсканируйте этот QR-код.',
|
||||||
|
'auth-card.qr.preparing': 'Готовим QR-код…',
|
||||||
|
'auth-card.qr.aria': 'QR-код для входа в Telegram. Отсканируйте его телефоном.',
|
||||||
|
// Обратный отсчёт до серверного таймаута моста (10 минут). Сам QR
|
||||||
|
// ротируется ~раз в 30 секунд (Telegram-серверный пуш через MTProto),
|
||||||
|
// и тут отображается всегда свежий — отсчёт показывает оставшееся
|
||||||
|
// окно ВСЕГО ВХОДА, а не валидность конкретного отображаемого QR.
|
||||||
|
// Формат «MM:SS» нагляднее «через N секунд» при минутном масштабе.
|
||||||
|
'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}',
|
||||||
|
'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
||||||
|
// Шаги для пользователя — соответствуют пути в актуальной версии Telegram
|
||||||
|
// на момент M13. Если Telegram перенесёт пункт меню, это правится тут
|
||||||
|
// одной строкой; код кнопок не зависит от текста шагов.
|
||||||
|
'auth-card.qr.step-1': 'Откройте «Настройки → Устройства» в Telegram.',
|
||||||
|
'auth-card.qr.step-2': 'Нажмите «Подключить устройство» и отсканируйте этот QR-код.',
|
||||||
|
'auth-card.qr.step-3': 'Если включён облачный пароль — введите его в следующем шаге.',
|
||||||
|
// --- Inline errors -----------------------------------------------------
|
||||||
|
'auth-error.invalid-code': 'Код неверный. Попробуйте снова.',
|
||||||
|
'auth-error.wrong-password': 'Пароль неверный. Попробуйте снова.',
|
||||||
|
'auth-error.invalid-value': 'Значение не принято: {reason}',
|
||||||
|
'auth-error.submit-failed': 'Telegram не принял ввод: {reason}',
|
||||||
|
'auth-error.login-in-progress':
|
||||||
|
'У бота уже идёт другой вход. Нажмите «Отмена» и попробуйте снова.',
|
||||||
|
'auth-error.max-logins':
|
||||||
|
'Достигнут лимит входов ({limit}). Сначала выйдите из существующего аккаунта.',
|
||||||
|
'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.',
|
||||||
|
'auth-error.start-failed': 'Не удалось начать вход: {reason}',
|
||||||
|
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||||
|
// --- Logout ------------------------------------------------------------
|
||||||
|
// Same readability rationale as `card.login.name` — the bridgev2 command
|
||||||
|
// name belongs in the wire payload, not on the button.
|
||||||
|
'card.logout.name': 'Выйти из Telegram',
|
||||||
|
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
|
||||||
|
'card.logout.confirm-prompt': 'Точно выйти?',
|
||||||
|
'card.logout.confirm-yes': 'Выйти',
|
||||||
|
'card.logout.confirm-no': 'Отмена',
|
||||||
|
'card.logout.gated': 'Идентификатор сессии ещё загружается — подождите секунду.',
|
||||||
|
// --- Diagnostics in transcript ----------------------------------------
|
||||||
|
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
||||||
|
'diag.ready': 'Готов отправлять команды.',
|
||||||
|
'diag.checking-status': 'Проверяю статус подключения…',
|
||||||
|
'diag.send-failed': 'ошибка отправки: {message}',
|
||||||
|
'diag.history-marker': '─── история ───',
|
||||||
|
'diag.history-unavailable': 'Не удалось прочитать историю — проверяю статус заново.',
|
||||||
|
// QR-сообщения никогда не выводятся целиком в transcript — body содержит
|
||||||
|
// токен `tg://login?token=…`, который мост стирает после скана; сохранять
|
||||||
|
// его в DOM-логе виджета означало бы пережить эту защиту. Поэтому в логе
|
||||||
|
// только нейтральные диагностические строки.
|
||||||
|
'diag.qr-issued': 'QR-код обновлён.',
|
||||||
|
'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.',
|
||||||
|
// --- Bootstrap failure -------------------------------------------------
|
||||||
|
'bootstrap.failed': 'Widget не запустился',
|
||||||
|
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
|
||||||
|
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StringKey = keyof typeof RU;
|
||||||
91
apps/widget-telegram/src/main.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { readBootstrap } from './bootstrap';
|
||||||
|
import { App } from './App';
|
||||||
|
import { createT } from './i18n';
|
||||||
|
import { WidgetApi, buildCapabilities } from './widget-api';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
// Input-mode detector for hover styling. CSS gates `:hover` and
|
||||||
|
// `:focus-visible` rules on `:root[data-input="mouse"]` because Capacitor's
|
||||||
|
// Android Chromium WebView synthesises `:hover` on the focused element
|
||||||
|
// after a tap and never clears it until the next interaction elsewhere —
|
||||||
|
// without the gate, every tap leaves a sticky hover state on the tapped
|
||||||
|
// card («card greys out after tap and only un-greys when you tap a
|
||||||
|
// different button»).
|
||||||
|
//
|
||||||
|
// Truth comes from `pointerdown.pointerType`. The capture-phase listener
|
||||||
|
// runs in the same task as any post-tap `:hover` synthesis, so a touch
|
||||||
|
// tap on Android lands in 'touch' mode in the same render frame as the
|
||||||
|
// synthesised hover would paint.
|
||||||
|
//
|
||||||
|
// Initial mode is plain 'mouse'. matchMedia-based guessing was tried
|
||||||
|
// here and dropped — every interaction-media query is mis-reported on at
|
||||||
|
// least one shipping device: Capacitor Android WebView falsely matches
|
||||||
|
// `hover: hover` and `any-pointer: fine` on pure-touch phones;
|
||||||
|
// Samsung / OnePlus / Moto Androids expose a virtual-mouse HID and
|
||||||
|
// falsely match `pointer: fine`; older Firefox-on-Windows desktops
|
||||||
|
// reported `pointer: coarse` despite a real mouse. Defaulting to 'mouse'
|
||||||
|
// is strictly no worse than any of those queries on any device: a
|
||||||
|
// desktop / hybrid user gets hover affordances from frame zero, and a
|
||||||
|
// touch user cannot trigger `:hover` before tapping because there is no
|
||||||
|
// pointer hovering anything — by the time the first tap fires
|
||||||
|
// `:hover` (synthesised), our listener has already moved the attribute
|
||||||
|
// to 'touch'. Pen / stylus also lands in 'touch' (pointerType is `pen`,
|
||||||
|
// matched by the `!== 'mouse'` branch).
|
||||||
|
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 someone opened the widget URL directly (no host params), or a
|
||||||
|
// host bug failed to provide them. Either way render a self-contained
|
||||||
|
// diagnostic instead of going silent. Bootstrap failed before we could
|
||||||
|
// read clientLanguage from the URL, so let createT fall back to
|
||||||
|
// navigator.language.
|
||||||
|
const t = createT();
|
||||||
|
render(
|
||||||
|
<div class="app">
|
||||||
|
<div class="error-banner">
|
||||||
|
<strong>{t('bootstrap.failed')}</strong>
|
||||||
|
{t('bootstrap.missing-params', { names: result.missing.join(', ') })}{' '}
|
||||||
|
{t('bootstrap.embedded-only', { route: '/bots/telegram' })}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
root
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Apply initial theme synchronously so the first paint isn't flashed
|
||||||
|
// through the wrong palette.
|
||||||
|
document.documentElement.dataset.theme = result.bootstrap.theme;
|
||||||
|
|
||||||
|
// Instantiate the WidgetApi BEFORE React render. The constructor attaches
|
||||||
|
// the `window.addEventListener('message', ...)` listener synchronously,
|
||||||
|
// so by the time the host's ClientWidgetApi fires its capabilities
|
||||||
|
// request on iframe `load` we're already listening.
|
||||||
|
//
|
||||||
|
// The pre-fix flow built the WidgetApi inside App.tsx's useEffect, which
|
||||||
|
// runs AFTER React's first commit. On a fresh mount the bundle parse +
|
||||||
|
// initial render took long enough for the host's request to arrive
|
||||||
|
// after the listener was attached, so it worked by accident. On the
|
||||||
|
// *second* mount (after «Show chat» → «Show widget») the bundle is
|
||||||
|
// browser-cached and parses near-instantly; the host's request raced
|
||||||
|
// ahead of useEffect, the listener missed it, and capability handshake
|
||||||
|
// hung forever — only the «Соединение с Vojo…» diag line ever showed.
|
||||||
|
const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId));
|
||||||
|
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||||
|
}
|
||||||
1057
apps/widget-telegram/src/state.ts
Normal file
948
apps/widget-telegram/src/styles.css
Normal file
|
|
@ -0,0 +1,948 @@
|
||||||
|
/* Dawn palette — must stay in sync with
|
||||||
|
* docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
|
||||||
|
* (DAWN const, lines 4-23). The widget renders inside the Vojo chat slot
|
||||||
|
* which is itself a Dawn surface; the iframe inherits the same visual
|
||||||
|
* canon to feel like a continuation of the host. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #181a20;
|
||||||
|
--bg2: #0d0e11;
|
||||||
|
--surface: #21232b;
|
||||||
|
--surface2: #2a2d36;
|
||||||
|
--divider: rgba(255, 255, 255, 0.06);
|
||||||
|
--hairline: rgba(255, 255, 255, 0.08);
|
||||||
|
--text: #e6e6e9;
|
||||||
|
--muted: rgba(230, 230, 233, 0.55);
|
||||||
|
--faint: rgba(230, 230, 233, 0.32);
|
||||||
|
--fleet: #9580ff;
|
||||||
|
--fleet-soft: #a59cff;
|
||||||
|
--green: #7dd3a8;
|
||||||
|
--amber: #d4b88a;
|
||||||
|
--rose: #c08e7b;
|
||||||
|
--section-pad-x: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] {
|
||||||
|
/* Light theme is intentionally a thin remap. Vojo is dark-default; the
|
||||||
|
* theme param exists so we don't fight an explicit user/host setting,
|
||||||
|
* not because we expect daily light-mode use. */
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--bg2: #ffffff;
|
||||||
|
--surface: #f0f0f2;
|
||||||
|
--surface2: #e8e8ec;
|
||||||
|
--divider: rgba(0, 0, 0, 0.08);
|
||||||
|
--hairline: rgba(0, 0, 0, 0.1);
|
||||||
|
--text: #1a1a1d;
|
||||||
|
--muted: rgba(26, 26, 29, 0.62);
|
||||||
|
--faint: rgba(26, 26, 29, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:root {
|
||||||
|
--section-pad-x: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Kills the translucent grey overlay iOS/Android WebViews paint on top
|
||||||
|
* of any tapped element. On the wide refresh card this overlay was
|
||||||
|
* read as «button stuck on grey» — the underlying state was correct,
|
||||||
|
* the WebView's tap-highlight was not. Web browsers ignore this. */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.45 -apple-system, 'Segoe UI', 'Inter', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hero (avatar + name + handle + description + three-dots menu) is
|
||||||
|
* OWNED BY THE HOST, not the widget — see src/app/features/bots/BotShell.tsx.
|
||||||
|
* Removing the widget-side hero collapses the duplicate header that used to
|
||||||
|
* sit between the host's BotShellHero (which the user actually sees) and
|
||||||
|
* the iframe content. The widget body now starts with the active-state
|
||||||
|
* section directly. */
|
||||||
|
|
||||||
|
/* ── Section ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 24px var(--section-pad-x) 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section + .section {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section label — same dark-bg pill vocabulary as `.section-status` so the
|
||||||
|
* two pieces in the section-header row read as a matched pair (label
|
||||||
|
* pill + status pill). The pill chrome wraps the existing uppercase
|
||||||
|
* letter-spaced typography; chip is non-interactive, no cursor. */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status pill — button-styled but intentionally non-interactive (no
|
||||||
|
* cursor:pointer, no hover). Replaces the section header for stateful
|
||||||
|
* sections (disconnected / connected / unknown / logging_out) — the
|
||||||
|
* pill itself carries the section's identity, so a separate
|
||||||
|
* `.section-label` would just duplicate the meaning. Same dark-bg
|
||||||
|
* vocabulary (--bg2 / divider border) as `.recovery-action` and the
|
||||||
|
* host hero's «О боте» chip. */
|
||||||
|
.section-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status.connected {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.section-status.connected .dot {
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 0 3px rgba(125, 211, 168, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status.disconnected {
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
.section-status.disconnected .dot {
|
||||||
|
background: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status.checking {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.section-status.checking .dot {
|
||||||
|
background: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wraps the section-status pill + a labeled refresh action when the
|
||||||
|
* state has no other affordance (unknown / logging_out / connected
|
||||||
|
* without loginId). Without this row, the user can stare at a
|
||||||
|
* «Проверка статуса…» pill forever if the first list-logins reply
|
||||||
|
* dropped on the wire. */
|
||||||
|
.section-recovery-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.section-recovery-row > .section-status {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
:root[data-input='mouse'] .recovery-action:hover:not(:disabled) {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--hairline);
|
||||||
|
}
|
||||||
|
.recovery-action:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.recovery-action svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Command card (action card with name + desc + chevron) ──────── */
|
||||||
|
|
||||||
|
.command-card {
|
||||||
|
/* The widget runs in an iframe, so it does NOT inherit the host's
|
||||||
|
* `button { -webkit-appearance: button }` rule (src/index.css:112). The
|
||||||
|
* browser default for <button> on WebKit is `auto`, which on iOS/Android
|
||||||
|
* Capacitor WebView resolves to native button rendering — the WebView
|
||||||
|
* draws its own focus/active overlay ON TOP of our explicit background.
|
||||||
|
* That overlay was the «button greys out and doesn't snap back» bug:
|
||||||
|
* after tap, the WebView holds the native focus paint until focus moves
|
||||||
|
* elsewhere. Setting appearance:none strips the native paint and makes
|
||||||
|
* our CSS the sole source of truth, matching what the host does for
|
||||||
|
* inputs (src/index.css:122-124). On the OLD 70px icon-only refresh
|
||||||
|
* chip the native overlay had nowhere to render visibly; on a wide
|
||||||
|
* command-card it was very visible. Web browsers ignore appearance for
|
||||||
|
* <button> already, so this only matters on native WebViews. */
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
transition: border-color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover scoped to mouse-mode sessions only. Capacitor Android WebView
|
||||||
|
* reports `(hover: hover)` as TRUE on a pure-touch device (verified
|
||||||
|
* via on-device console.log), so a media-query gate doesn't work — the
|
||||||
|
* rule would apply, then the WebView would synthesise `:hover` on the
|
||||||
|
* focused element after tap and leave it stuck until the user tapped
|
||||||
|
* elsewhere (visible symptom: card greys after tap, only un-greys on
|
||||||
|
* tapping a different button). `[data-input]` is set in main.tsx from
|
||||||
|
* the actual `pointerdown.pointerType`, which the WebView reports
|
||||||
|
* truthfully even when its media queries lie. */
|
||||||
|
:root[data-input='mouse'] .command-card:hover:not(:disabled) {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard focus ring — same data-input gate. On touch sessions there's
|
||||||
|
* no keyboard navigation to support and the ring would also stick (focus
|
||||||
|
* stays on the tapped button until something else takes it). */
|
||||||
|
:root[data-input='mouse'] .command-card:focus-visible {
|
||||||
|
outline: 2px solid var(--fleet);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-name {
|
||||||
|
/* Sans-serif + on-surface text color — the previous monospace + fleet-soft
|
||||||
|
* styling read like a `/login` CLI label. With «Войти в Telegram» as the
|
||||||
|
* actual name (no slash, no command-line mimicry), the row should look
|
||||||
|
* like a primary action title, not a code token. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When the chevron slot carries an icon (e.g. refresh card uses
|
||||||
|
* `<RefreshIcon />` instead of `›`), size the SVG explicitly — the icon
|
||||||
|
* has no intrinsic width and would expand to 300×150 (SVG default) inside
|
||||||
|
* a flex-shrink:0 container. 18px keeps it visually equivalent to the
|
||||||
|
* `›` glyph used by the other cards. */
|
||||||
|
.command-card-chevron svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic leading-icon slot — every command-card carries a semantic
|
||||||
|
* left-side glyph (mirror of the right-side chevron). Picks up
|
||||||
|
* `currentColor` from the parent and stays muted by default; the
|
||||||
|
* `.danger` modifier on logout deliberately does NOT colour the lead
|
||||||
|
* icon so the rose accent stays reserved for the title (one accent
|
||||||
|
* per card). */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spin the leading refresh icon while the card is in its `refreshing`
|
||||||
|
* in-flight state. Combined with `disabled` (which dims the card to
|
||||||
|
* opacity 0.5 and gates :hover via :not(:disabled)), the spinner is
|
||||||
|
* the unambiguous «I'm working» signal — no more guessing whether the
|
||||||
|
* click registered. The selector targets the lead slot since the
|
||||||
|
* refresh card moved its glyph from the chevron (right) to the lead
|
||||||
|
* slot (left) for parity with every other card. */
|
||||||
|
.command-card.refreshing .command-card-lead-icon svg {
|
||||||
|
animation: command-card-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes command-card-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Transcript ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.transcript {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* Custom scrollbar styled into the dark palette. Native browser
|
||||||
|
* scrollbars (gray, system-themed) clash with the Dawn surface. */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--surface2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.transcript::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.transcript::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--surface2);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid var(--bg2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
.transcript::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 2px solid var(--bg2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line {
|
||||||
|
padding: 4px 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line + .transcript-line {
|
||||||
|
border-top: 1px dashed var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line .ts {
|
||||||
|
color: var(--faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line .body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.from-bot .body {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.from-user .body {
|
||||||
|
color: var(--fleet-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.diag .body {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.error .body {
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-empty {
|
||||||
|
color: var(--faint);
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Destructive card — keeps the red name to mark «Выйти из Telegram» as a
|
||||||
|
* destructive action, distinguishing it from the primary login card.
|
||||||
|
* Hover border stays on the generic input-gated rule (hairline) so the
|
||||||
|
* accent is consistent across touch and mouse modes. A previous
|
||||||
|
* `.command-card.danger:hover { border-color: var(--rose) }` override
|
||||||
|
* was dead in mouse mode (lower specificity than the input-gated rule)
|
||||||
|
* and only fired in the pre-first-pointerdown touch stub. */
|
||||||
|
.command-card.danger .command-card-name {
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline confirm-in-place body for the destructive logout card. The button
|
||||||
|
* group lives inside the same card frame — no modal, no layout shift. */
|
||||||
|
.command-card-confirm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-prompt {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-yes,
|
||||||
|
.command-card-confirm-no,
|
||||||
|
.btn-primary,
|
||||||
|
.btn-text,
|
||||||
|
.btn-icon {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-yes {
|
||||||
|
background: var(--rose);
|
||||||
|
color: #0c0c0e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-no {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-yes:disabled,
|
||||||
|
.command-card-confirm-no:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Auth card (login forms inside transcript section) ───────────── */
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card.error {
|
||||||
|
border-color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-hint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.12s, box-shadow 0.12s;
|
||||||
|
}
|
||||||
|
.auth-input:hover:not(:focus):not(:disabled) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
.auth-input:focus {
|
||||||
|
border-color: var(--fleet);
|
||||||
|
/* Stronger ring than border-color alone — matches Dawn's emphasis on
|
||||||
|
* accent halos (BotsDesktop avatar shadow / hero-status.ok glow). */
|
||||||
|
box-shadow: 0 0 0 3px rgba(149, 128, 255, 0.18);
|
||||||
|
}
|
||||||
|
.auth-card.error .auth-input {
|
||||||
|
border-color: var(--rose);
|
||||||
|
}
|
||||||
|
.auth-card.error .auth-input:focus {
|
||||||
|
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input.code,
|
||||||
|
.auth-input.password {
|
||||||
|
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btn-icon:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--fleet);
|
||||||
|
color: #0c0c0e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn-text:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-error {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-warn {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-waiting {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--faint);
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Countdown text on the code form: same baseline tone as waiting hint
|
||||||
|
* but a touch more prominent because it carries an actual number. The
|
||||||
|
* color tween softens the muted→amber transition at expiry — without it
|
||||||
|
* the line jumps between palettes mid-sentence, which reads broken
|
||||||
|
* against Dawn's measured aesthetic. */
|
||||||
|
.auth-card-countdown {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 18px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
transition: color 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.auth-card-countdown.expired {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── QR-login panel ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Override the auth-card row layout — QR panel stacks vertically with the
|
||||||
|
* matrix as the visual anchor. Keeps the same outer chrome (border, radius,
|
||||||
|
* padding) so it reads as a sibling to the phone/code/password forms. */
|
||||||
|
.auth-card-qr {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The QR matrix sits on a hard #fff plate regardless of theme — phone
|
||||||
|
* camera scanners need maximum contrast, and the bridge's PNG fallback
|
||||||
|
* also bakes in a white background. The frame is centered, fixed-size,
|
||||||
|
* with a soft inner padding so the quiet zone (already 4 modules in the
|
||||||
|
* SVG itself) is reinforced visually for low-contrast displays. */
|
||||||
|
.auth-card-qr-frame {
|
||||||
|
align-self: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* Lock the inner box to the SVG's rendered size so the placeholder
|
||||||
|
* variant doesn't collapse to zero height while the matrix is being
|
||||||
|
* computed (`buildQrModules` is synchronous but the first React commit
|
||||||
|
* after `start_qr_login` flips state with tgUrl='', and we want the
|
||||||
|
* placeholder to occupy the same footprint). */
|
||||||
|
min-width: 260px;
|
||||||
|
min-height: 260px;
|
||||||
|
/* Drop a subtle outer shadow so the white plate visually separates from
|
||||||
|
* the surrounding dark surface — without this the corners look
|
||||||
|
* paste-on-paper. */
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06), 0 12px 24px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder while we wait for the bridge's first qr_displayed event.
|
||||||
|
* Same visual vocabulary as `.section-status.checking`: amber dot + muted
|
||||||
|
* text — but inverted onto the white plate so the colors work. */
|
||||||
|
.auth-card-qr-placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: rgba(26, 26, 29, 0.62);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
padding: 96px 16px;
|
||||||
|
}
|
||||||
|
.auth-card-qr-placeholder .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--amber);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step list under the QR — explicit phone-side instructions matter more
|
||||||
|
* here than for SMS, because Telegram's «Link Device» menu isn't a place
|
||||||
|
* users hit often (vs the typing-an-SMS-code muscle memory). */
|
||||||
|
.auth-card-qr-steps {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.4em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 19px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.auth-card-qr-steps li::marker {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.auth-card-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.btn-primary,
|
||||||
|
.btn-text {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PasswordForm wraps its input + show/hide toggle in `.password-row`
|
||||||
|
* so the toggle pill sits next to the input on desktop. On narrow
|
||||||
|
* viewports that nested row stays row-direction with `flex-shrink: 0`
|
||||||
|
* on `.btn-icon`, and the input's monospace `font-size: 20px` +
|
||||||
|
* `letter-spacing: 4px` (see `.auth-input.password`) pushes the toggle
|
||||||
|
* off-screen. Continue the same column-stack pattern the outer
|
||||||
|
* `.auth-card-row` already uses so the toggle drops below the input
|
||||||
|
* full-width — visually consistent with btn-primary / btn-text. */
|
||||||
|
.password-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.password-row .btn-icon {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact .command-card on mobile — preserves the «two-row title +
|
||||||
|
* chevron» structure but trims padding so a single login/logout card
|
||||||
|
* doesn't dominate a phone-height viewport. */
|
||||||
|
.command-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.command-card-name {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.command-card-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile QR plate — keep edge-to-edge readable. The 232px SVG matches
|
||||||
|
* desktop, but the surrounding plate gets a smaller min-size to fit
|
||||||
|
* narrower viewports without horizontal scroll. */
|
||||||
|
.auth-card-qr-frame {
|
||||||
|
min-width: 232px;
|
||||||
|
min-height: 232px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.auth-card-qr-placeholder {
|
||||||
|
padding: 80px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Linkified transcript bodies ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.transcript-line a {
|
||||||
|
color: var(--fleet-soft);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.transcript-line a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hint text ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--faint);
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Diagnostic banner (pre-bootstrap failure) ───────────────────── */
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
margin: var(--section-pad-x);
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(192, 142, 123, 0.08);
|
||||||
|
border: 1px solid var(--rose);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--rose);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--rose);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner code {
|
||||||
|
background: var(--bg2);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── About modal ─────────────────────────────────────────────────── */
|
||||||
|
/* Lightweight modal — fixed inside the widget iframe, not crossing into
|
||||||
|
* the host. Backdrop click + Escape close; no focus-trap library (the
|
||||||
|
* widget is a small surface — a heavier mechanism would be overkill). */
|
||||||
|
|
||||||
|
.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;
|
||||||
|
/* Animate in so the panel doesn't feel like a hard pop — matches the
|
||||||
|
* reassuring tone of the body copy itself. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
1
apps/widget-telegram/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
326
apps/widget-telegram/src/widget-api.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
// Minimal matrix-widget-api transport implemented inline. We don't pull
|
||||||
|
// the full SDK because:
|
||||||
|
// - it's CommonJS and forces ESM interop juggling we hit on the dev
|
||||||
|
// fixture in Phase 2 (esm.sh wrapping made WidgetApi unavailable as
|
||||||
|
// a constructor);
|
||||||
|
// - the surface we use is small: capabilities reply, theme_change reply,
|
||||||
|
// send_event request, read_events request, get_openid request, live
|
||||||
|
// event delivery via send_event toWidget.
|
||||||
|
//
|
||||||
|
// Protocol shapes match
|
||||||
|
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
|
||||||
|
// (in the host repo). Default request timeout on the host transport is
|
||||||
|
// 10 s — keep that in mind for bridge-bot replies that take time.
|
||||||
|
|
||||||
|
import type { WidgetBootstrap } from './bootstrap';
|
||||||
|
|
||||||
|
export type RoomEvent = {
|
||||||
|
type: string;
|
||||||
|
event_id: string;
|
||||||
|
room_id: string;
|
||||||
|
sender: string;
|
||||||
|
origin_server_ts: number;
|
||||||
|
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
||||||
|
unsigned: Record<string, unknown>;
|
||||||
|
// `m.room.redaction` events carry `redacts` at the top level (room v < 11)
|
||||||
|
// and/or inside `content.redacts` (v11+). The host driver mirrors at both
|
||||||
|
// for forward-compat; the widget-side parser reads either.
|
||||||
|
redacts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToWidgetMessage = {
|
||||||
|
api: 'toWidget';
|
||||||
|
widgetId: string;
|
||||||
|
requestId: string;
|
||||||
|
action: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
// Present when this message IS a reply to a prior toWidget request.
|
||||||
|
// Per matrix-widget-api PostmessageTransport: replies preserve the original
|
||||||
|
// `api` field and add `response`. Both directions follow the same shape.
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FromWidgetMessage = {
|
||||||
|
api: 'fromWidget';
|
||||||
|
widgetId: string;
|
||||||
|
requestId: string;
|
||||||
|
action: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Capability = string;
|
||||||
|
|
||||||
|
export type WidgetApiEvents = {
|
||||||
|
ready: () => void;
|
||||||
|
liveEvent: (ev: RoomEvent) => void;
|
||||||
|
themeChange: (name: 'light' | 'dark') => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export class WidgetApi {
|
||||||
|
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
|
||||||
|
|
||||||
|
private readonly pending = new Map<
|
||||||
|
string,
|
||||||
|
{ resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
|
||||||
|
>();
|
||||||
|
|
||||||
|
private requestSeq = 0;
|
||||||
|
|
||||||
|
private isReady = false;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly bootstrap: WidgetBootstrap,
|
||||||
|
private readonly capabilities: Capability[]
|
||||||
|
) {
|
||||||
|
window.addEventListener('message', this.onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
window.removeEventListener('message', this.onMessage);
|
||||||
|
this.pending.forEach(({ reject }) => reject(new Error('disposed')));
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
|
||||||
|
const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
|
||||||
|
list.push(listener);
|
||||||
|
// `ready` is a one-shot lifecycle signal. If the handshake completed
|
||||||
|
// before this listener attached (cached-bundle race: host fires the
|
||||||
|
// capabilities request on iframe `load`, the WidgetApi catches and
|
||||||
|
// resolves it during script init, then React's useEffect runs *after*
|
||||||
|
// that and attaches the `ready` listener), replay synchronously so
|
||||||
|
// App.tsx still flips `handshakeOk` and fires `list-logins`.
|
||||||
|
if (event === 'ready' && this.isReady) {
|
||||||
|
(listener as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendText(body: string): Promise<{ event_id: string }> {
|
||||||
|
return this.fromWidget('send_event', {
|
||||||
|
type: 'm.room.message',
|
||||||
|
content: { msgtype: 'm.text', body },
|
||||||
|
}) as Promise<{ event_id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open an external URL via the host. The host receives this on a
|
||||||
|
// SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from
|
||||||
|
// matrix-widget-api's `fromWidget` so it doesn't route through
|
||||||
|
// ClientWidgetApi's request/response machinery.
|
||||||
|
//
|
||||||
|
// Why this exists: cross-origin iframes inside Capacitor's Android
|
||||||
|
// WebView silently drop `<a target="_blank">` clicks — the WebView
|
||||||
|
// doesn't have a multi-window concept, and the host's global
|
||||||
|
// `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks
|
||||||
|
// inside the host document, not inside the iframe (cross-origin
|
||||||
|
// events don't bubble across the frame boundary). The widget posts
|
||||||
|
// this message instead; the host calls `openExternalUrl(url)` which
|
||||||
|
// routes to `Browser.open` on native and `window.open` on web.
|
||||||
|
public openExternalUrl(url: string): void {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
api: 'io.vojo.bot-widget',
|
||||||
|
action: 'open-external-url',
|
||||||
|
data: { url },
|
||||||
|
},
|
||||||
|
this.bootstrap.parentOrigin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always prefix outbound commands with `<commandPrefix> ` (trailing space —
|
||||||
|
// bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both
|
||||||
|
// the management room and any other room the bot may have been moved to.
|
||||||
|
// Form-field submissions (phone / code / password) go through this same
|
||||||
|
// helper because bridgev2's stored CommandState fallback only fires after
|
||||||
|
// queue.go:108 routes the message — and that route also requires the
|
||||||
|
// prefix outside the management room.
|
||||||
|
public sendCommand(rawBody: string): Promise<{ event_id: string }> {
|
||||||
|
const body = `${this.bootstrap.commandPrefix} ${rawBody}`;
|
||||||
|
return this.sendText(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// M12.5 timeline-resume probe. Action name is MSC2876 (`read_events`); the
|
||||||
|
// capability is MSC2762 timeline (already requested at construction). We
|
||||||
|
// pass `room_ids: [bootstrap.roomId]` explicitly so the host's
|
||||||
|
// ClientWidgetApi takes the modern code path that calls our driver's
|
||||||
|
// `readRoomTimeline` (single-room cap-checked) rather than the deprecated
|
||||||
|
// `readRoomEvents` fallback. Driver returns events newest-first; reversing
|
||||||
|
// to chronological order is the caller's job.
|
||||||
|
//
|
||||||
|
// `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR
|
||||||
|
// post-scan cleanup events. `msgtype` is honoured only for m.room.message
|
||||||
|
// (matches the driver's `readRoomTimeline` semantics).
|
||||||
|
public async readTimeline(opts: {
|
||||||
|
limit: number;
|
||||||
|
type?: 'm.room.message' | 'm.room.redaction';
|
||||||
|
msgtype?: 'm.text' | 'm.notice' | 'm.image';
|
||||||
|
}): Promise<RoomEvent[]> {
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
type: opts.type ?? 'm.room.message',
|
||||||
|
limit: opts.limit,
|
||||||
|
room_ids: [this.bootstrap.roomId],
|
||||||
|
};
|
||||||
|
if (opts.msgtype !== undefined) data.msgtype = opts.msgtype;
|
||||||
|
const res = await this.fromWidget('org.matrix.msc2876.read_events', data);
|
||||||
|
return (res.events as RoomEvent[] | undefined) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit<K extends keyof WidgetApiEvents>(
|
||||||
|
event: K,
|
||||||
|
...args: Parameters<WidgetApiEvents[K]>
|
||||||
|
): void {
|
||||||
|
const list = this.listeners[event] as
|
||||||
|
| Array<(...a: Parameters<WidgetApiEvents[K]>) => void>
|
||||||
|
| undefined;
|
||||||
|
list?.forEach((fn) => fn(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextRequestId(): string {
|
||||||
|
this.requestSeq += 1;
|
||||||
|
return `widget-tg-${Date.now()}-${this.requestSeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private postToHost(msg: ToWidgetMessage | FromWidgetMessage): void {
|
||||||
|
window.parent.postMessage(msg, this.bootstrap.parentOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage = (ev: MessageEvent): void => {
|
||||||
|
if (ev.origin !== this.bootstrap.parentOrigin) return;
|
||||||
|
// Source-window guard: every legit widget API message comes from the
|
||||||
|
// host window that embedded our iframe — i.e. window.parent. A foreign
|
||||||
|
// tab/frame on the same origin (think browser extension content
|
||||||
|
// script, popup, or sibling iframe) could otherwise post a forged
|
||||||
|
// message that passes the origin check. We only accept messages
|
||||||
|
// whose `source` is literally `window.parent`. The `widgetId` check
|
||||||
|
// a few lines down is a soft filter; this is the hard one.
|
||||||
|
if (ev.source !== window.parent) return;
|
||||||
|
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
|
||||||
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
if (msg.widgetId !== this.bootstrap.widgetId) return;
|
||||||
|
|
||||||
|
if (msg.api === 'toWidget') {
|
||||||
|
this.handleToWidget(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.api === 'fromWidget' && msg.response) {
|
||||||
|
const pending = this.pending.get(msg.requestId);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(msg.requestId);
|
||||||
|
const err = (msg.response as { error?: { message?: string } }).error;
|
||||||
|
if (err) pending.reject(new Error(err.message ?? 'request failed'));
|
||||||
|
else pending.resolve(msg.response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private replyTo(msg: ToWidgetMessage, response: Record<string, unknown>): void {
|
||||||
|
this.postToHost({
|
||||||
|
api: msg.api,
|
||||||
|
widgetId: msg.widgetId,
|
||||||
|
requestId: msg.requestId,
|
||||||
|
action: msg.action,
|
||||||
|
data: msg.data,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleToWidget(msg: ToWidgetMessage): void {
|
||||||
|
if (!msg.requestId || !msg.action) return;
|
||||||
|
switch (msg.action) {
|
||||||
|
case 'capabilities': {
|
||||||
|
this.replyTo(msg, { capabilities: this.capabilities });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'notify_capabilities': {
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
if (!this.isReady) {
|
||||||
|
this.isReady = true;
|
||||||
|
this.emit('ready');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'supported_api_versions': {
|
||||||
|
this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'theme_change': {
|
||||||
|
const name = (msg.data?.name as string | undefined) ?? '';
|
||||||
|
const themed: 'light' | 'dark' = name === 'dark' ? 'dark' : 'light';
|
||||||
|
this.emit('themeChange', themed);
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'send_event': {
|
||||||
|
// Live event push from host. Forward `m.room.message` (carries the
|
||||||
|
// bot's notices / errors / `m.image` QR-login broadcasts) AND
|
||||||
|
// `m.room.redaction` (post-scan QR cleanup, see BotWidgetDriver
|
||||||
|
// `sanitizeBotWidgetRedactionEvent`). State events (m.room.member)
|
||||||
|
// also arrive on this channel — we still ignore them here.
|
||||||
|
const data = msg.data as Partial<RoomEvent> | undefined;
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
data.event_id &&
|
||||||
|
(data.type === 'm.room.message' || data.type === 'm.room.redaction')
|
||||||
|
) {
|
||||||
|
this.emit('liveEvent', data as RoomEvent);
|
||||||
|
}
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'update_state': {
|
||||||
|
// Initial room state push from host (m.room.member members).
|
||||||
|
// M11 ignores this; future milestones can use it for header chrome.
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// Be liberal — reply empty so the host's request promise resolves.
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fromWidget(
|
||||||
|
action: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const requestId = this.nextRequestId();
|
||||||
|
this.pending.set(requestId, { resolve, reject });
|
||||||
|
this.postToHost({
|
||||||
|
api: 'fromWidget',
|
||||||
|
widgetId: this.bootstrap.widgetId,
|
||||||
|
requestId,
|
||||||
|
action,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (this.pending.has(requestId)) {
|
||||||
|
this.pending.delete(requestId);
|
||||||
|
reject(new Error(`${action} timed out after ${FROM_WIDGET_REQUEST_TIMEOUT_MS}ms`));
|
||||||
|
}
|
||||||
|
}, FROM_WIDGET_REQUEST_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability set must match docs/plans/bots_tab.md (Phase 3 contract) and
|
||||||
|
// the host's BotWidgetDriver.getBotWidgetCapabilities. Anything else is
|
||||||
|
// silently dropped by the host's validateCapabilities — keep this aligned.
|
||||||
|
//
|
||||||
|
// `m.image` and `m.room.redaction` are the QR-login additions (M13). The
|
||||||
|
// host sanitizer for `m.image` strips `url` / `file` / `info`, leaving only
|
||||||
|
// `body` (the bridge encodes `tg://login?token=...` there) plus
|
||||||
|
// `m.relates_to` / `m.new_content` for QR rotation edits. Redactions
|
||||||
|
// signal that the QR was consumed by a successful scan.
|
||||||
|
export const buildCapabilities = (roomId: string): Capability[] => [
|
||||||
|
`org.matrix.msc2762.timeline:${roomId}`,
|
||||||
|
'org.matrix.msc2762.send.event:m.room.message#m.text',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.text',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.notice',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.image',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.redaction',
|
||||||
|
'org.matrix.msc2762.receive.state_event:m.room.member',
|
||||||
|
];
|
||||||
21
apps/widget-telegram/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
25
apps/widget-telegram/vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
// Build artefact lives at apps/widget-telegram/dist/. The deploy step
|
||||||
|
// (out of repo) rsyncs this into ~/vojo/widgets/telegram/ on the server,
|
||||||
|
// which Caddy serves from /var/www/widgets/telegram via the
|
||||||
|
// widgets.vojo.chat block (see docs/plans/bots_tab.md Phase 3).
|
||||||
|
//
|
||||||
|
// `base: './'` keeps every generated asset path relative so the same
|
||||||
|
// build can sit under /telegram/ on widgets.vojo.chat without rewrites.
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
plugins: [preact()],
|
||||||
|
build: {
|
||||||
|
target: 'es2020',
|
||||||
|
sourcemap: true,
|
||||||
|
// Inline CSS for a single round-trip; the widget is small and the
|
||||||
|
// host's iframe handshake budget is already tight (10s default).
|
||||||
|
cssCodeSplit: false,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 8081,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
137
apps/widget-whatsapp/README.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# @vojo/widget-whatsapp
|
||||||
|
|
||||||
|
Vojo WhatsApp bridge management widget — mounts inside `/bots/whatsapp`
|
||||||
|
in the Vojo client. Drives the mautrix-whatsapp bridge bot
|
||||||
|
(`@whatsappbot:vojo.chat`) by sending bridgev2 commands in the control DM
|
||||||
|
and rendering the bot's text replies into a typed login flow.
|
||||||
|
|
||||||
|
This is **not** a WhatsApp client — Vojo continues using the Matrix room
|
||||||
|
the bridge writes to. The widget is a panel that handles authentication
|
||||||
|
(QR scan or pairing code) and surfaces session status.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── bootstrap.ts Parse URL params the host appends (mirrors BotWidgetEmbed.ts)
|
||||||
|
├── widget-api.ts Inline matrix-widget-api postMessage transport (no SDK)
|
||||||
|
├── App.tsx UI: login forms, QR / pairing-code panels, transcript pane
|
||||||
|
├── main.tsx Entry: init bootstrap, render App or diagnostic
|
||||||
|
├── styles.css Theme-aware CSS (Vojo Dawn palette)
|
||||||
|
├── state.ts Login state machine + hydrate-from-timeline
|
||||||
|
├── i18n/ Russian primary + English fallback
|
||||||
|
└── bridge-protocol/
|
||||||
|
├── types.ts LoginEvent discriminated union
|
||||||
|
├── parser.ts Dispatch shim
|
||||||
|
└── dialects/
|
||||||
|
└── bridgev2_v0264.ts Regex table pinned to mautrix-whatsapp v0.26.4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Login flows
|
||||||
|
|
||||||
|
WhatsApp's mautrix bridge ships TWO login flows (see
|
||||||
|
`pkg/connector/login.go::GetLoginFlows`):
|
||||||
|
|
||||||
|
1. **QR** (`!wa login qr`) — bridge emits a rotating `m.image` whose body
|
||||||
|
is the raw whatsmeow handshake payload (`<ref>,<noise>,<identity>,<adv>`,
|
||||||
|
four base64 fields). The widget renders it as a QR matrix client-side.
|
||||||
|
Whatsmeow `qrIntervals = [60s, 20s, 20s, 20s, 20s, 20s]` — first QR
|
||||||
|
lasts 60 seconds, then five rotations of 20 seconds each. Total active
|
||||||
|
window: 2 minutes 40 seconds. Each rotation arrives as an `m.replace`
|
||||||
|
edit of the original event; the state machine matches on the original
|
||||||
|
id and repaints the matrix.
|
||||||
|
|
||||||
|
2. **Pairing code** (`!wa login phone`) — alternative for users whose
|
||||||
|
camera doesn't work or who prefer typing. The user enters a phone
|
||||||
|
number; the bridge replies with two notices:
|
||||||
|
- `Input the pairing code in the WhatsApp mobile app to log in`
|
||||||
|
- The 8-character code itself (`XXXX-XXXX`, custom base32 alphabet).
|
||||||
|
The widget renders the code prominently and the user enters it in
|
||||||
|
WhatsApp → Settings → Linked devices → Link with phone number.
|
||||||
|
|
||||||
|
There is **no 2FA cloud-password step** — multidevice handshake is
|
||||||
|
single-factor. The state machine has no `awaiting_password` arm.
|
||||||
|
|
||||||
|
## Capability contract
|
||||||
|
|
||||||
|
The widget requests EXACTLY this set (matches the host's
|
||||||
|
`BotWidgetDriver.getBotWidgetCapabilities`):
|
||||||
|
|
||||||
|
```
|
||||||
|
org.matrix.msc2762.timeline:<roomId>
|
||||||
|
org.matrix.msc2762.send.event:m.room.message#m.text
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.text
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.notice
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.image
|
||||||
|
org.matrix.msc2762.receive.event:m.room.redaction
|
||||||
|
org.matrix.msc2762.receive.state_event:m.room.member
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything else is silently dropped by the host. The capability set is
|
||||||
|
identical to the Telegram widget's M13 expansion — the host driver
|
||||||
|
already supports `m.image` + `m.room.redaction`.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/widget-whatsapp && npm install
|
||||||
|
cd /home/ubuntu/projects/vojo/cinny && cat > config.local.json <<'JSON'
|
||||||
|
{
|
||||||
|
"bots": [
|
||||||
|
{ "id": "whatsapp", "experience": { "type": "matrix-widget", "url": "http://localhost:8083/" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
Run both servers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal 1 — widget on :8083 with HMR
|
||||||
|
cd apps/widget-whatsapp && npm run dev
|
||||||
|
|
||||||
|
# terminal 2 — host SPA on :8080
|
||||||
|
cd /home/ubuntu/projects/vojo/cinny && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8080/bots/whatsapp`. The host's URL validator
|
||||||
|
accepts `http://localhost:*` only in dev builds.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs to `apps/widget-whatsapp/dist/`. Deploy by rsyncing `dist/*` into
|
||||||
|
`~/vojo/widgets/whatsapp/` on the production host. The VSCode task
|
||||||
|
`Deploy widgets` already includes the third subshell — running it from
|
||||||
|
the host root pushes all three widgets in sequence.
|
||||||
|
|
||||||
|
## Capacitor (Android)
|
||||||
|
|
||||||
|
`capacitor.config.ts` already allows `widgets.vojo.chat` for the existing
|
||||||
|
TG / Discord widgets — no extra entry needed for WhatsApp.
|
||||||
|
|
||||||
|
## Hosting (server-side)
|
||||||
|
|
||||||
|
Same Caddy `widgets.vojo.chat` block as the other widgets — add a third
|
||||||
|
`handle_path /whatsapp/* { … }` block alongside `/telegram/*` and
|
||||||
|
`/discord/*`. Then `mkdir -p ~/vojo/widgets/whatsapp` on the server, run
|
||||||
|
the deploy task, and verify with
|
||||||
|
`curl -I https://widgets.vojo.chat/whatsapp/index.html`.
|
||||||
|
|
||||||
|
## Source-of-truth pointers
|
||||||
|
|
||||||
|
- mautrix-whatsapp connector: <https://github.com/mautrix/whatsapp/blob/main/pkg/connector/login.go>
|
||||||
|
- mautrix-whatsapp connector (post-login session events):
|
||||||
|
<https://github.com/mautrix/whatsapp/blob/main/pkg/connector/handlewhatsapp.go>
|
||||||
|
- whatsmeow QR format: <https://github.com/tulir/whatsmeow/blob/main/pair.go> (`makeQRData`)
|
||||||
|
- whatsmeow pairing-code: <https://github.com/tulir/whatsmeow/blob/main/pair-code.go> (`PairPhone`)
|
||||||
|
- bridgev2 commands layer (shared with mautrix-telegram):
|
||||||
|
<https://github.com/mautrix/go/blob/main/bridgev2/commands/login.go>
|
||||||
|
|
||||||
|
The dialect file `src/bridge-protocol/dialects/bridgev2_v0264.ts` has
|
||||||
|
inline upstream pointers per regex; when the bridge image is upgraded,
|
||||||
|
spot-check those pointers and either confirm the wording is still valid
|
||||||
|
or drop a sibling dialect file with new regexes.
|
||||||
12
apps/widget-whatsapp/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>WhatsApp bridge — Vojo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1999
apps/widget-whatsapp/package-lock.json
generated
Normal file
21
apps/widget-whatsapp/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "@vojo/widget-whatsapp",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Vojo WhatsApp bridge management widget — mounts inside /bots/whatsapp",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "10.22.1",
|
||||||
|
"qrcode-generator": "1.4.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "2.9.0",
|
||||||
|
"typescript": "5.4.5",
|
||||||
|
"vite": "5.4.19"
|
||||||
|
}
|
||||||
|
}
|
||||||
1459
apps/widget-whatsapp/src/App.tsx
Normal file
67
apps/widget-whatsapp/src/bootstrap.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// Parse the URL params the 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;
|
||||||
|
/** Bridge command prefix (e.g. `!wa`). Always non-empty — the host
|
||||||
|
* validator (catalog.ts) defaults missing values to `!tg` and rejects
|
||||||
|
* malformed overrides. The widget prepends `<commandPrefix> ` to every
|
||||||
|
* outbound command and form-field value (bridgev2/queue.go:118 strips
|
||||||
|
* exactly `prefix+" "`). For mautrix-whatsapp the operator must set
|
||||||
|
* `commandPrefix: "!wa"` in /config.json — connector.go ships
|
||||||
|
* `DefaultCommandPrefix: "!wa"`. */
|
||||||
|
commandPrefix: string;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
clientLanguage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BootstrapResult =
|
||||||
|
| { ok: true; bootstrap: WidgetBootstrap }
|
||||||
|
| { ok: false; missing: string[] };
|
||||||
|
|
||||||
|
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const;
|
||||||
|
|
||||||
|
export const readBootstrap = (search: string): BootstrapResult => {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const get = (k: string) => params.get(k) ?? '';
|
||||||
|
|
||||||
|
const missing = REQUIRED.filter((k) => !params.get(k));
|
||||||
|
if (missing.length > 0) return { ok: false, missing: [...missing] };
|
||||||
|
|
||||||
|
// Origin is what the widget validates against on incoming postMessage —
|
||||||
|
// see widget-api.ts. Falling back to '*' would defeat the security
|
||||||
|
// boundary, so a malformed parentUrl bails out as a missing-param error.
|
||||||
|
let parentOrigin: string;
|
||||||
|
try {
|
||||||
|
parentOrigin = new URL(get('parentUrl')).origin;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, missing: ['parentUrl'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeRaw = get('theme');
|
||||||
|
const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
bootstrap: {
|
||||||
|
widgetId: get('widgetId'),
|
||||||
|
parentUrl: get('parentUrl'),
|
||||||
|
parentOrigin,
|
||||||
|
roomId: get('roomId'),
|
||||||
|
userId: get('userId'),
|
||||||
|
botId: get('botId'),
|
||||||
|
botMxid: get('botMxid'),
|
||||||
|
commandPrefix: get('commandPrefix'),
|
||||||
|
theme,
|
||||||
|
clientLanguage: get('clientLanguage'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,849 @@
|
||||||
|
// Dialect: mautrix-whatsapp v0.26.4 (16 Apr 2026) on bridgev2 framework.
|
||||||
|
// Generated against connector + bridgev2 commit hashes current as of
|
||||||
|
// research date 2026-05-05.
|
||||||
|
//
|
||||||
|
// Each regex below is paired with its upstream source line. If wording
|
||||||
|
// drifts in a future patch, replace this file with a sibling
|
||||||
|
// `bridgev2_v0265.ts` (or whatever) and switch the import in
|
||||||
|
// ../parser.ts.
|
||||||
|
//
|
||||||
|
// Body encoding note: bridgev2 routes replies through `format.RenderMarkdown`
|
||||||
|
// (bridgev2/commands/event.go). Our host driver strips `formatted_body`
|
||||||
|
// (Phase 2 contract), so the widget only ever sees the markdown source —
|
||||||
|
// backticks, asterisks, escaped angle-brackets stay literal.
|
||||||
|
//
|
||||||
|
// === Upstream pointers (verified 2026-05-05) ===
|
||||||
|
//
|
||||||
|
// SHARED bridgev2 commands (identical to mautrix-telegram dialect):
|
||||||
|
// github.com/mautrix/go/blob/main/bridgev2/commands/login.go
|
||||||
|
// - Phone field prompt: line 207 (UserInput → "Please enter your <Name>")
|
||||||
|
// - list-logins reply: user.go:185-190 ("\n* `<id>` (<Name>) - `<state>`")
|
||||||
|
// - logout reply: commands/login.go:591 ("Logged out")
|
||||||
|
// - cancel replies: commands/processor.go:198/200
|
||||||
|
// ("Login cancelled.", "No ongoing command.")
|
||||||
|
// - login_in_progress: commands/login.go:83
|
||||||
|
// ("You already have an ongoing login...")
|
||||||
|
// - max_logins: commands/login.go:74-79
|
||||||
|
// ("You have reached the maximum number of logins (N)")
|
||||||
|
// - login_not_found: commands/login.go:587/68 ("Login `id` not found")
|
||||||
|
// - flow_required / invalid: commands/login.go:107/98
|
||||||
|
// - unknown_command: commands/processor.go:163
|
||||||
|
// - generic error traps: commands/login.go (Failed to ..., Login failed: ...)
|
||||||
|
// - login_failed display-and-wait branch:
|
||||||
|
// commands/login.go:366 ("Login failed: %v")
|
||||||
|
// - QR rendering as m.image: bridgev2/commands/login.go sendQR (`Body: qr`)
|
||||||
|
//
|
||||||
|
// CONNECTOR mautrix-whatsapp:
|
||||||
|
// github.com/mautrix/whatsapp/blob/main/pkg/connector/login.go
|
||||||
|
// - Phone field name: "Phone number" + description
|
||||||
|
// "Your WhatsApp phone number in international format"
|
||||||
|
// - QR Instructions: "Scan the QR code with the WhatsApp mobile app to log in"
|
||||||
|
// - Code Instructions: "Input the pairing code in the WhatsApp mobile app to log in"
|
||||||
|
// - Login complete Instructions: fmt.Sprintf("Successfully logged in as %s", ul.RemoteName)
|
||||||
|
// where RemoteName = "+<phone-number>"
|
||||||
|
// - Connector errors (RespError values, surface via login_failed trap):
|
||||||
|
// CLIENT_OUTDATED: "Got client outdated error while waiting for QRs..."
|
||||||
|
// MULTIDEVICE_NOT_ENABLED: "Please enable WhatsApp web multidevice..."
|
||||||
|
// LOGIN_TIMEOUT: "Entering code or scanning QR timed out. Please try again."
|
||||||
|
// UNEXPECTED_EVENT: "Unexpected event while waiting for login"
|
||||||
|
// PHONE_NUMBER_TOO_SHORT: "Phone number too short"
|
||||||
|
// PHONE_NUMBER_NOT_INTERNATIONAL: "Phone number must be in international format"
|
||||||
|
// RATE_LIMITED: "Rate limited by WhatsApp"
|
||||||
|
// PAIR_ERROR: "<go-error from PairError event>"
|
||||||
|
//
|
||||||
|
// github.com/mautrix/whatsapp/blob/main/pkg/connector/handlewhatsapp.go
|
||||||
|
// - external logout: "You were logged out from another device. Relogin to..."
|
||||||
|
// "Your phone was logged out from WhatsApp. Relogin to..."
|
||||||
|
// "You were logged out for an unknown reason. Relogin to..."
|
||||||
|
// "You're not logged into WhatsApp. Relogin to continue using the bridge."
|
||||||
|
// - connection: "Reconnecting to WhatsApp...", "Disconnected from WhatsApp. Trying to reconnect.",
|
||||||
|
// "Your phone hasn't been seen in over 12 days...",
|
||||||
|
// "The WhatsApp web servers are not responding...",
|
||||||
|
// "Connecting to the WhatsApp web servers failed.",
|
||||||
|
// "Stream replaced: the bridge was started in another location."
|
||||||
|
//
|
||||||
|
// QR PAYLOAD (whatsmeow):
|
||||||
|
// github.com/tulir/whatsmeow/blob/main/pair.go ::makeQRData
|
||||||
|
// strings.Join([]string{ref, noise, identity, adv}, ",")
|
||||||
|
// → 4 base64-ish fields separated by literal commas. NOT a URL.
|
||||||
|
//
|
||||||
|
// PAIRING CODE FORMAT (whatsmeow):
|
||||||
|
// github.com/tulir/whatsmeow/blob/main/pair-code.go ::PairPhone
|
||||||
|
// 8 chars from base32 alphabet "123456789ABCDEFGHJKLMNPQRSTVWXYZ"
|
||||||
|
// formatted as XXXX-XXXX (4 chars + "-" + 4 chars).
|
||||||
|
|
||||||
|
import type { LoginEvent, ListedLogin, ParsableEvent, ExternalLogoutReason } from '../types';
|
||||||
|
|
||||||
|
// --- Regex table — shared bridgev2 wording -------------------------------
|
||||||
|
|
||||||
|
// list-logins, empty: bridgev2/commands/login.go → `You're not logged in`.
|
||||||
|
// NO trailing period. Same as Telegram dialect — kept anchored just in case
|
||||||
|
// a future bridgev2 patch drifts.
|
||||||
|
const NOT_LOGGED_IN_RE = /^you'?re not logged in\.?$/i;
|
||||||
|
|
||||||
|
// list-logins, non-empty: bridgev2/user.go ships a leading `\n` due to a
|
||||||
|
// `make([]string, N) + append` bug. Each row is
|
||||||
|
// `* \`<id>\` (<RemoteName>) - \`<state>\``.
|
||||||
|
//
|
||||||
|
// For WhatsApp:
|
||||||
|
// <id> = JID-derived login id (digits, possibly digits.0)
|
||||||
|
// <RemoteName> = "+<phone-number>" (e.g. "+12345678901")
|
||||||
|
// <state> = state string ("CONNECTED" etc)
|
||||||
|
//
|
||||||
|
// Greedy `(.+)` capture for name backtracks to the LAST `)` before
|
||||||
|
// ` - `<state>`` — paranoid against future RemoteName drift even though
|
||||||
|
// WhatsApp's RemoteName is currently always `+<digits>`.
|
||||||
|
const LOGIN_LIST_ROW_RE = /^\s*\*\s+`([^`]+)`\s+\((.+)\)\s+-\s+`([^`]+)`\s*$/gm;
|
||||||
|
|
||||||
|
// Phone prompt — bridgev2/commands/login.go composes
|
||||||
|
// `Please enter your <field.Name>\n<field.Description>`. Connector field
|
||||||
|
// is { Name: "Phone number", Description: "Your WhatsApp phone number in
|
||||||
|
// international format" } — but we anchor on the prefix only so that an
|
||||||
|
// upstream tweak to the description doesn't break detection.
|
||||||
|
const PHONE_PROMPT_RE = /^please enter your phone number\b/i;
|
||||||
|
|
||||||
|
// Login success — bridgev2 renders Instructions as a plain reply. WhatsApp
|
||||||
|
// connector's success Instructions: `Successfully logged in as +<phone>`.
|
||||||
|
// Distinct from Telegram's `Successfully logged in as @handle (\`id\`)` —
|
||||||
|
// no parens, no numeric ID. Capture the handle (which IS the phone).
|
||||||
|
//
|
||||||
|
// Tolerate optional trailing period (bridgev2 doesn't add one but a future
|
||||||
|
// patch might) and optional surrounding whitespace.
|
||||||
|
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+(\+?[\w.+-]+)\.?$/i;
|
||||||
|
|
||||||
|
// Logout — bridgev2/commands/login.go → `Logged out` (no period).
|
||||||
|
const LOGOUT_OK_RE = /^logged out\.?$/i;
|
||||||
|
|
||||||
|
// Cancel — bridgev2/commands/processor.go ::CommandCancel emits
|
||||||
|
// `Reply("%s cancelled.", action)` where `action` is the stored
|
||||||
|
// CommandState.Action. Today every WA login path uses Action="Login",
|
||||||
|
// so the rendered string is "Login cancelled." — but matching that
|
||||||
|
// literal would fail if a future bridgev2 ever introduces another
|
||||||
|
// action (e.g. "Logout"/"Relogin") that triggers this reply path.
|
||||||
|
// The relaxed pattern matches «<word> cancelled.» so the cancel-ok
|
||||||
|
// flow stays robust to the upstream wording shape, not its action
|
||||||
|
// name. Source: https://raw.githubusercontent.com/mautrix/go/main/bridgev2/commands/processor.go
|
||||||
|
const CANCEL_OK_RE = /^\S+ cancelled\.?$/i;
|
||||||
|
const CANCEL_NO_OP_RE = /^no ongoing command\.?$/i;
|
||||||
|
|
||||||
|
// Login already in progress — bridgev2/commands/login.go.
|
||||||
|
const LOGIN_IN_PROGRESS_RE = /^you already have an ongoing login\b/i;
|
||||||
|
|
||||||
|
// Max logins — bridgev2/commands/login.go. Captures the limit.
|
||||||
|
const MAX_LOGINS_RE = /^you have reached the maximum number of logins \((\d+)\)/i;
|
||||||
|
|
||||||
|
// Login id not found — bridgev2/commands/login.go (logout / relogin).
|
||||||
|
const LOGIN_NOT_FOUND_RE = /^login `([^`]+)` not found\b/i;
|
||||||
|
|
||||||
|
// Flow selector errors — bridgev2/commands/login.go. WhatsApp returns
|
||||||
|
// `flow_required` for bare `!wa login` because GetLoginFlows returns 2
|
||||||
|
// flows. The widget always sends `login qr` / `login phone`, so this
|
||||||
|
// trap exists as defence-in-depth (e.g. the user typed `!wa login` in
|
||||||
|
// chat-fallback).
|
||||||
|
const FLOW_REQUIRED_RE = /^please specify a login flow\b/i;
|
||||||
|
const FLOW_INVALID_RE = /^invalid login flow `([^`]+)`/i;
|
||||||
|
|
||||||
|
// Unknown command — bridgev2/commands/processor.go.
|
||||||
|
const UNKNOWN_COMMAND_RE = /^unknown command, use the `help` command/i;
|
||||||
|
|
||||||
|
// Generic error traps. Each anchors on a distinct prefix.
|
||||||
|
const INVALID_VALUE_RE = /^invalid value:\s*(.*)$/i;
|
||||||
|
const SUBMIT_FAILED_RE = /^failed to submit input:\s*(.*)$/i;
|
||||||
|
const PREPARE_FAILED_RE = /^failed to prepare login process:\s*(.*)$/i;
|
||||||
|
const START_FAILED_RE = /^failed to start login:\s*(.*)$/i;
|
||||||
|
// `Login failed: %v` from doLoginDisplayAndWait Wait error path.
|
||||||
|
// All connector-side WhatsApp login errors funnel through here.
|
||||||
|
const LOGIN_FAILED_RE = /^login failed:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// --- Regex table — connector-specific wording ----------------------------
|
||||||
|
|
||||||
|
// QR Instructions — connector login.go ::makeQRStep:
|
||||||
|
// `Scan the QR code with the WhatsApp mobile app to log in`.
|
||||||
|
//
|
||||||
|
// The widget doesn't strictly need to recognise this on its own (the
|
||||||
|
// m.image with the QR data is the operative signal for state transition),
|
||||||
|
// but emitting an `unknown` for it would litter the transcript with diag
|
||||||
|
// lines for every QR rotation. We swallow it as a discrete event so the
|
||||||
|
// state machine can ignore it without leaving it in transcript.
|
||||||
|
const QR_INSTRUCTIONS_RE = /^scan the qr code with the whatsapp mobile app\b/i;
|
||||||
|
|
||||||
|
// Pairing-code Instructions — connector login.go ::SubmitUserInput:
|
||||||
|
// `Input the pairing code in the WhatsApp mobile app to log in`. First
|
||||||
|
// of TWO bot replies after a phone-number submit on `!wa login phone`;
|
||||||
|
// the actual code lands in the next reply.
|
||||||
|
const PAIRING_CODE_INSTRUCTIONS_RE = /^input the pairing code in the whatsapp mobile app\b/i;
|
||||||
|
|
||||||
|
// Pairing code body — `XXXX-XXXX` from whatsmeow's PairPhone, rendered
|
||||||
|
// via bridgev2's ReplyAdvanced as `<code>XXXX-XXXX</code>` HTML. After
|
||||||
|
// `format.RenderMarkdown` (mautrix/go) routes through `HTMLToContent` →
|
||||||
|
// `SafeMarkdownCode` (format/markdown.go), the body field is ALWAYS
|
||||||
|
// the markdown-source `` `XXXX-XXXX` `` (backticks wrapped around the
|
||||||
|
// code). The earlier comment claimed «either plain or backticked» —
|
||||||
|
// in practice bridgev2 always emits the backticked form; the regex's
|
||||||
|
// `\`?` keeps the plain-form path tolerant for future framework
|
||||||
|
// changes that strip the wrapping.
|
||||||
|
// Character class follows whatsmeow's custom base32 alphabet
|
||||||
|
// `123456789ABCDEFGHJKLMNPQRSTVWXYZ` exactly: digits 1-9, uppercase
|
||||||
|
// letters minus I, O, U.
|
||||||
|
const PAIRING_CODE_RE = /^\s*`?([1-9A-HJ-NP-TV-Z]{4}-[1-9A-HJ-NP-TV-Z]{4})`?\s*$/;
|
||||||
|
|
||||||
|
// External-logout reasons — connector handlewhatsapp.go. Each anchors on
|
||||||
|
// the verbatim wording, captures nothing (the kind itself encodes the
|
||||||
|
// reason). Matching three classes:
|
||||||
|
// 1. Logged out from another device (multidevice unlink elsewhere).
|
||||||
|
// 2. Phone was logged out from WhatsApp (user logged out the WA app
|
||||||
|
// itself, which kills every linked device).
|
||||||
|
// 3. Logged out for an unknown reason (everything else, including
|
||||||
|
// "You're not logged into WhatsApp" idle-bridge case).
|
||||||
|
const LOGGED_OUT_FROM_ANOTHER_DEVICE_RE = /^you were logged out from another device\b/i;
|
||||||
|
const PHONE_LOGGED_OUT_RE = /^your phone was logged out from whatsapp\b/i;
|
||||||
|
const LOGGED_OUT_UNKNOWN_RE = /^you were logged out for an unknown reason\b/i;
|
||||||
|
// "You're not logged into WhatsApp. Relogin to continue using the bridge."
|
||||||
|
// — emitted by the connector at startup if no session exists OR after a
|
||||||
|
// re-init that found no session. Treated as `external_logout{unknown}`
|
||||||
|
// because the visible result (need to re-login) is identical.
|
||||||
|
const NOT_LOGGED_INTO_WHATSAPP_RE = /^you'?re not logged into whatsapp\b/i;
|
||||||
|
|
||||||
|
// Connection warnings — connector handlewhatsapp.go. None of these mean
|
||||||
|
// the user has to do anything; surface in transcript only.
|
||||||
|
// `Connect failure: 405 client outdated. Bridge must be updated.` IS
|
||||||
|
// effectively a hard wall (no flow can succeed until the bridge image
|
||||||
|
// is upgraded), but surfacing it as a connection_warning rather than
|
||||||
|
// an `unknown` keeps the transcript readable; the user will see it
|
||||||
|
// alongside the eventual login_failed.
|
||||||
|
// `You're not connected to WhatsApp` is the human-readable label of
|
||||||
|
// the WANotConnected BridgeState code — it doesn't typically reach
|
||||||
|
// the management room as an m.notice, but match it just in case a
|
||||||
|
// future bridgev2 patch wires it into one.
|
||||||
|
const CONNECTION_WARNING_RES: RegExp[] = [
|
||||||
|
/^reconnecting to whatsapp/i,
|
||||||
|
/^disconnected from whatsapp\. trying to reconnect/i,
|
||||||
|
/^your phone hasn'?t been seen in over\b/i,
|
||||||
|
/^the whatsapp web servers are not responding\b/i,
|
||||||
|
/^connecting to the whatsapp web servers failed/i,
|
||||||
|
/^stream replaced: the bridge was started in another location/i,
|
||||||
|
/^connect failure: \d+\b/i,
|
||||||
|
/^you'?re not connected to whatsapp\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Body parser ---------------------------------------------------------
|
||||||
|
|
||||||
|
const trimReplyBody = (raw: string): string => raw.trim();
|
||||||
|
|
||||||
|
const parseLoginList = (body: string): ListedLogin[] => {
|
||||||
|
const logins: ListedLogin[] = [];
|
||||||
|
// matchAll requires the global flag — rebuild the RegExp each call so
|
||||||
|
// the shared instance's lastIndex doesn't bleed between callers.
|
||||||
|
const re = new RegExp(LOGIN_LIST_ROW_RE.source, LOGIN_LIST_ROW_RE.flags);
|
||||||
|
for (const match of body.matchAll(re)) {
|
||||||
|
const [, id, name, state] = match;
|
||||||
|
logins.push({ id, name, state });
|
||||||
|
}
|
||||||
|
return logins;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchExternalLogout = (body: string): ExternalLogoutReason | undefined => {
|
||||||
|
if (LOGGED_OUT_FROM_ANOTHER_DEVICE_RE.test(body)) return 'another_device';
|
||||||
|
if (PHONE_LOGGED_OUT_RE.test(body)) return 'phone_logged_out';
|
||||||
|
if (LOGGED_OUT_UNKNOWN_RE.test(body)) return 'unknown';
|
||||||
|
if (NOT_LOGGED_INTO_WHATSAPP_RE.test(body)) return 'unknown';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isConnectionWarning = (body: string): boolean =>
|
||||||
|
CONNECTION_WARNING_RES.some((re) => re.test(body));
|
||||||
|
|
||||||
|
export const parseBridgev2V0264Body = (rawBody: string): LoginEvent => {
|
||||||
|
const body = trimReplyBody(rawBody);
|
||||||
|
if (body.length === 0) return { kind: 'unknown' };
|
||||||
|
|
||||||
|
// Order: highly-specific terminal/transitional matches first, generic
|
||||||
|
// error traps last. The login-list parser comes early because its anchor
|
||||||
|
// (` * `<id>` `) wouldn't false-match anything else, and the alternative
|
||||||
|
// — `not_logged_in` — covers the empty-list case explicitly.
|
||||||
|
|
||||||
|
// Async session events (connector-emitted) — try BEFORE shared bridgev2
|
||||||
|
// patterns because `You're not logged into WhatsApp` wording overlaps
|
||||||
|
// partially with `You're not logged in` (NOT_LOGGED_IN_RE) — we need
|
||||||
|
// to win on the more specific trap.
|
||||||
|
const externalLogout = matchExternalLogout(body);
|
||||||
|
if (externalLogout) return { kind: 'external_logout', reason: externalLogout };
|
||||||
|
if (isConnectionWarning(body)) return { kind: 'connection_warning', text: body };
|
||||||
|
|
||||||
|
if (NOT_LOGGED_IN_RE.test(body)) return { kind: 'not_logged_in' };
|
||||||
|
|
||||||
|
const successMatch = LOGIN_SUCCESS_RE.exec(body);
|
||||||
|
if (successMatch) {
|
||||||
|
return {
|
||||||
|
kind: 'login_success',
|
||||||
|
handle: successMatch[1].trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PHONE_PROMPT_RE.test(body)) return { kind: 'awaiting_phone' };
|
||||||
|
|
||||||
|
// QR Instructions — discrete kind, swallowed by the state machine
|
||||||
|
// (the m.image carries the operative signal). MUST come BEFORE the
|
||||||
|
// pairing-code regex so the order is unambiguous.
|
||||||
|
if (QR_INSTRUCTIONS_RE.test(body)) return { kind: 'unknown' };
|
||||||
|
if (PAIRING_CODE_INSTRUCTIONS_RE.test(body)) return { kind: 'pairing_code_instructions' };
|
||||||
|
|
||||||
|
// Pairing code body — must be checked AFTER the various error traps
|
||||||
|
// because a Go-error tail could in theory contain an 8-char hyphenated
|
||||||
|
// sequence. In practice the upstream alphabet (1-9 + A-HJ-NP-TV-Z)
|
||||||
|
// doesn't overlap with timestamps or PII tokens, but order matters
|
||||||
|
// for defensiveness.
|
||||||
|
// Skip checking it here at the top — the ordered fall-through later
|
||||||
|
// catches it after error traps.
|
||||||
|
|
||||||
|
if (LOGOUT_OK_RE.test(body)) return { kind: 'logout_ok' };
|
||||||
|
if (CANCEL_OK_RE.test(body)) return { kind: 'cancel_ok' };
|
||||||
|
if (CANCEL_NO_OP_RE.test(body)) return { kind: 'cancel_no_op' };
|
||||||
|
if (LOGIN_IN_PROGRESS_RE.test(body)) return { kind: 'login_in_progress' };
|
||||||
|
if (UNKNOWN_COMMAND_RE.test(body)) return { kind: 'unknown_command' };
|
||||||
|
if (FLOW_REQUIRED_RE.test(body)) return { kind: 'flow_required' };
|
||||||
|
|
||||||
|
const maxMatch = MAX_LOGINS_RE.exec(body);
|
||||||
|
if (maxMatch) {
|
||||||
|
const limit = Number(maxMatch[1]);
|
||||||
|
return { kind: 'max_logins', limit: Number.isFinite(limit) ? limit : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const notFoundMatch = LOGIN_NOT_FOUND_RE.exec(body);
|
||||||
|
if (notFoundMatch) return { kind: 'login_not_found', loginId: notFoundMatch[1] };
|
||||||
|
|
||||||
|
const flowInvalidMatch = FLOW_INVALID_RE.exec(body);
|
||||||
|
if (flowInvalidMatch) return { kind: 'flow_invalid', flowId: flowInvalidMatch[1] };
|
||||||
|
|
||||||
|
const invalidValueMatch = INVALID_VALUE_RE.exec(body);
|
||||||
|
if (invalidValueMatch) return { kind: 'invalid_value', reason: invalidValueMatch[1].trim() };
|
||||||
|
|
||||||
|
const submitFailedMatch = SUBMIT_FAILED_RE.exec(body);
|
||||||
|
if (submitFailedMatch) return { kind: 'submit_failed', reason: submitFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
const prepareFailedMatch = PREPARE_FAILED_RE.exec(body);
|
||||||
|
if (prepareFailedMatch) return { kind: 'prepare_failed', reason: prepareFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
const startFailedMatch = START_FAILED_RE.exec(body);
|
||||||
|
if (startFailedMatch) return { kind: 'start_failed', reason: startFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
const loginFailedMatch = LOGIN_FAILED_RE.exec(body);
|
||||||
|
if (loginFailedMatch) return { kind: 'login_failed', reason: loginFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
// Pairing code body — checked AFTER all error traps so a Go-error tail
|
||||||
|
// matching the pattern by accident doesn't pre-empt a real error
|
||||||
|
// classification. The `^` anchor + character class is strict enough
|
||||||
|
// that false matches against arbitrary text are unlikely.
|
||||||
|
const pairingMatch = PAIRING_CODE_RE.exec(body);
|
||||||
|
if (pairingMatch) return { kind: 'pairing_code_displayed', code: pairingMatch[1] };
|
||||||
|
|
||||||
|
// Fall-through to login-list AFTER the error traps so a row that happens
|
||||||
|
// to start with `* ` mid-error-message doesn't get mistaken for a login
|
||||||
|
// list.
|
||||||
|
const logins = parseLoginList(body);
|
||||||
|
if (logins.length > 0) return { kind: 'logins_listed', logins };
|
||||||
|
|
||||||
|
return { kind: 'unknown' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Full-event parser ---------------------------------------------------
|
||||||
|
//
|
||||||
|
// `parseEventBridgev2V0264` dispatches on `event.type` and routes:
|
||||||
|
//
|
||||||
|
// * `m.room.redaction` → `qr_redacted`. The state machine pairs the
|
||||||
|
// redaction's `redacts` against the active QR event id and decides
|
||||||
|
// whether it's a meaningful signal or unrelated cleanup.
|
||||||
|
//
|
||||||
|
// * `m.room.message` + `msgtype=m.image` → `qr_displayed` when the body
|
||||||
|
// contains a whatsmeow QR payload (4 comma-separated base64 fields).
|
||||||
|
//
|
||||||
|
// * `m.room.message` + `msgtype=m.text|m.notice` → existing
|
||||||
|
// `parseBridgev2V0264Body(body)` path.
|
||||||
|
|
||||||
|
// Whatsmeow QR data: `<ref>,<base64-noise>,<base64-identity>,<base64-adv>`.
|
||||||
|
// Each field is alphanumeric + base64 fillers + a few extras commonly seen
|
||||||
|
// in `ref` (`@`, `:`, `.`, `-`, `_`). Match exactly 4 comma-separated
|
||||||
|
// non-empty alphanumeric chunks at the start of the string. NO leading
|
||||||
|
// whitespace tolerance because the bridge's `Body: qr` (sendQR in
|
||||||
|
// bridgev2/commands/login.go) is a clean assignment with no prefix.
|
||||||
|
//
|
||||||
|
// Strictness rationale: false-positives here are catastrophic — we'd
|
||||||
|
// emit a `qr_displayed` for an arbitrary text image caption, the state
|
||||||
|
// machine would render its body into a QR matrix, and the user would
|
||||||
|
// see a meaningless QR. The 4-field shape and the alphabet are tight
|
||||||
|
// enough to avoid that against any realistic m.image body.
|
||||||
|
const WA_QR_PAYLOAD_RE = /^[A-Za-z0-9+/=@:_.\-]+(?:,[A-Za-z0-9+/=@:_.\-]+){3}$/;
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
export const parseEventBridgev2V0264 = (event: ParsableEvent): LoginEvent => {
|
||||||
|
if (event.type === 'm.room.redaction') {
|
||||||
|
// `redacts` is mirrored at the top level by the host sanitizer (see
|
||||||
|
// `sanitizeBotWidgetRedactionEvent` in BotWidgetDriver.ts), but check
|
||||||
|
// both spots for forward-compat with future drivers / SDK shapes.
|
||||||
|
const target =
|
||||||
|
typeof event.redacts === 'string'
|
||||||
|
? event.redacts
|
||||||
|
: isObject(event.content) && typeof event.content.redacts === 'string'
|
||||||
|
? event.content.redacts
|
||||||
|
: undefined;
|
||||||
|
if (!target) return { kind: 'unknown' };
|
||||||
|
return { kind: 'qr_redacted', redactsEventId: target };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type !== 'm.room.message') return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const msgtype = event.content?.msgtype;
|
||||||
|
|
||||||
|
if (msgtype === 'm.image') {
|
||||||
|
// Edits replace `body` by spec; bridgev2 ALSO mirrors the new payload
|
||||||
|
// into `m.new_content.body`. Prefer `m.new_content.body` when present
|
||||||
|
// (so an older SDK pre-flattening edit content still lets us extract
|
||||||
|
// the rotated QR) and fall back to `body`.
|
||||||
|
const newContent = isObject(event.content['m.new_content'])
|
||||||
|
? (event.content['m.new_content'] as { body?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const editedBody =
|
||||||
|
typeof newContent?.body === 'string' ? newContent.body : undefined;
|
||||||
|
const directBody = typeof event.content.body === 'string' ? event.content.body : '';
|
||||||
|
const body = (editedBody ?? directBody).trim();
|
||||||
|
|
||||||
|
if (!WA_QR_PAYLOAD_RE.test(body)) return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const relatesTo = isObject(event.content['m.relates_to'])
|
||||||
|
? (event.content['m.relates_to'] as { rel_type?: unknown; event_id?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const replacesEventId =
|
||||||
|
relatesTo?.rel_type === 'm.replace' && typeof relatesTo.event_id === 'string'
|
||||||
|
? relatesTo.event_id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
qrData: body,
|
||||||
|
eventId: event.event_id,
|
||||||
|
replacesEventId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgtype !== 'm.text' && msgtype !== 'm.notice') return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const body = typeof event.content.body === 'string' ? event.content.body : '';
|
||||||
|
return parseBridgev2V0264Body(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DEV sanity assertions -----------------------------------------------
|
||||||
|
// Vite tree-shakes this branch in production builds: `import.meta.env.DEV`
|
||||||
|
// is replaced with the literal `false` and the call site collapses, so the
|
||||||
|
// fixture array never ships. Failure throws — HMR/dev-overlay surfaces the
|
||||||
|
// first regression on reload.
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
runSanityChecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSanityChecks(): void {
|
||||||
|
const cases: Array<[string, LoginEvent]> = [
|
||||||
|
// Shared bridgev2 wordings (verified identical to mautrix-telegram).
|
||||||
|
["You're not logged in", { kind: 'not_logged_in' }],
|
||||||
|
["You're not logged in.", { kind: 'not_logged_in' }],
|
||||||
|
[
|
||||||
|
'Please enter your Phone number\nYour WhatsApp phone number in international format',
|
||||||
|
{ kind: 'awaiting_phone' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// WhatsApp connector-side: success format has NO parens, NO numericId.
|
||||||
|
// Handle is the phone number with leading `+`.
|
||||||
|
[
|
||||||
|
'Successfully logged in as +12345678901',
|
||||||
|
{ kind: 'login_success', handle: '+12345678901' },
|
||||||
|
],
|
||||||
|
// Edge: trailing period, just in case bridgev2 ever adds one.
|
||||||
|
[
|
||||||
|
'Successfully logged in as +12345678901.',
|
||||||
|
{ kind: 'login_success', handle: '+12345678901' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Logout / cancel — same as Telegram dialect.
|
||||||
|
['Logged out', { kind: 'logout_ok' }],
|
||||||
|
['Login cancelled.', { kind: 'cancel_ok' }],
|
||||||
|
['No ongoing command.', { kind: 'cancel_no_op' }],
|
||||||
|
|
||||||
|
// Login-progress / max-logins / not-found — same as Telegram dialect.
|
||||||
|
[
|
||||||
|
'You already have an ongoing login. You can use `!wa cancel` to cancel it.',
|
||||||
|
{ kind: 'login_in_progress' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'You have reached the maximum number of logins (1). Please logout from an existing login before creating a new one. If you want to re-authenticate an existing login, use the `!wa relogin` command.',
|
||||||
|
{ kind: 'max_logins', limit: 1 },
|
||||||
|
],
|
||||||
|
['Login `12345678901.0` not found', { kind: 'login_not_found', loginId: '12345678901.0' }],
|
||||||
|
['Unknown command, use the `help` command for help.', { kind: 'unknown_command' }],
|
||||||
|
|
||||||
|
// flow_required / flow_invalid — bridgev2 emits these because WA
|
||||||
|
// has TWO flows (qr + phone). The widget sends the full command so
|
||||||
|
// these traps are defence-in-depth.
|
||||||
|
[
|
||||||
|
'Please specify a login flow, e.g. `login qr`.\n\n* `qr` - Scan a QR code...\n* `phone` - Input your phone number...\n',
|
||||||
|
{ kind: 'flow_required' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Invalid login flow `wat`. Available options:\n\n* `qr` - ...',
|
||||||
|
{ kind: 'flow_invalid', flowId: 'wat' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Generic error traps — same shape as Telegram dialect.
|
||||||
|
['Invalid value: must start with +', { kind: 'invalid_value', reason: 'must start with +' }],
|
||||||
|
[
|
||||||
|
'Failed to submit input: Phone number too short',
|
||||||
|
{ kind: 'submit_failed', reason: 'Phone number too short' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Failed to prepare login process: connector unavailable',
|
||||||
|
{ kind: 'prepare_failed', reason: 'connector unavailable' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Failed to start login: whatsapp connect timeout',
|
||||||
|
{ kind: 'start_failed', reason: 'whatsapp connect timeout' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Connector login-failed surfacings (verified upstream — every
|
||||||
|
// RespError listed in pkg/connector/login.go funnels through here).
|
||||||
|
[
|
||||||
|
'Login failed: Phone number too short',
|
||||||
|
{ kind: 'login_failed', reason: 'Phone number too short' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Login failed: Phone number must be in international format',
|
||||||
|
{ kind: 'login_failed', reason: 'Phone number must be in international format' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Login failed: Rate limited by WhatsApp',
|
||||||
|
{ kind: 'login_failed', reason: 'Rate limited by WhatsApp' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Login failed: Got client outdated error while waiting for QRs. The bridge must be updated to continue.',
|
||||||
|
{
|
||||||
|
kind: 'login_failed',
|
||||||
|
reason:
|
||||||
|
'Got client outdated error while waiting for QRs. The bridge must be updated to continue.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Login failed: Please enable WhatsApp web multidevice and scan the QR code again.',
|
||||||
|
{
|
||||||
|
kind: 'login_failed',
|
||||||
|
reason: 'Please enable WhatsApp web multidevice and scan the QR code again.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Login failed: Entering code or scanning QR timed out. Please try again.',
|
||||||
|
{
|
||||||
|
kind: 'login_failed',
|
||||||
|
reason: 'Entering code or scanning QR timed out. Please try again.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Login failed: Unexpected event while waiting for login',
|
||||||
|
{ kind: 'login_failed', reason: 'Unexpected event while waiting for login' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Login failed: pair error: invalid signature',
|
||||||
|
{ kind: 'login_failed', reason: 'pair error: invalid signature' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Pairing-code instructions + the code itself (two separate notices).
|
||||||
|
[
|
||||||
|
'Input the pairing code in the WhatsApp mobile app to log in',
|
||||||
|
{ kind: 'pairing_code_instructions' },
|
||||||
|
],
|
||||||
|
// Code body in two valid shapes — plain and markdown-backticked.
|
||||||
|
['ABCD-1234', { kind: 'pairing_code_displayed', code: 'ABCD-1234' }],
|
||||||
|
['`WXYZ-9876`', { kind: 'pairing_code_displayed', code: 'WXYZ-9876' }],
|
||||||
|
// Spaces around the code — RenderMarkdown sometimes preserves a
|
||||||
|
// leading newline; trim handles it but the regex's `\s*` is belt-
|
||||||
|
// and-suspenders.
|
||||||
|
[' PQRS-4567 ', { kind: 'pairing_code_displayed', code: 'PQRS-4567' }],
|
||||||
|
// Negative case — alphabet excludes I/O/U; an `I` in the slot must
|
||||||
|
// NOT match. Prevents a stray sentence being misread as a code.
|
||||||
|
['ABID-1234', { kind: 'unknown' }],
|
||||||
|
|
||||||
|
// QR Instructions — swallowed silently as `unknown`.
|
||||||
|
[
|
||||||
|
'Scan the QR code with the WhatsApp mobile app to log in',
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// External logout — three reasons.
|
||||||
|
[
|
||||||
|
'You were logged out from another device. Relogin to continue using the bridge.',
|
||||||
|
{ kind: 'external_logout', reason: 'another_device' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Your phone was logged out from WhatsApp. Relogin to continue using the bridge.',
|
||||||
|
{ kind: 'external_logout', reason: 'phone_logged_out' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'You were logged out for an unknown reason. Relogin to continue using the bridge.',
|
||||||
|
{ kind: 'external_logout', reason: 'unknown' },
|
||||||
|
],
|
||||||
|
// Connector-startup notice — same effect as external_logout.
|
||||||
|
[
|
||||||
|
"You're not logged into WhatsApp. Relogin to continue using the bridge.",
|
||||||
|
{ kind: 'external_logout', reason: 'unknown' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Connection warnings — surfaced in transcript only.
|
||||||
|
[
|
||||||
|
'Reconnecting to WhatsApp...',
|
||||||
|
{ kind: 'connection_warning', text: 'Reconnecting to WhatsApp...' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Disconnected from WhatsApp. Trying to reconnect.',
|
||||||
|
{
|
||||||
|
kind: 'connection_warning',
|
||||||
|
text: 'Disconnected from WhatsApp. Trying to reconnect.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.",
|
||||||
|
{
|
||||||
|
kind: 'connection_warning',
|
||||||
|
text: "Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'The WhatsApp web servers are not responding. The bridge will try to reconnect.',
|
||||||
|
{
|
||||||
|
kind: 'connection_warning',
|
||||||
|
text: 'The WhatsApp web servers are not responding. The bridge will try to reconnect.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Connecting to the WhatsApp web servers failed.',
|
||||||
|
{
|
||||||
|
kind: 'connection_warning',
|
||||||
|
text: 'Connecting to the WhatsApp web servers failed.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Stream replaced: the bridge was started in another location.',
|
||||||
|
{
|
||||||
|
kind: 'connection_warning',
|
||||||
|
text: 'Stream replaced: the bridge was started in another location.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Bridge-image outdated — `Connect failure: 405 client outdated.
|
||||||
|
// Bridge must be updated.` from connector handlewhatsapp.go.
|
||||||
|
// Surfaces as a connection_warning (no state change), the
|
||||||
|
// eventual login_failed will deliver the actionable error.
|
||||||
|
'Connect failure: 405 client outdated. Bridge must be updated.',
|
||||||
|
{
|
||||||
|
kind: 'connection_warning',
|
||||||
|
text: 'Connect failure: 405 client outdated. Bridge must be updated.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Relaxed cancel regex — match any leading word + "cancelled." so a
|
||||||
|
// future bridgev2 introducing additional CommandState.Action values
|
||||||
|
// (e.g. "Logout cancelled.") still resolves to cancel_ok. Today
|
||||||
|
// only "Login cancelled." is emitted, but the relaxed match keeps
|
||||||
|
// us robust to upstream drift.
|
||||||
|
['Login cancelled.', { kind: 'cancel_ok' }],
|
||||||
|
['Logout cancelled.', { kind: 'cancel_ok' }],
|
||||||
|
['Relogin cancelled.', { kind: 'cancel_ok' }],
|
||||||
|
|
||||||
|
// Truly unrecognised body — keeps the transcript usable when
|
||||||
|
// bridgev2 wording drifts.
|
||||||
|
[
|
||||||
|
'Some completely unknown bridge reply that does not match any anchor',
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Login list with the leading-newline bug (verified present in
|
||||||
|
// bridgev2 user.go:185 — same as Telegram dialect).
|
||||||
|
[
|
||||||
|
'\n* `12345678901.0` (+12345678901) - `CONNECTED`',
|
||||||
|
{
|
||||||
|
kind: 'logins_listed',
|
||||||
|
logins: [{ id: '12345678901.0', name: '+12345678901', state: 'CONNECTED' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Same row without the bug — keeps matching after upstream fix.
|
||||||
|
[
|
||||||
|
'* `12345678901.0` (+12345678901) - `CONNECTED`',
|
||||||
|
{
|
||||||
|
kind: 'logins_listed',
|
||||||
|
logins: [{ id: '12345678901.0', name: '+12345678901', state: 'CONNECTED' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [body, expected] of cases) {
|
||||||
|
const actual = parseBridgev2V0264Body(body);
|
||||||
|
if (!sameEvent(actual, expected)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[bridgev2_v0264 sanity] mismatch', { body, actual, expected });
|
||||||
|
throw new Error(
|
||||||
|
`bridgev2_v0264 parser sanity failed for body ${JSON.stringify(body)} — see console for diff`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEventBridgev2V0264 — exercises the full-event dispatch (m.image,
|
||||||
|
// m.room.redaction, m.notice fall-through). Same throw-on-mismatch
|
||||||
|
// pattern as the body-only parser cases above.
|
||||||
|
const eventCases: Array<[ParsableEvent, LoginEvent]> = [
|
||||||
|
[
|
||||||
|
// Canonical whatsmeow QR — 4 comma-separated base64 fields.
|
||||||
|
// This shape comes from go.mau.fi/whatsmeow/pair.go::makeQRData.
|
||||||
|
// The first field (`ref`) typically starts with `2@<base64>`; the
|
||||||
|
// next three are pure base64.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$qr1',
|
||||||
|
sender: '@whatsappbot:vojo.chat',
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.image',
|
||||||
|
body: '2@AbCdEfGhIjKl,bmFtZTE=,aWRlbnQx,YWR2c2Vj',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
qrData: '2@AbCdEfGhIjKl,bmFtZTE=,aWRlbnQx,YWR2c2Vj',
|
||||||
|
eventId: '$qr1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// QR rotation edit — `m.relates_to.rel_type=m.replace` + new payload
|
||||||
|
// inside `m.new_content.body`. The edited payload must take
|
||||||
|
// precedence over the literal `body`.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$qr2',
|
||||||
|
sender: '@whatsappbot:vojo.chat',
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.image',
|
||||||
|
body: '2@OldRef,old1,old2,old3',
|
||||||
|
'm.relates_to': { rel_type: 'm.replace', event_id: '$qr1' },
|
||||||
|
'm.new_content': {
|
||||||
|
msgtype: 'm.image',
|
||||||
|
body: '2@NewRef,new1,new2,new3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
qrData: '2@NewRef,new1,new2,new3',
|
||||||
|
eventId: '$qr2',
|
||||||
|
replacesEventId: '$qr1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Bare m.image without 4-field comma payload — bridge has no business
|
||||||
|
// sending these to the control DM, but if it does we keep the line
|
||||||
|
// as `unknown` (transcript surfaces a diag, no QR-state mutation).
|
||||||
|
// The string has 1 comma → not 4 fields → declined.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$rand',
|
||||||
|
sender: '@whatsappbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.image', body: 'something, unrelated' },
|
||||||
|
},
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// 3 fields (one too few) — declined as `unknown`. Defensive against
|
||||||
|
// a future bridge protocol revision that drops a field; we'd rather
|
||||||
|
// miss the QR than render a malformed login token into a QR matrix.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$shortqr',
|
||||||
|
sender: '@whatsappbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.image', body: 'a,b,c' },
|
||||||
|
},
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Redaction — top-level `redacts` (host sanitizer mirrors there).
|
||||||
|
{
|
||||||
|
type: 'm.room.redaction',
|
||||||
|
event_id: '$red1',
|
||||||
|
sender: '@whatsappbot:vojo.chat',
|
||||||
|
content: { redacts: '$qr1' },
|
||||||
|
redacts: '$qr1',
|
||||||
|
},
|
||||||
|
{ kind: 'qr_redacted', redactsEventId: '$qr1' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Redaction missing target — sanitizer should already reject; defence
|
||||||
|
// in depth.
|
||||||
|
{
|
||||||
|
type: 'm.room.redaction',
|
||||||
|
event_id: '$red2',
|
||||||
|
sender: '@whatsappbot:vojo.chat',
|
||||||
|
content: {},
|
||||||
|
},
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// m.notice fall-through — preserves existing body-side parser path.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$n1',
|
||||||
|
sender: '@whatsappbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.notice', body: "You're not logged in" },
|
||||||
|
},
|
||||||
|
{ kind: 'not_logged_in' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// m.notice carrying the pairing code — full event-level test.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$pc1',
|
||||||
|
sender: '@whatsappbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.notice', body: 'ABCD-1234' },
|
||||||
|
},
|
||||||
|
{ kind: 'pairing_code_displayed', code: 'ABCD-1234' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// m.notice carrying an external-logout notice — full event level.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$xl1',
|
||||||
|
sender: '@whatsappbot:vojo.chat',
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.notice',
|
||||||
|
body: 'Your phone was logged out from WhatsApp. Relogin to continue using the bridge.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'external_logout', reason: 'phone_logged_out' },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [event, expected] of eventCases) {
|
||||||
|
const actual = parseEventBridgev2V0264(event);
|
||||||
|
if (!sameEvent(actual, expected)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[bridgev2_v0264 event sanity] mismatch', {
|
||||||
|
event,
|
||||||
|
actual,
|
||||||
|
expected,
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`bridgev2_v0264 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameEvent(a: LoginEvent, b: LoginEvent): boolean {
|
||||||
|
if (a.kind !== b.kind) return false;
|
||||||
|
// Shallow JSON-compare the discriminated payload. Good enough for the
|
||||||
|
// small set of structures we emit; deeper equality would only matter if
|
||||||
|
// we returned arbitrary nested data.
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
17
apps/widget-whatsapp/src/bridge-protocol/parser.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Parser shim. The widget consumes a single `parseEvent(rawEvent)` and
|
||||||
|
// the dialect handles the full event surface — m.text, m.notice, m.image
|
||||||
|
// (QR broadcasts), m.room.redaction (post-scan cleanup). v1 ships one
|
||||||
|
// dialect, `bridgev2_v0264`, for the operator's current bridge image.
|
||||||
|
// When bridgev2 / mautrix-whatsapp wording drifts in a future Go release,
|
||||||
|
// add a sibling dialect file and switch the import below.
|
||||||
|
//
|
||||||
|
// The dialects/ subdirectory is kept as a seam for that swap; we don't
|
||||||
|
// implement runtime autodetect (the operator owns one bridge image at a
|
||||||
|
// time and a parser pin is honest about that).
|
||||||
|
|
||||||
|
import type { LoginEvent, ParsableEvent } from './types';
|
||||||
|
import { parseEventBridgev2V0264 } from './dialects/bridgev2_v0264';
|
||||||
|
|
||||||
|
export type { ParsableEvent };
|
||||||
|
|
||||||
|
export const parseEvent = (event: ParsableEvent): LoginEvent => parseEventBridgev2V0264(event);
|
||||||
122
apps/widget-whatsapp/src/bridge-protocol/types.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
// LoginEvent — discriminated union the parser emits and the state machine
|
||||||
|
// consumes. One LoginEvent per inbound m.notice / m.text / m.image /
|
||||||
|
// m.room.redaction from the bridge bot.
|
||||||
|
//
|
||||||
|
// Source-of-truth for every kind below is the Go-dialect wording table in
|
||||||
|
// dialects/bridgev2_v0264.ts (mautrix-whatsapp v0.26.4 + bridgev2 shared
|
||||||
|
// commands). WhatsApp uses the SAME bridgev2 framework as Telegram, so the
|
||||||
|
// shared command wordings (`Please enter your X`, `You're not logged in`,
|
||||||
|
// `Logged out`, list-logins format, cancel replies) are byte-identical to
|
||||||
|
// the Telegram dialect — only the connector-specific lines differ.
|
||||||
|
//
|
||||||
|
// WhatsApp-specific differences vs Telegram dialect:
|
||||||
|
// - TWO login flows: `qr` and `phone` (pairing-code). `!wa login` alone
|
||||||
|
// replies `Please specify a login flow…` (flow_required) — the widget
|
||||||
|
// always sends the full command (`login qr` / `login phone`).
|
||||||
|
// - QR payload is NOT a URL: it's a raw whatsmeow handshake
|
||||||
|
// `<ref>,<base64-noise>,<base64-identity>,<base64-adv>` (4 comma-
|
||||||
|
// separated base64 fields). NEVER appended to transcript verbatim;
|
||||||
|
// the adv-secret segment IS the login token.
|
||||||
|
// - QR rotation interval differs from Telegram: first QR lasts 60s,
|
||||||
|
// then 5 more × 20s each (whatsmeow `qrIntervals`). Total active
|
||||||
|
// window is 2 min 40 s, vs Telegram's 10 min.
|
||||||
|
// - NO 2FA cloud-password flow. Multi-device pairing is single-factor;
|
||||||
|
// the QR scan / pairing-code IS the auth.
|
||||||
|
// - Login success format: `Successfully logged in as +<phone>` — no
|
||||||
|
// parens, no numeric ID. Handle is the phone number itself.
|
||||||
|
// - Pairing-code flow (NEW vs Telegram): bridge replies with two
|
||||||
|
// m.notice messages — the Instructions string then the code itself
|
||||||
|
// wrapped in `<code>…</code>` HTML (host driver strips formatted_body,
|
||||||
|
// leaving the plain `XXXX-XXXX` markdown source in `body`).
|
||||||
|
// - Async session events from the connector: external logout (phone
|
||||||
|
// unlinked the device), connection warnings (transient disconnects).
|
||||||
|
|
||||||
|
export type ListedLogin = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shape of an inbound event the dialect parser needs to look at. Matches
|
||||||
|
// the wire shape produced by the host's BotWidgetDriver sanitizer; declared
|
||||||
|
// here (not in widget-api.ts) so the dialect doesn't import from the
|
||||||
|
// transport layer.
|
||||||
|
export type ParsableEvent = {
|
||||||
|
type: string;
|
||||||
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
origin_server_ts?: number;
|
||||||
|
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
||||||
|
redacts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reasons why WhatsApp logged us out asynchronously (not via `!wa logout`).
|
||||||
|
// Carried inside `external_logout` so the UI can pick a wording variant
|
||||||
|
// that matches the user's understanding ("phone unlinked from settings"
|
||||||
|
// vs "another linked device kicked us out").
|
||||||
|
export type ExternalLogoutReason = 'another_device' | 'phone_logged_out' | 'unknown';
|
||||||
|
|
||||||
|
export type LoginEvent =
|
||||||
|
// --- shared bridgev2 command replies (same wording as Telegram) ---------
|
||||||
|
| { kind: 'logins_listed'; logins: ListedLogin[] }
|
||||||
|
| { kind: 'not_logged_in' }
|
||||||
|
| { kind: 'awaiting_phone' }
|
||||||
|
| { kind: 'login_success'; handle: string }
|
||||||
|
| { kind: 'logout_ok' }
|
||||||
|
| { kind: 'cancel_ok' }
|
||||||
|
| { kind: 'cancel_no_op' }
|
||||||
|
| { kind: 'login_in_progress' }
|
||||||
|
| { kind: 'max_logins'; limit?: number }
|
||||||
|
| { kind: 'login_not_found'; loginId?: string }
|
||||||
|
| { kind: 'flow_required' }
|
||||||
|
| { kind: 'flow_invalid'; flowId?: string }
|
||||||
|
| { kind: 'unknown_command' }
|
||||||
|
| { kind: 'invalid_value'; reason?: string }
|
||||||
|
// Generic Go-error trap from bridgev2/commands/login.go's display-and-
|
||||||
|
// wait branch (`Login failed: <err>`). For mautrix-whatsapp every
|
||||||
|
// connector-side login error funnels through here:
|
||||||
|
// - `Phone number too short`
|
||||||
|
// - `Phone number must be in international format`
|
||||||
|
// - `Rate limited by WhatsApp`
|
||||||
|
// - `Got client outdated error while waiting for QRs. The bridge
|
||||||
|
// must be updated to continue.`
|
||||||
|
// - `Please enable WhatsApp web multidevice and scan the QR code
|
||||||
|
// again.`
|
||||||
|
// - `Entering code or scanning QR timed out. Please try again.`
|
||||||
|
// - `Unexpected event while waiting for login`
|
||||||
|
// - `Pair error: <err>` (specific PairError surfacing)
|
||||||
|
// The widget keeps the verbatim reason string and does NOT sub-classify
|
||||||
|
// — the upstream wording is structured enough that the user can read it.
|
||||||
|
| { kind: 'login_failed'; reason?: string }
|
||||||
|
| { kind: 'submit_failed'; reason?: string }
|
||||||
|
| { kind: 'prepare_failed'; reason?: string }
|
||||||
|
| { kind: 'start_failed'; reason?: string }
|
||||||
|
// --- QR-flow lifecycle (m.image broadcasts, m.room.redaction cleanup) ---
|
||||||
|
// `qrData` is the raw whatsmeow payload — keep it OUT of any DOM-level
|
||||||
|
// log. The state machine renders it into a QR matrix client-side; once
|
||||||
|
// rendered the matrix is harmless (a screenshot of it would be stale by
|
||||||
|
// the next rotation), but the raw string itself should never be append-
|
||||||
|
// ed to the transcript.
|
||||||
|
| { kind: 'qr_displayed'; qrData: string; eventId: string; replacesEventId?: string }
|
||||||
|
| { kind: 'qr_redacted'; redactsEventId: string }
|
||||||
|
// --- Pairing-code flow (WhatsApp-specific) ------------------------------
|
||||||
|
// First of two notices after a phone-number submit on `!wa login phone`:
|
||||||
|
// `Input the pairing code in the WhatsApp mobile app to log in`. The
|
||||||
|
// state machine flips into a "pairing code is coming" interstitial on
|
||||||
|
// this event so the user sees an immediate change after submit.
|
||||||
|
| { kind: 'pairing_code_instructions' }
|
||||||
|
// Second of the two notices: the actual `XXXX-XXXX` code. The state
|
||||||
|
// machine flips to `pairing_code_shown{code}` and the UI renders the
|
||||||
|
// code prominently with copy-friendly letter-spacing.
|
||||||
|
| { kind: 'pairing_code_displayed'; code: string }
|
||||||
|
// --- Async post-login session events (connector-emitted m.notice) -------
|
||||||
|
// External logout — the bridge lost its session because the phone or
|
||||||
|
// another linked device unlinked us. Routes the live state to
|
||||||
|
// disconnected with a `lastError` flag so the UI surfaces a banner.
|
||||||
|
| { kind: 'external_logout'; reason: ExternalLogoutReason }
|
||||||
|
// Soft connection warnings — `Reconnecting to WhatsApp...`, `Disconnected
|
||||||
|
// from WhatsApp. Trying to reconnect.`, `Your phone hasn't been seen…`.
|
||||||
|
// The widget surfaces these in the transcript only; state isn't
|
||||||
|
// touched (the bridge is still operational, just having a hiccup).
|
||||||
|
| { kind: 'connection_warning'; text: string }
|
||||||
|
| { kind: 'unknown' };
|
||||||
108
apps/widget-whatsapp/src/i18n/en.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// English fallback. Mirror the RU key set; `Record<StringKey, string>`
|
||||||
|
// enforces every RU key has an EN counterpart at compile time.
|
||||||
|
|
||||||
|
import type { StringKey } from './ru';
|
||||||
|
|
||||||
|
export const EN: Record<StringKey, string> = {
|
||||||
|
'status.unknown': 'Checking status…',
|
||||||
|
'status.disconnected': 'WhatsApp not linked',
|
||||||
|
'status.connected': 'WhatsApp linked',
|
||||||
|
'status.connected-as': 'WhatsApp linked as {handle}',
|
||||||
|
'status.logging-out': 'Signing out…',
|
||||||
|
'status.qr-verifying': 'Verifying sign-in…',
|
||||||
|
'status.pairing-verifying': 'Verifying sign-in…',
|
||||||
|
'card.login-qr.name': 'Sign in with QR code',
|
||||||
|
'card.login-qr.desc': 'Scan a QR code from the WhatsApp mobile app',
|
||||||
|
'card.login-pairing.name': 'Sign in by phone number',
|
||||||
|
'card.login-pairing.desc': 'Enter your number and get an 8-character code for WhatsApp',
|
||||||
|
'card.refresh.aria': 'Refresh status',
|
||||||
|
'card.refresh.label': 'Refresh status',
|
||||||
|
'card.refresh.name': 'Refresh status',
|
||||||
|
'card.refresh.desc': 'Re-check whether WhatsApp is linked',
|
||||||
|
'card.refresh.in-flight': 'Checking…',
|
||||||
|
'warning.title': 'Important before linking WhatsApp',
|
||||||
|
'warning.body-1':
|
||||||
|
'Mautrix-whatsapp connects to your account through the same linked-device mechanism as WhatsApp Web. Technically a standard API — but unlike other messengers, WhatsApp’s terms of service explicitly forbid connecting through third-party clients, and Meta may ban your account for it.',
|
||||||
|
'warning.tos-label': 'WhatsApp terms of service:',
|
||||||
|
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
|
||||||
|
'card.about.name': 'How the WhatsApp bot works',
|
||||||
|
'card.about.desc': 'How it works and the risks — tap to read',
|
||||||
|
'about.title': 'About the WhatsApp bot',
|
||||||
|
'about.body-1':
|
||||||
|
'This bot connects WhatsApp to Vojo. After sign-in, your private chats and groups from WhatsApp will appear in Vojo’s chat list, and replies from the Vojo app will be sent to your contacts as normal WhatsApp messages.',
|
||||||
|
'about.body-2':
|
||||||
|
'Sign-in requires the WhatsApp mobile app on a phone with an active account. You can either scan a QR code via Settings → Linked devices → Link a device, or enter an 8-character pairing code via Settings → Linked devices → Link with phone number.',
|
||||||
|
'about.body-3':
|
||||||
|
'The connection runs through the open-source mautrix-whatsapp bridge. It creates a WhatsApp session on the Vojo server and uses it to connect WhatsApp with your Vojo account: receive messages from WhatsApp and send your replies back. Your WhatsApp account keeps working on your phone as usual — the bridge connects in parallel as another linked device.',
|
||||||
|
'about.github-label': 'The bridge source code is public on GitHub:',
|
||||||
|
'about.github-url': 'https://github.com/mautrix/whatsapp',
|
||||||
|
'about.body-4':
|
||||||
|
'You can revoke access at any time — either with the “Sign out of WhatsApp” button here, or inside WhatsApp itself under Settings → Linked devices → Log out of all devices.',
|
||||||
|
'about.close': 'Close',
|
||||||
|
'about.aria-close': 'Close “About this bot”',
|
||||||
|
'auth-card.phone.title': 'Sign in with a pairing code',
|
||||||
|
'auth-card.phone.label': 'Phone number',
|
||||||
|
'auth-card.phone.placeholder': '+15551234567',
|
||||||
|
'auth-card.phone.hint':
|
||||||
|
'Enter your phone number including the country code. WhatsApp will then generate an 8-character pairing code that you enter in the WhatsApp app.',
|
||||||
|
'auth-card.phone.submit': 'Get code',
|
||||||
|
'auth-card.phone.cooldown': 'Retry in {seconds}s',
|
||||||
|
'auth-card.pairing-code.title': 'Enter this code in WhatsApp',
|
||||||
|
'auth-card.pairing-code.hint':
|
||||||
|
'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.',
|
||||||
|
'auth-card.pairing-code.preparing': 'Preparing the code…',
|
||||||
|
'auth-card.pairing-code.aria': 'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.',
|
||||||
|
'auth-card.pairing-code.countdown': 'Time left to enter: {minutes}:{seconds}',
|
||||||
|
'auth-card.pairing-code.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
||||||
|
'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.',
|
||||||
|
'auth-card.pairing-code.step-2': 'Go to Settings → Linked devices.',
|
||||||
|
'auth-card.pairing-code.step-3': 'Tap Link a device → Link with phone number.',
|
||||||
|
'auth-card.pairing-code.step-4': 'Enter this code and confirm sign-in on your phone.',
|
||||||
|
'auth-card.qr.title': 'QR code sign-in',
|
||||||
|
'auth-card.qr.hint': 'Open WhatsApp on your phone and scan this QR code.',
|
||||||
|
'auth-card.qr.preparing': 'Preparing QR code…',
|
||||||
|
'auth-card.qr.aria': 'QR code for WhatsApp sign-in. Scan it with your phone.',
|
||||||
|
'auth-card.qr.countdown': 'Time left to scan: {minutes}:{seconds}',
|
||||||
|
'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
||||||
|
'auth-card.qr.step-1': 'Open WhatsApp on your phone.',
|
||||||
|
'auth-card.qr.step-2': 'Go to Settings → Linked devices.',
|
||||||
|
'auth-card.qr.step-3': 'Tap Link a device and scan the QR code.',
|
||||||
|
'auth-card.cancel': 'Cancel',
|
||||||
|
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.',
|
||||||
|
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
||||||
|
'auth-error.invalid-value': 'Value not accepted: {reason}',
|
||||||
|
'auth-error.submit-failed': 'WhatsApp refused the input: {reason}',
|
||||||
|
'auth-error.start-failed': 'Failed to start sign-in: {reason}',
|
||||||
|
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
|
||||||
|
'auth-error.login-in-progress':
|
||||||
|
'The bot already has another sign-in flow open. Click Cancel and retry.',
|
||||||
|
'auth-error.max-logins': 'Login limit reached ({limit}). Sign out of an existing account first.',
|
||||||
|
'auth-error.unknown-command':
|
||||||
|
'The bot does not recognise this command — check the prefix in config.json.',
|
||||||
|
'auth-error.external-logout.another-device':
|
||||||
|
'WhatsApp unlinked this device from another device. Sign in again.',
|
||||||
|
'auth-error.external-logout.phone-logged-out':
|
||||||
|
'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.',
|
||||||
|
'auth-error.external-logout.unknown':
|
||||||
|
'WhatsApp dropped the session. Sign in again.',
|
||||||
|
'card.logout.name': 'Sign out of WhatsApp',
|
||||||
|
'card.logout.desc': 'End the session for this account',
|
||||||
|
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||||
|
'card.logout.confirm-yes': 'Sign out',
|
||||||
|
'card.logout.confirm-no': 'Cancel',
|
||||||
|
'card.logout.gated': 'Session identifier still loading — give it a moment.',
|
||||||
|
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
|
||||||
|
'diag.ready': 'Ready to send commands.',
|
||||||
|
'diag.checking-status': 'Checking connection status…',
|
||||||
|
'diag.send-failed': 'send failed: {message}',
|
||||||
|
'diag.history-marker': '─── history ───',
|
||||||
|
'diag.history-unavailable': 'Could not read history — re-checking status.',
|
||||||
|
'diag.qr-issued': 'QR code refreshed.',
|
||||||
|
'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.',
|
||||||
|
'diag.pairing-code-issued': 'Pairing code issued.',
|
||||||
|
'diag.connection-warning': '{text}',
|
||||||
|
'diag.external-logout': 'WhatsApp dropped the session — sign-in needed.',
|
||||||
|
'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}.',
|
||||||
|
};
|
||||||
30
apps/widget-whatsapp/src/i18n/index.ts
Normal file
|
|
@ -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, string>): string => {
|
||||||
|
if (!vars) return s;
|
||||||
|
return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickDict = (clientLanguage: string | undefined): Record<StringKey, string> => {
|
||||||
|
const lang = (
|
||||||
|
clientLanguage ||
|
||||||
|
(typeof navigator !== 'undefined' ? navigator.language : '') ||
|
||||||
|
'ru'
|
||||||
|
).toLowerCase();
|
||||||
|
return lang.startsWith('en') ? EN : RU;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type T = (key: StringKey, vars?: Record<string, string>) => string;
|
||||||
|
|
||||||
|
export const createT = (clientLanguage?: string): T => {
|
||||||
|
const dict = pickDict(clientLanguage);
|
||||||
|
return (key, vars) => interpolate(dict[key], vars);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { StringKey };
|
||||||
191
apps/widget-whatsapp/src/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
// Russian primary copy. To add a string:
|
||||||
|
// 1. add the key + RU value here (this file is the canonical key list — `en.ts`
|
||||||
|
// and the `StringKey` type derive from it),
|
||||||
|
// 2. add the same key + EN value in `en.ts`,
|
||||||
|
// 3. consume via `t('key', { var: 'x' })` in components.
|
||||||
|
// Interpolation uses `{name}` placeholders resolved against the second arg.
|
||||||
|
//
|
||||||
|
// The widget no longer renders a hero — that block lives in the host's
|
||||||
|
// BotShellHero. Status is surfaced inline inside the relevant section.
|
||||||
|
|
||||||
|
export const RU = {
|
||||||
|
// --- Inline section status ---------------------------------------------
|
||||||
|
'status.unknown': 'Проверка статуса…',
|
||||||
|
'status.disconnected': 'WhatsApp не привязан',
|
||||||
|
'status.connected': 'WhatsApp привязан',
|
||||||
|
'status.connected-as': 'WhatsApp привязан как {handle}',
|
||||||
|
'status.logging-out': 'Завершение сеанса…',
|
||||||
|
// QR-вход: после успешного скана мост стирает QR и переходит к
|
||||||
|
// подтверждению линка. Это короткий промежуточный pill.
|
||||||
|
'status.qr-verifying': 'Проверяем вход…',
|
||||||
|
// Pairing-code вход: после ввода кода в приложении ждём, пока WhatsApp
|
||||||
|
// подтвердит линк. По времени совпадает с qr-verifying — секунды.
|
||||||
|
'status.pairing-verifying': 'Проверяем вход…',
|
||||||
|
// --- Section headers ---------------------------------------------------
|
||||||
|
'card.login-qr.name': 'Войти по QR-коду',
|
||||||
|
'card.login-qr.desc': 'Отсканировать QR из мобильного приложения WhatsApp',
|
||||||
|
// WA-эквивалент TG-шного «Войти по номеру». User flow по сути такой
|
||||||
|
// же, как в Telegram: сабмит номера → бот выдаёт код → код вводится.
|
||||||
|
// Отличие: в TG код вводится в виджет, в WA — в само приложение
|
||||||
|
// WhatsApp. Имя кнопки одинаковое для consistency между виджетами.
|
||||||
|
'card.login-pairing.name': 'Войти по номеру',
|
||||||
|
'card.login-pairing.desc': 'Ввести номер и получить 8-символьный код для WhatsApp',
|
||||||
|
'card.refresh.aria': 'Обновить статус',
|
||||||
|
'card.refresh.label': 'Обновить статус',
|
||||||
|
'card.refresh.name': 'Обновить статус',
|
||||||
|
'card.refresh.desc': 'Перепроверить, привязан ли WhatsApp',
|
||||||
|
'card.refresh.in-flight': 'Проверяю…',
|
||||||
|
// --- About panel -------------------------------------------------------
|
||||||
|
// WhatsApp-only Meta-ToS risk disclosure is folded into the About
|
||||||
|
// modal as an amber callout at the top of the body. The AboutCard
|
||||||
|
// itself carries `command-card warn` (amber border + amber name)
|
||||||
|
// and a triangle warning glyph in the lead slot — instead of the
|
||||||
|
// info-circle TG / Discord use — so the «риски» half of the hybrid
|
||||||
|
// description («о работе и рисках») is visible at a glance before
|
||||||
|
// the user opens the modal. TG / Discord get the plain «вход,
|
||||||
|
// безопасность, исходный код» variant because they don't carry an
|
||||||
|
// account-loss risk in the same way (Telegram user ToS doesn't
|
||||||
|
// forbid third-party clients; Discord's restriction on self-bots
|
||||||
|
// lives in developer policies, not user ToS proper). The amber
|
||||||
|
// block keeps the unique WhatsApp framing without claiming anything
|
||||||
|
// about TG / Discord by comparison.
|
||||||
|
//
|
||||||
|
// ToS reference for the body: https://www.whatsapp.com/legal/terms-of-service
|
||||||
|
// section «Harm To WhatsApp Or Our Users» forbids «software or
|
||||||
|
// APIs that function substantially the same as our Services» and
|
||||||
|
// «accounts for our Services through unauthorized or automated
|
||||||
|
// means».
|
||||||
|
'warning.title': 'Важно знать до подключения WhatsApp',
|
||||||
|
'warning.body-1':
|
||||||
|
'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.',
|
||||||
|
// Источник про запрет в ToS — даём юзеру возможность дойти до
|
||||||
|
// оригинала самому, не доверять нам на слово. Кликается потому что
|
||||||
|
// host-side iframe sandbox получил allow-popups (см.
|
||||||
|
// src/app/features/bots/BotWidgetEmbed.ts).
|
||||||
|
'warning.tos-label': 'Условия использования WhatsApp:',
|
||||||
|
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
|
||||||
|
'card.about.name': 'Как работает WhatsApp-бот',
|
||||||
|
// Hybrid copy: tells the user the modal carries BOTH the «как
|
||||||
|
// работает» explainer AND the Meta-ToS risk disclosure. «нажмите,
|
||||||
|
// чтобы прочесть» reinforces interactivity — the amber border +
|
||||||
|
// warning triangle help but the explicit verb seals it.
|
||||||
|
'card.about.desc': 'Информация о работе и рисках — нажмите, чтобы прочесть',
|
||||||
|
'about.title': 'О боте WhatsApp',
|
||||||
|
'about.body-1':
|
||||||
|
'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.',
|
||||||
|
'about.body-2':
|
||||||
|
'Для входа нужно мобильное приложение WhatsApp на телефоне с активным аккаунтом. Можно либо отсканировать QR-код через «Настройки → Связанные устройства → Привязать устройство», либо ввести 8-символьный код через «Настройки → Связанные устройства → Привязать с помощью номера телефона».',
|
||||||
|
'about.body-3':
|
||||||
|
'Подключение работает через open-source мост mautrix-whatsapp. Он создаёт WhatsApp-сессию на сервере Vojo и использует её для связи WhatsApp с вашим аккаунтом Vojo: получает сообщения из WhatsApp и отправляет ваши ответы обратно. WhatsApp-аккаунт продолжит работать на телефоне как обычно — мост подключается параллельно, как ещё одно связанное устройство.',
|
||||||
|
'about.github-label': 'Исходный код моста открыт на GitHub:',
|
||||||
|
'about.github-url': 'https://github.com/mautrix/whatsapp',
|
||||||
|
'about.body-4':
|
||||||
|
'Отозвать доступ можно в любой момент — кнопкой «Выйти из WhatsApp» здесь, либо в самом WhatsApp через «Настройки → Связанные устройства → Выйти со всех устройств».',
|
||||||
|
'about.close': 'Закрыть',
|
||||||
|
'about.aria-close': 'Закрыть «О боте»',
|
||||||
|
// --- Phone form (pairing-code flow) ------------------------------------
|
||||||
|
'auth-card.phone.title': 'Вход по коду из приложения',
|
||||||
|
'auth-card.phone.label': 'Номер телефона',
|
||||||
|
'auth-card.phone.placeholder': '+79991234567',
|
||||||
|
// Подсказка, объясняющая что произойдёт после сабмита: мост создаст
|
||||||
|
// 8-символьный код, который надо ввести в WhatsApp app. Пользователь
|
||||||
|
// должен понимать, что код не SMS-OTP, а pairing-token.
|
||||||
|
'auth-card.phone.hint':
|
||||||
|
'Введите номер с кодом страны. После этого WhatsApp создаст 8-символьный код — его нужно будет ввести в приложении.',
|
||||||
|
'auth-card.phone.submit': 'Получить код',
|
||||||
|
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
||||||
|
// --- Pairing-code form -------------------------------------------------
|
||||||
|
'auth-card.pairing-code.title': 'Введите этот код в WhatsApp',
|
||||||
|
'auth-card.pairing-code.hint':
|
||||||
|
'Откройте WhatsApp на телефоне и введите этот код в форме «Связанные устройства → Привязать с помощью номера телефона».',
|
||||||
|
'auth-card.pairing-code.preparing': 'Готовим код…',
|
||||||
|
'auth-card.pairing-code.aria': 'Код для входа в WhatsApp. Введите его в приложении на телефоне.',
|
||||||
|
'auth-card.pairing-code.countdown': 'На ввод осталось {minutes}:{seconds}',
|
||||||
|
'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
||||||
|
'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.',
|
||||||
|
'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».',
|
||||||
|
'auth-card.pairing-code.step-3': 'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
|
||||||
|
'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.',
|
||||||
|
// --- QR form -----------------------------------------------------------
|
||||||
|
'auth-card.qr.title': 'Вход по QR-коду',
|
||||||
|
'auth-card.qr.hint': 'Откройте WhatsApp на телефоне и отсканируйте этот QR-код.',
|
||||||
|
'auth-card.qr.preparing': 'Готовим QR-код…',
|
||||||
|
'auth-card.qr.aria': 'QR-код для входа в WhatsApp. Отсканируйте его телефоном.',
|
||||||
|
// Обратный отсчёт до серверного таймаута. Whatsmeow ротирует QR по
|
||||||
|
// расписанию 60 с + 5 × 20 с = 2 мин 40 с активного окна. Сам QR в
|
||||||
|
// панели всегда свежий (мост шлёт m.replace edits на каждой ротации),
|
||||||
|
// отсчёт показывает оставшееся окно ВСЕГО входа.
|
||||||
|
'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}',
|
||||||
|
'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
||||||
|
'auth-card.qr.step-1': 'Откройте WhatsApp на телефоне.',
|
||||||
|
'auth-card.qr.step-2': 'Перейдите в «Настройки → Связанные устройства».',
|
||||||
|
'auth-card.qr.step-3': 'Нажмите «Привязать устройство» и отсканируйте QR-код.',
|
||||||
|
// --- Shared form chrome ------------------------------------------------
|
||||||
|
'auth-card.cancel': 'Отмена',
|
||||||
|
'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.',
|
||||||
|
// --- Inline errors -----------------------------------------------------
|
||||||
|
// login_failed reasons — мы сохраняем верхатимный текст ошибки от
|
||||||
|
// upstream. Это даёт юзеру максимально точную диагностику без перевода,
|
||||||
|
// которое может разъехаться с реальной причиной. Шаблон обёрнут.
|
||||||
|
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
||||||
|
'auth-error.invalid-value': 'Значение не принято: {reason}',
|
||||||
|
'auth-error.submit-failed': 'WhatsApp не принял ввод: {reason}',
|
||||||
|
'auth-error.start-failed': 'Не удалось начать вход: {reason}',
|
||||||
|
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||||
|
'auth-error.login-in-progress':
|
||||||
|
'У бота уже идёт другой вход. Нажмите «Отмена» и попробуйте снова.',
|
||||||
|
'auth-error.max-logins':
|
||||||
|
'Достигнут лимит входов ({limit}). Сначала выйдите из существующего аккаунта.',
|
||||||
|
'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.',
|
||||||
|
// External-logout варианты — три причины, у каждой своя UX-формулировка.
|
||||||
|
// «another_device» — другой связанный девайс отвязал нас (например, юзер
|
||||||
|
// отвязал bridge с другого ноутбука). «phone_logged_out» — юзер вышел
|
||||||
|
// из WhatsApp на самом телефоне, что ломает все связанные устройства.
|
||||||
|
// «unknown» — fallback, в т.ч. для startup-нотисов «You're not logged
|
||||||
|
// into WhatsApp».
|
||||||
|
'auth-error.external-logout.another-device':
|
||||||
|
'WhatsApp отвязал это устройство с другого устройства. Войдите снова.',
|
||||||
|
'auth-error.external-logout.phone-logged-out':
|
||||||
|
'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.',
|
||||||
|
'auth-error.external-logout.unknown':
|
||||||
|
'WhatsApp разорвал сессию. Войдите снова.',
|
||||||
|
// --- Logout ------------------------------------------------------------
|
||||||
|
'card.logout.name': 'Выйти из WhatsApp',
|
||||||
|
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
|
||||||
|
'card.logout.confirm-prompt': 'Точно выйти?',
|
||||||
|
'card.logout.confirm-yes': 'Выйти',
|
||||||
|
'card.logout.confirm-no': 'Отмена',
|
||||||
|
'card.logout.gated': 'Идентификатор сессии ещё загружается — подождите секунду.',
|
||||||
|
// --- Diagnostics in transcript ----------------------------------------
|
||||||
|
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
||||||
|
'diag.ready': 'Готов отправлять команды.',
|
||||||
|
'diag.checking-status': 'Проверяю статус подключения…',
|
||||||
|
'diag.send-failed': 'ошибка отправки: {message}',
|
||||||
|
'diag.history-marker': '─── история ───',
|
||||||
|
'diag.history-unavailable': 'Не удалось прочитать историю — проверяю статус заново.',
|
||||||
|
// QR-сообщения никогда не выводятся целиком в transcript — body содержит
|
||||||
|
// raw whatsmeow handshake (включая adv-secret, который IS the login
|
||||||
|
// token). Сохранять его в DOM-логе виджета означало бы пережить мост-
|
||||||
|
// редакцию. В логе только нейтральные диагностические строки.
|
||||||
|
'diag.qr-issued': 'QR-код обновлён.',
|
||||||
|
'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.',
|
||||||
|
// Pairing-код — не такой же чувствительный как QR adv-secret (это
|
||||||
|
// 8-символьный one-time pairing token, действителен ~3 минуты), но
|
||||||
|
// всё равно по аналогии с QR не дублируем его в transcript — UI и так
|
||||||
|
// показывает код большим моноширинным текстом. В логе только нейтральная
|
||||||
|
// диагностика, чтобы trail был последовательный.
|
||||||
|
'diag.pairing-code-issued': 'Код для входа выдан.',
|
||||||
|
// Connection warnings от connector handlewhatsapp.go — они не меняют
|
||||||
|
// state виджета, просто пишутся в transcript verbatim, чтобы юзер
|
||||||
|
// понимал, что мост борется с подключением.
|
||||||
|
'diag.connection-warning': '{text}',
|
||||||
|
// External-logout transcript echo — короткая строка под красным
|
||||||
|
// баннером.
|
||||||
|
'diag.external-logout': 'WhatsApp разорвал сессию — нужен повторный вход.',
|
||||||
|
// --- Bootstrap failure -------------------------------------------------
|
||||||
|
'bootstrap.failed': 'Widget не запустился',
|
||||||
|
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
|
||||||
|
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StringKey = keyof typeof RU;
|
||||||
70
apps/widget-whatsapp/src/main.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { readBootstrap } from './bootstrap';
|
||||||
|
import { App } from './App';
|
||||||
|
import { createT } from './i18n';
|
||||||
|
import { WidgetApi, buildCapabilities } from './widget-api';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
// Input-mode detector — see apps/widget-telegram/src/main.tsx for the
|
||||||
|
// full rationale. Default to 'mouse'; the capture-phase pointerdown
|
||||||
|
// listener flips to 'touch' on the first non-mouse pointerType.
|
||||||
|
// matchMedia guessing was dropped — every variant
|
||||||
|
// (`any-pointer: coarse|fine`, `hover: hover`, `pointer: fine|coarse`)
|
||||||
|
// is mis-reported on at least one shipping device.
|
||||||
|
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 someone opened the widget URL directly (no host params), or a
|
||||||
|
// host bug failed to provide them. Either way render a self-contained
|
||||||
|
// diagnostic instead of going silent. Bootstrap failed before we could
|
||||||
|
// read clientLanguage from the URL, so let createT fall back to
|
||||||
|
// navigator.language.
|
||||||
|
const t = createT();
|
||||||
|
render(
|
||||||
|
<div class="app">
|
||||||
|
<div class="error-banner">
|
||||||
|
<strong>{t('bootstrap.failed')}</strong>
|
||||||
|
{t('bootstrap.missing-params', { names: result.missing.join(', ') })}{' '}
|
||||||
|
{t('bootstrap.embedded-only', { route: '/bots/whatsapp' })}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
root
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Apply initial theme synchronously so the first paint isn't flashed
|
||||||
|
// through the wrong palette.
|
||||||
|
document.documentElement.dataset.theme = result.bootstrap.theme;
|
||||||
|
|
||||||
|
// Instantiate the WidgetApi BEFORE React render. The constructor attaches
|
||||||
|
// the `window.addEventListener('message', ...)` listener synchronously,
|
||||||
|
// so by the time the host's ClientWidgetApi fires its capabilities
|
||||||
|
// request on iframe `load` we're already listening.
|
||||||
|
//
|
||||||
|
// The pre-fix flow built the WidgetApi inside App.tsx's useEffect, which
|
||||||
|
// runs AFTER React's first commit. On a fresh mount the bundle parse +
|
||||||
|
// initial render took long enough for the host's request to arrive
|
||||||
|
// after the listener was attached, so it worked by accident. On the
|
||||||
|
// *second* mount (after «Show chat» → «Show widget») the bundle is
|
||||||
|
// browser-cached and parses near-instantly; the host's request raced
|
||||||
|
// ahead of useEffect, the listener missed it, and capability handshake
|
||||||
|
// hung forever — only the «Соединение с Vojo…» diag line ever showed.
|
||||||
|
const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId));
|
||||||
|
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||||
|
}
|
||||||
1026
apps/widget-whatsapp/src/state.ts
Normal file
1114
apps/widget-whatsapp/src/styles.css
Normal file
1
apps/widget-whatsapp/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
327
apps/widget-whatsapp/src/widget-api.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
// Minimal matrix-widget-api transport implemented inline. We don't pull
|
||||||
|
// the full SDK because:
|
||||||
|
// - it's CommonJS and forces ESM interop juggling that we hit on the
|
||||||
|
// dev fixture in the Telegram widget's M2 phase (esm.sh wrapping made
|
||||||
|
// WidgetApi unavailable as a constructor);
|
||||||
|
// - the surface we use is small: capabilities reply, theme_change reply,
|
||||||
|
// send_event request, read_events request, get_openid request, live
|
||||||
|
// event delivery via send_event toWidget.
|
||||||
|
//
|
||||||
|
// Protocol shapes match
|
||||||
|
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
|
||||||
|
// (in the host repo). Default request timeout on the host transport is
|
||||||
|
// 10 s — keep that in mind for bridge-bot replies that take time.
|
||||||
|
|
||||||
|
import type { WidgetBootstrap } from './bootstrap';
|
||||||
|
|
||||||
|
export type RoomEvent = {
|
||||||
|
type: string;
|
||||||
|
event_id: string;
|
||||||
|
room_id: string;
|
||||||
|
sender: string;
|
||||||
|
origin_server_ts: number;
|
||||||
|
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
||||||
|
unsigned: Record<string, unknown>;
|
||||||
|
// `m.room.redaction` events carry `redacts` at the top level (room v < 11)
|
||||||
|
// and/or inside `content.redacts` (v11+). The host driver mirrors at both
|
||||||
|
// for forward-compat; the widget-side parser reads either.
|
||||||
|
redacts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToWidgetMessage = {
|
||||||
|
api: 'toWidget';
|
||||||
|
widgetId: string;
|
||||||
|
requestId: string;
|
||||||
|
action: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
// Present when this message IS a reply to a prior toWidget request.
|
||||||
|
// Per matrix-widget-api PostmessageTransport: replies preserve the original
|
||||||
|
// `api` field and add `response`. Both directions follow the same shape.
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FromWidgetMessage = {
|
||||||
|
api: 'fromWidget';
|
||||||
|
widgetId: string;
|
||||||
|
requestId: string;
|
||||||
|
action: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Capability = string;
|
||||||
|
|
||||||
|
export type WidgetApiEvents = {
|
||||||
|
ready: () => void;
|
||||||
|
liveEvent: (ev: RoomEvent) => void;
|
||||||
|
themeChange: (name: 'light' | 'dark') => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export class WidgetApi {
|
||||||
|
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
|
||||||
|
|
||||||
|
private readonly pending = new Map<
|
||||||
|
string,
|
||||||
|
{ resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
|
||||||
|
>();
|
||||||
|
|
||||||
|
private requestSeq = 0;
|
||||||
|
|
||||||
|
private isReady = false;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly bootstrap: WidgetBootstrap,
|
||||||
|
private readonly capabilities: Capability[]
|
||||||
|
) {
|
||||||
|
window.addEventListener('message', this.onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
window.removeEventListener('message', this.onMessage);
|
||||||
|
this.pending.forEach(({ reject }) => reject(new Error('disposed')));
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
|
||||||
|
const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
|
||||||
|
list.push(listener);
|
||||||
|
// `ready` is a one-shot lifecycle signal. If the handshake completed
|
||||||
|
// before this listener attached (cached-bundle race: host fires the
|
||||||
|
// capabilities request on iframe `load`, the WidgetApi catches and
|
||||||
|
// resolves it during script init, then React's useEffect runs *after*
|
||||||
|
// that and attaches the `ready` listener), replay synchronously so
|
||||||
|
// App.tsx still flips `handshakeOk` and fires `list-logins`.
|
||||||
|
if (event === 'ready' && this.isReady) {
|
||||||
|
(listener as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendText(body: string): Promise<{ event_id: string }> {
|
||||||
|
return this.fromWidget('send_event', {
|
||||||
|
type: 'm.room.message',
|
||||||
|
content: { msgtype: 'm.text', body },
|
||||||
|
}) as Promise<{ event_id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open an external URL via the host. The host receives this on a
|
||||||
|
// SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from
|
||||||
|
// matrix-widget-api's `fromWidget` so it doesn't route through
|
||||||
|
// ClientWidgetApi's request/response machinery.
|
||||||
|
//
|
||||||
|
// Why this exists: cross-origin iframes inside Capacitor's Android
|
||||||
|
// WebView silently drop `<a target="_blank">` clicks — the WebView
|
||||||
|
// doesn't have a multi-window concept, and the host's global
|
||||||
|
// `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks
|
||||||
|
// inside the host document, not inside the iframe (cross-origin
|
||||||
|
// events don't bubble across the frame boundary). The widget posts
|
||||||
|
// this message instead; the host calls `openExternalUrl(url)` which
|
||||||
|
// routes to `Browser.open` on native and `window.open` on web.
|
||||||
|
public openExternalUrl(url: string): void {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
api: 'io.vojo.bot-widget',
|
||||||
|
action: 'open-external-url',
|
||||||
|
data: { url },
|
||||||
|
},
|
||||||
|
this.bootstrap.parentOrigin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always prefix outbound commands with `<commandPrefix> ` (trailing space —
|
||||||
|
// bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both
|
||||||
|
// the management room and any other room the bot may have been moved to.
|
||||||
|
// Form-field submissions (phone number) go through this same helper because
|
||||||
|
// bridgev2's stored CommandState fallback only fires after queue.go:108
|
||||||
|
// routes the message — and that route also requires the prefix outside the
|
||||||
|
// management room.
|
||||||
|
public sendCommand(rawBody: string): Promise<{ event_id: string }> {
|
||||||
|
const body = `${this.bootstrap.commandPrefix} ${rawBody}`;
|
||||||
|
return this.sendText(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline-resume probe. Action name is MSC2876 (`read_events`); the
|
||||||
|
// capability is MSC2762 timeline (already requested at construction). We
|
||||||
|
// pass `room_ids: [bootstrap.roomId]` explicitly so the host's
|
||||||
|
// ClientWidgetApi takes the modern code path that calls our driver's
|
||||||
|
// `readRoomTimeline` (single-room cap-checked) rather than the deprecated
|
||||||
|
// `readRoomEvents` fallback. Driver returns events newest-first; reversing
|
||||||
|
// to chronological order is the caller's job.
|
||||||
|
//
|
||||||
|
// `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR
|
||||||
|
// post-scan cleanup events. `msgtype` is honoured only for m.room.message
|
||||||
|
// (matches the driver's `readRoomTimeline` semantics).
|
||||||
|
public async readTimeline(opts: {
|
||||||
|
limit: number;
|
||||||
|
type?: 'm.room.message' | 'm.room.redaction';
|
||||||
|
msgtype?: 'm.text' | 'm.notice' | 'm.image';
|
||||||
|
}): Promise<RoomEvent[]> {
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
type: opts.type ?? 'm.room.message',
|
||||||
|
limit: opts.limit,
|
||||||
|
room_ids: [this.bootstrap.roomId],
|
||||||
|
};
|
||||||
|
if (opts.msgtype !== undefined) data.msgtype = opts.msgtype;
|
||||||
|
const res = await this.fromWidget('org.matrix.msc2876.read_events', data);
|
||||||
|
return (res.events as RoomEvent[] | undefined) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit<K extends keyof WidgetApiEvents>(
|
||||||
|
event: K,
|
||||||
|
...args: Parameters<WidgetApiEvents[K]>
|
||||||
|
): void {
|
||||||
|
const list = this.listeners[event] as
|
||||||
|
| Array<(...a: Parameters<WidgetApiEvents[K]>) => void>
|
||||||
|
| undefined;
|
||||||
|
list?.forEach((fn) => fn(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextRequestId(): string {
|
||||||
|
this.requestSeq += 1;
|
||||||
|
return `widget-wa-${Date.now()}-${this.requestSeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private postToHost(msg: ToWidgetMessage | FromWidgetMessage): void {
|
||||||
|
window.parent.postMessage(msg, this.bootstrap.parentOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage = (ev: MessageEvent): void => {
|
||||||
|
if (ev.origin !== this.bootstrap.parentOrigin) return;
|
||||||
|
// Source-window guard: every legit widget API message comes from the
|
||||||
|
// host window that embedded our iframe — i.e. window.parent. A foreign
|
||||||
|
// tab/frame on the same origin (think browser extension content
|
||||||
|
// script, popup, or sibling iframe) could otherwise post a forged
|
||||||
|
// message that passes the origin check. We only accept messages
|
||||||
|
// whose `source` is literally `window.parent`. The `widgetId` check
|
||||||
|
// a few lines down is a soft filter; this is the hard one.
|
||||||
|
if (ev.source !== window.parent) return;
|
||||||
|
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
|
||||||
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
if (msg.widgetId !== this.bootstrap.widgetId) return;
|
||||||
|
|
||||||
|
if (msg.api === 'toWidget') {
|
||||||
|
this.handleToWidget(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.api === 'fromWidget' && msg.response) {
|
||||||
|
const pending = this.pending.get(msg.requestId);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(msg.requestId);
|
||||||
|
const err = (msg.response as { error?: { message?: string } }).error;
|
||||||
|
if (err) pending.reject(new Error(err.message ?? 'request failed'));
|
||||||
|
else pending.resolve(msg.response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private replyTo(msg: ToWidgetMessage, response: Record<string, unknown>): void {
|
||||||
|
this.postToHost({
|
||||||
|
api: msg.api,
|
||||||
|
widgetId: msg.widgetId,
|
||||||
|
requestId: msg.requestId,
|
||||||
|
action: msg.action,
|
||||||
|
data: msg.data,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleToWidget(msg: ToWidgetMessage): void {
|
||||||
|
if (!msg.requestId || !msg.action) return;
|
||||||
|
switch (msg.action) {
|
||||||
|
case 'capabilities': {
|
||||||
|
this.replyTo(msg, { capabilities: this.capabilities });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'notify_capabilities': {
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
if (!this.isReady) {
|
||||||
|
this.isReady = true;
|
||||||
|
this.emit('ready');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'supported_api_versions': {
|
||||||
|
this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'theme_change': {
|
||||||
|
const name = (msg.data?.name as string | undefined) ?? '';
|
||||||
|
const themed: 'light' | 'dark' = name === 'dark' ? 'dark' : 'light';
|
||||||
|
this.emit('themeChange', themed);
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'send_event': {
|
||||||
|
// Live event push from host. Forward `m.room.message` (carries the
|
||||||
|
// bot's notices / errors / `m.image` QR-login broadcasts AND the
|
||||||
|
// pairing-code text) AND `m.room.redaction` (post-scan QR cleanup,
|
||||||
|
// see BotWidgetDriver `sanitizeBotWidgetRedactionEvent`). State
|
||||||
|
// events (m.room.member) also arrive on this channel — we still
|
||||||
|
// ignore them here.
|
||||||
|
const data = msg.data as Partial<RoomEvent> | undefined;
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
data.event_id &&
|
||||||
|
(data.type === 'm.room.message' || data.type === 'm.room.redaction')
|
||||||
|
) {
|
||||||
|
this.emit('liveEvent', data as RoomEvent);
|
||||||
|
}
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'update_state': {
|
||||||
|
// Initial room state push from host (m.room.member members).
|
||||||
|
// We don't use these yet; future milestones can use it for header chrome.
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// Be liberal — reply empty so the host's request promise resolves.
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fromWidget(
|
||||||
|
action: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const requestId = this.nextRequestId();
|
||||||
|
this.pending.set(requestId, { resolve, reject });
|
||||||
|
this.postToHost({
|
||||||
|
api: 'fromWidget',
|
||||||
|
widgetId: this.bootstrap.widgetId,
|
||||||
|
requestId,
|
||||||
|
action,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (this.pending.has(requestId)) {
|
||||||
|
this.pending.delete(requestId);
|
||||||
|
reject(new Error(`${action} timed out after ${FROM_WIDGET_REQUEST_TIMEOUT_MS}ms`));
|
||||||
|
}
|
||||||
|
}, FROM_WIDGET_REQUEST_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability set must match the host's BotWidgetDriver.getBotWidgetCapabilities.
|
||||||
|
// Anything else is silently dropped by the host's validateCapabilities.
|
||||||
|
//
|
||||||
|
// `m.image` and `m.room.redaction` are the QR-login additions (already in
|
||||||
|
// place from the Telegram widget M13). The host sanitizer for `m.image`
|
||||||
|
// strips `url` / `file` / `info`, leaving only `body` (the bridge encodes
|
||||||
|
// the QR payload there) plus `m.relates_to` / `m.new_content` for QR
|
||||||
|
// rotation edits. Redactions signal that the QR was consumed by a
|
||||||
|
// successful scan.
|
||||||
|
export const buildCapabilities = (roomId: string): Capability[] => [
|
||||||
|
`org.matrix.msc2762.timeline:${roomId}`,
|
||||||
|
'org.matrix.msc2762.send.event:m.room.message#m.text',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.text',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.notice',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.image',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.redaction',
|
||||||
|
'org.matrix.msc2762.receive.state_event:m.room.member',
|
||||||
|
];
|
||||||
21
apps/widget-whatsapp/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
27
apps/widget-whatsapp/vite.config.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
// Build artefact lives at apps/widget-whatsapp/dist/. The deploy step
|
||||||
|
// (out of repo) rsyncs this into ~/vojo/widgets/whatsapp/ on the server,
|
||||||
|
// which Caddy serves from /var/www/widgets/whatsapp via the
|
||||||
|
// widgets.vojo.chat block.
|
||||||
|
//
|
||||||
|
// `base: './'` keeps every generated asset path relative so the same
|
||||||
|
// build can sit under /whatsapp/ on widgets.vojo.chat without rewrites.
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
plugins: [preact()],
|
||||||
|
build: {
|
||||||
|
target: 'es2020',
|
||||||
|
sourcemap: true,
|
||||||
|
// Inline CSS for a single round-trip; the widget is small and the
|
||||||
|
// host's iframe handshake budget is already tight (10s default).
|
||||||
|
cssCodeSplit: false,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// Different port from widget-telegram (8081) and widget-discord (8082)
|
||||||
|
// so all three can run side-by-side during local development.
|
||||||
|
port: 8083,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -4,9 +4,34 @@ const config: CapacitorConfig = {
|
||||||
appId: 'chat.vojo.app',
|
appId: 'chat.vojo.app',
|
||||||
appName: 'Vojo',
|
appName: 'Vojo',
|
||||||
webDir: 'dist',
|
webDir: 'dist',
|
||||||
|
// Bot widgets (docs/plans/bots_tab.md Phase 2): on Android, Capacitor's
|
||||||
|
// BridgeWebViewClient.shouldOverrideUrlLoading does NOT check
|
||||||
|
// request.isForMainFrame(), so any cross-origin iframe URL not in
|
||||||
|
// appAllowNavigationMask is hijacked into an external Intent.ACTION_VIEW
|
||||||
|
// and the iframe stays blank. Same-origin /widgets/... is safe (resolves
|
||||||
|
// to https://localhost under Capacitor).
|
||||||
|
//
|
||||||
|
// The Discord widget renders a nested hCaptcha iframe inside the
|
||||||
|
// widgets.vojo.chat frame; without `*.hcaptcha.com` in the allowlist
|
||||||
|
// the captcha challenge stays blank on Android and login is dead-ended.
|
||||||
|
server: {
|
||||||
|
allowNavigation: [
|
||||||
|
'widgets.vojo.chat',
|
||||||
|
'js.hcaptcha.com',
|
||||||
|
'newassets.hcaptcha.com',
|
||||||
|
'*.hcaptcha.com',
|
||||||
|
],
|
||||||
|
},
|
||||||
android: {
|
android: {
|
||||||
// Keep default: resolveServiceWorkerRequests = true
|
// Keep default: resolveServiceWorkerRequests = true
|
||||||
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
|
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
|
||||||
|
//
|
||||||
|
// WebView bg color before first body paint. Without this the WebView
|
||||||
|
// paints transparent/black during bundle hydration, which combined with
|
||||||
|
// the post-splash window theme produced a visible black flash between
|
||||||
|
// the Android 12+ system splash and the in-app AuthSplashScreen mascot.
|
||||||
|
// Matches the native splash + AuthLayout + body backgrounds.
|
||||||
|
backgroundColor: '#0d0e11',
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
PushNotifications: {
|
PushNotifications: {
|
||||||
|
|
|
||||||