Долгое время наблюдал, как знакомая косметолог ведет запись на прием к себе. Ей писали в вацап, телегу, смс и звонили, она все это переносила на айпад в заметки. Как-то спросил: А че удобно все это? Сказала, что нет, конечно. Отнимает много времени. И тут я герой весь в белом, говорю: Давай бота в телеге сделаю. Вот с этого все и началось. Пропущу историю создания бота этого. Хотя он по сути и стал отправной точкой. Через пару месяцев сделал ей миниапп. О, как она была рада! И параллельно начал размышлять о саас в миниапп для услуг по записи. Делал себе все тихонько, а потом чудесная новость: телегу тормозят и хотят заблочить. Вот тут у меня подгорело, конечно… Решил не хоронить проект, а перевести все в PWA. Ну сказано – сделано. Хотя вот тут я и словил кучу проблем.

Проблема №1: Как сделать одно приложение для Telegram и Браузера

У меня один React-билд для всех платформ. Но интерфейс должен подстраиваться:

  • В Telegram — показать кнопку «Войти через Telegram» и скрыть Яндекс/Google/VK

  • В браузере — показать все OAuth-кнопки и хедер с навигацией

Для этого я сделал простую детекцию среды на уровне window. Telegram Web App SDK inject’ит объект TelegramWebApp в глобальную область видимости, когда приложение запущено внутри мессенджера.

// frontend/src/utils/telegram.ts
export const getWebApp = (): TelegramWebApp | null => {
  try {
    return window.Telegram?.WebApp ?? null
  } catch {
    return null
  }
}

export const getInitData = (): string | null => {
  try {
    return window.Telegram?.WebApp?.initData ?? null
  } catch {
    return null
  }
}

Telegram Web App SDK inject’ит объект window.Telegram.WebApp только когда приложение запущено внутри мессенджера. В браузере — null

// frontend/src/contexts/TelegramAuthContext.tsx
export const TelegramAuthContext = createContext<{ inMiniApp: boolean | null }>({ 
  inMiniApp: null 
})

Контекст хранит флаг inMiniApp:

  • null — среда ещё не определена (загрузка)

  • true — Telegram Mini App

  • false — браузер (PWA)

// frontend/src/components/profile/ProfileDetailsCard.tsx
const { inMiniApp } = useContext(TelegramAuthContext)
const inTelegram = inMiniApp === true

Компоненты читают контекст и подстраивают интерфейс. Например, скрывают кнопки OAuth в Telegram

// frontend/src/components/profile/ProfileSettingsCard.tsx
import { AUTH_PROVIDERS_VISIBILITY } from '../../config/authButtons'

// В рендере:
{PROVIDERS_ORDER.filter((key) => AUTH_PROVIDERS_VISIBILITY[key]).map((key) => {
  // Рендер кнопок Яндекс/Google/VK/Telegram
})}

Конфиг AUTH_PROVIDERS_VISIBILITY управляет видимостью кнопок входа. В Telegram скрываем OAuth, в браузере — показываем все.

вход Telegram
вход Telegram
вход PWA
вход PWA

Проблема №2: Авторизация и объединение аккаунтов

Пользователь может войти двумя способами:

  • В Telegram — через Mini App (initData → JWT)

  • В браузере (PWA) — через Яндекс/Google/VK (OAuth → JWT)

class User(Base):
    telegram_id = Column(BigInteger, nullable=True, index=True)  # сделал nullable
    google_id = Column(String(255), nullable=True, index=True)
    yandex_id = Column(String(255), nullable=True, index=True)
    vk_id = Column(String(255), nullable=True, index=True)
    phone = Column(String(50), nullable=True, index=True)
    email = Column(String(100), nullable=True, index=True)

Один пользователь может иметь заполненные несколько полей: telegram_id + yandex_id + phone. Это и есть «объединённый» аккаунт.

# backend/app/services/auth_service.py
async def auth_init(self, init_data: str) -> AuthInitResponse:
    # ... валидация initData ...
    telegram_id = int(raw_id)
    
    # 1. Ищу пользователя по telegram_id
    user = await self.repository.get_by_telegram_id(telegram_id)
    
    if not user:
        # 2. Если не найден — ищу по телефону из initData
        phone = tg_user.get("phone_number") or tg_user.get("phone")
        if phone and str(phone).strip():
            from app.core.phone_utils import normalize_phone
            phone_norm = normalize_phone(str(phone).strip())
            if phone_norm:
                phone_user = await self.repository.get_by_phone(phone_norm)
                if phone_user and not phone_user.telegram_id:
                    # 3. Нашёл по телефону → привязываю telegram_id к существующему аккаунту
                    await self.repository.link_telegram(phone_user.id, telegram_id)
                    await self.db.commit()
                    logger.info("auth_init: linked telegram_id=%s to user_id=%s by phone", 
                                telegram_id, phone_user.id)
                    return AuthInitResponse(status="ok", user={...})
        
        return AuthInitResponse(status="pending_phone", telegram_id=telegram_id, user=None)
    
    return AuthInitResponse(status="ok", user={...})

