Files
marketplaces/docs/TELEGRAM_BOT_INTEGRATION.md

490 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Telegram-бот авторизация — Руководство по реализации
> **Цель документа**: пошаговая инструкция для Go-разработчика. После выполнения всех шагов — авторизация через Telegram-бота будет работать.
---
## Статус
| Компонент | Готов? |
|-----------|--------|
| Frontend (Angular) — диалог входа, поллинг, сессия, корзина | ✅ Готов |
| Telegram бот (обработка `/start`) | ❌ Нужно сделать |
| Backend — 3 HTTP-эндпоинта авторизации | ❌ Нужно сделать |
| Хранилище сессий (Redis / PostgreSQL / in-memory) | ❌ Нужно сделать |
| CORS для cookie-based запросов | ❌ Нужно настроить |
---
## Архитектура
```
┌──────────┐ 1. t.me/Bot?start=auth_token ┌──────────────┐
│ │ ──────────────────────────────────────>│ Telegram │
│ Браузер │ │ (облако) │
│ │ 2. /start auth_{token} │ │
│ │ └──────┬───────┘
│ │ │
│ │ v
│ │ ┌──────────────┐
│ │ 4. GET /auth/session │ Go Backend │
│ │ (Cookie) — поллинг 3 сек │ │
│ │ ──────────────────────────────────────>│ - Bot handler│
│ │ │ - Auth API │
│ │ 5. { sessionId, displayName, ... } │ - Sessions │
│ │ <─────────────────────────────────────│ │
│ │ └──────┬───────┘
│ │ 3. GET /auth/telegram/callback │
│ │ + Set-Cookie: session=... ◄──────┘
│ │ <─────────────────────────────────────
└──────────┘
```
---
## Шаг 1: Создать бота в @BotFather
1. Открыть https://t.me/BotFather
2. `/newbot` (или использовать существующего)
3. Сохранить **BOT_TOKEN** — он понадобится в шаге 3
Нужны два бота:
| Бренд | Username бота | Домен фронтенда | API сервер |
|-------|---------------|------------------|------------|
| Dexar | `DexarSupport_bot` | `dexarmarket.ru` | `api.dexarmarket.ru:445` |
| Novo | `novomarket_bot` | `novo.market` | `api.novo.market:444` |
---
## Шаг 2: Хранилище сессий
Нужна таблица или Map для хранения сессий:
```go
type Session struct {
SessionID string `json:"sessionId"`
TelegramUserID int64 `json:"telegramUserId"`
Username *string `json:"username"` // может быть null
DisplayName string `json:"displayName"`
Active bool `json:"active"`
ExpiresAt time.Time `json:"expiresAt"`
}
```
Можно использовать:
- **Redis** (рекомендуется для продакшена) — `SET session:{id} {json} EX 86400`
- **PostgreSQL** — таблица `auth_sessions`
- **sync.Map** — для MVP/тестирования (не переживёт рестарт)
**Время жизни сессии:** 24 часа (фронтенд перепроверяет за 60 сек до `expiresAt`).
---
## Шаг 3: Обработчик команды `/start` в боте
### Что получает бот
Когда пользователь кликает ссылку:
```
https://t.me/DexarSupport_bot?start=auth_https%3A%2F%2Fapi.dexarmarket.ru%3A445%2Fauth%2Ftelegram%2Fcallback
```
Бот получает Update с `Message.Text`:
```
/start auth_https%3A%2F%2Fapi.dexarmarket.ru%3A445%2Fauth%2Ftelegram%2Fcallback
```
### Что должен сделать бот
```go
func handleStart(update tgbotapi.Update) {
text := update.Message.Text
user := update.Message.From
// 1. Извлечь callback URL
if !strings.HasPrefix(text, "/start auth_") {
// Обычный /start — показать приветствие
return
}
callbackURL, _ := url.QueryUnescape(strings.TrimPrefix(text, "/start auth_"))
// 2. Создать сессию
session := Session{
SessionID: uuid.New().String(),
TelegramUserID: user.ID,
Username: stringPtr(user.UserName), // может быть ""
DisplayName: buildDisplayName(user), // "Имя Фамилия"
Active: true,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
saveSession(session) // сохранить в Redis/DB
// 3. Отправить пользователю кнопку "Войти"
// Кнопка открывает callback URL с токеном сессии
loginURL := callbackURL + "?token=" + session.SessionID
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Нажмите кнопку чтобы войти:")
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonURL("🔐 Войти на сайт", loginURL),
),
)
bot.Send(msg)
}
func buildDisplayName(user *tgbotapi.User) string {
name := user.FirstName
if user.LastName != "" {
name += " " + user.LastName
}
return name
}
```
> **Важно**: Telegram ограничивает параметр `start` до 64 символов. URL callback'а ~62 символа — проходит впритык. Альтернатива — не передавать callback URL в параметре, а захардкодить его в боте (рекомендуется).
### Рекомендация: захардкодить callback URL
```go
const callbackURL = "https://api.dexarmarket.ru:445/auth/telegram/callback"
func handleStart(update tgbotapi.Update) {
text := update.Message.Text
if !strings.HasPrefix(text, "/start auth") {
return
}
// ... создать сессию ...
loginURL := callbackURL + "?token=" + session.SessionID
// ... отправить кнопку ...
}
```
Тогда фронтенд может упростить ссылку до:
```
https://t.me/DexarSupport_bot?start=auth
```
---
## Шаг 4: HTTP-эндпоинт `GET /auth/telegram/callback`
Этот URL открывается в **браузере пользователя** (по клику на кнопку в боте).
```go
// GET /auth/telegram/callback?token={sessionId}
func handleTelegramCallback(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing token", 400)
return
}
// 1. Проверить что сессия существует
session, err := getSession(token)
if err != nil || !session.Active {
http.Error(w, "invalid or expired token", 401)
return
}
// 2. Установить HttpOnly cookie
http.SetCookie(w, &http.Cookie{
Name: "dx_session",
Value: session.SessionID,
Path: "/",
HttpOnly: true,
Secure: true, // обязательно для HTTPS
SameSite: http.SameSiteNoneMode, // кросс-доменные запросы
MaxAge: 86400, // 24 часа
Domain: ".dexarmarket.ru", // доступна для поддоменов
})
// 3. Редирект на сайт
http.Redirect(w, r, "https://dexarmarket.ru", http.StatusFound)
}
```
### Про SameSite и Domain
| Параметр | Значение | Почему |
|----------|----------|--------|
| `SameSite` | `None` | Frontend на `dexarmarket.ru` шлёт запросы к `api.dexarmarket.ru:445` — разные origins |
| `Secure` | `true` | Обязательно при `SameSite=None` |
| `Domain` | `.dexarmarket.ru` | Cookie доступна и на `dexarmarket.ru` и на `api.dexarmarket.ru` |
| `HttpOnly` | `true` | Недоступна из JavaScript — защита от XSS |
---
## Шаг 5: HTTP-эндпоинт `GET /auth/session`
Фронтенд вызывает каждые 3 секунды с `withCredentials: true` (браузер подставляет cookie).
```go
// GET /auth/session
func handleGetSession(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("dx_session")
if err != nil {
http.Error(w, "unauthorized", 401)
return
}
session, err := getSession(cookie.Value)
if err != nil {
http.Error(w, "unauthorized", 401)
return
}
// Проверить не истекла ли
if time.Now().After(session.ExpiresAt) {
session.Active = false
saveSession(session) // обновить в хранилище
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
```
### Формат ответа (200)
Фронтенд ожидает **точно эти поля**:
```json
{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"telegramUserId": 123456789,
"username": "ivan_petrov",
"displayName": "Иван Петров",
"active": true,
"expiresAt": "2026-03-25T14:30:00Z"
}
```
| Поле | Тип | Обязательно | Примечание |
|------|-----|-------------|------------|
| `sessionId` | string (UUID) | да | Используется для websession эндпоинтов покупки |
| `telegramUserId` | number | да | Telegram user ID |
| `username` | string / null | нет | Telegram @username (может отсутствовать) |
| `displayName` | string | да | "Имя Фамилия" — показывается в UI |
| `active` | boolean | да | `false` = сессия истекла |
| `expiresAt` | string (ISO 8601) | да | Время истечения. Фронтенд перепроверяет за 60 сек до |
### Ответ при ошибке (401)
Любой HTTP статус не-200 → фронтенд считает "не авторизован".
---
## Шаг 6: HTTP-эндпоинт `POST /auth/logout`
```go
// POST /auth/logout
func handleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("dx_session")
if err == nil {
deleteSession(cookie.Value)
}
// Очистить cookie
http.SetCookie(w, &http.Cookie{
Name: "dx_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteNoneMode,
MaxAge: -1, // удалить
Domain: ".dexarmarket.ru",
})
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":"ok"}`))
}
```
---
## Шаг 7: CORS
Фронтенд шлёт запросы с `withCredentials: true` (cookie). Бэкенд **обязан** вернуть правильные CORS-заголовки.
```go
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Только для наших доменов
allowed := map[string]bool{
"https://dexarmarket.ru": true,
"https://www.dexarmarket.ru": true,
"https://novo.market": true,
"https://www.novo.market": true,
"http://localhost:4200": true, // для разработки
}
if allowed[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin) // НЕ "*" — запрещено с credentials
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == "OPTIONS" {
w.WriteHeader(200)
return
}
next.ServeHTTP(w, r)
})
}
```
> **Критично**: `Access-Control-Allow-Origin` **не может быть** `"*"` если используется `withCredentials`. Нужно вернуть конкретный origin.
---
## Шаг 8: Роутинг — добавить к существующему API
Эти 3 эндпоинта добавляются к существующему Go серверу на `api.dexarmarket.ru:445`:
```go
mux := http.NewServeMux()
// Существующие эндпоинты
mux.HandleFunc("GET /items/{id}", handleGetItem)
mux.HandleFunc("GET /category", handleGetCategories)
mux.HandleFunc("POST /websession/{id}", handleWebSession)
mux.HandleFunc("POST /websession/{id}/qr", handleCreateQR)
mux.HandleFunc("GET /websession/{id}/{qrId}", handleCheckPayment)
// НОВЫЕ — авторизация
mux.HandleFunc("GET /auth/session", handleGetSession)
mux.HandleFunc("GET /auth/telegram/callback", handleTelegramCallback)
mux.HandleFunc("POST /auth/logout", handleLogout)
// Обернуть в CORS middleware
handler := corsMiddleware(mux)
http.ListenAndServeTLS(":445", "cert.pem", "key.pem", handler)
```
---
## Шаг 9: Запуск Telegram бота
Бот может работать в режиме **long polling** (проще) или **webhook** (production).
### Long Polling (для начала)
```go
func main() {
bot, _ := tgbotapi.NewBotAPI(os.Getenv("BOT_TOKEN"))
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := bot.GetUpdatesChan(u)
for update := range updates {
if update.Message != nil && strings.HasPrefix(update.Message.Text, "/start") {
handleStart(update)
}
}
}
```
### Переменные окружения
```env
BOT_TOKEN=123456:ABC-DEF... # от @BotFather
CALLBACK_URL=https://api.dexarmarket.ru:445/auth/telegram/callback
FRONTEND_URL=https://dexarmarket.ru
SESSION_TTL=24h
REDIS_URL=localhost:6379 # если используете Redis
```
---
## Шаг 10: Проверка — как тестировать
### 1. Запустить бот и сервер
```bash
BOT_TOKEN=... go run .
```
### 2. Открыть маркетплейс, нажать "Войти"
Фронтенд откроет `https://t.me/DexarSupport_bot?start=auth_...`
### 3. В Telegram — нажать "Start" → кнопку "Войти на сайт"
Бот создаёт сессию, кнопка ведёт на `/auth/telegram/callback?token=...`
### 4. Браузер получает cookie, редиректит на маркетплейс
### 5. Фронтенд (поллинг каждые 3 сек) находит сессию → диалог закрывается
### Логи для отладки
Что проверять если не работает:
| Проблема | Где смотреть |
|----------|-------------|
| Бот не отвечает | `BOT_TOKEN` правильный? Бот получает updates? |
| Кнопка "Войти" не открывается | URL `/auth/telegram/callback` доступен? SSL? |
| Cookie не устанавливается | `SameSite`, `Secure`, `Domain` правильные? HTTPS? |
| Фронтенд не находит сессию | CORS настроен? `Access-Control-Allow-Credentials: true`? |
| Сессия не находится | Хранилище работает? TTL не слишком маленький? |
---
## Полный чеклист
- [ ] Бот создан в @BotFather, токен получен
- [ ] `BOT_TOKEN` сохранён в переменных окружения сервера
- [ ] Хранилище сессий настроено (Redis / PostgreSQL / sync.Map)
- [ ] Обработчик `/start auth_...` в боте создаёт сессию и отправляет inline-кнопку
- [ ] `GET /auth/telegram/callback?token=...` устанавливает HttpOnly cookie и редиректит
- [ ] `GET /auth/session` читает cookie и возвращает JSON сессии (или 401)
- [ ] `POST /auth/logout` удаляет сессию и cookie
- [ ] CORS настроен для `dexarmarket.ru` / `novo.market` с `credentials: true`
- [ ] SSL сертификат установлен (обязательно для `Secure` cookie)
- [ ] Тест: клик "Войти" → Telegram → кнопка → cookie → сессия найдена ✅
---
## Как фронтенд использует `sessionId` после авторизации
После успешного входа `sessionId` из ответа `/auth/session` используется для **покупки**:
```
POST /websession/{sessionId} ← синхронизация корзины
POST /websession/{sessionId}/qr ← создание QR для СБП
GET /websession/{sessionId}/{qrId} ← проверка статуса оплаты
```
Формат данных корзины:
```json
[
{
"itemID": 123,
"quantity": 2,
"colour": "#ff0000",
"size": "XL",
"price": 1500
}
]
```
> Цвет приходит от бэкенда как `0xff0000`, фронтенд конвертирует в `#ff0000`.
> Размер `"default"` — не показывается пользователю.
---
## Дополнительно: Telegram CloudStorage
Внутри Telegram Mini App корзина дополнительно сохраняется в `CloudStorage` (API Telegram). Это **автоматически** — бэкенду ничего делать не нужно. Работает только когда сайт открыт через Telegram.
## Дополнительно: `initDataUnsafe`
Фронтенд читает `window.Telegram.WebApp.initDataUnsafe.user` для отображения имени пользователя в UI (при написании отзывов). Это **не используется для авторизации** — только для отображения. Бэкенду ничего делать не нужно.