Compare commits

..

138 commits

Author SHA1 Message Date
7b3a4145a7 fix(channels): back active-workspace persistence with a jotai atom so the native pager sees switcher picks instead of a stale memoized localStorage read 2026-05-22 01:18:15 +03:00
765445c091 feat(discord-widget): render Open-in-Channels card after login via VOJO-LOGIN-SPACE-V1 sentinel and generic open-matrix-to widget action 2026-05-21 14:08:50 +03:00
408b9eefc3 chore(eslint): give widget apps their own Preact-aware root config so host airbnb and react rules stop flagging valid Preact code in pre-commit 2026-05-21 14:08:21 +03:00
8d8b39e897 fix(telegram-widget): stack password row column on mobile so the show toggle does not overflow off the right edge 2026-05-21 13:43:11 +03:00
055b7d3692 fix(workspace-switcher): switch space rows to SurfaceVariant so inactive bg blends with the sheet silhouette instead of reading as dark cards 2026-05-21 01:05:34 +03:00
78504262d3 feat(stream-header): flatten web curtain to a tabsRow-border divider pixel-aligned with PageHeader at WEB_TABS_ROW_PX=54 and gate keyboard probe to native 2026-05-20 23:20:17 +03:00
cde50cff0f fix(bots): pad BotStatePage wrapper with safe-top so the mobile back-arrow header clears the Android status bar on the connect-bot empty state 2026-05-20 02:39:21 +03:00
6ca6b69d48 feat(stream-header): contextual Plus on Channels opens create-channel inside workspace and create-community on landing via StreamHeader.primaryAction 2026-05-20 01:59:04 +03:00
240bb54c29 refactor(stream-header): reset live drag on gesture teardown, drop dead pinned-local fallback, narrow commit() to peek|closed, add exhaustive transition guards and align stale comments 2026-05-20 00:59:17 +03:00
8fb885df1b feat(stream-header): free-range curtain drag through full pin↔closed↔peek range with bottomPinned-aware body bail and native-only handle 2026-05-20 00:26:10 +03:00
ab283e9788 refactor(stream-header): unify curtain gestures onto dual handle+body surfaces with 1:1 handle and rubber-band body, scroll-aware bail 2026-05-19 23:27:19 +03:00
e866cd3830 feat(stream-header): move pin/unpin gesture onto dedicated 32px drag-handle with 1:1 finger tracking and desktop-style grabber animation 2026-05-19 18:26:37 +03:00
7c5a1f2ee7 fix(mobile-tabs-pager): restore segment-button taps in pager mode via opacity-0 per-pane row and route their commits through an instant no-transition strip jump 2026-05-19 14:28:56 +03:00
0422a9832f feat(stream-header): pin chats curtain over static pager header on drag-up with per-tab atom, native-only rubber-band gesture and pinned-aware horseshoe sheet coordination 2026-05-19 11:50:31 +03:00
4a9d5f6384 fix(mobile-tabs-pager): paint Background.OnContainer on pagerRoot so panes mounted outside PageRoot inherit readable text on native 2026-05-18 22:58:53 +03:00
870e13d895 feat(mobile-tabs-pager): swipe between Direct, Channels and Bots on Capacitor native with static header, 24px gap, atom-bridged action icons and inert offscreen panes 2026-05-18 22:00:53 +03:00
727a53a776 fix(horseshoe): extend mobile DM and Channels wrappers up over the safe-top zone so the StreamHeader curtain paints the status-bar strip on drag-up 2026-05-18 15:14:58 +03:00
af97549e48 tweak(stream-header): require curtain drag past 90% of full peek travel to commit so short drags snap back as accidental 2026-05-18 02:12:55 +03:00
0c704aac38 tweak(bubbles): paint peer bg and horseshoe void pure black and flip own/peer flat-corner to bottom-left/top-left at 16px radius 2026-05-18 02:04:39 +03:00
b26340fa7d feat(electron): add desktop wrapper packaging Vojo as Windows zip with privileged vojo:// scheme, HashRouter override and native chrome 2026-05-18 01:50:16 +03:00
5dbe83aa9d feat(push): real sender+room avatars via MXC bridge with adaptive shortcut icons, plus review fixes (GROUP_ALERT_ALL, eventId dedup, isEncrypted privacy default, structured roomname parse, mark-as-read optimistic docs) 2026-05-17 17:39:19 +03:00
4b4454fa1d docs(android): document MessagingStyle pipeline, channel split, callId-session dedup and edit-collapse defer rationale 2026-05-17 02:42:45 +03:00
de348eb4fc fix(push): close E2EE-flip race by re-checking room encryption at reply send time, pre-flight credentials before optimistic echo, share callId-dedup helpers between FCM and Worker 2026-05-17 02:41:27 +03:00
06778702b2 feat(push): inline RemoteInput reply on per-room notifications for cleartext rooms with optimistic local echo and encryption-state re-dump 2026-05-17 02:29:20 +03:00
8a80194fe5 fix(push): dedup re-rings within one call session via composite (roomId, callSessionId) key so a participant rejoin doesn't re-alert 2026-05-17 02:22:20 +03:00
38d24e5527 feat(push): group room messages into a per-room MessagingStyle conversation with DM/group channels, mark-as-read action and receipt-driven dismiss 2026-05-17 02:06:21 +03:00
408f165f60 feat(push): add WorkManager polling fallback that delivers notifications via /_matrix/client/v3/notifications when FCM is blocked 2026-05-17 01:27:55 +03:00
b9aad691b5 update gitignore with Vite config 2026-05-16 22:06:35 +03:00
770609b964 feat(media): make the desktop right-side media pane resizable with a smart max that subtracts chat-list width and a chat-column reservation 2026-05-16 20:45:54 +03:00
ebf2cfe07b fix(sidebar): raise resizable page-nav min width from 320 to 384px so StreamHeader segments and action icons stop clipping at the floor 2026-05-16 20:31:11 +03:00
2d101a40fc fix(channels): collapse /channels/ index into one mobile pane and add Create-community CTA next to Find-community on empty state 2026-05-16 20:25:01 +03:00
f2ecca64da feat(share): receive Android system share intents and drop the payload into the next chat the user opens via a top banner cue 2026-05-16 19:33:06 +03:00
6982ec374e chore(lint): close all typecheck and eslint tech debt to enable husky pre-commit hook with --max-warnings 0 2026-05-16 17:22:53 +03:00
45c69317ff feat(message): paint non-own bubbles via --vojo-peer-bubble-bg in Stream + Channel layouts and Stream rail/day-divider via --vojo-timeline-rail 2026-05-16 13:13:28 +03:00
c78984a6d8 fix(direct): exclude bridged portal rooms from Direct tab so Telegram chats live exclusively in their per-bridge personal filtering space 2026-05-16 00:31:38 +03:00
bfd72dc1ff tweak(stream-header): collapse peek1/peek2 into single peek snap so one drag reveals both action chips at once 2026-05-15 23:47:40 +03:00
0eb2e056c0 feat(stream-header): rebuild Direct/Channels/Bots header as a curtain layered above tabs with peek chips, inline forms, and VisualViewport keyboard compensation 2026-05-15 23:05:34 +03:00
81d23be61f feat(theme): ship Settings picker with system/light/dark and Vojo light palette reshading sidebar, chat, bubbles, horseshoe void and PWA chrome 2026-05-15 01:06:49 +03:00
8e2db986b4 feat(composer): tighten action-row padding, extract button JSX, and rotate placeholder across 12 hour-keyed variants 2026-05-15 00:13:51 +03:00
646cb7b124 feat(members): carve rounded TL/BL on members drawer with 12px void seam to chat and extract VoidGap helper consolidating four per-pane seams 2026-05-14 21:11:36 +03:00
8400ef54ee feat(syslines): render membership and room-state events as sender-anchored chat bubbles via StreamLayout instead of thin rail syslines 2026-05-14 01:39:51 +03:00
a893e86d92 tweak(direct): dial DM list avatars down to 48px with 68px row height for a less heavy density 2026-05-14 01:12:17 +03:00
2d74848509 feat(direct): enlarge DM list avatars to size 500 with 80px row height and matched virtualizer estimate for a denser two-line layout 2026-05-14 01:05:32 +03:00
c3a384b651 fix(page-nav): hold min-height:0 on scrolling middle and shrink-no on Direct bottom rows so flex pressure no longer squashes them below their NavItem floor 2026-05-14 01:05:21 +03:00
3c7c79fb6c fix(calls): split per-session bubbles by joined-count boundary with expiry-aware ongoing, post-ring duration, and same-caller retry merging 2026-05-14 00:27:38 +03:00
f5e992daad fix(time): use Intl numeric day-month-year everywhere so chat day dividers follow the system locale instead of hardcoded English full-month 2026-05-13 23:30:52 +03:00
2dac76f9af chore(i18n): rename DM direct-stream segment label to Direct 2026-05-13 23:30:42 +03:00
1ee1d50c41 fix(dm-name): drop matrix-js-sdk mxid disambiguation suffix from DM room names by using peer rawDisplayName 2026-05-13 22:54:26 +03:00
d3e69e042f fix(i18n): track system language on every launch instead of caching first-seen value in localStorage 2026-05-13 22:54:06 +03:00
5d4a50f593 feat(legal): publish Privacy Policy and account-deletion pages with About-screen link and Play Store feature graphic 2026-05-13 22:53:58 +03:00
5c16649e0c chore(versioning): derive Android versionName from git describe to match __APP_VERSION__ from vite 2026-05-13 22:53:48 +03:00
30e477d2cd feat(calls): render m.call.member events as one aggregate chat bubble per call aligned to initiator side 2026-05-13 15:57:23 +03:00
6a6b7acf15 feat(channels): rebuild thread drawer and channel rows as chat-style bubble cards that merge thread-summary into the bubble footer 2026-05-13 15:21:13 +03:00
3dee9f099f feat(thread-drawer): wrap in horseshoe seam with rounded TL/BL and add pointer/keyboard resize clamped to viewport/3 2026-05-13 14:42:15 +03:00
11c46d9250 feat(channels): replace workspace switcher popout with sliding horseshoe sheet and inline create-channel row, retire sidebar CreateTab 2026-05-13 14:21:39 +03:00
c27f8a7cc2 chore(mascot): re-encode as 2s VP9 cycle and center auth drop-shadow on character 2026-05-13 04:17:14 +03:00
4e7ea405ab chore(android): enable R8 shrinker with keep rules, set up release signing, and strip sourcemaps from APK 2026-05-13 04:17:09 +03:00
ffb80bff88 chore(bundle): trim web bundle by dropping unused Twemoji TTF, Prism languages, and MediaPipe blur wasm 2026-05-13 04:17:03 +03:00
663aece487 feat(media-viewer): atom-driven horseshoe shell over chat replacing Overlay modal for image+video with anchor-aware pinch/wheel zoom and swipe prev/next 2026-05-13 02:47:04 +03:00
4654836092 feat(chat): hide composer on scroll-up past 200px and replace jump-to-latest chip with circular FAB that pulses on incoming live messages 2026-05-13 01:36:29 +03:00
635fb91022 feat(settings): replace Modal500 with /settings route plus mobile bottom-up horseshoe sheet overlaying DM list via clip-path mask 2026-05-13 00:01:26 +03:00
c6bb66958d feat(profile-rail): size mobile user-card rail to measured content height with 85vh cap and inner scroll only on overflow 2026-05-12 02:06:10 +03:00
149382299a feat(safe-area): extend Android edge-to-edge top inset via --vojo-safe-top var and collapse profile horseshoe header with measured height 2026-05-12 01:54:30 +03:00
ce82d66883 feat(safe-area): paint Android edge-to-edge top/bottom strips with the active surface tone instead of a fixed body bg 2026-05-11 23:13:56 +03:00
f38cb42344 feat(direct-tabs): spread DM/Channels/Bots tabs edge-to-edge and underline active segment with purple bar on the header's grey rule 2026-05-11 21:04:07 +03:00
785b679b61 feat(horseshoe): add 12px void gap between chat and profile pane with rounded TL/BL on profile, drop page-nav right rounding 2026-05-11 20:33:51 +03:00
de2354f1da fix(composer): re-anchor timeline scrollTop when overlay composer height changes via prop-driven layout effect 2026-05-11 18:10:56 +03:00
41a9af19e3 feat(composer): floating overlay above timeline with Gemini-style two-row layout and Android-WebView stuck-hover gate 2026-05-11 17:47:37 +03:00
2337b05140 feat(chat): invert canvas/bubble tones and darken timeline rail and media frames 2026-05-11 15:16:53 +03:00
ab6c65a4e0 fix(profile): keep desktop user-room-profile pane open on outside clicks, close only via × or Esc 2026-05-11 14:09:07 +03:00
e3e61afd4c feat(profile): merge 1:1 header avatar and title into single identity button so name taps open the profile sheet 2026-05-11 14:09:07 +03:00
4d0b508ebb feat(horseshoe): split web page-nav and chat panel with 12px void gap and rounded inner corners on both sides 2026-05-11 13:54:40 +03:00
023a6a439c feat(sidebar): add resizable left page-nav via pointer/keyboard with localStorage-persisted width, clamped [320, viewport/3] and tactile min/max indicator 2026-05-11 02:49:39 +03:00
c992e910ee feat(profile): add mouse drag for mobile horseshoe via pointer events with origin-tagged drag state and userSelect:none on viewports 2026-05-11 02:22:28 +03:00
2bbaf4dfcf fix(profile): override avatar circle force via && self-doubled selector instead of source-order-dependent !important double-up 2026-05-11 02:00:12 +03:00
117bb9fba4 refactor(profile): rebuild mobile horseshoe as single silhouette wrapper with Vaul ease curves replacing the two-radius emerge handoff 2026-05-11 02:00:04 +03:00
626a7c2d1d feat(profile): inline-expand hero avatar in desktop side pane instead of swapping the whole card to full-view 2026-05-11 01:11:09 +03:00
9c204c1af6 feat(profile): drop pill background on user-card kebab and enlarge the dots icon 2026-05-11 00:58:39 +03:00
4b39046c09 feat(horseshoe): bump top profile and bottom call horseshoe radius to 32px with 12px void gap 2026-05-11 00:54:58 +03:00
3fed5ff873 chore(layout): disable SidebarNav 66px rail rendering; component preserved for later entry-point redistribution 2026-05-10 22:12:31 +03:00
d4b05619a8 feat(channels): ship M6 workspace switcher dropdown with rename-reactive trigger and rows for multi-space users 2026-05-10 20:56:14 +03:00
0b31e6b930 feat(channels): route room-in-space navigations through /channels/ with eventId-anchored permalinks and channels-aware cold-push redirect 2026-05-10 20:13:50 +03:00
896a2e2083 feat(channels): M4a drawer rich chrome with edit menu reactions and reply affordances 2026-05-10 18:48:55 +03:00
e80453785e feat(channels): ship M4 per-thread unread with thread receipts mute-aware atom and architectural cleanup 2026-05-10 14:35:51 +03:00
307af24d1e feat(channels): ship M3 channel timeline avatar-name layout with thread summary cards and drawer header counter polish 2026-05-10 01:06:29 +03:00
4632be30f7 feat(channels): ship M2 thread drawer + composer + shareable thread URL with cold-load relations repair 2026-05-09 22:49:53 +03:00
851f3d30a3 feat(channels): drop NEW badge from Каналы segment per product call 2026-05-09 15:09:24 +03:00
075b6cf69c fix(android): redirect to root on logout to dodge Capacitor SPA-fallback failure on URL-encoded path segments 2026-05-09 15:07:35 +03:00
efe58dc2e2 feat(channels): ship M1 — Каналы segment with /channels/ routes and channels-mode RoomTimeline filter 2026-05-09 15:06:13 +03:00
c6eba1e935 chore(deps): bump matrix-js-sdk 40.2.0 → 41.4.0 with SessionMembershipData barrel and EC sticky-event capability extension 2026-05-09 13:00:45 +03:00
3ea01a9c3f chore(deps): bump matrix-js-sdk 39.4.0 → 40.2.0 adapting sessionMembershipsForRoom removal and dedup-key trichotomy 2026-05-09 12:18:02 +03:00
0d93a223d0 chore(deps): bump matrix-js-sdk 38.4.0 → 39.4.0 adapting MatrixRTCSession.slotDescription rename 2026-05-09 11:33:55 +03:00
6560f0b424 chore(deps): bump matrix-js-sdk 38.2.0 → 38.4.0 2026-05-09 02:45:59 +03:00
b30704dd96 fix(boot): drop 3-dots splash menu, add 10s fetch timeouts, surface logout on init/start/sync-error per Element Web pattern 2026-05-09 02:26:16 +03:00
cd824e0c90 feat(profile): mobile top-horseshoe rail and desktop right pane host a Dawn-style user card with hero, info rows, and floating 3-dot actions menu 2026-05-08 19:04:12 +03:00
f4292611cf feat(calls): split-horseshoe call surface with redesigned ring/active pill, orbit border, custom outline icons, tap-to-room 2026-05-08 01:30:36 +03:00
d58e69d49f refactor(timeline): rebuild stream row as 3-track CSS grid with auto-sized time column so 24h and AM/PM render with consistent gaps without JS measurement 2026-05-07 23:36:18 +03:00
997375b307 feat(timeline): square image+video bubbles with username overlay, reactions outside bubble, edge-anchored mobile rail, horizontal day divider 2026-05-07 21:24:50 +03:00
ce308776cd update docs 2026-05-06 23:12:57 +03:00
97a50e29f9 feat(bots): add bot-widget loading bar with cycle-complete hide and sync-state deference, plus matching cycle-complete polish on SyncIndicator 2026-05-06 22:11:42 +03:00
17ba496b7e fix(bots-widgets): default data-input to mouse and drop dead danger:hover rose override so hover works from frame zero on hybrid devices 2026-05-06 17:29:11 +03:00
998813eff4 refactor(direct): drop the bottom DM-nav status strip with the vojo.chat label and e2ee chip 2026-05-06 16:37:22 +03:00
ece9e922e3 fix(user-profile): round the avatar surface plate and replace its outline with a box-shadow ring so corners no longer poke past the circle 2026-05-06 16:19:47 +03:00
7af69574f4 feat(bots): render bot-shell command cards as a fixed 2-column grid with equal-height rows 2026-05-06 16:19:41 +03:00
e8865cec5f fix(direct): lock DmStreamRow title-block height so the room name stops jumping when row 2 collapses on hover 2026-05-06 16:19:34 +03:00
1d64275bae feat(bots-discord): land hCaptcha challenge handling for QR-login with sentinel-prefixed bridge protocol and Dawn-themed widget UI 2026-05-06 14:19:45 +03:00
295dfbb796 feat(bots-discord): drop the obsolete Devices step from the QR-scan navigation copy 2026-05-05 22:59:39 +03:00
526515dcde feat(bots-whatsapp): drop the unpredictable-bans paragraph from the AboutCard risk callout 2026-05-05 22:53:27 +03:00
42d9ccfbf3 feat(bots): unify command-card chrome with left-side semantic icons and fold WA Meta-ToS warning into the AboutCard modal 2026-05-05 19:59:24 +03:00
bc360e84cc feat(bots-whatsapp): land Preact widget for mautrix-whatsapp QR + pairing-code login, Meta-ToS warning card, and cross-iframe external-link relay 2026-05-05 15:25:16 +03:00
5eb12f888b feat(bots-discord): land Preact widget for mautrix-discord QR-login with ping-based status, reconnect recovery, and discordapp.com URL parser 2026-05-05 02:17:30 +03:00
156570826a feat(bots-telegram): land QR-code login flow rendered client-side from m.image body via qrcode-generator with bridge-race-tolerant state machine 2026-05-05 01:02:36 +03:00
e46bba2f7d feat(bots): polish the Telegram bot widget UI and fix Android WebView sticky-hover via pointerType-based input-mode detection 2026-05-04 18:34:51 +03:00
b2f3b668c5 feat(splash): hold Android system splash on screen until web mascot paints via custom LaunchSplash plugin 2026-05-04 18:31:10 +03:00
817dad383c remove stale plan 2026-05-03 23:56:28 +03:00
949860bc1a feat(connection): replace 30s 'Connecting...' banner with bottom-edge sync indicator and resume-grace window for phone-unlock UX 2026-05-03 22:35:22 +03:00
f102593081 Revert "feat(connection): replace 30s 'Connecting...' banner with bottom-edge sync indicator and homeserver footer dot, drop mascot loading splash"
This reverts commit a1ff5db724.
2026-05-03 20:15:26 +03:00
e547c466a8 feat(settings): drop user-facing time/date format toggle and derive everything from system locale via Intl.DateTimeFormat 2026-05-03 19:27:54 +03:00
a1ff5db724 feat(connection): replace 30s 'Connecting...' banner with bottom-edge sync indicator and homeserver footer dot, drop mascot loading splash 2026-05-03 18:21:00 +03:00
8f49124043 feat(ui): force every user and room avatar to render as a circle via globalStyle override on the folds Avatar wrapper 2026-05-03 14:48:27 +03:00
ed1544dd5e feat(direct): land inline invite cards with spam-filter toggle and retire the /inbox/ tree along with its sidebar tab 2026-05-03 13:49:33 +03:00
bae6761683 feat(bots): render Matrix-native bot avatar in BotCard sidebar row and BotShellHero so server-side avatar_url propagates without client patches 2026-05-03 13:22:10 +03:00
316c3eb9fd feat(bots-telegram): land M12.5 timeline-resume hydrate to recover pending login forms after widget reload via read_events scan 2026-05-03 02:36:17 +03:00
35ade7e941 feat(bots-telegram): ship M12 login flow with BotShell host hero and Go bridgev2 dialect parser 2026-05-02 22:12:37 +03:00
e43b0fb597 feat(bots-telegram): land Phase 3 widget scaffold with Dawn UI, dev config overlay, and prod origin allowlist 2026-05-02 13:22:25 +03:00
d961dddfbc feat(bots): land Phase 2 widget host/driver with retry UX and route-aware notifications 2026-05-02 00:44:52 +03:00
83e246da1f Add runtime-configured bot tab 2026-05-01 20:21:55 +03:00
357a2024f4 feat(bots): M1 wire Direct segment to /bots/ placeholder and rename label to Роботы 2026-05-01 14:42:00 +03:00
96085ba6a1 Update auth footer branding 2026-04-29 23:12:08 +03:00
b5ea37d57a feat(direct): use compact native phone layout for new chat to keep form fitted under the on-screen keyboard 2026-04-29 22:30:39 +03:00
212d3e3482 feat(invite): split user id into username and server fields and close prompt on successful invite 2026-04-29 21:45:33 +03:00
3cd1611ee2 fix(call): restore Android CallStyle banner for DM voice calls in encrypted rooms 2026-04-29 13:45:13 +03:00
d2c77496a7 redesign(p4): land Dawn RoomViewHeader for all rooms with peer chrome, presence, member-count subline, and reactive bridge gate. 2026-04-29 01:03:12 +03:00
103d6ad8a1 redesign(p3c): collapse Home into universal Direct, drop legacy layouts, gate room flavour on member-count, and clear orphan settings. 2026-04-28 21:52:31 +03:00
5bf0aeb00b redesign(p3a): land Stream message layout for DMs with rail, author dots, asymmetric bubbles, Stream day-divider, and sysline state events 2026-04-28 00:54:53 +03:00
e230e688de chore: upgrade TypeScript to 5.4 with bundler module resolution and reformat repo against tightened ESLint 2026-04-27 13:07:49 +03:00
ed3e5c0640 redesign(p2): rebuild DM list panel with stream header, segmented tabs, self-row, new-chat row, footer status, and live timeline rerender 2026-04-26 23:34:20 +03:00
0c89e9fda0 redesign(p0): land Dawn dark-theme foundation with one-shot migration and edge-to-edge polish 2026-04-26 21:30:22 +03:00
525 changed files with 60742 additions and 10021 deletions