Если пользователь ранее зарегистрировался в PWA через Яндекс (с телефоном), а потом зашёл через Telegram Mini App с тем же номером — система найдёт его по телефону и автоматически привяжет telegram_id.

# backend/app/api/endpoints/oauth.py
@router.post("/yandex", response_model=TokenResponse)
async def auth_yandex(payload: YandexAuthRequest, db: AsyncSession = Depends(get_db)):
    verified = await exchange_yandex_code(payload.code, payload.redirect_uri)
    repo = UserRepository(db)
    
    # 1. Ищу по yandex_id
    user = await repo.get_by_yandex_id(verified["yandex_id"])
    
    # 2. Если не найден — ищу по email
    if not user and verified.get("email"):
        user = await repo.get_by_email(verified["email"])
        if user:
            await repo.link_yandex(user.id, verified["yandex_id"])
            await db.commit()
            logger.info("Auto-linked Yandex account for user %s", user.id)
    
    # 3. Если не найден — ищу по телефону (+7 и 8 проверяю)
    if not user and verified.get("phone"):
        phone_norm = normalize_phone(verified.get("phone"))
        if phone_norm:
            user = await repo.get_by_phone(phone_norm)
            if not user and phone_norm.startswith("+7") and len(phone_norm) == 12:
                user = await repo.get_by_phone("8" + phone_norm[2:])  # на всякий случай
            if user:
                await repo.link_yandex(user.id, verified["yandex_id"])
                await db.commit()
                logger.info("Auto-linked Yandex account for user %s by phone", user.id)
    
    # 4. Если вообще не нашёл — создаю нового пользователя
    if not user:
        user = await _create_oauth_user(repo, db, "yandex_id", verified["yandex_id"], verified)
    
    return TokenResponse(**create_token_pair(user.id))

Та же логика для Google и VK: сначала поиск по provider_id, потом по email, потом по phone. Если найден — привязываем провайдер к существующему аккаунту.

// frontend/src/components/profile/ProfileSettingsCard.tsx
import { AUTH_PROVIDERS_VISIBILITY } from '../../config/authButtons'

const PROVIDERS_ORDER = ['yandex', 'google', 'vk', 'telegram'] as const

{PROVIDERS_ORDER.filter((key) => AUTH_PROVIDERS_VISIBILITY[key]).map((key) => {
  const connected = linkedProviders[key] === true
  return (
    <div key={key}>
      <span>{t(`settings.provider${key.charAt(0).toUpperCase() + key.slice(1)}`)}</span>
      {connected ? (
        <span>✅ {t('settings.connected')}</span>
      ) : (
        <button onClick={handleLink}>Привязать</button>
      )}
    </div>
  )
})}

В профиле пользователь видит, какие провайдеры уже подключены, и может добавить новые через /link/* эндпоинты.

# backend/app/api/endpoints/oauth.py
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)):
    """Обновить JWT по refresh token."""
    token_payload = verify_refresh_token(payload.refresh_token)
    if not token_payload:
        raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
    
    user_id = int(token_payload["sub"])
    repo = UserRepository(db)
    user = await repo.get_by_id(user_id)
    
    if not user or user.is_blocked:
        raise HTTPException(status_code=404, detail="User not found")
    
    return TokenResponse(**create_token_pair(user.id))

Сейчас это stateless-схема: токены не хранятся в БД, нет детекции повторного использования. Планирую добавить таблицу refresh_tokens с jti и used_at.

Что в итоге

Сейчас проект находится в открытой бете. Платформа работает, вход через Telegram и OAuth объединён, один фронт для PWA и Mini App. Но это только начало. В процессе всплывают неочевидные вещи, которые надеюсь поправить с помощью обратной связи от первых пользователей.

А ещё было много других проблем, которые не вошли в эту статью: как я делал карту с поиском по границам, как настроил автодеплой за 5 минут, как прикрутил Web Push, как реализовал мультирольную систему. Обо всём этом — в следующих статьях.

Да и в целом в этом проекте масса решений. И надеюсь, что комьюнити что‑то подскажет, а то глаз уже замылен.