feat: Telegram Mini App integration and UX improvements
- Добавлена полная интеграция с Telegram Mini App (динамическая загрузка SDK) - Отдельный компактный дизайн для Telegram Mini App - Добавлен loader при инициализации (предотвращает мелькание SMS-авторизации) - Улучшена навигация: кнопки "Назад" и "К списку заявок" теперь сохраняют авторизацию - Telegram Mini App: кнопка "Выход" просто закрывает приложение - Telegram Mini App: заявки "В работе" скрыты из списка - Веб-версия: для заявок "В работе" добавлена кнопка "Просмотреть в Telegram" (ссылка на @klientprav_bot) - Telegram Mini App: кнопки действий в черновиках расположены вертикально - Веб-версия: убрано отображение номера телефона в приветствии - Исправлена проблема с возвратом к списку черновиков (не требует повторной SMS-авторизации) - Заблокировано удаление и редактирование заявок со статусом "В работе" - Добавлена документация по Telegram Mini App интеграции
This commit is contained in:
60
backend/app/api/banks.py
Normal file
60
backend/app/api/banks.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Banks API - получение списка банков СБП
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import httpx
|
||||
import logging
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/banks", tags=["Banks"])
|
||||
|
||||
|
||||
@router.get("/nspk")
|
||||
async def get_nspk_banks():
|
||||
"""
|
||||
Получить список банков СБП из внешнего API
|
||||
Проксирует запрос для избежания Mixed Content ошибок (HTTPS -> HTTP)
|
||||
"""
|
||||
try:
|
||||
# URL внешнего API
|
||||
external_api_url = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(external_api_url)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to fetch banks: HTTP {response.status_code}")
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Failed to fetch banks list: {response.status_code}"
|
||||
)
|
||||
|
||||
banks_data = response.json()
|
||||
logger.info(f"✅ Loaded {len(banks_data)} banks from external API")
|
||||
|
||||
return banks_data
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while fetching banks")
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="Timeout while fetching banks list"
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Request error while fetching banks: {e}")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Failed to connect to banks API: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error while fetching banks: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ import uuid
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from ..services.redis_service import redis_service
|
||||
from ..services.database import db
|
||||
from ..services.crm_mysql_service import crm_mysql_service
|
||||
from ..services.n8n_service import check_workflow_status, restart_workflow, MIN_RESTART_INTERVAL
|
||||
# Убрали импорты из n8n_service - больше не нужны для webhook подхода
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
||||
@@ -241,7 +242,7 @@ async def list_drafts(
|
||||
OR c.payload->>'phone' = $2
|
||||
OR c.payload->>'phone' = $3
|
||||
)
|
||||
AND (c.status_code != 'approved' OR c.status_code IS NULL)
|
||||
AND (c.status_code NOT IN ('approved', 'in_work', 'submitted', 'completed', 'rejected') OR c.status_code IS NULL)
|
||||
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20
|
||||
@@ -268,7 +269,7 @@ async def list_drafts(
|
||||
c.updated_at
|
||||
FROM clpr_claims c
|
||||
WHERE c.session_token = $1
|
||||
AND (c.status_code != 'approved' OR c.status_code IS NULL)
|
||||
AND (c.status_code NOT IN ('approved', 'in_work', 'submitted', 'completed', 'rejected') OR c.status_code IS NULL)
|
||||
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20
|
||||
@@ -392,10 +393,11 @@ async def list_drafts(
|
||||
# Формируем список документов со статусами
|
||||
documents_list = []
|
||||
for doc_req in documents_required:
|
||||
doc_name = doc_req.get('name', 'Документ')
|
||||
# Пробуем разные поля для названия документа (field_label приоритетнее)
|
||||
doc_name = doc_req.get('field_label') or doc_req.get('name') or 'Документ'
|
||||
doc_id = doc_req.get('id', '')
|
||||
is_required = doc_req.get('required', False)
|
||||
# Проверяем загружен ли (по name или id)
|
||||
# Проверяем загружен ли (по field_label или name)
|
||||
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
|
||||
documents_list.append({
|
||||
"name": doc_name,
|
||||
@@ -498,10 +500,40 @@ async def get_draft(claim_id: str):
|
||||
|
||||
# 🔍 ОТЛАДКА: Логируем наличие documents_required
|
||||
documents_required = payload.get('documents_required', []) if isinstance(payload, dict) else []
|
||||
documents_meta = payload.get('documents_meta', []) if isinstance(payload, dict) else []
|
||||
logger.info(f"🔍 Черновик {final_claim_id}: status_code={row.get('status_code')}, documents_required count={len(documents_required) if isinstance(documents_required, list) else 0}")
|
||||
if documents_required:
|
||||
logger.info(f"🔍 documents_required: {documents_required[:2]}...") # Первые 2 для примера
|
||||
|
||||
# Подсчет документов (как в списке черновиков)
|
||||
documents_required_list = documents_required if isinstance(documents_required, list) else []
|
||||
documents_meta_list = documents_meta if isinstance(documents_meta, list) else []
|
||||
|
||||
# Считаем загруженные (уникальные по field_label)
|
||||
uploaded_labels = set()
|
||||
for doc in documents_meta_list:
|
||||
label = doc.get('field_label') or doc.get('field_name')
|
||||
if label:
|
||||
uploaded_labels.add(label)
|
||||
|
||||
documents_uploaded = len(uploaded_labels)
|
||||
documents_total = len(documents_required_list) if documents_required_list else 0
|
||||
|
||||
# Формируем список документов со статусами
|
||||
documents_list = []
|
||||
for doc_req in documents_required_list:
|
||||
# Пробуем разные поля для названия документа (field_label приоритетнее)
|
||||
doc_name = doc_req.get('field_label') or doc_req.get('name') or 'Документ'
|
||||
doc_id = doc_req.get('id', '')
|
||||
is_required = doc_req.get('required', False)
|
||||
# Проверяем загружен ли (по field_label или name)
|
||||
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
|
||||
documents_list.append({
|
||||
"name": doc_name,
|
||||
"required": is_required,
|
||||
"uploaded": is_uploaded,
|
||||
})
|
||||
|
||||
# ✅ Проверяем флаг подтверждения данных контакта из CRM (поле cf_2624)
|
||||
# Простой способ: делаем прямой SQL запрос к БД (таблицы vtiger_*)
|
||||
# ПРИМЕЧАНИЕ: Если таблицы vtiger_* находятся в MySQL (а не PostgreSQL),
|
||||
@@ -604,7 +636,11 @@ async def get_draft(claim_id: str):
|
||||
"channel": row.get('channel'),
|
||||
"created_at": row['created_at'].isoformat() if row.get('created_at') else None,
|
||||
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
|
||||
"payload": payload
|
||||
"payload": payload,
|
||||
# Информация о документах
|
||||
"documents_total": documents_total,
|
||||
"documents_uploaded": documents_uploaded,
|
||||
"documents_list": documents_list,
|
||||
},
|
||||
# ✅ Флаги подтверждения данных контакта (из CRM поля cf_2624)
|
||||
"contact_data_confirmed": contact_data_confirmed,
|
||||
@@ -908,48 +944,95 @@ async def load_wizard_data(claim_id: str):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
|
||||
|
||||
|
||||
async def _check_and_restart_workflow_if_needed(channel: str):
|
||||
async def _send_buffered_messages_to_webhook():
|
||||
"""
|
||||
Проверяет и перезапускает workflow если нужно (в фоне)
|
||||
Защита от частых перезапусков через Redis lock
|
||||
Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
|
||||
"""
|
||||
try:
|
||||
# Проверяем lock - если недавно перезапускали, пропускаем
|
||||
lock_key = f"workflow_restart_lock:{channel}"
|
||||
lock_value = await redis_service.get(lock_key)
|
||||
|
||||
if lock_value:
|
||||
logger.info(f"⏸️ Workflow недавно перезапускался, пропускаем (lock active)")
|
||||
if not settings.n8n_description_webhook:
|
||||
logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
|
||||
return
|
||||
|
||||
# Проверяем статус workflow
|
||||
workflow_data = await check_workflow_status()
|
||||
buffer_key = "description"
|
||||
messages = await redis_service.buffer_get_all(buffer_key)
|
||||
|
||||
if not messages:
|
||||
logger.info("📭 Буфер пуст, нечего отправлять")
|
||||
return
|
||||
|
||||
logger.info(f"📤 Отправляю {len(messages)} сообщений из буфера в n8n webhook...")
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for buffered_message in messages:
|
||||
try:
|
||||
# Восстанавливаем формат для n8n: массив с channel и message
|
||||
channel = buffered_message.get("channel", f"{settings.redis_prefix}description")
|
||||
message_data = buffered_message.get("message", buffered_message.get("event", buffered_message))
|
||||
|
||||
webhook_payload = [
|
||||
{
|
||||
"channel": channel,
|
||||
"message": message_data
|
||||
}
|
||||
]
|
||||
|
||||
response = await client.post(
|
||||
settings.n8n_description_webhook,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
sent_count += 1
|
||||
logger.info(
|
||||
f"✅ Буферированное сообщение отправлено: "
|
||||
f"session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
# НЕ возвращаем в буфер - успешно отправили
|
||||
else:
|
||||
# HTTP ошибка - возвращаем в буфер
|
||||
failed_count += 1
|
||||
logger.warning(
|
||||
f"⚠️ n8n вернул ошибку {response.status_code}, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
failed_count += 1
|
||||
logger.warning(
|
||||
f"⏱️ Таймаут при отправке из буфера, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
f"🔌 Ошибка подключения к n8n: {e}, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
f"❌ Неожиданная ошибка при отправке из буфера: {e}, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}",
|
||||
exc_info=True
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
logger.info(
|
||||
f"📊 Результат отправки буфера: {sent_count} отправлено, "
|
||||
f"{failed_count} возвращено в буфер"
|
||||
)
|
||||
|
||||
if workflow_data:
|
||||
is_active = workflow_data.get("active", False)
|
||||
if not is_active:
|
||||
logger.warning(f"⚠️ Workflow НЕ активен! Активирую и перезапускаю...")
|
||||
# Workflow выключен — нужно его ВКЛЮЧИТЬ
|
||||
else:
|
||||
logger.info(
|
||||
f"⚠️ Workflow активен, но нет подписчиков. Перезапускаю workflow..."
|
||||
)
|
||||
|
||||
# Устанавливаем lock на MIN_RESTART_INTERVAL секунд
|
||||
await redis_service.set(lock_key, "1", expire=MIN_RESTART_INTERVAL)
|
||||
|
||||
# Перезапускаем
|
||||
success = await restart_workflow()
|
||||
|
||||
if success:
|
||||
logger.info("✅ Workflow успешно перезапущен")
|
||||
else:
|
||||
logger.error("❌ Не удалось перезапустить workflow")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось проверить статус workflow, пропускаем перезапуск")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"❌ Ошибка при проверке/перезапуске workflow: {e}")
|
||||
logger.exception(f"❌ Ошибка при отправке буфера: {e}")
|
||||
|
||||
|
||||
@router.post("/description")
|
||||
@@ -958,12 +1041,18 @@ async def publish_ticket_form_description(
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
Публикует свободное описание проблемы в Redis канал ticket_form:description
|
||||
(слушается воркфлоу в n8n)
|
||||
Отправляет описание проблемы в n8n через webhook (вместо Redis pub/sub)
|
||||
"""
|
||||
try:
|
||||
if not settings.n8n_description_webhook:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="N8N description webhook не настроен"
|
||||
)
|
||||
|
||||
# Формируем данные в формате, который ожидает n8n workflow
|
||||
channel = payload.channel or f"{settings.redis_prefix}description"
|
||||
event = {
|
||||
message = {
|
||||
"type": "ticket_form_description",
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id, # Опционально - может быть None
|
||||
@@ -976,7 +1065,13 @@ async def publish_ticket_form_description(
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
event_json = json.dumps(event, ensure_ascii=False)
|
||||
# n8n workflow ожидает массив с объектом, содержащим channel и message
|
||||
webhook_payload = [
|
||||
{
|
||||
"channel": channel,
|
||||
"message": message
|
||||
}
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"📝 TicketForm description received",
|
||||
@@ -991,81 +1086,111 @@ async def publish_ticket_form_description(
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"📡 Publishing to Redis channel",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"event_type": event["type"],
|
||||
"event_keys": list(event.keys()),
|
||||
"json_length": len(event_json),
|
||||
},
|
||||
)
|
||||
# Retry-логика: пытаемся отправить в n8n webhook
|
||||
max_attempts = 3
|
||||
initial_delay = 1 # секунды
|
||||
|
||||
subscribers_count = await redis_service.publish(channel, event_json)
|
||||
|
||||
logger.info(
|
||||
"✅ TicketForm description published to Redis",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"session_id": payload.session_id,
|
||||
"subscribers_count": subscribers_count,
|
||||
"event_json_preview": event_json[:500],
|
||||
},
|
||||
)
|
||||
|
||||
if subscribers_count == 0:
|
||||
logger.warning(
|
||||
f"⚠️ WARNING: No subscribers on channel {channel}! "
|
||||
f"n8n workflow is not listening to this channel. "
|
||||
f"Saving message to buffer and restarting workflow..."
|
||||
)
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
logger.info(
|
||||
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
settings.n8n_description_webhook,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
f"✅ Описание успешно отправлено в n8n webhook (попытка {attempt})",
|
||||
extra={
|
||||
"session_id": payload.session_id,
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
)
|
||||
|
||||
# Успешно отправили - возвращаем успех
|
||||
return {
|
||||
"success": True,
|
||||
"event": message,
|
||||
"attempt": attempt,
|
||||
}
|
||||
else:
|
||||
# HTTP ошибка (не 200)
|
||||
logger.warning(
|
||||
f"⚠️ Попытка {attempt}: n8n вернул статус {response.status_code}",
|
||||
extra={
|
||||
"session_id": payload.session_id,
|
||||
"status_code": response.status_code,
|
||||
"response_preview": response.text[:200],
|
||||
}
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(
|
||||
f"⏱️ Попытка {attempt}: таймаут при отправке в n8n webhook",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(
|
||||
f"🔌 Попытка {attempt}: ошибка подключения к n8n: {e}",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"❌ Попытка {attempt}: неожиданная ошибка: {e}",
|
||||
extra={"session_id": payload.session_id},
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Сохраняем сообщение в буфер для последующей отправки
|
||||
buffer_message = {
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id,
|
||||
"event": event,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
await redis_service.buffer_push("description", buffer_message)
|
||||
logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}")
|
||||
|
||||
# Запускаем проверку и перезапуск workflow в фоне
|
||||
background_tasks.add_task(_check_and_restart_workflow_if_needed, channel)
|
||||
# Если это не последняя попытка - ждём перед следующей
|
||||
if attempt < max_attempts:
|
||||
wait_time = initial_delay * (2 ** (attempt - 1)) # Экспоненциальный backoff
|
||||
logger.info(f"⏳ Жду {wait_time} секунд перед следующей попыткой...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# Дополнительная проверка: логируем полный event для отладки
|
||||
logger.debug(
|
||||
"🔍 Full event data published",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"event": event,
|
||||
},
|
||||
# Все попытки исчерпаны - сохраняем в буфер
|
||||
logger.error(
|
||||
f"❌ Все {max_attempts} попытки исчерпаны, сохраняю в буфер",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
# Формируем ответ с информацией о подписчиках
|
||||
response_data = {
|
||||
"success": True,
|
||||
|
||||
buffer_message = {
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id,
|
||||
"channel": channel,
|
||||
"subscribers_count": subscribers_count,
|
||||
"event": event,
|
||||
"message": message, # Сохраняем message для последующей отправки
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
await redis_service.buffer_push("description", buffer_message)
|
||||
logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}")
|
||||
|
||||
# Если подписчиков нет - сообщаем что обработка займёт больше времени
|
||||
if subscribers_count == 0:
|
||||
buffer_size = await redis_service.buffer_size("description")
|
||||
response_data["warning"] = (
|
||||
# Запускаем фоновую задачу для отправки из буфера
|
||||
background_tasks.add_task(_send_buffered_messages_to_webhook)
|
||||
|
||||
buffer_size = await redis_service.buffer_size("description")
|
||||
return {
|
||||
"success": True,
|
||||
"event": message,
|
||||
"buffered": True,
|
||||
"warning": (
|
||||
"Обработка вашего обращения займёт немного больше времени. "
|
||||
"Идёт автоматическое восстановление системы. "
|
||||
"Ваше сообщение сохранено и будет обработано в ближайшее время."
|
||||
)
|
||||
response_data["workflow_recovering"] = True
|
||||
response_data["message_buffered"] = True
|
||||
response_data["buffer_size"] = buffer_size
|
||||
),
|
||||
"buffer_size": buffer_size,
|
||||
}
|
||||
|
||||
return response_data
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("❌ Failed to publish ticket form description")
|
||||
logger.exception("❌ Failed to send ticket form description to n8n")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Не удалось опубликовать описание: {e}"
|
||||
detail=f"Не удалось отправить описание: {e}"
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ N8N_POLICY_CHECK_WEBHOOK = settings.n8n_policy_check_webhook or None
|
||||
N8N_FILE_UPLOAD_WEBHOOK = settings.n8n_file_upload_webhook or None
|
||||
N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook
|
||||
N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook
|
||||
N8N_TG_AUTH_WEBHOOK = settings.n8n_tg_auth_webhook or None
|
||||
|
||||
|
||||
@router.post("/policy/check")
|
||||
@@ -219,6 +220,72 @@ async def proxy_file_upload(
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка загрузки файла: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tg/auth")
|
||||
async def proxy_telegram_auth(request: Request):
|
||||
"""
|
||||
Проксирует авторизацию Telegram WebApp (Mini App) в n8n webhook.
|
||||
|
||||
Используется backend-эндпоинтом /api/v1/tg/auth:
|
||||
- backend валидирует initData
|
||||
- затем вызывает этот роут для маппинга telegram_user_id → unified_id в n8n
|
||||
"""
|
||||
if not N8N_TG_AUTH_WEBHOOK:
|
||||
logger.error("[TG] N8N_TG_AUTH_WEBHOOK не задан в .env — webhook не вызывается")
|
||||
raise HTTPException(status_code=500, detail="N8N Telegram auth webhook не настроен")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
logger.info(
|
||||
"[TG] Proxy → n8n webhook %s: telegram_user_id=%s, session_token=%s",
|
||||
N8N_TG_AUTH_WEBHOOK[:50] + "...",
|
||||
body.get("telegram_user_id", "unknown"),
|
||||
body.get("session_token", "unknown"),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
N8N_TG_AUTH_WEBHOOK,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
logger.info("[TG] n8n webhook ответ: status=%s, body длина=%s", response.status_code, len(response_text))
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"[TG] n8n webhook success. Response: %s",
|
||||
response_text[:500],
|
||||
)
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"❌ Failed to parse Telegram auth JSON: %s. Response: %s",
|
||||
e,
|
||||
response_text[:500],
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
|
||||
|
||||
logger.error(
|
||||
"[TG] n8n webhook вернул ошибку %s: %s",
|
||||
response.status_code,
|
||||
response_text[:500],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"N8N Telegram auth error: {response_text}",
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("[TG] Таймаут при вызове n8n Telegram auth webhook")
|
||||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (Telegram auth)")
|
||||
except Exception as e:
|
||||
logger.exception("[TG] Ошибка при вызове n8n Telegram auth: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка авторизации Telegram: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/claim/create")
|
||||
async def proxy_create_claim(request: Request):
|
||||
"""
|
||||
|
||||
@@ -15,14 +15,19 @@ async def send_sms_code(request: SMSSendRequest):
|
||||
|
||||
- **phone**: Номер телефона в формате +79001234567
|
||||
"""
|
||||
from ..config import settings
|
||||
|
||||
code = await sms_service.send_verification_code(request.phone)
|
||||
|
||||
if code:
|
||||
return {
|
||||
response = {
|
||||
"success": True,
|
||||
"message": "Код отправлен на указанный номер",
|
||||
"debug_code": code # Всегда возвращаем код для dev модалки
|
||||
"message": "Код отправлен на указанный номер"
|
||||
}
|
||||
# 🔧 DEV MODE: Возвращаем debug_code только в development
|
||||
if settings.debug or settings.app_env == "development":
|
||||
response["debug_code"] = code
|
||||
return response
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
|
||||
154
backend/app/api/telegram_auth.py
Normal file
154
backend/app/api/telegram_auth.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Telegram Mini App (WebApp) auth endpoint.
|
||||
|
||||
/api/v1/tg/auth:
|
||||
- Принимает init_data от Telegram WebApp и (опционально) session_token
|
||||
- Валидирует init_data и извлекает данные пользователя Telegram
|
||||
- Проксирует telegram_user_id в n8n для получения unified_id/контакта
|
||||
- Создаёт сессию в Redis через существующий /api/v1/session/create
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
|
||||
from ..config import settings
|
||||
from . import n8n_proxy
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tg", tags=["Telegram"])
|
||||
|
||||
|
||||
class TelegramAuthRequest(BaseModel):
|
||||
init_data: str
|
||||
session_token: Optional[str] = None
|
||||
|
||||
|
||||
class TelegramAuthResponse(BaseModel):
|
||||
success: bool
|
||||
session_token: str
|
||||
unified_id: str
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
|
||||
|
||||
def _generate_session_token() -> str:
|
||||
"""Генерирует новый session_token в формате, похожем на текущий веб-флоу."""
|
||||
import uuid
|
||||
|
||||
return f"sess-{uuid.uuid4()}"
|
||||
|
||||
|
||||
@router.post("/auth", response_model=TelegramAuthResponse)
|
||||
async def telegram_auth(request: TelegramAuthRequest):
|
||||
"""
|
||||
Авторизация пользователя через Telegram WebApp.
|
||||
|
||||
Ничего не ломает в текущем SMS-флоу: это параллельный способ входа.
|
||||
"""
|
||||
# Логирование: что пришло на бэкенд
|
||||
init_data = request.init_data or ""
|
||||
logger.info(
|
||||
"[TG] POST /api/v1/tg/auth вызван: init_data длина=%s, session_token передан=%s",
|
||||
len(init_data),
|
||||
bool(request.session_token),
|
||||
)
|
||||
if not init_data:
|
||||
logger.warning("[TG] init_data пустой — запрос отклонён")
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен")
|
||||
|
||||
bot_token_configured = bool((getattr(settings, "telegram_bot_token", None) or "").strip())
|
||||
n8n_webhook_configured = bool((getattr(settings, "n8n_tg_auth_webhook", None) or "").strip())
|
||||
logger.info("[TG] Конфиг: TELEGRAM_BOT_TOKEN задан=%s, N8N_TG_AUTH_WEBHOOK задан=%s", bot_token_configured, n8n_webhook_configured)
|
||||
|
||||
# 1. Валидация и разбор init_data
|
||||
try:
|
||||
tg_user = extract_telegram_user(request.init_data)
|
||||
except TelegramAuthError as e:
|
||||
logger.warning("[TG] Ошибка валидации initData: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
telegram_user_id = tg_user["telegram_user_id"]
|
||||
logger.info("[TG] Telegram user валиден: id=%s, username=%s", telegram_user_id, tg_user.get("username"))
|
||||
|
||||
# 2. Определяем session_token
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
|
||||
# 3. Вызываем n8n через прокси для маппинга telegram_user_id → unified_id
|
||||
n8n_payload = {
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"username": tg_user.get("username"),
|
||||
"first_name": tg_user.get("first_name"),
|
||||
"last_name": tg_user.get("last_name"),
|
||||
"session_token": session_token,
|
||||
"form_id": "ticket_form",
|
||||
"init_data": request.init_data, # сырая строка из Telegram (подпись уже проверена)
|
||||
}
|
||||
logger.info("[TG] Вызов n8n webhook, payload keys=%s", list(n8n_payload.keys()))
|
||||
|
||||
# Используем уже существующий n8n_proxy роут (внутренний вызов)
|
||||
try:
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
# Объект с async .json() для proxy_telegram_auth(request), без Pydantic __root__
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: dict):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
dummy_request = _DummyRequest(n8n_payload)
|
||||
n8n_response = await n8n_proxy.proxy_telegram_auth(dummy_request) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
logger.info("[TG] n8n ответ получен: keys=%s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
|
||||
except HTTPException:
|
||||
# Пробрасываем HTTPException наверх
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[TG] Ошибка вызова n8n Telegram auth webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
||||
|
||||
# Ожидаем от n8n как минимум unified_id
|
||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
logger.error("[TG] n8n не вернул unified_id. Полный ответ: %s", n8n_data)
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для Telegram пользователя")
|
||||
|
||||
# 4. Создаём сессию в Redis через существующий /api/v1/session/create
|
||||
# Для Telegram телефон может быть ещё неизвестен, поэтому передаём пустые строки при отсутствии.
|
||||
session_request = session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
)
|
||||
|
||||
try:
|
||||
await session_api.create_session(session_request)
|
||||
except HTTPException:
|
||||
# Если ошибка уже обёрнута в HTTPException — пробрасываем как есть
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error creating Redis session for Telegram user")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка создания сессии: {str(e)}")
|
||||
|
||||
return TelegramAuthResponse(
|
||||
success=True,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone,
|
||||
has_drafts=has_drafts,
|
||||
)
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"""
|
||||
Конфигурация приложения
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
ENV_PATH = BASE_DIR / ".env"
|
||||
|
||||
# Список CORS, обновляется при изменении .env (чтобы не перезапускать бэкенд)
|
||||
_cors_origins_live: List[str] = []
|
||||
_settings_cache: Optional["Settings"] = None
|
||||
_env_mtime_cache: float = 0
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# ============================================
|
||||
@@ -179,6 +184,13 @@ class Settings(BaseSettings):
|
||||
n8n_file_upload_webhook: str = ""
|
||||
n8n_create_contact_webhook: str = "https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27"
|
||||
n8n_create_claim_webhook: str = "https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d"
|
||||
n8n_description_webhook: str = "https://n8n.clientright.pro/webhook/aiform_description" # Webhook для обработки описания проблемы
|
||||
n8n_tg_auth_webhook: str = "" # Webhook для авторизации пользователей Telegram WebApp (Mini App)
|
||||
|
||||
# ============================================
|
||||
# TELEGRAM BOT
|
||||
# ============================================
|
||||
telegram_bot_token: str = "" # Токен бота для проверки initData WebApp
|
||||
|
||||
# ============================================
|
||||
# LOGGING
|
||||
@@ -192,9 +204,25 @@ class Settings(BaseSettings):
|
||||
extra = "ignore" # Игнорируем лишние поля из .env
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
"""Текущие настройки. При изменении .env подхватываются без перезапуска."""
|
||||
global _settings_cache, _env_mtime_cache, _cors_origins_live
|
||||
mtime = os.path.getmtime(ENV_PATH) if ENV_PATH.exists() else 0.0
|
||||
if _settings_cache is None or mtime > _env_mtime_cache:
|
||||
_settings_cache = Settings()
|
||||
_env_mtime_cache = mtime
|
||||
_cors_origins_live.clear()
|
||||
_cors_origins_live.extend(_settings_cache.cors_origins_list)
|
||||
return _settings_cache
|
||||
|
||||
|
||||
def get_cors_origins_live() -> List[str]:
|
||||
"""
|
||||
Список CORS origins для middleware; обновляется при изменении .env без перезапуска.
|
||||
Обработчики, которые используют get_settings() при каждом запросе, тоже видят новые значения.
|
||||
"""
|
||||
get_settings() # обновить кеш и _cors_origins_live при изменении .env
|
||||
return _cors_origins_live
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -6,14 +6,14 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
|
||||
from .config import settings
|
||||
from .config import settings, get_cors_origins_live, get_settings
|
||||
from .services.database import db
|
||||
from .services.redis_service import redis_service
|
||||
from .services.rabbitmq_service import rabbitmq_service
|
||||
from .services.policy_service import policy_service
|
||||
from .services.crm_mysql_service import crm_mysql_service
|
||||
from .services.s3_service import s3_service
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
@@ -93,14 +93,19 @@ app = FastAPI(
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS
|
||||
# CORS (список обновляется при изменении .env без перезапуска)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_origins=get_cors_origins_live(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# Обновление конфига с .env при каждом запросе, чтобы CORS и прочее подхватывали изменения
|
||||
@app.middleware("http")
|
||||
async def refresh_config_on_request(request, call_next):
|
||||
get_settings()
|
||||
return await call_next(request)
|
||||
|
||||
# API Routes
|
||||
app.include_router(sms.router)
|
||||
@@ -112,6 +117,8 @@ app.include_router(events.router)
|
||||
app.include_router(n8n_proxy.router) # 🔒 Безопасный proxy к n8n webhooks
|
||||
app.include_router(session.router) # 🔑 Session management через Redis
|
||||
app.include_router(documents.router) # 📄 Documents upload and processing
|
||||
app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
|
||||
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -13,6 +13,7 @@ logger = logging.getLogger(__name__)
|
||||
WORKFLOW_ID = "b4K4u851b4JFivyD"
|
||||
N8N_URL = "https://n8n.clientright.pro"
|
||||
MIN_RESTART_INTERVAL = 300 # Минимум 5 минут между перезапусками
|
||||
MAX_RETRY_ATTEMPTS = 2 # Максимум попыток перезапуска подряд
|
||||
|
||||
|
||||
async def check_workflow_status() -> Optional[dict]:
|
||||
@@ -50,7 +51,7 @@ async def check_workflow_status() -> Optional[dict]:
|
||||
|
||||
async def restart_workflow() -> bool:
|
||||
"""
|
||||
Перезапуск workflow через n8n API
|
||||
Перезапуск workflow через n8n API с улучшенной обработкой зависших состояний
|
||||
|
||||
Returns:
|
||||
True если успешно, False при ошибке
|
||||
@@ -63,50 +64,86 @@ async def restart_workflow() -> bool:
|
||||
if not headers:
|
||||
return False
|
||||
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Шаг 1: Деактивировать workflow
|
||||
# Увеличиваем таймаут для обработки зависших workflow
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Шаг 1: Проверяем текущий статус
|
||||
logger.info(f"🔍 Проверяю текущий статус workflow {WORKFLOW_ID}...")
|
||||
status_response = await client.get(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if status_response.status_code == 200:
|
||||
workflow_data = status_response.json()
|
||||
is_active = workflow_data.get("active", False)
|
||||
logger.info(f"📊 Workflow активен: {is_active}")
|
||||
|
||||
# Шаг 2: Деактивировать workflow (даже если уже неактивен - для сброса состояния)
|
||||
logger.info(f"🔄 Деактивирую workflow {WORKFLOW_ID}...")
|
||||
deactivate_response = await client.post(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if deactivate_response.status_code not in [200, 404]:
|
||||
logger.warning(
|
||||
f"⚠️ Неожиданный статус при деактивации: "
|
||||
f"{deactivate_response.status_code}"
|
||||
try:
|
||||
deactivate_response = await client.post(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate",
|
||||
headers=headers,
|
||||
timeout=15.0 # Отдельный таймаут для деактивации
|
||||
)
|
||||
else:
|
||||
logger.info("✅ Workflow деактивирован")
|
||||
|
||||
if deactivate_response.status_code in [200, 404]:
|
||||
logger.info("✅ Workflow деактивирован")
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ Неожиданный статус при деактивации: "
|
||||
f"{deactivate_response.status_code} - {deactivate_response.text[:200]}"
|
||||
)
|
||||
# Продолжаем даже если деактивация не удалась - возможно workflow уже неактивен
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("⏱️ Таймаут при деактивации workflow (возможно завис)")
|
||||
# Продолжаем попытку активации - иногда помогает
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Ошибка при деактивации: {e}, продолжаю...")
|
||||
|
||||
# Задержка перед активацией
|
||||
import asyncio
|
||||
await asyncio.sleep(2)
|
||||
# Задержка перед активацией (увеличена для стабильности)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Шаг 2: Активировать workflow
|
||||
# Шаг 3: Активировать workflow
|
||||
logger.info(f"🔄 Активирую workflow {WORKFLOW_ID}...")
|
||||
activate_response = await client.post(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if activate_response.status_code == 200:
|
||||
logger.info("✅ Workflow активирован")
|
||||
|
||||
# После успешного перезапуска отправляем сообщения из буфера
|
||||
await _send_buffered_messages()
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"❌ Ошибка активации workflow: "
|
||||
f"{activate_response.status_code} - {activate_response.text[:200]}"
|
||||
try:
|
||||
activate_response = await client.post(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate",
|
||||
headers=headers,
|
||||
timeout=15.0 # Отдельный таймаут для активации
|
||||
)
|
||||
|
||||
if activate_response.status_code == 200:
|
||||
logger.info("✅ Workflow активирован")
|
||||
|
||||
# Дополнительная задержка для инициализации trigger node
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# После успешного перезапуска отправляем сообщения из буфера
|
||||
await _send_buffered_messages()
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"❌ Ошибка активации workflow: "
|
||||
f"{activate_response.status_code} - {activate_response.text[:200]}"
|
||||
)
|
||||
return False
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ Таймаут при активации workflow - возможно n8n перегружен")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при активации workflow: {e}")
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ Общий таймаут при перезапуске workflow")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}")
|
||||
logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -65,17 +65,11 @@ class SMSService:
|
||||
logger.warning("SMS отправка отключена в конфигурации")
|
||||
return False
|
||||
|
||||
# 🔧 DEV: ПРИНУДИТЕЛЬНО ОТКЛЮЧЕНА ОТПРАВКА SMS
|
||||
# Раскомментировать для продакшена!
|
||||
logger.info(f"🔧 DEV MODE: SMS to {phone} ЗАБЛОКИРОВАНА (экономим бюджет!)")
|
||||
logger.info(f"📱 Message: {message}")
|
||||
return True
|
||||
|
||||
# DEBUG MODE: Не отправляем реальные SMS, экономим бюджет
|
||||
# if settings.debug or settings.app_env == "development":
|
||||
# logger.info(f"🔧 DEBUG MODE: SMS to {phone} not sent (saving money!)")
|
||||
# logger.info(f"📱 Message would be: {message}")
|
||||
# return True
|
||||
# 🔧 DEV MODE: Не отправляем реальные SMS в development, экономим бюджет
|
||||
if settings.debug or settings.app_env == "development":
|
||||
logger.info(f"🔧 DEV MODE: SMS to {phone} not sent (saving money!)")
|
||||
logger.info(f"📱 Message would be: {message}")
|
||||
return True # Возвращаем True чтобы код сохранился в Redis для проверки
|
||||
|
||||
try:
|
||||
# Получаем актуальный токен
|
||||
|
||||
132
backend/app/services/telegram_auth.py
Normal file
132
backend/app/services/telegram_auth.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Telegram WebApp (Mini App) auth helper.
|
||||
|
||||
В этом модуле:
|
||||
- Парсим и валидируем initData от Telegram WebApp
|
||||
- Проверяем подпись по токену бота из настроек
|
||||
- Возвращаем разобранные данные пользователя Telegram
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramAuthError(Exception):
|
||||
"""Ошибка проверки подлинности Telegram initData."""
|
||||
|
||||
|
||||
def _parse_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Разбирает строку initData в словарь.
|
||||
|
||||
Формат initData — это query string, см. Telegram WebApp docs.
|
||||
"""
|
||||
data: Dict[str, Any] = {}
|
||||
for key, value in parse_qsl(init_data, keep_blank_values=True):
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
def verify_telegram_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Проверяет подпись initData согласно Telegram WebApp правилам.
|
||||
|
||||
Алгоритм из официальной документации:
|
||||
- Берём токен бота: BOT_TOKEN
|
||||
- Вычисляем secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
|
||||
- Собираем data_check_string: строки "<key>=<value>" по всем полям, кроме 'hash',
|
||||
отсортированные по key, соединённые '\n'
|
||||
- Считаем хэш: HMAC_SHA256(secret_key, data_check_string)
|
||||
- Сравниваем с полем 'hash' из initData (hex)
|
||||
"""
|
||||
if not init_data:
|
||||
logger.warning("[TG] verify_telegram_init_data: init_data пустой")
|
||||
raise TelegramAuthError("init_data is empty")
|
||||
|
||||
bot_token = (getattr(settings, "telegram_bot_token", None) or "").strip()
|
||||
if not bot_token:
|
||||
logger.warning("[TG] verify_telegram_init_data: TELEGRAM_BOT_TOKEN не задан в .env")
|
||||
raise TelegramAuthError("Telegram bot token is not configured")
|
||||
|
||||
parsed = _parse_init_data(init_data)
|
||||
logger.info("[TG] initData распарсен, ключи: %s", list(parsed.keys()))
|
||||
|
||||
received_hash = parsed.pop("hash", None)
|
||||
if not received_hash:
|
||||
logger.warning("[TG] В initData отсутствует поле hash")
|
||||
raise TelegramAuthError("Missing hash in init_data")
|
||||
|
||||
# Формируем data_check_string
|
||||
data_check_items = []
|
||||
for key in sorted(parsed.keys()):
|
||||
value = parsed[key]
|
||||
data_check_items.append(f"{key}={value}")
|
||||
data_check_string = "\n".join(data_check_items)
|
||||
|
||||
# secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
|
||||
secret_key = hmac.new(
|
||||
key="WebAppData".encode("utf-8"),
|
||||
msg=bot_token.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
# HMAC_SHA256(secret_key, data_check_string)
|
||||
calculated_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
logger.warning("[TG] Подпись initData не совпадает (неверный токен бота или поддельные данные)")
|
||||
raise TelegramAuthError("Invalid init_data hash")
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def extract_telegram_user(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Валидирует initData и возвращает данные пользователя Telegram.
|
||||
|
||||
В field `user` лежит JSON-строка с полями:
|
||||
{
|
||||
"id": 123456789,
|
||||
"first_name": "...",
|
||||
"last_name": "...",
|
||||
"username": "...",
|
||||
...
|
||||
}
|
||||
"""
|
||||
import json
|
||||
|
||||
parsed = verify_telegram_init_data(init_data)
|
||||
|
||||
user_raw = parsed.get("user")
|
||||
if not user_raw:
|
||||
logger.warning("[TG] В initData отсутствует поле user")
|
||||
raise TelegramAuthError("No user field in init_data")
|
||||
|
||||
try:
|
||||
user_obj = json.loads(user_raw)
|
||||
except Exception as e:
|
||||
raise TelegramAuthError(f"Failed to parse user JSON: {e}") from e
|
||||
|
||||
if "id" not in user_obj:
|
||||
raise TelegramAuthError("Telegram user.id is missing")
|
||||
|
||||
return {
|
||||
"telegram_user_id": str(user_obj.get("id")),
|
||||
"username": user_obj.get("username"),
|
||||
"first_name": user_obj.get("first_name"),
|
||||
"last_name": user_obj.get("last_name"),
|
||||
"language_code": user_obj.get("language_code"),
|
||||
"raw": user_obj,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user