- Добавлена полная интеграция с 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 интеграции
528 lines
24 KiB
Python
528 lines
24 KiB
Python
"""
|
||
N8N Webhook Proxy Router
|
||
Безопасное проксирование запросов к n8n webhooks.
|
||
Frontend не знает прямых URL webhooks!
|
||
"""
|
||
import httpx
|
||
import logging
|
||
from fastapi import APIRouter, HTTPException, File, UploadFile, Form, Request
|
||
from fastapi.responses import JSONResponse
|
||
from typing import Optional
|
||
|
||
from ..config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/n8n", tags=["n8n-proxy"])
|
||
|
||
|
||
# URL webhooks - берём из settings (defaults в config.py)
|
||
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")
|
||
async def proxy_policy_check(request: Request):
|
||
"""
|
||
Проксирует проверку полиса к n8n webhook
|
||
|
||
Frontend отправляет: POST /api/n8n/policy/check
|
||
Backend проксирует к: https://n8n.clientright.pro/webhook/{uuid}
|
||
"""
|
||
if not N8N_POLICY_CHECK_WEBHOOK:
|
||
raise HTTPException(status_code=500, detail="N8N webhook не настроен")
|
||
|
||
try:
|
||
# Получаем JSON body от фронтенда
|
||
body = await request.json()
|
||
body.setdefault('form_id', 'ticket_form')
|
||
|
||
logger.info(f"🔄 Proxy policy check: {body.get('policy_number', 'unknown')}")
|
||
|
||
# Проксируем запрос к n8n
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
response = await client.post(
|
||
N8N_POLICY_CHECK_WEBHOOK,
|
||
json=body,
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
response_text = response.text
|
||
logger.info(f"✅ Policy check success. Response: {response_text[:500]}")
|
||
|
||
try:
|
||
return response.json()
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to parse JSON: {e}. Response: {response_text[:500]}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
|
||
else:
|
||
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
|
||
raise HTTPException(
|
||
status_code=response.status_code,
|
||
detail=f"N8N error: {response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("⏱️ N8N webhook timeout")
|
||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n")
|
||
except Exception as e:
|
||
logger.error(f"❌ Error proxying to n8n: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка проверки полиса: {str(e)}")
|
||
|
||
|
||
@router.post("/contact/create")
|
||
async def proxy_create_contact(request: Request):
|
||
"""
|
||
Проксирует создание контакта к n8n webhook
|
||
|
||
Frontend отправляет: POST /api/n8n/contact/create
|
||
Backend проксирует к: https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27
|
||
"""
|
||
if not N8N_CREATE_CONTACT_WEBHOOK:
|
||
raise HTTPException(status_code=500, detail="N8N contact webhook не настроен")
|
||
|
||
try:
|
||
body = await request.json()
|
||
|
||
logger.info(
|
||
"🔄 Proxy create contact: phone=%s, session_id=%s, form_id=%s",
|
||
body.get('phone', 'unknown'),
|
||
body.get('session_id', 'unknown'),
|
||
body.get('form_id', 'missing')
|
||
)
|
||
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
response = await client.post(
|
||
N8N_CREATE_CONTACT_WEBHOOK,
|
||
json=body,
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
response_text = response.text
|
||
logger.info(f"✅ Contact created successfully. Response: {response_text[:500]}")
|
||
|
||
if not response_text or response_text.strip() == '':
|
||
logger.error(f"❌ N8N returned empty response")
|
||
raise HTTPException(status_code=500, detail="N8N вернул пустой ответ")
|
||
|
||
try:
|
||
return response.json()
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to parse JSON: {e}. Response: {response_text[:500]}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
|
||
else:
|
||
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
|
||
raise HTTPException(
|
||
status_code=response.status_code,
|
||
detail=f"N8N error: {response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("⏱️ N8N webhook timeout")
|
||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n")
|
||
except Exception as e:
|
||
import traceback
|
||
logger.error(f"❌ Error proxying to n8n: {e}")
|
||
logger.error(f"❌ Traceback: {traceback.format_exc()}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка создания контакта: {str(e)}")
|
||
|
||
|
||
@router.post("/upload/file")
|
||
async def proxy_file_upload(
|
||
file: UploadFile = File(...),
|
||
claim_id: Optional[str] = Form(None),
|
||
voucher: Optional[str] = Form(None),
|
||
session_id: Optional[str] = Form(None),
|
||
file_type: Optional[str] = Form(None),
|
||
filename: Optional[str] = Form(None),
|
||
upload_timestamp: Optional[str] = Form(None)
|
||
):
|
||
"""
|
||
Проксирует загрузку файла к n8n webhook
|
||
|
||
Frontend отправляет: POST /api/n8n/upload/file (multipart/form-data)
|
||
Backend проксирует к: https://n8n.clientright.pro/webhook/{uuid}
|
||
"""
|
||
if not N8N_FILE_UPLOAD_WEBHOOK:
|
||
raise HTTPException(status_code=500, detail="N8N upload webhook не настроен")
|
||
|
||
try:
|
||
logger.info(f"🔄 Proxy file upload: {file.filename} for claim {claim_id}")
|
||
|
||
# Читаем файл
|
||
file_content = await file.read()
|
||
|
||
# Формируем multipart/form-data для n8n
|
||
files = {
|
||
'file': (file.filename, file_content, file.content_type)
|
||
}
|
||
|
||
data = {}
|
||
if claim_id:
|
||
data['claim_id'] = claim_id
|
||
if voucher:
|
||
data['voucher'] = voucher
|
||
if session_id:
|
||
data['session_id'] = session_id
|
||
if file_type:
|
||
data['file_type'] = file_type
|
||
if filename:
|
||
data['filename'] = filename
|
||
if upload_timestamp:
|
||
data['upload_timestamp'] = upload_timestamp
|
||
|
||
# Проксируем запрос к n8n
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
response = await client.post(
|
||
N8N_FILE_UPLOAD_WEBHOOK,
|
||
files=files,
|
||
data=data
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
response_text = response.text
|
||
logger.info(f"✅ File upload success")
|
||
|
||
if not response_text or response_text.strip() == '':
|
||
# n8n может вернуть пустой ответ, возвращаем заглушку
|
||
logger.warning("⚠️ N8N upload webhook вернул пустой ответ, подставляю default payload")
|
||
return {"success": True, "message": "n8n: empty response"}
|
||
|
||
try:
|
||
return response.json()
|
||
except Exception as e:
|
||
logger.error(f"❌ Не удалось распарсить JSON от n8n: {e}. Response: {response_text[:500]}")
|
||
# Возвращаем текстовое содержимое чтобы фронт мог показать пользователю
|
||
return JSONResponse(
|
||
status_code=200,
|
||
content={
|
||
"success": True,
|
||
"message": "n8n upload returned non-JSON response",
|
||
"raw": response_text
|
||
}
|
||
)
|
||
else:
|
||
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
|
||
raise HTTPException(
|
||
status_code=response.status_code,
|
||
detail=f"N8N error: {response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("⏱️ N8N webhook timeout")
|
||
raise HTTPException(status_code=504, detail="Таймаут загрузки файла")
|
||
except Exception as e:
|
||
logger.error(f"❌ Error proxying file to n8n: {e}")
|
||
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):
|
||
"""
|
||
Проксирует создание черновика заявки к n8n webhook
|
||
|
||
Frontend отправляет: POST /api/n8n/claim/create
|
||
Backend проксирует к: https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d
|
||
"""
|
||
if not N8N_CREATE_CLAIM_WEBHOOK:
|
||
raise HTTPException(status_code=500, detail="N8N claim webhook не настроен")
|
||
|
||
try:
|
||
# Получаем JSON body от фронтенда
|
||
body = await request.json()
|
||
|
||
logger.info(f"🔄 Proxy create claim: event_type={body.get('event_type', 'unknown')}, claim_id={body.get('claim_id', 'unknown')}")
|
||
|
||
# Проксируем запрос к n8n
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
response = await client.post(
|
||
N8N_CREATE_CLAIM_WEBHOOK,
|
||
json=body,
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
response_text = response.text
|
||
logger.info(f"✅ Claim created successfully. Response: {response_text[:200]}")
|
||
|
||
# Проверяем что ответ не пустой
|
||
if not response_text or response_text.strip() == '':
|
||
logger.error(f"❌ N8N returned empty response")
|
||
raise HTTPException(status_code=500, detail="N8N вернул пустой ответ")
|
||
|
||
try:
|
||
return response.json()
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to parse JSON: {e}. Response: {response_text[:500]}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
|
||
else:
|
||
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
|
||
raise HTTPException(
|
||
status_code=response.status_code,
|
||
detail=f"N8N error: {response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("⏱️ N8N webhook timeout")
|
||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n")
|
||
except Exception as e:
|
||
logger.error(f"❌ Error proxying to n8n: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка создания заявки: {str(e)}")
|
||
|
||
|
||
@router.post("/documents/attach")
|
||
async def attach_document_to_crm(request: Request):
|
||
"""
|
||
Привязывает загруженные файлы к проекту или заявке в vTiger CRM
|
||
|
||
Входные данные (массив документов):
|
||
[
|
||
{
|
||
"claim_id": "CLM-2025-11-02-WNRZZZ",
|
||
"contact_id": "320096",
|
||
"project_id": "396868",
|
||
"ticket_id": "396936", // Опционально
|
||
"filename": "boarding_pass.pdf",
|
||
"file_type": "flight_delay_boarding_or_ticket",
|
||
"file": "/bucket/path/to/file.pdf" // Без хоста, добавим https://s3.twcstorage.ru
|
||
}
|
||
]
|
||
|
||
Логика:
|
||
- Если указан ticket_id → привязываем к HelpDesk (заявке)
|
||
- Иначе → привязываем к Project (проекту)
|
||
"""
|
||
CRM_UPLOAD_ENDPOINT = "https://crm.clientright.ru/upload_documents_to_crm.php"
|
||
S3_HOST = "https://s3.twcstorage.ru"
|
||
|
||
try:
|
||
body = await request.json()
|
||
|
||
# Поддерживаем как массив, так и одиночный объект
|
||
documents_array = body if isinstance(body, list) else [body]
|
||
|
||
logger.info(f"📎 Attaching {len(documents_array)} document(s)")
|
||
|
||
# Обрабатываем каждый документ
|
||
processed_documents = []
|
||
for idx, doc in enumerate(documents_array):
|
||
contact_id = doc.get('contact_id')
|
||
project_id = doc.get('project_id')
|
||
ticket_id = doc.get('ticket_id') # Опционально
|
||
|
||
# Поддерживаем оба формата: file_url и file
|
||
file_path = doc.get('file') or doc.get('file_url')
|
||
if not file_path:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Document #{idx}: отсутствует поле 'file' или 'file_url'"
|
||
)
|
||
|
||
# Строим полный S3 URL если это путь без хоста
|
||
if file_path.startswith('/'):
|
||
file_url = S3_HOST + file_path
|
||
elif not file_path.startswith('http'):
|
||
file_url = S3_HOST + '/' + file_path
|
||
else:
|
||
file_url = file_path
|
||
|
||
# Поддерживаем оба формата: file_name и filename
|
||
file_name = doc.get('filename') or doc.get('file_name')
|
||
if not file_name:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Document #{idx}: отсутствует поле 'filename' или 'file_name'"
|
||
)
|
||
|
||
file_type = doc.get('file_type', 'Документ')
|
||
|
||
# Валидация обязательных полей
|
||
if not all([contact_id, project_id]):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Document #{idx}: обязательные поля: contact_id, project_id"
|
||
)
|
||
|
||
logger.info(f" [{idx+1}/{len(documents_array)}] {file_name} (type: {file_type})")
|
||
logger.info(f" Contact: {contact_id}, Project: {project_id}, Ticket: {ticket_id or 'N/A'}")
|
||
logger.info(f" File URL: {file_url}")
|
||
|
||
processed_documents.append({
|
||
"file_url": file_url,
|
||
"file_name": file_name,
|
||
"upload_description": file_type,
|
||
"contactid": int(contact_id),
|
||
"pages": 1
|
||
})
|
||
|
||
# Берем общие параметры из первого документа
|
||
first_doc = documents_array[0]
|
||
|
||
# Формируем payload для upload_documents_to_crm.php
|
||
upload_payload = {
|
||
"documents": processed_documents,
|
||
"projectid": int(first_doc.get('project_id')),
|
||
"ticket_id": int(first_doc.get('ticket_id')) if first_doc.get('ticket_id') else None,
|
||
"user_id": 1
|
||
}
|
||
|
||
logger.info(f"📤 Sending to CRM: {upload_payload}")
|
||
|
||
# Отправляем запрос к CRM
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
response = await client.post(
|
||
CRM_UPLOAD_ENDPOINT,
|
||
json=upload_payload,
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
response_text = response.text
|
||
logger.info(f"✅ Document attached successfully. Response: {response_text[:300]}")
|
||
|
||
try:
|
||
result = response.json()
|
||
|
||
# Проверяем успешность
|
||
if result.get('success') and result.get('results'):
|
||
results_array = result['results']
|
||
|
||
# Обрабатываем результаты для каждого документа
|
||
processed_results = []
|
||
errors = []
|
||
|
||
for idx, res in enumerate(results_array):
|
||
if res.get('status') == 'success':
|
||
crm_result = res.get('crm_result', {})
|
||
|
||
processed_results.append({
|
||
"document_id": crm_result.get('document_id'),
|
||
"document_numeric_id": crm_result.get('document_numeric_id'),
|
||
"attached_to": "ticket" if res.get('ticket_id') else "project",
|
||
"attached_to_id": res.get('ticket_id') or res.get('projectid'),
|
||
"file_name": res.get('file_name'),
|
||
"file_type": res.get('description'),
|
||
"s3_bucket": crm_result.get('s3_bucket'),
|
||
"s3_key": crm_result.get('s3_key'),
|
||
"file_size": crm_result.get('file_size'),
|
||
"message": crm_result.get('message')
|
||
})
|
||
|
||
logger.info(f" ✅ [{idx+1}] {res.get('file_name')} → {crm_result.get('document_id')}")
|
||
else:
|
||
# Ошибка для конкретного документа
|
||
error_msg = res.get('crm_result', {}).get('message', 'Unknown error')
|
||
errors.append({
|
||
"file_name": res.get('file_name'),
|
||
"error": error_msg
|
||
})
|
||
logger.error(f" ❌ [{idx+1}] {res.get('file_name')}: {error_msg}")
|
||
|
||
# Если есть хотя бы один успешный результат - считаем успехом
|
||
if processed_results:
|
||
return {
|
||
"success": True,
|
||
"total_processed": len(results_array),
|
||
"successful": len(processed_results),
|
||
"failed": len(errors),
|
||
"results": processed_results,
|
||
"errors": errors if errors else None
|
||
}
|
||
else:
|
||
# Все документы упали с ошибкой
|
||
logger.error(f"❌ All documents failed: {errors}")
|
||
raise HTTPException(status_code=500, detail=f"Все документы не удалось привязать: {errors}")
|
||
else:
|
||
logger.error(f"❌ Unexpected CRM response: {result}")
|
||
raise HTTPException(status_code=500, detail="Неожиданный ответ от CRM")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to parse CRM response: {e}. Response: {response_text[:500]}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа CRM: {str(e)}")
|
||
else:
|
||
logger.error(f"❌ CRM returned {response.status_code}: {response.text}")
|
||
raise HTTPException(
|
||
status_code=response.status_code,
|
||
detail=f"CRM error: {response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("⏱️ CRM upload timeout")
|
||
raise HTTPException(status_code=504, detail="Таймаут загрузки в CRM")
|
||
except HTTPException:
|
||
raise # Пробрасываем HTTPException как есть
|
||
except Exception as e:
|
||
logger.error(f"❌ Error attaching document: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Ошибка привязки документа: {str(e)}")
|
||
|