История о том, как я делал бота для записи на прием, а сделал небольшой SaaS
Долгое время наблюдал, как знакомая косметолог ведет запись на прием к себе. Ей писали в вацап, телегу, смс и звонили, она все это переносила на айпад в заметки. Как-то спросил: А че удобно все это? Сказала, что нет, конечно. Отнимает много времени. И тут я герой весь в белом, говорю: Давай бота в телеге сделаю. Вот с этого все и началось. Пропущу историю создания бота этого. Хотя он по сути и стал отправной точкой. Через пару месяцев сделал ей миниапп. О, как она была рада! И параллельно начал размышлять о саас в миниапп для услуг по записи. Делал себе все тихонько, а потом чудесная новость: телегу тормозят и хотят заблочить. Вот тут у меня подгорело, конечно… Решил не хоронить проект, а перевести все в 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 Appfalse— браузер (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, в браузере — показываем все.


Проблема №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, как реализовал мультирольную систему. Обо всём этом — в следующих статьях.
Да и в целом в этом проекте масса решений. И надеюсь, что комьюнити что‑то подскажет, а то глаз уже замылен.