""" Универсальный auth: один endpoint для TG и MAX. Принимает channel (tg|max) и init_data, валидирует, дергает N8N_AUTH_WEBHOOK, пишет сессию в Redis по ключу session:{channel}:{channel_user_id} и session:{session_token}. """ import logging import os import uuid from typing import Optional, Any, Dict, Union 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 need_profile_confirm: Optional[bool] = None profile_needs_attention: Optional[bool] = None def _to_bool(v: Any) -> Optional[bool]: if v is None: return None if isinstance(v, bool): return v if isinstance(v, (int, float)): if v == 1: return True if v == 0: return False if isinstance(v, str): s = v.strip().lower() if s in ("1", "true", "yes", "y", "да"): return True if s in ("0", "false", "no", "n", "нет", ""): return False return 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 обязателен") logger.debug("[AUTH] init_data length=%s", len(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") # URL из settings или напрямую из env (если в config нет поля n8n_auth_webhook) webhook_url = (getattr(settings, "n8n_auth_webhook", None) or os.environ.get("N8N_AUTH_WEBHOOK") 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, } # При мультиботе (Telegram или MAX) передаём bot_id (из extract_telegram_user / extract_max_user) if user.get("bot_id"): payload["bot_id"] = user["bot_id"] 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): # n8n Respond to Webhook может вернуть { "json": { success, phone, ... } } if "json" in raw and isinstance(raw.get("json"), dict): data = raw["json"] logger.info("[AUTH] парсинг: raw — dict с json, data keys=%s", list(data.keys())) else: 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")) # Флаг «профиль требует внимания»: приходит из n8n, прокидываем в сессию и на фронт need_profile_confirm = _to_bool( data.get("need_profile_confirm") if "need_profile_confirm" in data else data.get("needProfileConfirm") ) profile_needs_attention = _to_bool( data.get("profile_needs_attention") if "profile_needs_attention" in data else data.get("profileNeedsAttention") ) if profile_needs_attention is None: profile_needs_attention = need_profile_confirm # 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: # Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение msg = data.get("message") or "Ошибка авторизации." logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку: message=%s", msg) logger.debug("[AUTH] полный data при success=false: %s", data) return AuthUniversalResponse( success=False, need_contact=False, message=msg, ) # 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} _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) if _phone is not None and not isinstance(_phone, str): _phone = str(_phone).strip() or None elif isinstance(_phone, str): _phone = _phone.strip() or None session_data = { "unified_id": unified_id, "phone": _phone, "contact_id": _contact_id, "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, "need_profile_confirm": need_profile_confirm, "profile_needs_attention": profile_needs_attention, } logger.info("[AUTH] session_data: unified_id=%s, phone=%s", unified_id, session_data.get("phone")) 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), need_profile_confirm=need_profile_confirm, profile_needs_attention=profile_needs_attention, )