From d8fe0b605b04d921695320c92209cee9da9f70d1 Mon Sep 17 00:00:00 2001 From: Fedor Date: Tue, 24 Feb 2026 16:17:59 +0300 Subject: [PATCH] Unified auth and sessions: POST /api/v1/auth, session by channel:id and token, need_contact fix, n8n parsing, TTL 24h --- DOCKER-COMPOSE-README.md | 23 ++ backend/app/api/auth2.py | 34 ++- backend/app/api/auth_universal.py | 204 +++++++++++++ backend/app/api/claims.py | 148 +++++++-- backend/app/api/events.py | 135 +++++++- backend/app/api/max_auth.py | 26 +- backend/app/api/models.py | 1 + backend/app/api/profile.py | 208 +++++++++++++ backend/app/api/session.py | 117 ++++++- backend/app/api/telegram_auth.py | 40 ++- backend/app/config.py | 25 +- backend/app/main.py | 25 +- docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js | 51 ++++ docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md | 31 ++ docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md | 95 ++++++ frontend/src/App.tsx | 20 +- frontend/src/components/BottomBar.tsx | 108 +++++-- .../src/components/form/StepDescription.tsx | 17 +- .../src/components/form/StepWizardPlan.tsx | 288 ++++++++++++++---- frontend/src/pages/ClaimForm.tsx | 260 +++++----------- frontend/src/pages/HelloAuth.tsx | 192 ++++++------ frontend/src/pages/Profile.css | 34 +++ frontend/src/pages/Profile.tsx | 152 +++++++++ 23 files changed, 1785 insertions(+), 449 deletions(-) create mode 100644 DOCKER-COMPOSE-README.md create mode 100644 backend/app/api/auth_universal.py create mode 100644 backend/app/api/profile.py create mode 100644 docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js create mode 100644 docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md create mode 100644 docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md create mode 100644 frontend/src/pages/Profile.css create mode 100644 frontend/src/pages/Profile.tsx diff --git a/DOCKER-COMPOSE-README.md b/DOCKER-COMPOSE-README.md new file mode 100644 index 0000000..60358c3 --- /dev/null +++ b/DOCKER-COMPOSE-README.md @@ -0,0 +1,23 @@ +# Docker Compose в этом каталоге + +**Для сайта miniapp.clientright.ru используется один compose — в корне репозитория:** + +``` +/var/www/fastuser/data/www/miniapp.clientright.ru/docker-compose.yml +``` + +Он поднимает: `miniapp_frontend` (5179), `miniapp_backend` (8205), `miniapp_redis` (6383). +Запуск из корня: `docker compose up -d`. + +--- + +Файлы в **aiform_prod/**: + +| Файл | Назначение | Порты | +|------|------------|--------| +| `docker-compose.yml` | Старый стек (ticket_form_*), не для miniapp.clientright.ru | 5175, host | +| `docker-compose.prod.yml` | Другой прод (miniapp_front/back на 4176), не для miniapp.clientright.ru | 4176 | +| `docker-compose.dev.yml` | Дев aiform (aiform_frontend_dev, aiform_backend_dev) | 5177, 8201 | +| `docker-compose.full.yml` | Полный стек ERV (postgres, redis, pgadmin и т.д.) | 8100, 5173, … | + +Их можно не поднимать для работы miniapp.clientright.ru. Оставлены для истории/других окружений. diff --git a/backend/app/api/auth2.py b/backend/app/api/auth2.py index ea6a03f..563c9b5 100644 --- a/backend/app/api/auth2.py +++ b/backend/app/api/auth2.py @@ -85,14 +85,23 @@ async def login(request: Auth2LoginRequest): n8n_response = await n8n_proxy.proxy_telegram_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type] n8n_data = jsonable_encoder(n8n_response) + _result = n8n_data.get("result") + _result_dict = _result if isinstance(_result, dict) else {} - 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") + _raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact") + need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1")) + if need_contact: + logger.info("[AUTH2] TG: n8n need_contact — возвращаем need_contact=true") + return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True) + + unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId") + contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId") + phone = n8n_data.get("phone") or _result_dict.get("phone") has_drafts = n8n_data.get("has_drafts") if not unified_id: - raise HTTPException(status_code=500, detail="n8n не вернул unified_id") + logger.info("[AUTH2] TG: n8n не вернул unified_id — возвращаем need_contact=true") + return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True) await session_api.create_session(session_api.SessionCreateRequest( session_token=session_token, @@ -100,6 +109,7 @@ async def login(request: Auth2LoginRequest): phone=phone or "", contact_id=contact_id or "", ttl_hours=24, + chat_id=str(tg_user["telegram_user_id"]) if tg_user.get("telegram_user_id") is not None else None, )) first_name = tg_user.get("first_name") or "" @@ -143,18 +153,23 @@ async def login(request: Auth2LoginRequest): n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type] n8n_data = jsonable_encoder(n8n_response) + _result = n8n_data.get("result") + _result_dict = _result if isinstance(_result, dict) else {} - need_contact = n8n_data.get("need_contact") or (n8n_data.get("result") or {}).get("need_contact") + _raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact") + need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1")) if need_contact: + logger.info("[AUTH2] MAX: n8n need_contact — возвращаем need_contact=true") return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True) - 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") + unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId") + contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId") + phone = n8n_data.get("phone") or _result_dict.get("phone") has_drafts = n8n_data.get("has_drafts") if not unified_id: - raise HTTPException(status_code=500, detail="n8n не вернул unified_id") + logger.info("[AUTH2] MAX: n8n не вернул unified_id — возвращаем need_contact=true") + return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True) await session_api.create_session(session_api.SessionCreateRequest( session_token=session_token, @@ -162,6 +177,7 @@ async def login(request: Auth2LoginRequest): phone=phone or "", contact_id=contact_id or "", ttl_hours=24, + chat_id=str(max_user["max_user_id"]) if max_user.get("max_user_id") is not None else None, )) first_name = max_user.get("first_name") or "" diff --git a/backend/app/api/auth_universal.py b/backend/app/api/auth_universal.py new file mode 100644 index 0000000..4fa772a --- /dev/null +++ b/backend/app/api/auth_universal.py @@ -0,0 +1,204 @@ +""" +Универсальный auth: один endpoint для TG и MAX. +Принимает channel (tg|max) и init_data, валидирует, дергает N8N_AUTH_WEBHOOK, +пишет сессию в Redis по ключу session:{channel}:{channel_user_id} и session:{session_token}. +""" + +import logging +import uuid +from typing import Optional, Any, Dict + +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ..config import settings +from ..services.telegram_auth import extract_telegram_user, TelegramAuthError +from ..services.max_auth import extract_max_user, MaxAuthError +from . import session as session_api + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/auth", tags=["auth-universal"]) + + +class AuthUniversalRequest(BaseModel): + channel: str # tg | max + init_data: str + + +class AuthUniversalResponse(BaseModel): + success: bool + need_contact: Optional[bool] = None + message: Optional[str] = None + session_token: Optional[str] = None + unified_id: Optional[str] = None + phone: Optional[str] = None + contact_id: Optional[str] = None + has_drafts: Optional[bool] = None + + +@router.post("", response_model=AuthUniversalResponse) +async def auth_universal(request: AuthUniversalRequest): + """ + Универсальная авторизация: channel (tg|max) + init_data. + Валидируем init_data, получаем channel_user_id, вызываем N8N_AUTH_WEBHOOK, + при успехе пишем сессию в Redis по session:{channel}:{channel_user_id}. + """ + logger.info("[AUTH] POST /api/v1/auth вызван: channel=%s", request.channel) + channel = (request.channel or "").strip().lower() + if channel not in ("tg", "telegram", "max"): + channel = "telegram" if channel.startswith("tg") else "max" + # В n8n и Redis всегда передаём telegram, не tg + if channel == "tg": + channel = "telegram" + + init_data = (request.init_data or "").strip() + if not init_data: + raise HTTPException(status_code=400, detail="init_data обязателен") + + # 1) Извлечь channel_user_id из init_data + channel_user_id: Optional[str] = None + if channel == "telegram": + try: + user = extract_telegram_user(init_data) + channel_user_id = user.get("telegram_user_id") + except TelegramAuthError as e: + logger.warning("[TG] Ошибка валидации init_data: %s", e) + raise HTTPException(status_code=400, detail=str(e)) + else: + try: + user = extract_max_user(init_data) + channel_user_id = user.get("max_user_id") + except MaxAuthError as e: + logger.warning("[MAX] Ошибка валидации init_data: %s", e) + raise HTTPException(status_code=400, detail=str(e)) + + if not channel_user_id: + raise HTTPException(status_code=400, detail="Не удалось получить channel_user_id из init_data") + + webhook_url = (getattr(settings, "n8n_auth_webhook", None) or "").strip() + if not webhook_url: + logger.error("N8N_AUTH_WEBHOOK не задан в .env") + raise HTTPException(status_code=503, detail="Сервис авторизации не настроен") + + # 2) Вызвать n8n + payload = { + "channel": channel, + "channel_user_id": channel_user_id, + "init_data": init_data, + } + logger.info("[AUTH] Вызов N8N_AUTH_WEBHOOK: channel=%s, channel_user_id=%s", channel, channel_user_id) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + webhook_url, + json=payload, + headers={"Content-Type": "application/json"}, + ) + except httpx.TimeoutException: + logger.error("[AUTH] Таймаут N8N_AUTH_WEBHOOK") + raise HTTPException(status_code=504, detail="Таймаут сервиса авторизации") + except Exception as e: + logger.exception("[AUTH] Ошибка вызова N8N_AUTH_WEBHOOK: %s", e) + raise HTTPException(status_code=502, detail="Ошибка сервиса авторизации") + + # Лог: что пришло от n8n (сырой ответ) + try: + _body = response.text or "" + logger.info("[AUTH] n8n ответ: status=%s, body_len=%s, body_preview=%s", response.status_code, len(_body), _body[:500] if _body else "") + except Exception: + pass + + try: + raw = response.json() + logger.info("[AUTH] raw type=%s, is_list=%s, len=%s", type(raw).__name__, isinstance(raw, list), len(raw) if isinstance(raw, (list, dict)) else 0) + if isinstance(raw, list) and len(raw) > 0: + logger.info("[AUTH] raw[0] keys=%s", list(raw[0].keys()) if isinstance(raw[0], dict) else type(raw[0]).__name__) + + # n8n может вернуть: массив [{ json: { ... } }] или массив объектов напрямую [{ success, unified_id, ... }] + if isinstance(raw, list) and len(raw) > 0 and isinstance(raw[0], dict): + first = raw[0] + if "json" in first: + data = first["json"] + logger.info("[AUTH] парсинг: взяли first['json'], data keys=%s", list(data.keys()) if isinstance(data, dict) else "?") + elif "success" in first or "unified_id" in first: + data = first + logger.info("[AUTH] парсинг: взяли first как data, keys=%s", list(data.keys())) + else: + data = {} + logger.warning("[AUTH] парсинг: first без json/success/unified_id, data={}") + elif isinstance(raw, dict): + data = raw + logger.info("[AUTH] парсинг: raw — dict, keys=%s", list(data.keys())) + else: + data = {} + logger.warning("[AUTH] парсинг: неизвестный формат raw, data={}") + except Exception as e: + logger.warning("[AUTH] Ответ n8n не JSON: %s", (response.text or "")[:300]) + raise HTTPException(status_code=502, detail="Некорректный ответ сервиса авторизации") + + logger.info("[AUTH] data: success=%s, need_contact=%s, unified_id=%s", data.get("success"), data.get("need_contact"), data.get("unified_id")) + + # 3) need_contact — только если n8n явно вернул need_contact (закрыть приложение и попросить контакт в чате) + need_contact = ( + data.get("need_contact") is True + or data.get("need_contact") == 1 + or (isinstance(data.get("need_contact"), str) and data.get("need_contact", "").strip().lower() in ("true", "1")) + ) + if need_contact: + logger.info("[AUTH] ответ: need_contact=true → закрыть приложение") + return AuthUniversalResponse( + success=False, + need_contact=True, + message=(data.get("message") or "Пользователь не найден. Поделитесь контактом в чате с ботом."), + ) + if data.get("success") is False: + # Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение + logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку") + return AuthUniversalResponse( + success=False, + need_contact=False, + message=(data.get("message") or "Ошибка авторизации."), + ) + + # 4) Успех: unified_id и т.д. + unified_id = data.get("unified_id") + if not unified_id and isinstance(data.get("result"), dict): + unified_id = (data.get("result") or {}).get("unified_id") + if not unified_id: + logger.warning("[AUTH] n8n не вернул unified_id: %s", data) + logger.info("[AUTH] ответ: нет unified_id → need_contact=true, закрыть приложение") + return AuthUniversalResponse(success=False, need_contact=True, message="Контакт не найден.") + + # 5) Записать сессию в Redis по session:{channel}:{channel_user_id} и session:{session_token} + session_data = { + "unified_id": unified_id, + "phone": data.get("phone") or (data.get("result") or {}).get("phone") if isinstance(data.get("result"), dict) else None, + "contact_id": data.get("contact_id") or (data.get("result") or {}).get("contact_id") if isinstance(data.get("result"), dict) else None, + "has_drafts": data.get("has_drafts", False) or (data.get("result") or {}).get("has_drafts", False) if isinstance(data.get("result"), dict) else False, + "chat_id": channel_user_id, + } + try: + await session_api.set_session_by_channel_user(channel, channel_user_id, session_data) + except HTTPException: + raise + except Exception as e: + logger.exception("[AUTH] Ошибка записи сессии в Redis: %s", e) + raise HTTPException(status_code=500, detail="Ошибка сохранения сессии") + + session_token = str(uuid.uuid4()) + try: + await session_api.set_session_by_token(session_token, session_data) + except Exception as e: + logger.warning("[AUTH] Двойная запись session_token в Redis: %s", e) + + logger.info("[AUTH] ответ: success=true, session_token=%s..., unified_id=%s", session_token[:8] if session_token else "", unified_id) + return AuthUniversalResponse( + success=True, + session_token=session_token, + unified_id=unified_id, + phone=session_data.get("phone"), + contact_id=session_data.get("contact_id"), + has_drafts=session_data.get("has_drafts", False), + ) diff --git a/backend/app/api/claims.py b/backend/app/api/claims.py index 3019614..deaf18e 100644 --- a/backend/app/api/claims.py +++ b/backend/app/api/claims.py @@ -14,6 +14,7 @@ from datetime import datetime import json import logging import asyncio +import os from ..services.redis_service import redis_service from ..services.database import db from ..services.crm_mysql_service import crm_mysql_service @@ -23,7 +24,10 @@ from ..config import settings router = APIRouter(prefix="/api/v1/claims", tags=["Claims"]) logger = logging.getLogger(__name__) -N8N_TICKET_FORM_FINAL_WEBHOOK = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3" + +def _get_ticket_form_webhook() -> str: + """URL webhook n8n для wizard и create. Менять в .env: N8N_TICKET_FORM_FINAL_WEBHOOK""" + return (getattr(settings, "n8n_ticket_form_final_webhook", None) or "").strip() or "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3" @router.post("/wizard") @@ -59,16 +63,32 @@ async def submit_wizard(request: Request): }, ) + webhook_url = _get_ticket_form_webhook() async with httpx.AsyncClient(timeout=120.0) as client: response = await client.post( - N8N_TICKET_FORM_FINAL_WEBHOOK, + webhook_url, data=data, files=files or None, ) text = response.text or "" - + logger.info( + "n8n wizard response: status=%s, body_length=%s, body_preview=%s", + response.status_code, + len(text), + text[:1500] if len(text) > 1500 else text, + extra={"claim_id": data.get("claim_id"), "session_id": data.get("session_id")}, + ) if response.status_code == 200: + try: + parsed = json.loads(text) + logger.info( + "n8n wizard response (parsed): keys=%s", + list(parsed.keys()) if isinstance(parsed, dict) else type(parsed).__name__, + extra={"session_id": data.get("session_id")}, + ) + except Exception: + pass logger.info( "✅ TicketForm wizard webhook OK", extra={"response_preview": text[:500]}, @@ -121,9 +141,10 @@ async def create_claim(request: Request): ) # Проксируем запрос к n8n + webhook_url = _get_ticket_form_webhook() async with httpx.AsyncClient(timeout=60.0) as client: response = await client.post( - N8N_TICKET_FORM_FINAL_WEBHOOK, + webhook_url, json=body, headers={"Content-Type": "application/json"}, ) @@ -962,12 +983,44 @@ async def load_wizard_data(claim_id: str): raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}") +# Актуальный webhook для описания проблемы (n8n.clientright.ru). Старый aiform_description на .pro больше не используем. +DESCRIPTION_WEBHOOK_DEFAULT = "https://n8n.clientright.ru/webhook/ticket_form_description" + +DEBUG_LOG_PATH = "/app/logs/debug-2a4d38.log" + + +def _debug_log(hy: str, msg: str, data: dict): + try: + import time + line = json.dumps({ + "sessionId": "2a4d38", + "hypothesisId": hy, + "location": "claims.py:publish_ticket_form_description", + "message": msg, + "data": data, + "timestamp": int(time.time() * 1000), + }, ensure_ascii=False) + "\n" + with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f: + f.write(line) + except Exception: + pass + + +def _get_description_webhook_url() -> str: + """URL webhook для описания проблемы: только env N8N_DESCRIPTION_WEBHOOK или константа (старый .pro не используем).""" + url = (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "").strip() + if url: + return url + return DESCRIPTION_WEBHOOK_DEFAULT + + async def _send_buffered_messages_to_webhook(): """ Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub) """ try: - if not settings.n8n_description_webhook: + description_webhook_url = _get_description_webhook_url() + if not description_webhook_url: logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера") return @@ -998,7 +1051,7 @@ async def _send_buffered_messages_to_webhook(): ] response = await client.post( - settings.n8n_description_webhook, + description_webhook_url, json=webhook_payload, # Отправляем в формате массива headers={"Content-Type": "application/json"} ) @@ -1059,27 +1112,53 @@ async def publish_ticket_form_description( background_tasks: BackgroundTasks ): """ - Отправляет описание проблемы в n8n через webhook (вместо Redis pub/sub) + Отправляет описание проблемы в n8n через webhook. URL: N8N_DESCRIPTION_WEBHOOK из env или константа (n8n.clientright.ru). """ + # #region agent log + _debug_log("H1_H4", "POST /description handler entered", {"session_id": getattr(payload, "session_id", None)}) + # #endregion try: - if not settings.n8n_description_webhook: + description_webhook_url = _get_description_webhook_url() + # #region agent log + _debug_log("H3_H5", "description webhook URL resolved", {"url": description_webhook_url[:80] if description_webhook_url else "", "env_N8N": (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "")[:80]}) + # #endregion + if not description_webhook_url: raise HTTPException( status_code=500, detail="N8N description webhook не настроен" ) - + + # Если unified_id не передан — подставляем из сессии в Redis (tg/max auth создают сессию с unified_id) + unified_id = payload.unified_id + contact_id = payload.contact_id + phone = payload.phone + if not unified_id and payload.session_id: + try: + session_key = f"session:{payload.session_id}" + session_raw = await redis_service.client.get(session_key) + if session_raw: + session_data = json.loads(session_raw) + unified_id = unified_id or session_data.get("unified_id") + contact_id = contact_id or session_data.get("contact_id") + phone = phone or session_data.get("phone") + if unified_id: + logger.info("📝 unified_id/contact_id/phone подставлены из сессии Redis: session_key=%s", session_key) + except Exception as e: + logger.warning("Не удалось прочитать сессию из Redis для подстановки unified_id: %s", e) + # Формируем данные в формате, который ожидает n8n workflow channel = payload.channel or f"{settings.redis_prefix}description" message = { "type": "ticket_form_description", "session_id": payload.session_id, "claim_id": payload.claim_id, # Опционально - может быть None - "phone": payload.phone, + "phone": phone, "email": payload.email, - "unified_id": payload.unified_id, # ✅ Unified ID пользователя - "contact_id": payload.contact_id, # ✅ Contact ID пользователя + "unified_id": unified_id, # из запроса или из сессии Redis + "contact_id": contact_id, "description": payload.problem_description.strip(), "source": payload.source, + "entry_channel": (payload.entry_channel or "web").strip() or "web", # telegram | max | web — для роутинга в n8n "timestamp": datetime.utcnow().isoformat(), } @@ -1092,13 +1171,11 @@ async def publish_ticket_form_description( ] logger.info( - "📝 TicketForm description received", + "📝 TicketForm description received → webhook=%s", + description_webhook_url[:80] + ("..." if len(description_webhook_url) > 80 else ""), extra={ "session_id": payload.session_id, "claim_id": payload.claim_id or "not_set", - "phone": payload.phone, - "unified_id": payload.unified_id or "not_set", - "contact_id": payload.contact_id or "not_set", "description_length": len(payload.problem_description), "channel": channel, }, @@ -1114,23 +1191,44 @@ async def publish_ticket_form_description( f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook", extra={"session_id": payload.session_id} ) - + # #region agent log + _debug_log("H2_H4", "about to POST to n8n webhook", {"attempt": attempt, "url_short": description_webhook_url[:60] if description_webhook_url else ""}) + # #endregion async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( - settings.n8n_description_webhook, + description_webhook_url, json=webhook_payload, # Отправляем в формате массива headers={"Content-Type": "application/json"} ) - + # #region agent log + _debug_log("H4", "n8n webhook response", {"status": response.status_code, "url_short": description_webhook_url[:60] if description_webhook_url else ""}) + # #endregion if response.status_code == 200: + response_body = response.text or "" logger.info( - f"✅ Описание успешно отправлено в n8n webhook (попытка {attempt})", - extra={ - "session_id": payload.session_id, - "status_code": response.status_code, - } + "✅ Описание успешно отправлено в n8n webhook (попытка %s), ответ n8n (length=%s): %s", + attempt, + len(response_body), + response_body[:2000] if len(response_body) > 2000 else response_body, + extra={"session_id": payload.session_id}, + ) + try: + parsed_n8n = json.loads(response_body) + logger.info( + "n8n description response (parsed): keys=%s", + list(parsed_n8n.keys()) if isinstance(parsed_n8n, dict) else type(parsed_n8n).__name__, + extra={"session_id": payload.session_id}, + ) + except Exception: + pass + # После описания фронт подписывается на SSE — логируем, на что именно + logger.info( + "📡 После описания в n8n клиент подпишется на: " + "channel_ocr=ocr_events:%s (GET /api/v1/events/%s), " + "channel_plan=claim:plan:%s (GET /api/v1/claim-plan/%s)", + payload.session_id, payload.session_id, payload.session_id, payload.session_id, + extra={"session_id": payload.session_id}, ) - # Успешно отправили - возвращаем успех return { "success": True, diff --git a/backend/app/api/events.py b/backend/app/api/events.py index 3ee1057..9ca4d0d 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -9,12 +9,108 @@ from pydantic import BaseModel from typing import Dict, Any from app.services.redis_service import redis_service from app.services.database import db +from app.config import settings import logging logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1", tags=["Events"]) +# Типы для единого отображения на фронте: тип + текст (+ data для consumer_complaint) +DISPLAY_EVENT_TYPES = ("trash_message", "out_of_scope", "consumer_consultation", "consumer_complaint") + + +def _normalize_display_event(actual_event: dict) -> dict: + """ + Приводит событие к формату { event_type, message [, data] } для единого отображения. + event_type — один из: trash_message (красный), out_of_scope (жёлтый), + consumer_consultation (синий), consumer_complaint (зелёный). + """ + raw_type = actual_event.get("event_type") or actual_event.get("type") + payload = actual_event.get("payload") or actual_event.get("data") or {} + if isinstance(payload, str): + try: + payload = json.loads(payload) if payload else {} + except Exception: + payload = {} + if not isinstance(payload, dict): + payload = {} + msg = (actual_event.get("message") or payload.get("message") or "").strip() or "Ответ получен" + + # Если n8n уже прислал один из четырёх типов — не перезаписываем, отдаём как есть (синий/зелёный не превращаем в жёлтый) + if raw_type in DISPLAY_EVENT_TYPES: + return { + "event_type": raw_type, + "message": msg or "Ответ получен", + "data": actual_event.get("data", {}), + "suggested_actions": (actual_event.get("suggested_actions") or payload.get("suggested_actions")) if raw_type == "out_of_scope" else None, + } + + if raw_type == "trash_message" or payload.get("intent") == "trash": + return { + "event_type": "trash_message", + "message": msg or "К сожалению, это обращение не по тематике.", + "data": actual_event.get("data", {}), + } + if raw_type == "out_of_scope": + return { + "event_type": "out_of_scope", + "message": msg or "К сожалению, мы не можем помочь с этим вопросом.", + "data": actual_event.get("data", {}), + "suggested_actions": actual_event.get("suggested_actions") or payload.get("suggested_actions"), + } + if raw_type == "consumer_intent": + intent = payload.get("intent") or actual_event.get("intent") + if intent == "consultation": + return { + "event_type": "consumer_consultation", + "message": msg or "Понял. Это похоже на консультацию.", + "data": {}, + } + return { + "event_type": "consumer_complaint", + "message": msg or "Обращение принято.", + "data": actual_event.get("data", {}), + } + if raw_type == "documents_list_ready": + return { + "event_type": "consumer_complaint", + "message": msg or "Подготовлен список документов.", + "data": { + **actual_event.get("data", {}), + "documents_required": actual_event.get("documents_required"), + "claim_id": actual_event.get("claim_id"), + }, + } + if raw_type in ("wizard_ready", "wizard_plan_ready", "claim_plan_ready"): + return { + "event_type": "consumer_complaint", + "message": msg or "План готов.", + "data": actual_event.get("data", actual_event), + } + if raw_type == "ocr_status" and actual_event.get("status") == "ready": + return { + "event_type": "consumer_complaint", + "message": msg or "Данные подтверждены.", + "data": actual_event.get("data", {}), + } + # Если есть текст сообщения, но тип неизвестен — считаем out_of_scope, чтобы фронт точно показал ответ + if msg and msg.strip() and raw_type not in ( + "documents_list_ready", "document_uploaded", "document_ocr_completed", + "ocr_status", "claim_ready", "claim_plan_ready", "claim_plan_error", + ): + return { + "event_type": "out_of_scope", + "message": msg.strip(), + "data": actual_event.get("data", {}), + "suggested_actions": actual_event.get("suggested_actions"), + } + # Остальные события — прозрачно, только дополняем message + out = dict(actual_event) + if "message" not in out or not out.get("message"): + out["message"] = msg + return out + class EventPublish(BaseModel): """Модель для публикации события""" @@ -84,7 +180,10 @@ async def stream_events(task_id: str): Returns: StreamingResponse с событиями """ - logger.info(f"🚀 SSE connection requested for session_token: {task_id}") + logger.info( + "🚀 SSE connection requested for session_token: %s → channel=ocr_events:%s (Redis %s:%s)", + task_id, task_id, settings.redis_host, settings.redis_port, + ) async def event_generator(): """Генератор событий из Redis Pub/Sub""" @@ -95,7 +194,10 @@ async def stream_events(task_id: str): pubsub = redis_service.client.pubsub() await pubsub.subscribe(channel) - logger.info(f"📡 Client subscribed to {channel}") + logger.info( + "📡 Subscribed to channel=%s on Redis %s:%s (проверка: redis-cli -h %s PUBSUB NUMSUB %s)", + channel, settings.redis_host, settings.redis_port, settings.redis_host, channel, + ) # Отправляем начальное событие yield f"data: {json.dumps({'status': 'connected', 'message': 'Подключено к событиям'})}\n\n" @@ -298,10 +400,14 @@ async def stream_events(task_id: str): except Exception as e: logger.error(f"❌ Error loading form_draft from PostgreSQL: {e}") + # Единый формат для фронта: событие с полями event_type и message (и data при необходимости) + raw_event_type = actual_event.get("event_type") + raw_status = actual_event.get("status") + actual_event = _normalize_display_event(actual_event) # Отправляем событие клиенту (плоский формат) event_json = json.dumps(actual_event, ensure_ascii=False, default=str) - event_type_sent = actual_event.get('event_type', 'unknown') - event_status = actual_event.get('status', 'unknown') + event_type_sent = actual_event.get("event_type", "unknown") + event_status = actual_event.get("status") or (actual_event.get("data") or {}).get("status") or "unknown" # Логируем размер и наличие данных data_info = actual_event.get('data', {}) has_form_draft = 'form_draft' in data_info if isinstance(data_info, dict) else False @@ -310,18 +416,21 @@ async def stream_events(task_id: str): # Если обработка завершена - закрываем соединение # НЕ закрываем для documents_list_ready и document_ocr_completed (ждём ещё события) - if event_status in ['completed', 'error'] and event_type_sent not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']: + if event_status in ['completed', 'error'] and (raw_event_type or event_type_sent) not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']: logger.info(f"✅ Task {task_id} finished, closing SSE") break - # Закрываем для финальных событий + # Закрываем для финальных событий (raw_event_type до нормализации) + if raw_event_type in ['claim_ready', 'claim_plan_ready', 'wizard_ready', 'wizard_plan_ready']: + logger.info(f"✅ Final event {raw_event_type} sent, closing SSE") + break if event_type_sent in ['claim_ready', 'claim_plan_ready']: logger.info(f"✅ Final event {event_type_sent} sent, closing SSE") break # Закрываем для ocr_status ready (форма заявления готова) - if event_type_sent == 'ocr_status' and event_status == 'ready': - logger.info(f"✅ OCR ready event sent, closing SSE") + if raw_event_type == "ocr_status" and raw_status == "ready": + logger.info("✅ OCR ready event sent, closing SSE") break else: logger.info(f"⏰ Timeout waiting for message on {channel}") @@ -369,7 +478,10 @@ async def stream_claim_plan(session_token: str): } } """ - logger.info(f"🚀 Claim plan SSE connection requested for session_token: {session_token}") + logger.info( + "🚀 Claim plan SSE: session_token=%s → channel=claim:plan:%s (Redis %s:%s)", + session_token, session_token, settings.redis_host, settings.redis_port, + ) async def claim_plan_generator(): """Генератор событий из Redis Pub/Sub для claim:plan канала""" @@ -379,7 +491,10 @@ async def stream_claim_plan(session_token: str): pubsub = redis_service.client.pubsub() await pubsub.subscribe(channel) - logger.info(f"📡 Client subscribed to {channel}") + logger.info( + "📡 Subscribed to channel=%s on Redis %s:%s (PUBSUB NUMSUB %s)", + channel, settings.redis_host, settings.redis_port, channel, + ) # Отправляем начальное событие yield f"data: {json.dumps({'status': 'connected', 'message': 'Ожидание данных заявления...'})}\n\n" diff --git a/backend/app/api/max_auth.py b/backend/app/api/max_auth.py index c27733e..b96458b 100644 --- a/backend/app/api/max_auth.py +++ b/backend/app/api/max_auth.py @@ -106,19 +106,28 @@ async def max_auth(request: MaxAuthRequest): logger.exception("[MAX] Ошибка вызова n8n MAX auth webhook: %s", e) raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}") - need_contact = n8n_data.get("need_contact") or (n8n_data.get("result") or {}).get("need_contact") + logger.info("[MAX] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__) + _result = n8n_data.get("result") + _result_dict = _result if isinstance(_result, dict) else {} + _raw = ( + n8n_data.get("need_contact") + or _result_dict.get("need_contact") + or n8n_data.get("needContact") + or _result_dict.get("needContact") + ) + need_contact = _raw is True or _raw == 1 or (isinstance(_raw, str) and str(_raw).strip().lower() in ("true", "1")) if need_contact: - logger.info("[MAX] n8n: need_contact — юзер не в базе, закрываем приложение") + logger.info("[MAX] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение") return MaxAuthResponse(success=False, need_contact=True) - 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_res = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone") - has_drafts = n8n_data.get("has_drafts") + unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId") + contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId") + phone_res = n8n_data.get("phone") or _result_dict.get("phone") + has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts") if not unified_id: - logger.error("[MAX] n8n не вернул unified_id. Ответ: %s", n8n_data) - raise HTTPException(status_code=500, detail="n8n не вернул unified_id для пользователя MAX") + logger.info("[MAX] n8n не вернул unified_id (юзер не в базе) — возвращаем need_contact=true. Ответ: %s", n8n_data) + return MaxAuthResponse(success=False, need_contact=True) session_request = session_api.SessionCreateRequest( session_token=session_token, @@ -126,6 +135,7 @@ async def max_auth(request: MaxAuthRequest): phone=phone_res or phone or "", contact_id=contact_id or "", ttl_hours=24, + chat_id=str(max_user_id) if max_user_id else None, ) try: diff --git a/backend/app/api/models.py b/backend/app/api/models.py index eb6ad23..c9544b6 100644 --- a/backend/app/api/models.py +++ b/backend/app/api/models.py @@ -75,4 +75,5 @@ class TicketFormDescriptionRequest(BaseModel): problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации") source: str = Field("ticket_form", description="Источник события") channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)") + entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web — для роутинга в n8n") diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py new file mode 100644 index 0000000..e1343df --- /dev/null +++ b/backend/app/api/profile.py @@ -0,0 +1,208 @@ +""" +Профиль пользователя: контактные данные из CRM через n8n webhook. + +GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id. +unified_id берётся из сессии по session_token или передаётся явно. + +----- Что уходит на N8N_CONTACT_WEBHOOK (POST body) ----- + - unified_id (str): идентификатор пользователя в CRM + - entry_channel (str): "telegram" | "max" | "web" + - chat_id (str, опционально): Telegram user id или Max user id + - session_token, contact_id, phone (опционально) + +----- Как n8n должен возвращать ответ ----- + +1) Ничего не нашло (контакт не найден в CRM или нет данных): + - HTTP 200 + - Тело: пустой массив [] ИЛИ объект {"items": []} + Пример: [] или {"items": []} + +2) Нашло контакт(ы): + - HTTP 200 + - Тело: массив контактов ИЛИ объект с полем items/contact/data: + • [] → нормализуется в {"items": []} + • {"items": [...]} → как есть + • {"contact": {...}} → один контакт в items + • {"contact": [...]} → массив в items + • {"data": [...]} → массив в items + • один объект {...} → один элемент в items + + Поля контакта (snake_case или camelCase, фронт смотрит оба): + last_name/lastName, first_name/firstName, middle_name/middleName, + birth_date/birthDate, birth_place/birthPlace, inn, email, + registration_address/address/mailingstreet, mailing_address/postal_address, + bank_for_compensation/bank, phone/mobile/mobile_phone. +""" + +import logging +from typing import Optional + +import httpx +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +from app.config import settings + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/profile", tags=["profile"]) + + +class ProfileContactRequest(BaseModel): + """Запрос контактных данных: session_token, (channel + channel_user_id) или unified_id.""" + session_token: Optional[str] = Field(None, description="Токен сессии (unified_id подставится из Redis)") + unified_id: Optional[str] = Field(None, description="Unified ID пользователя в CRM") + channel: Optional[str] = Field(None, description="Канал: tg | max (для поиска сессии в Redis)") + channel_user_id: Optional[str] = Field(None, description="ID пользователя в канале (tg/max)") + entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web") + chat_id: Optional[str] = Field(None, description="Telegram user id или Max user id (для передачи в n8n)") + + +@router.get("/contact") +async def get_profile_contact( + session_token: Optional[str] = Query(None, description="Токен сессии"), + unified_id: Optional[str] = Query(None, description="Unified ID"), + channel: Optional[str] = Query(None, description="Канал: tg | max"), + channel_user_id: Optional[str] = Query(None, description="ID пользователя в канале"), + entry_channel: Optional[str] = Query(None, description="Канал: telegram | max | web"), + chat_id: Optional[str] = Query(None, description="Telegram/Max user id"), +): + """ + Получить контактные данные из CRM через n8n webhook. + Передайте session_token, (channel + channel_user_id) или unified_id. + """ + return await _fetch_contact( + session_token=session_token, + unified_id=unified_id, + channel=channel, + channel_user_id=channel_user_id, + entry_channel=entry_channel, + chat_id=chat_id, + ) + + +@router.post("/contact") +async def post_profile_contact(body: ProfileContactRequest): + """То же по телу запроса.""" + return await _fetch_contact( + session_token=body.session_token, + unified_id=body.unified_id, + channel=body.channel, + channel_user_id=body.channel_user_id, + entry_channel=body.entry_channel, + chat_id=body.chat_id, + ) + + +async def _fetch_contact( + session_token: Optional[str] = None, + unified_id: Optional[str] = None, + channel: Optional[str] = None, + channel_user_id: Optional[str] = None, + entry_channel: Optional[str] = None, + chat_id: Optional[str] = None, +) -> dict: + webhook_url = getattr(settings, "n8n_contact_webhook", None) or "" + if not webhook_url: + raise HTTPException( + status_code=503, + detail="N8N_CONTACT_WEBHOOK не настроен", + ) + + contact_id: Optional[str] = None + phone: Optional[str] = None + + # Сессия по channel + channel_user_id (универсальный auth пишет в Redis по этому ключу) + if not unified_id and channel and channel_user_id: + try: + from app.api.session import get_session_by_channel_user + session_data = await get_session_by_channel_user(channel.strip(), str(channel_user_id).strip()) + if session_data: + unified_id = session_data.get("unified_id") + contact_id = session_data.get("contact_id") + phone = session_data.get("phone") + if chat_id is None: + chat_id = session_data.get("chat_id") + except Exception as e: + logger.warning("Ошибка чтения сессии по channel: %s", e) + if not unified_id: + raise HTTPException(status_code=401, detail="Сессия недействительна или истекла") + + # Сессия по session_token + if not unified_id and session_token: + try: + from app.api.session import SessionVerifyRequest, verify_session + verify_res = await verify_session(SessionVerifyRequest(session_token=session_token)) + if getattr(verify_res, "valid", False): + unified_id = getattr(verify_res, "unified_id", None) + contact_id = getattr(verify_res, "contact_id", None) + phone = getattr(verify_res, "phone", None) + if chat_id is None: + chat_id = getattr(verify_res, "chat_id", None) + if not unified_id: + raise HTTPException(status_code=401, detail="Сессия недействительна или истекла") + except HTTPException: + raise + except Exception as e: + logger.warning("Ошибка верификации сессии для профиля: %s", e) + raise HTTPException(status_code=401, detail="Сессия недействительна") + + if not unified_id: + raise HTTPException( + status_code=400, + detail="Укажите session_token, (channel + channel_user_id) или unified_id", + ) + + # В хук уходит всё, что нужно для обработки в n8n: канал, unified_id, chat_id, contact_id, phone, session_token + payload: dict = { + "unified_id": unified_id, + "entry_channel": (entry_channel or "web").strip() or "web", + } + if session_token: + payload["session_token"] = session_token + if contact_id is not None: + payload["contact_id"] = contact_id + if phone is not None: + payload["phone"] = phone + if chat_id is not None and str(chat_id).strip(): + payload["chat_id"] = str(chat_id).strip() + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + webhook_url, + json=payload, + headers={"Content-Type": "application/json"}, + ) + except Exception as e: + logger.exception("Ошибка вызова N8N_CONTACT_WEBHOOK: %s", e) + raise HTTPException(status_code=502, detail="Сервис контактов временно недоступен") + + if response.status_code != 200: + logger.warning("N8N contact webhook вернул %s: %s", response.status_code, response.text[:500]) + raise HTTPException( + status_code=502, + detail="Сервис контактов вернул ошибку", + ) + + try: + data = response.json() + except Exception: + data = response.text or "" + + # Нормализация ответа n8n в единый формат { "items": [...] } + if isinstance(data, list): + return {"items": data if data else []} + if isinstance(data, dict): + if "items" in data and isinstance(data["items"], list): + return {"items": data["items"]} + if "contact" in data: + c = data["contact"] + return {"items": c if isinstance(c, list) else [c] if c else []} + if "data" in data and isinstance(data["data"], list): + return {"items": data["data"]} + # Один объект-контакт без обёртки (если есть хоть одно поле контакта — считаем контактом) + if data and isinstance(data, dict): + return {"items": [data]} + return {"items": []} + return {"items": []} diff --git a/backend/app/api/session.py b/backend/app/api/session.py index a801acb..179aafa 100644 --- a/backend/app/api/session.py +++ b/backend/app/api/session.py @@ -2,7 +2,8 @@ Session management API endpoints Обеспечивает управление сессиями пользователей через Redis: -- Верификация существующей сессии +- Верификация по session_token или по (channel, channel_user_id) +- Ключ Redis: session:{channel}:{channel_user_id} для универсального auth - Logout (удаление сессии) """ @@ -22,13 +23,83 @@ router = APIRouter(prefix="/api/v1/session", tags=["session"]) # Redis connection (используем существующее подключение) redis_client: Optional[redis.Redis] = None +# TTL для сессии по channel+channel_user_id (секунды). 0 = без TTL. +SESSION_BY_CHANNEL_TTL_HOURS = 24 -def init_redis(redis_conn: redis.Redis): - """Initialize Redis connection""" + +def init_redis(redis_conn: Optional[redis.Redis]): + """Initialize Redis connection (локальный Redis для сессий). None при shutdown.""" global redis_client redis_client = redis_conn +def _session_key_by_channel(channel: str, channel_user_id: str) -> str: + """Ключ Redis для сессии по каналу и id пользователя в канале.""" + return f"session:{channel}:{channel_user_id}" + + +async def set_session_by_channel_user( + channel: str, + channel_user_id: str, + data: Dict[str, Any], +) -> None: + """ + Записать сессию в Redis по ключу session:{channel}:{channel_user_id}. + data: unified_id, phone, contact_id, chat_id, has_drafts, ... + """ + if not redis_client: + raise HTTPException(status_code=500, detail="Redis connection not initialized") + key = _session_key_by_channel(channel, channel_user_id) + payload = { + "unified_id": data.get("unified_id") or "", + "phone": data.get("phone") or "", + "contact_id": data.get("contact_id") or "", + "chat_id": str(channel_user_id), + "has_drafts": data.get("has_drafts", False), + "verified_at": datetime.utcnow().isoformat(), + } + ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None + if ttl: + await redis_client.setex(key, ttl, json.dumps(payload)) + else: + await redis_client.set(key, json.dumps(payload)) + logger.info("Сессия записана: %s, unified_id=%s", key, payload.get("unified_id")) + + +async def get_session_by_channel_user(channel: str, channel_user_id: str) -> Optional[Dict[str, Any]]: + """Прочитать сессию из Redis по channel и channel_user_id. Если нет — None.""" + if not redis_client: + return None + key = _session_key_by_channel(channel, channel_user_id) + raw = await redis_client.get(key) + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +async def set_session_by_token(session_token: str, data: Dict[str, Any]) -> None: + """Записать сессию в Redis по ключу session:{session_token} (для совместимости с profile/claims).""" + if not redis_client: + return + key = f"session:{session_token}" + payload = { + "unified_id": data.get("unified_id") or "", + "phone": data.get("phone") or "", + "contact_id": data.get("contact_id") or "", + "chat_id": data.get("chat_id") or "", + "has_drafts": data.get("has_drafts", False), + "verified_at": datetime.utcnow().isoformat(), + } + ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None + if ttl: + await redis_client.setex(key, ttl, json.dumps(payload)) + else: + await redis_client.set(key, json.dumps(payload)) + + class SessionVerifyRequest(BaseModel): session_token: str @@ -39,10 +110,16 @@ class SessionVerifyResponse(BaseModel): unified_id: Optional[str] = None phone: Optional[str] = None contact_id: Optional[str] = None + chat_id: Optional[str] = None # telegram_user_id или max_user_id verified_at: Optional[str] = None expires_in_seconds: Optional[int] = None +class SessionVerifyByChannelRequest(BaseModel): + channel: str # tg | max + channel_user_id: str + + class SessionLogoutRequest(BaseModel): session_token: str @@ -92,6 +169,7 @@ async def verify_session(request: SessionVerifyRequest): unified_id=session_data.get('unified_id'), phone=session_data.get('phone'), contact_id=session_data.get('contact_id'), + chat_id=session_data.get('chat_id'), verified_at=session_data.get('verified_at'), expires_in_seconds=ttl if ttl > 0 else None ) @@ -143,20 +221,47 @@ async def logout_session(request: SessionLogoutRequest): raise HTTPException(status_code=500, detail=f"Ошибка при выходе: {str(e)}") +@router.post("/verify-by-channel", response_model=SessionVerifyResponse) +async def verify_session_by_channel(request: SessionVerifyByChannelRequest): + """ + Проверить сессию по channel и channel_user_id (ключ Redis: session:{channel}:{channel_user_id}). + Используется, когда клиент не хранит session_token и передаёт channel + channel_user_id. + """ + try: + data = await get_session_by_channel_user(request.channel, request.channel_user_id) + if not data: + return SessionVerifyResponse(success=True, valid=False) + ttl = await redis_client.ttl(_session_key_by_channel(request.channel, request.channel_user_id)) if redis_client else 0 + return SessionVerifyResponse( + success=True, + valid=True, + unified_id=data.get("unified_id"), + phone=data.get("phone"), + contact_id=data.get("contact_id"), + chat_id=data.get("chat_id"), + verified_at=data.get("verified_at"), + expires_in_seconds=ttl if ttl > 0 else None, + ) + except Exception as e: + logger.exception("Ошибка verify-by-channel: %s", e) + raise HTTPException(status_code=500, detail="Ошибка проверки сессии") + + class SessionCreateRequest(BaseModel): session_token: str unified_id: str phone: str contact_id: str ttl_hours: int = 24 + chat_id: Optional[str] = None # telegram_user_id или max_user_id для передачи в n8n как chat_id @router.post("/create") async def create_session(request: SessionCreateRequest): """ - Создать новую сессию (вызывается после успешной SMS верификации) + Создать новую сессию (вызывается после успешной SMS верификации или TG/MAX auth) - Обычно вызывается из Step1Phone после получения данных от n8n. + Обычно вызывается из Step1Phone после получения данных от n8n или из auth2/tg/max auth. """ try: if not redis_client: @@ -171,6 +276,8 @@ async def create_session(request: SessionCreateRequest): 'verified_at': datetime.utcnow().isoformat(), 'expires_at': (datetime.utcnow() + timedelta(hours=request.ttl_hours)).isoformat() } + if request.chat_id is not None: + session_data['chat_id'] = str(request.chat_id).strip() # Сохраняем в Redis с TTL await redis_client.setex( diff --git a/backend/app/api/telegram_auth.py b/backend/app/api/telegram_auth.py index 328f1e3..b2e98c4 100644 --- a/backend/app/api/telegram_auth.py +++ b/backend/app/api/telegram_auth.py @@ -31,11 +31,12 @@ class TelegramAuthRequest(BaseModel): class TelegramAuthResponse(BaseModel): success: bool - session_token: str - unified_id: str + session_token: Optional[str] = None + unified_id: Optional[str] = None contact_id: Optional[str] = None phone: Optional[str] = None has_drafts: Optional[bool] = None + need_contact: Optional[bool] = None def _generate_session_token() -> str: @@ -114,15 +115,35 @@ async def telegram_auth(request: TelegramAuthRequest): 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") + # Логируем сырой ответ n8n для отладки (ключи и need_contact/unified_id) + logger.info("[TG] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__) + _result = n8n_data.get("result") + _result_dict = _result if isinstance(_result, dict) else {} + if _result_dict: + logger.info("[TG] n8n result ключи: %s", list(_result_dict.keys())) + # Если n8n вернул need_contact — пользователя нет в базе, мини-апп должен закрыться + _raw = ( + n8n_data.get("need_contact") + or _result_dict.get("need_contact") + or n8n_data.get("needContact") + or _result_dict.get("needContact") + ) + need_contact = _raw is True or _raw == 1 or (isinstance(_raw, str) and str(_raw).strip().lower() in ("true", "1")) + if need_contact: + logger.info("[TG] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение") + return TelegramAuthResponse(success=False, need_contact=True) + + # Ожидаем от n8n как минимум unified_id + unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId") + contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId") + phone = n8n_data.get("phone") or _result_dict.get("phone") + has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts") + + # Нет unified_id = пользователь не найден в базе → тоже возвращаем need_contact, чтобы фронт закрыл мини-апп if not unified_id: - logger.error("[TG] n8n не вернул unified_id. Полный ответ: %s", n8n_data) - raise HTTPException(status_code=500, detail="n8n не вернул unified_id для Telegram пользователя") + logger.info("[TG] n8n не вернул unified_id (пользователь не в базе) — возвращаем need_contact=true. Ответ n8n: %s", n8n_data) + return TelegramAuthResponse(success=False, need_contact=True) # 4. Создаём сессию в Redis через существующий /api/v1/session/create # Для Telegram телефон может быть ещё неизвестен, поэтому передаём пустые строки при отсутствии. @@ -132,6 +153,7 @@ async def telegram_auth(request: TelegramAuthRequest): phone=phone or "", contact_id=contact_id or "", ttl_hours=24, + chat_id=str(telegram_user_id) if telegram_user_id else None, ) try: diff --git a/backend/app/config.py b/backend/app/config.py index 49e8282..a8fd520 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -62,20 +62,33 @@ class Settings(BaseSettings): return f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}" # ============================================ - # REDIS + # REDIS (внешний — события, буферы, SMS и т.д.) # ============================================ redis_host: str = "localhost" redis_port: int = 6379 redis_password: str = "CRM_Redis_Pass_2025_Secure!" redis_db: int = 0 redis_prefix: str = "ticket_form:" + + # Redis для сессий (локальный в Docker — miniapp_redis; снаружи — localhost:6383 или свой) + redis_session_host: str = "localhost" + redis_session_port: int = 6383 + redis_session_password: str = "" + redis_session_db: int = 0 @property def redis_url(self) -> str: - """Формирует URL для подключения к Redis""" + """Формирует URL для подключения к Redis (внешний)""" if self.redis_password: return f"redis://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}" return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}" + + @property + def redis_session_url(self) -> str: + """URL для локального Redis сессий""" + if self.redis_session_password: + return f"redis://:{self.redis_session_password}@{self.redis_session_host}:{self.redis_session_port}/{self.redis_session_db}" + return f"redis://{self.redis_session_host}:{self.redis_session_port}/{self.redis_session_db}" # ============================================ # RABBITMQ @@ -184,9 +197,14 @@ 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_description_webhook: str = "https://n8n.clientright.ru/webhook/ticket_form_description" # Webhook для описания проблемы (переопределяется через N8N_DESCRIPTION_WEBHOOK в .env) + # Wizard и финальная отправка заявки (create) — один webhook, меняется через .env + n8n_ticket_form_final_webhook: str = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3" n8n_tg_auth_webhook: str = "" # Webhook для авторизации пользователей Telegram WebApp (Mini App) + # Контактные данные из CRM для раздела «Профиль» (массив или пусто) + n8n_contact_webhook: str = "" # N8N_CONTACT_WEBHOOK в .env + # ============================================ # TELEGRAM BOT # ============================================ @@ -197,6 +215,7 @@ class Settings(BaseSettings): # ============================================ max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp n8n_max_auth_webhook: str = "" # Webhook n8n: max_user_id → unified_id, contact_id, has_drafts + n8n_auth_webhook: str = "" # Универсальный auth: channel + channel_user_id + init_data → unified_id, phone, contact_id, has_drafts # ============================================ # LOGGING diff --git a/backend/app/main.py b/backend/app/main.py index 1acca7a..289795c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ import time import uuid from typing import Any, Dict, Optional, Tuple +import redis.asyncio as redis from .config import settings, get_cors_origins_live, get_settings from .services.database import db from .services.redis_service import redis_service @@ -17,7 +18,7 @@ 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, banks, telegram_auth, max_auth, auth2, documents_draft_open +from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2, auth_universal, documents_draft_open, profile from .api import debug_session # Настройка логирования @@ -119,12 +120,23 @@ async def lifespan(app: FastAPI): logger.warning(f"⚠️ PostgreSQL not available: {e}") try: - # Подключаем Redis + # Подключаем внешний Redis (события, буферы, SMS и т.д.) await redis_service.connect() - # Инициализируем session API с Redis connection - session.init_redis(redis_service.client) except Exception as e: logger.warning(f"⚠️ Redis not available: {e}") + + try: + # Подключаем локальный Redis для сессий (отдельно от внешнего) + session_redis = await redis.from_url( + settings.redis_session_url, + encoding="utf-8", + decode_responses=True, + ) + await session_redis.ping() + session.init_redis(session_redis) + logger.info(f"✅ Session Redis connected: {settings.redis_session_host}:{settings.redis_session_port}") + except Exception as e: + logger.warning(f"⚠️ Session Redis not available: {e}") try: # Подключаем RabbitMQ @@ -159,6 +171,9 @@ async def lifespan(app: FastAPI): await db.disconnect() await redis_service.disconnect() + if session.redis_client: + await session.redis_client.close() + session.init_redis(None) await rabbitmq_service.disconnect() await policy_service.close() await crm_mysql_service.close() @@ -226,6 +241,8 @@ app.include_router(banks.router) # 🏦 Banks API (NSPK banks list) app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth app.include_router(max_auth.router) # 📱 MAX Mini App auth app.include_router(auth2.router) # 🆕 Alt auth endpoint (tg/max/sms) +app.include_router(auth_universal.router) # Универсальный auth: channel + init_data → N8N_AUTH_WEBHOOK, Redis session:{channel}:{channel_user_id} +app.include_router(profile.router) # 👤 Профиль: контакты из CRM через N8N_CONTACT_WEBHOOK app.include_router(documents_draft_open.router) # 🆕 Documents draft-open (isolated) app.include_router(debug_session.router) # 🔧 Debug helpers (set session + redirect) diff --git a/docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js b/docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js new file mode 100644 index 0000000..a83a9e5 --- /dev/null +++ b/docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js @@ -0,0 +1,51 @@ +// ======================================== +// Code Node: Формирование JSON для ответа N8N_CONTACT_WEBHOOK (профиль) +// Данные берутся из ноды select_user1 (SQL/запрос контакта). +// Выход этой ноды подаётся в "Respond to Webhook" как Response Body. +// ======================================== +// +// Вход из ноды select_user1 (массив строк или один item на строку): +// contactid, firstname, lastname, email, mobile, phone, birthday, mailingstreet, +// middle_name, birthplace, inn, verification, bank +// +// Выход для вебхука: { "items": [ { ...поля в snake_case... } ] } или { "items": [] } +// ======================================== + +// Данные из ноды select_user1 +const rawItems = $('select_user1').all(); +let rows = []; +if (rawItems.length === 1 && Array.isArray(rawItems[0].json)) { + rows = rawItems[0].json; +} else if (rawItems.length === 1 && Array.isArray(rawItems[0].json?.items)) { + rows = rawItems[0].json.items; +} else if (rawItems.length === 1 && rawItems[0].json && !Array.isArray(rawItems[0].json)) { + rows = [rawItems[0].json]; +} else { + rows = rawItems.map(i => i.json).filter(Boolean); +} + +function mapRow(r) { + const v = (key) => { + const x = r[key]; + return x !== undefined && x !== null && String(x).trim() !== '' ? String(x).trim() : ''; + }; + return { + contact_id: r.contactid ?? r.contact_id ?? '', + last_name: v('lastname') || v('last_name'), + first_name: v('firstname') || v('first_name'), + middle_name: v('middle_name') || v('middleName'), + birth_date: v('birthday') || v('birth_date') || v('birthDate'), + birth_place: v('birthplace') || v('birth_place') || v('birthPlace'), + inn: v('inn'), + email: v('email'), + registration_address: v('mailingstreet') || v('registration_address') || v('address'), + mailing_address: v('mailing_address') || v('postal_address'), + bank_for_compensation: v('bank') || v('bank_for_compensation'), + phone: v('mobile') || v('phone') || v('mobile_phone'), + }; +} + +const items = rows.map(mapRow); + +// Один выходной item с телом ответа для Respond to Webhook +return [{ json: { items } }]; diff --git a/docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md b/docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md new file mode 100644 index 0000000..43defb0 --- /dev/null +++ b/docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md @@ -0,0 +1,31 @@ +# Профиль: ответ N8N_CONTACT_WEBHOOK из SQL + +## Цепочка в n8n + +1. **Webhook** (POST) — получает от бэкенда `unified_id`, `entry_channel`, `chat_id`, `session_token`, `contact_id`, `phone`. +2. **SQL** — по `unified_id`/`contact_id` выбирает контакт из БД. Возвращает массив строк в формате: + - `contactid`, `firstname`, `lastname`, `email`, `mobile`, `phone`, `birthday`, `mailingstreet`, `middle_name`, `birthplace`, `inn`, `verification`, `bank` +3. **Code** — преобразует строки в JSON для ответа вебхука (см. `N8N_CODE_PROFILE_CONTACT_RESPONSE.js`). +4. **Respond to Webhook** — отдаёт ответ клиенту (тело = вывод Code). + +## Формат ответа + +- **Ничего не нашли:** вернуть **HTTP 200** и тело `{ "items": [] }`. +- **Нашли контакт(ы):** **HTTP 200** и тело `{ "items": [ { ...поля в snake_case... } ] }`. + +Поля контакта (уже в формате мини-апа после Code): + +- `last_name`, `first_name`, `middle_name` +- `birth_date`, `birth_place` +- `inn`, `email`, `phone` +- `registration_address` (в SQL: `mailingstreet` — адрес регистрации) +- `mailing_address`, `bank_for_compensation` + +## Подстановка Code-ноды + +- Скопировать код из `aiform_prod/docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js` в ноду **Code**. +- Вход Code — вывод SQL (один item с массивом в `json` или несколько items по одному контакту). +- Выход Code — один item с `{ "items": [ ... ] }`. +- В **Respond to Webhook** указать: ответить телом из предыдущей ноды (всё из Code), чтобы в ответ ушёл именно `{ "items": [...] }`. + +Если SQL не нашёл строк — перед Code добавьте условие (IF): при пустом результате отдавать в Respond to Webhook тело `{ "items": [] }` и статус 200. diff --git a/docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md b/docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md new file mode 100644 index 0000000..ad2f261 --- /dev/null +++ b/docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md @@ -0,0 +1,95 @@ +# Профиль пользователя и контакт-вебхук (N8N_CONTACT_WEBHOOK) + +Описание изменений: раздел «Профиль» в мини-апе, передача `chat_id` в n8n, формат ответа вебхука и Code-нода для формирования JSON из SQL. + +--- + +## 1. Раздел «Профиль» в мини-апе + +- **Роут:** `/profile` (фронт), кнопка «Профиль» в нижней панели ведёт на него без перезагрузки. +- **API:** `GET/POST /api/v1/profile/contact` — по `session_token` (или `unified_id`) запрашивает контактные данные из CRM через n8n-вебхук `N8N_CONTACT_WEBHOOK`. +- **Фронт:** страница `Profile.tsx` показывает поля: фамилия, имя, отчество, дата/место рождения, ИНН, email, адрес регистрации, почтовый адрес, банк для возмещения, мобильный телефон. Поддерживаются snake_case и camelCase из ответа. + +--- + +## 2. Конфиг и бэкенд + +- **config.py:** добавлена настройка `n8n_contact_webhook` из переменной окружения `N8N_CONTACT_WEBHOOK`. +- **main.py:** подключён роутер `profile`. +- **profile.py:** реализованы `GET/POST /api/v1/profile/contact`, верификация сессии по `session_token`, сборка тела запроса к вебхуку и нормализация ответа n8n в формат `{ "items": [...] }`. + +--- + +## 3. Передача chat_id (Telegram / Max user id) + +- **Сессия (session.py):** + - В `SessionCreateRequest` добавлено опциональное поле `chat_id`. + - При создании сессии в Redis сохраняется `chat_id`, если передан. + - В `SessionVerifyResponse` и в ответе `verify_session` возвращается `chat_id`. + +- **Где передаётся chat_id при создании сессии:** + - **auth2 (TG):** `chat_id = str(tg_user["telegram_user_id"])`. + - **auth2 (MAX):** `chat_id = str(max_user["max_user_id"])`. + - **telegram_auth:** `chat_id = str(telegram_user_id)`. + - **max_auth:** `chat_id = str(max_user_id)`. + - SMS-флоу: `chat_id` не передаётся. + +- **Профиль (profile.py):** + - В запрос к API добавлен параметр `chat_id` (query/body). + - При верификации сессии `chat_id` подставляется из сессии, если не передан явно. + - В теле POST на `N8N_CONTACT_WEBHOOK` всегда добавляется поле `chat_id` (строка), когда оно известно. + +- **Фронт (Profile.tsx):** + - При запросе профиля передаётся `chat_id`: из `Telegram.WebApp.initDataUnsafe?.user?.id` или из `WebApp.initDataUnsafe?.user?.id` (MAX). + +--- + +## 4. Формат запроса на N8N_CONTACT_WEBHOOK + +**Тело POST от бэкенда к n8n:** + +- `unified_id` (str) — идентификатор в CRM +- `entry_channel` (str) — `"telegram"` | `"max"` | `"web"` +- `chat_id` (str, опционально) — Telegram user id или Max user id +- `session_token`, `contact_id`, `phone` (опционально) + +--- + +## 5. Формат ответа из n8n (как возвращать и как маппится) + +**Ничего не нашли:** HTTP 200, тело: `[]` или `{ "items": [] }`. + +**Нашли контакт(ы):** HTTP 200, тело одно из: + +- массив `[{...}, ...]` → нормализуется в `{ "items": [...] }`; +- `{ "items": [...] }` — без изменений; +- `{ "contact": {...} }` / `{ "contact": [...] }` → в `items`; +- `{ "data": [...] }` → в `items`; +- один объект `{...}` → `{ "items": [{...}] }`; +- пустой объект `{}` → `{ "items": [] }`. + +**Поля контакта** (snake_case или camelCase): +`last_name`, `first_name`, `middle_name`, `birth_date`, `birth_place`, `inn`, `email`, `registration_address`, `mailing_address`, `bank_for_compensation`, `phone`. + +Подробности и маппинг полей описаны в докстринге модуля `backend/app/api/profile.py`. + +--- + +## 6. Code-нода n8n для ответа вебхука из SQL + +- **Файл:** `docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js` +- **Назначение:** после ноды **select_user1** (SQL) формирует JSON для ответа вебхука. +- **Вход:** данные из ноды `select_user1` (массив строк с полями contactid, firstname, lastname, email, mobile, phone, birthday, mailingstreet, middle_name, birthplace, inn, bank и т.д.). +- **Выход:** один item с `{ "items": [ {...}, ... ] }` в формате полей для мини-апа (snake_case). Пустой результат → `{ "items": [] }`. +- **Маппинг:** mailingstreet → registration_address, birthday → birth_date, birthplace → birth_place, bank → bank_for_compensation, mobile/phone → phone и т.д. + +Инструкция по цепочке Webhook → SQL → Code → Respond to Webhook: `docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md`. + +--- + +## 7. Прочие изменения (в рамках той же задачи) + +- События SSE: единый формат `event_type` + `message`, цвета по типу (trash_message, out_of_scope, consumer_consultation, consumer_complaint), не показывать «Подключено к событиям» как ответ, не перезаписывать consumer_consultation в out_of_scope. +- Кнопка «Домой» — программная навигация на главную. +- Закрытие приложения при `need_contact` от вебхука (повторный вызов close, fallback без initData). +- Передача в контакт-хук: unified_id, entry_channel, session_token, contact_id, phone, chat_id. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ecf55f..1ffa596 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,28 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import ClaimForm from './pages/ClaimForm'; import HelloAuth from './pages/HelloAuth'; +import Profile from './pages/Profile'; import BottomBar from './components/BottomBar'; import './App.css'; import { miniappLog, miniappSendLogs } from './utils/miniappLogger'; function App() { - const [pathname, setPathname] = useState(() => window.location.pathname || ''); + const [pathname, setPathname] = useState(() => { + const p = window.location.pathname || ''; + if (p !== '/hello' && !p.startsWith('/hello')) return '/hello'; + return p; + }); const [avatarUrl, setAvatarUrl] = useState(() => localStorage.getItem('user_avatar_url') || ''); const lastRouteTsRef = useRef(Date.now()); const lastPathRef = useRef(pathname); + useEffect(() => { + const path = window.location.pathname || '/'; + if (path !== '/hello' && !path.startsWith('/hello')) { + window.history.replaceState({}, '', '/hello' + (window.location.search || '') + (window.location.hash || '')); + } + }, []); + useEffect(() => { const onPopState = () => setPathname(window.location.pathname || ''); window.addEventListener('popstate', onPopState); @@ -65,12 +77,14 @@ function App() { return (
- {pathname.startsWith('/hello') ? ( + {pathname === '/profile' ? ( + + ) : pathname.startsWith('/hello') ? ( ) : ( )} - +
); } diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx index 7e873fe..3786bfb 100644 --- a/frontend/src/components/BottomBar.tsx +++ b/frontend/src/components/BottomBar.tsx @@ -6,22 +6,24 @@ import { miniappLog } from '../utils/miniappLogger'; interface BottomBarProps { currentPath: string; avatarUrl?: string; + onNavigate?: (path: string) => void; } -export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) { +export default function BottomBar({ currentPath, avatarUrl, onNavigate }: BottomBarProps) { const isHome = currentPath.startsWith('/hello'); + const isProfile = currentPath === '/profile'; const [backEnabled, setBackEnabled] = useState(false); // В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться useEffect(() => { - if (isHome) { + if (isHome || isProfile) { setBackEnabled(false); return; } setBackEnabled(false); const t = window.setTimeout(() => setBackEnabled(true), 1200); return () => window.clearTimeout(t); - }, [isHome, currentPath]); + }, [isHome, isProfile, currentPath]); const handleBack = (e: React.MouseEvent) => { e.preventDefault(); @@ -35,7 +37,7 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) { e.preventDefault(); const tgWebApp = (window as any).Telegram?.WebApp; const tgInitData = typeof tgWebApp?.initData === 'string' ? tgWebApp.initData : ''; - const isTg = + const hasTgContext = tgInitData.length > 0 || window.location.href.includes('tgWebAppData') || navigator.userAgent.includes('Telegram'); @@ -43,45 +45,70 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) { const maxWebApp = (window as any).WebApp; const maxInitData = typeof maxWebApp?.initData === 'string' ? maxWebApp.initData : ''; const maxStartParam = maxWebApp?.initDataUnsafe?.start_param; - const isMax = + const hasMaxContext = maxInitData.length > 0 || (typeof maxStartParam === 'string' && maxStartParam.length > 0); + // Если пользователь не поделился контактом, initData может быть пустым — всё равно пробуем close по наличию WebApp + const hasTgWebApp = !!tgWebApp && typeof tgWebApp.close === 'function'; + const hasMaxWebApp = !!maxWebApp && (typeof maxWebApp.close === 'function' || typeof maxWebApp.postEvent === 'function'); + miniappLog('bottom_bar_exit_click', { currentPath, - isTg, - isMax, + hasTgContext, + hasMaxContext, tgInitDataLen: tgInitData.length, maxInitDataLen: maxInitData.length, - hasTgClose: typeof tgWebApp?.close === 'function', - hasMaxClose: typeof maxWebApp?.close === 'function', - hasMaxPostEvent: typeof maxWebApp?.postEvent === 'function', + hasTgClose: hasTgWebApp, + hasMaxClose: hasMaxWebApp, }); - // ВАЖНО: telegram-web-app.js может объявлять Telegram.WebApp.close() вне Telegram. - // Поэтому выбираем платформу по реальному initData, иначе в MAX будем вызывать TG close и рано выходить. - if (isTg) { + // ВАЖНО: выбираем платформу по контексту (URL/UA/initData). Если оба есть — приоритет у того, у кого есть initData. + if (hasTgContext && hasTgWebApp && !hasMaxContext) { try { - if (typeof tgWebApp?.close === 'function') { - miniappLog('bottom_bar_exit_close', { platform: 'tg' }); - tgWebApp.close(); - return; - } - } catch (_) {} + miniappLog('bottom_bar_exit_close', { platform: 'tg' }); + tgWebApp.close(); + return; + } catch (err) { + miniappLog('bottom_bar_exit_error', { platform: 'tg', error: String(err) }); + } } - - if (isMax) { + if (hasMaxContext && hasMaxWebApp) { try { - if (typeof maxWebApp?.close === 'function') { + if (typeof maxWebApp.close === 'function') { miniappLog('bottom_bar_exit_close', { platform: 'max' }); maxWebApp.close(); return; } - if (typeof maxWebApp?.postEvent === 'function') { + if (typeof maxWebApp.postEvent === 'function') { miniappLog('bottom_bar_exit_close', { platform: 'max', method: 'postEvent' }); maxWebApp.postEvent('web_app_close'); return; } + } catch (err) { + miniappLog('bottom_bar_exit_error', { platform: 'max', error: String(err) }); + } + } + + // Когда контакт не дан, initData может быть пустым — пробуем закрыть по наличию объекта WebApp (без требования initData) + if (hasTgWebApp && !hasMaxWebApp) { + try { + miniappLog('bottom_bar_exit_close', { platform: 'tg_no_init', note: 'close without initData' }); + tgWebApp.close(); + return; + } catch (_) {} + } + if (hasMaxWebApp && !hasTgWebApp) { + try { + if (typeof maxWebApp.close === 'function') { + miniappLog('bottom_bar_exit_close', { platform: 'max_no_init', note: 'close without initData' }); + maxWebApp.close(); + return; + } + if (typeof maxWebApp.postEvent === 'function') { + maxWebApp.postEvent('web_app_close'); + return; + } } catch (_) {} } @@ -92,7 +119,7 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) { return (