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:
AI Assistant
2026-01-29 16:12:48 +03:00
parent 73524465fd
commit 2e45786e46
57 changed files with 6776 additions and 234 deletions

60
backend/app/api/banks.py Normal file
View 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)}"
)

View File

@@ -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}"
)

View File

@@ -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):
"""

View File

@@ -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,

View 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,
)

View File

@@ -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()

View File

@@ -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("/")

View File

@@ -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

View File

@@ -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:
# Получаем актуальный токен

View 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,
}