---
path: /profile/telegram
surface: customer
domain: account
auth: signed-in
source: apps/oshun/web/src/app/profile/telegram/page.tsx
status: walked
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'
---

# Profile · Telegram bridge

## 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.ts` no-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` — left `Profile · telegram`, right dynamic `linked`,
  `not linked`, or `unavailable`, 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`, then `data.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 `LBtn`s — "← Back to profile" (ghost),
  "Notifications" (ghost)

## States

- [ ] **Loading** — `bffGet('/v1/telegram/link')` blocks server render; no
      client skeleton
- [x] **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)
- [x] **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
- [x] **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.
- [x] **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 different `t.me` href
- [x] **Live linked-state refresh** — while a not-linked card is open, the
      client island polls the authenticated `/v1/telegram/link` read. Once the
      Telegram bot webhook completes the nonce, the card repaints in place to
      `data-profile-telegram-state="linked"` /
      `data-profile-telegram-linked="true"` without requiring a reload.
- [x] **Empty / BFF unavailable** — `bffGet('/v1/telegram/link')` returns null:
      the card renders `data-profile-telegram-state="unavailable"` /
      `data-profile-telegram-available="false"`, caption "Link unavailable",
      retry copy, and no Telegram deep-link anchor. The old generic
      `https://t.me/oshun_bot?start=link` fallback is not shown.
- [x] **Error (recoverable)** — BFF read failure keeps the user on
      `/profile/telegram` with an honest unavailable card and a same-route "Try
      again" link; refresh is hidden because there is no account-scoped link to
      rotate.
- [x] **Offline** — first-render BFF failure degrades to the unavailable card;
      after a live `/profile/telegram` render, the SW-served/offline branch
      keeps the already-rendered not-linked status, expiry copy, and exact
      `t.me` nonce href stable.
- [x] **Gated / anonymous** — `proxy.ts` redirect to `/welcome` before render
- [x] **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 link` and
      `Refresh link` targets remain at least 44 px tall.

## Interactions

- [x] **Open Telegram to link** (LBtn primary `lg`, href `data.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,
    and `Refresh link` remains the active recovery path.
  - Telemetry: none wired; E2E asserts the active wrapper, anchor, and
    descendants expose no `data-telemetry-event`, `data-analytics-event`, or
    `data-event` hook. Product still needs to decide whether a future
    `telegram_link_started` event is desired.
- [x] **Refresh link** (secondary submit button, not-linked state only)
  - Function: invokes a server action that posts to the real
    `POST /v1/telegram/link` route and redirects back to `/profile/telegram`
  - BFF contract: GET reuses a still-valid nonce; POST rotates an unlinked nonce
    and returns `202` with `cache-control: no-store`; linked accounts stay
    linked
  - Browser result: the visible `Open Telegram to link` href changes to a new
    `https://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
- [x] **`/unlink` instruction** (linked state) — non-interactive text inside a
      `<code>`; unlinking happens in the Telegram client, not on this page
- [x] **← Back to profile** (LBtn ghost, href `/profile`) → account hub
- [x] **Notifications** (LBtn ghost, href `/profile/notifications`) → channel
      prefs

## Data & contracts

- **Reads**: `bffGet('/v1/telegram/link')` →
  `TelegramLinkResponse { generatedAt, linked, nonce, deepLinkUrl, expiresAtIso, linkedChatLabel? }`
- **Unavailable read**: `bffGet` returns 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/link` to rotate an
  unlinked nonce. Linking happens in the Telegram bot via the deep link;
  unlinking via the bot's `/unlink` command.
- **Realtime / polling**: the not-linked client card polls the account-scoped
  `/v1/telegram/link` BFF 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 `bffGet` forwards `oshun-access` as a bearer and
  uses `cache: 'no-store'`; the BFF also sends `cache-control: no-store`
- **Auth/role check**: `proxy.ts` protected-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`](../../../V1/features.md#account)
- Architecture:
  [`V1/ARCHITECTURE.md#account`](../../../V1/ARCHITECTURE.md#account)
- Journeys:
  [`../../journeys/telegram-bot-assistant-delivery.md`](../../journeys/telegram-bot-assistant-delivery.md)
- Sibling routes:
  - [`profile.md`](./profile.md),
    [`profile-notifications.md`](./profile-notifications.md),
    [`profile-persona.md`](./profile-persona.md),
    [`profile-data.md`](./profile-data.md)
- Component sources:
  - `apps/oshun/web/src/app/profile/telegram/page.tsx`
  - `apps/oshun/web/src/app/profile/telegram/ProfileTelegramLinkPanel.tsx`
  - `apps/oshun/web/src/design-system/lilith/shells.tsx`
  - `apps/oshun/web/src/lib/server/bff-fetch.ts` (`bffGet`, `bffPost`)

## E2E coverage

- Backed by
  [`apps/oshun/web/e2e/profile-telegram-bridge.spec.ts`](../../../apps/oshun/web/e2e/profile-telegram-bridge.spec.ts)
  — browser coverage primes a real authenticated session, mints a live
  `/v1/telegram/link` nonce, verifies the not-linked card and exact `t.me`
  deep-link href, clicks `Refresh link` and verifies the rendered `t.me` href
  rotates through the real web+BFF stack, advances browser time beyond a live
  nonce's `expiresAtIso` to 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
  `/unlink` instruction without a navigation, checks the profile and
  notifications footer targets, verifies the 390 px
  layout/no-overflow/static/touch-target contract and the absence of
  `data-telemetry-event`, `data-analytics-event`, and `data-event` hooks 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
  forwarded `oshun-access` bearer to prove a real account-scoped BFF read
  failure renders the unavailable state with no generic `start=link` href and no
  refresh action.
- Backed by
  [`apps/oshun/web/src/app/profile/telegram/ProfileTelegramLinkPanel.test.tsx`](../../../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`](../../../apps/oshun/bff/src/__tests__/telegram-route.test.ts)
  — BFF route coverage asserts anonymous `GET /v1/telegram/link` is `401`, an
  authenticated GET mints an unlinked nonce, a second GET reuses that valid
  nonce, authenticated POST rotates the nonce and returns `202 no-store`, and
  the rotated nonce completes through `/telegram/webhook`.

## Open questions / known gaps

- [x] 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.
- [x] 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.
- [x] 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 generic `start=link` token is exposed.
- [x] No realtime / polling: closed by the client island's authenticated
      `/v1/telegram/link` polling. The user may leave `/profile/telegram` open
      after using the deep link; the linked card appears when the real webhook
      completes the nonce.
- [x] `next.revalidate: 60` stale-token risk — closed by current `bffGet`
      behavior: authenticated requests use `cache: 'no-store'`, and
      `/v1/telegram/link` returns `cache-control: no-store`.
- [x] No telemetry on the deep-link click: `profile-telegram-bridge.spec.ts`
      asserts the active deep-link wrapper, anchor, and descendants expose no
      `data-telemetry-event`, `data-analytics-event`, or `data-event` hooks.
      Product decision remains whether a future `telegram_link_started` event is
      desired.