View file

@ -1,2 +1,3 @@
experiment experiment
node_modules node_modules
*.css

View file

@ -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
View file

@ -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
View 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

View file

@ -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
View 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": []
}
]
}

View file

@ -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"

View file

@ -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.** { *; }

View file

@ -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 -->

View file

@ -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);
}
}

View 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);
}
}

View file

@ -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();
} }

View file

@ -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;
}
}
}

View file

@ -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();
}
}

View file

@ -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 onPauserenderRegistry edge so an in-flight JS // Short debounce on the onPauserenderRegistry 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

View 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;
}
}

View 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();
}
}

View file

@ -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);
}
}

View 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
// disablereenable 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());
}
}
}

View file

@ -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

View 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;
}
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View 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>

View file

@ -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
View 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
View file

@ -0,0 +1,3 @@
node_modules
dist
*.log

View 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.

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

File diff suppressed because it is too large Load diff

View 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'),
},
};
};

View 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);
}

View 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);

View 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' };

View 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 Vojos 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 Discords “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 youre not a robot',
'auth-card.captcha.hint':
'Discord asked for a CAPTCHA. Solve it below — sign-in will continue automatically once youre 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 bots 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}.',
};

View 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 };

View 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;

View 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);
}

View 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);
}

View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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',
];

View 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"]
}

View 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
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
*.local

View 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.

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

File diff suppressed because it is too large Load diff

View 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'),
},
};
};

View 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);
}

View 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);

View 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' };

View 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 Vojos 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}.',
};

View 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 };

View 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;

View 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);
}

File diff suppressed because it is too large Load diff

View 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 mutedamber 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);
}

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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',
];

View 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"]
}

View 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,
},
});

View 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.

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

File diff suppressed because it is too large Load diff

View 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'),
},
};
};

View file

@ -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);
}

View 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);

View 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' };

View 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, WhatsApps 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 Vojos 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}.',
};

View 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 };

View 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;

View 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);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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',
];

View 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"]
}

View 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,
},
});

View file

@ -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: {

Some files were not shown because too many files have changed in this diff Show more