Context. surface customer · domain account · route /profile/telegram · auth signed-in · source apps/oshun/web/src/app/profile/telegram/page.tsx
Last walked. 2026-05-29 automated runtime walk (Playwright headless) — render, /v1 data (2xx), console/page-errors, expected content, screenshot verified; live screen-reader, touch, offline, and telemetry-delivery checks pending a manual AT pass. Evidence: WALKTHROUGH/results/runtime-sweep-2026-05-29.md; focused direct-route addendum 2026-06-25 covers live-BFF not-linked nonce render, real Telegram webhook link completion, linked render, footer targets, and anonymous proxy redirect; 2026-06-28 addendum covers 390 px mobile/no-overflow, touch-target height, reduced-motion-static route subtree, offline-after-load stability, and verifies the deep-link click has no telemetry hook; 2026-06-28 refresh addendum covers the not-linked "Refresh link" control, real server-action POST to /v1/telegram/link, BFF nonce rotation, browser-visible t.me href replacement, and 44 px refresh target; 2026-06-28 expiry addendum covers the client countdown crossing expiresAtIso, disables the stale deep-link anchor, renders the "Link expired" 44 px disabled control, keeps refresh as the recovery path, and 2026-06-29 fallback addendum removes the generic https://t.me/oshun_bot?start=link fallback and covers a real protected-route render where the forwarded account bearer is rejected by the BFF, producing an honest unavailable card with no Telegram deep link or refresh action; 2026-06-29 focused telemetry addendum verifies the active Telegram wrapper, anchor, and descendants expose no data-telemetry-event, data-analytics-event, or data-event hooks, and the expired replacement control remains hook-free; 2026-06-30 live-status addendum covers the not-linked page staying open while the real Telegram webhook completes the nonce, authenticated /v1/telegram/link polling, in-place repaint to linked, and no full-page navigation during the transition
Purpose#
The opt-in Telegram link surface — "a side door, opt-in only." Shows whether the
account is linked to a Telegram chat; if not, surfaces a time-boxed deep link
(with remaining minutes) to open the bot and link; if linked, names the chat and
gives the /unlink instruction. Backed by /v1/telegram/link. Telegram is one
channel Lilith can reach when the phone is silenced (daily nudges and crisis
check-ins).
Entry points#
- Cross-domain footer button from
/profile/persona("Telegram") - "Back to profile" navigation from
/profile - Return-trip from the Telegram bot after the deep link is used; the page can stay open and poll into the linked state after the bot confirms
- Direct URL / bookmark — yes; auth required (
proxy.tsno-session →/welcome)
Layout regions#
Async server component in <LWebShell top={<LCustomerNav active="library" />}>
fetches the first /v1/telegram/link snapshot, then mounts a client state panel
with a narrower maxWidth: 640 (single-column, decision-focused). One state
card sits below the masthead.
- Header:
LCustomerNav(active="library") - Masthead:
LMasthead— leftProfile · telegram, right dynamiclinked,not linked, orunavailable, kicker "A side door, opt-in only", italic title "The Telegram bridge." - Intro:
LEyebrow"How this opens" + "Telegram is one place Lilith can reach if your phone is silenced…" - State card: accent-bordered when linked, rule-bordered otherwise — the
client panel branches on
data === null, thendata.linked(see States); not-linked state exposes the primary Telegram deep link plus a secondary refresh control and authenticated status polling; unavailable state exposes retry copy only - Footer / actions: two
LBtns — "← Back to profile" (ghost), "Notifications" (ghost)
States#
- Loading —
bffGet('/v1/telegram/link')blocks server render; no client skeleton - Linked —
data.linked === true: accent border; mono caption "Active link"; serif "Linked to {linkedChatLabel ?? 'your Telegram chat'}."; italic instruction to send the bot/unlink(takes effect immediately) - Not linked —
data.linked === false: rule border; mono caption "Not linked"; "Open the deep link below within {N} minutes." where N =max(0, round((expiresAtIso - now) / 60_000)); the primary deep-link button - Deep link expired — once the client countdown reaches
expiresAtIso, the copy changes to "The link window has closed. Refresh the link and use the newest button.", the primary anchor is removed, and a disabled "Link expired" button renders beside the still-active refresh control. - Manual deep-link refresh — not-linked state exposes
Refresh link; server action posts to/v1/telegram/link, the BFF rotates the unlinked nonce, and the route re-renders a differentt.mehref - Live linked-state refresh — while a not-linked card is open, the
client island polls the authenticated
/v1/telegram/linkread. Once the Telegram bot webhook completes the nonce, the card repaints in place todata-profile-telegram-state="linked"/data-profile-telegram-linked="true"without requiring a reload. - Empty / BFF unavailable —
bffGet('/v1/telegram/link')returns null: the card rendersdata-profile-telegram-state="unavailable"/data-profile-telegram-available="false", caption "Link unavailable", retry copy, and no Telegram deep-link anchor. The old generichttps://t.me/oshun_bot?start=linkfallback is not shown. - Error (recoverable) — BFF read failure keeps the user on
/profile/telegramwith an honest unavailable card and a same-route "Try again" link; refresh is hidden because there is no account-scoped link to rotate. - Offline — first-render BFF failure degrades to the unavailable card;
after a live
/profile/telegramrender, the SW-served/offline branch keeps the already-rendered not-linked status, expiry copy, and exactt.menonce href stable. - Gated / anonymous —
proxy.tsredirect to/welcomebefore render - Standalone PWA / narrow shell — no route-specific standalone branch;
the 390 px browser check proves the single-column shell has no horizontal
overflow, no route-subtree animations, and the
Open Telegram to linkandRefresh linktargets remain at least 44 px tall.
Interactions#
- Open Telegram to link (LBtn primary
lg, hrefdata.deepLinkUrl; not-linked active-window state only)- Function: navigates to the Telegram deep link (e.g.
https://t.me/oshun_bot?start=link-<nonce>); the bot confirms in chat, then the user returns to see updated state - Keyboard: primary focusable in the card; Enter activates; rendered as
<a> - Screen reader: announces "Open Telegram to link, link"
- Touch target: LBtn
lg(~52 px tall) — meets ≥ 44 px - Mobile (≤ 640 px): card is already single-column at this width
- Offline behavior: deep link opens the Telegram app, which requires network to complete linking; no in-app queue
- Disabled when: the browser clock reaches or passes
expiresAtIso; the anchor is removed, a disabled "Link expired" button renders in its place, andRefresh linkremains the active recovery path. - Telemetry: none wired; E2E asserts the active wrapper, anchor, and
descendants expose no
data-telemetry-event,data-analytics-event, ordata-eventhook. Product still needs to decide whether a futuretelegram_link_startedevent is desired.
- Function: navigates to the Telegram deep link (e.g.
- Refresh link (secondary submit button, not-linked state only)
- Function: invokes a server action that posts to the real
POST /v1/telegram/linkroute and redirects back to/profile/telegram - BFF contract: GET reuses a still-valid nonce; POST rotates an unlinked nonce
and returns
202withcache-control: no-store; linked accounts stay linked - Browser result: the visible
Open Telegram to linkhref changes to a newhttps://t.me/oshun_bot?start=link-<nonce>value - Touch target: custom secondary button is 52 px tall — meets ≥ 44 px
- Offline behavior: no offline queue; if the page is already offline after load, the previously rendered link remains stable
- Function: invokes a server action that posts to the real
-
/unlinkinstruction (linked state) — non-interactive text inside a<code>; unlinking happens in the Telegram client, not on this page - ← Back to profile (LBtn ghost, href
/profile) → account hub - Notifications (LBtn ghost, href
/profile/notifications) → channel prefs
Data & contracts#
- Reads:
bffGet('/v1/telegram/link')→TelegramLinkResponse { generatedAt, linked, nonce, deepLinkUrl, expiresAtIso, linkedChatLabel? } - Unavailable read:
bffGetreturns null on BFF/network/auth failure; the page renders an unavailable state and does not fabricate a generic Telegram token. - Writes: the refresh control posts to
/v1/telegram/linkto rotate an unlinked nonce. Linking happens in the Telegram bot via the deep link; unlinking via the bot's/unlinkcommand. - Realtime / polling: the not-linked client card polls the account-scoped
/v1/telegram/linkBFF read with the active browser auth token while the page remains open. A linked response immediately replaces the deep-link controls with the linked chat state; no websocket/subscription is used. - Caching: authenticated
bffGetforwardsoshun-accessas a bearer and usescache: 'no-store'; the BFF also sendscache-control: no-store - Auth/role check:
proxy.tsprotected-by-default; no-session →/welcome - Metadata:
title: 'Profile · Telegram', description re one-tap deep link / daily nudges / crisis check-ins,alternates.canonical: '/profile/telegram'
Cross-references#
- Feature spec:
V1/features.md#account - Architecture:
V1/ARCHITECTURE.md#account - Journeys:
../../journeys/telegram-bot-assistant-delivery.md - Sibling routes:
- Component sources:
apps/oshun/web/src/app/profile/telegram/page.tsxapps/oshun/web/src/app/profile/telegram/ProfileTelegramLinkPanel.tsxapps/oshun/web/src/design-system/lilith/shells.tsxapps/oshun/web/src/lib/server/bff-fetch.ts(bffGet,bffPost)
E2E coverage#
- Backed by
apps/oshun/web/e2e/profile-telegram-bridge.spec.ts— browser coverage primes a real authenticated session, mints a live/v1/telegram/linknonce, verifies the not-linked card and exactt.medeep-link href, clicksRefresh linkand verifies the renderedt.mehref rotates through the real web+BFF stack, advances browser time beyond a live nonce'sexpiresAtIsoto verify the expired copy, removed anchor, disabled "Link expired" control, 44 px target, and refresh recovery, completes a live nonce through the real/telegram/webhook/start link-<nonce>path while the browser stays on the not-linked page, confirms BFF linked read-back, waits for authenticated polling to repaint the same page to the linked card and/unlinkinstruction without a navigation, checks the profile and notifications footer targets, verifies the 390 px layout/no-overflow/static/touch-target contract and the absence ofdata-telemetry-event,data-analytics-event, anddata-eventhooks on the active deep-link wrapper, anchor, and descendants, switches the browser offline after load to prove the rendered not-linked state stays stable, proves an anonymous visit redirects before the route renders, and poisons only the forwardedoshun-accessbearer to prove a real account-scoped BFF read failure renders the unavailable state with no genericstart=linkhref and no refresh action. - Backed by
apps/oshun/web/src/app/profile/telegram/ProfileTelegramLinkPanel.test.tsx— component coverage asserts active countdown/link rendering and timer-driven transition to the disabled expired state while keeping refresh visible, plus authenticated polling repaint from not-linked to linked. - Backed by
apps/oshun/bff/src/__tests__/telegram-route.test.ts— BFF route coverage asserts anonymousGET /v1/telegram/linkis401, an authenticated GET mints an unlinked nonce, a second GET reuses that valid nonce, authenticated POST rotates the nonce and returns202 no-store, and the rotated nonce completes through/telegram/webhook.
Open questions / known gaps#
- Trigger a refresh of the token when the user asks for it: the not-linked
card now exposes
Refresh link, and E2E verifies the BFF rotates the unlinked nonce and the browser-visible href changes. - The deep-link button is disabled when the link window has elapsed: the
client countdown island removes the stale anchor at
expiresAtIso, shows a disabled "Link expired" control, and keeps manual refresh available. - The hardcoded fallback (
https://t.me/oshun_bot?start=link, 15-min expiry) is removed. BFF failure now renders an unavailable state and E2E asserts no genericstart=linktoken is exposed. - No realtime / polling: closed by the client island's authenticated
/v1/telegram/linkpolling. The user may leave/profile/telegramopen after using the deep link; the linked card appears when the real webhook completes the nonce. -
next.revalidate: 60stale-token risk — closed by currentbffGetbehavior: authenticated requests usecache: 'no-store', and/v1/telegram/linkreturnscache-control: no-store. - No telemetry on the deep-link click:
profile-telegram-bridge.spec.tsasserts the active deep-link wrapper, anchor, and descendants expose nodata-telemetry-event,data-analytics-event, ordata-eventhooks. Product decision remains whether a futuretelegram_link_startedevent is desired.