from __future__ import annotations import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request, HTTPException from fastapi.responses import JSONResponse from sqlalchemy import select, text, func from sqlalchemy.orm import selectinload from app.config import settings from app.database import engine, async_session processed_callbacks = set() from app.models import Base, BotConversation, BotProcessingLog from app.max_api import max_api from app.settings_cache import settings_cache from app.yandex_gpt import yandex_gpt from app.handlers.greeting import handle_greeting, handle_message from app.handlers.handoff import handle_callback, handle_contact_share from fastapi.middleware.cors import CORSMiddleware logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Starting max_bot service...") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) logger.info("Database tables ensured.") await _apply_migrations() await _seed_default_settings() await _register_webhook() yield await max_api.close() logger.info("max_bot service stopped.") app = FastAPI(title="AegisOne MAX Bot", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["https://service.aegisone.ru", "https://aegisone.ru"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) async def _apply_migrations(): async with engine.begin() as conn: try: await conn.execute(text(""" DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name='bot_tickets' AND column_name='status' ) THEN ALTER TABLE bot_tickets ADD COLUMN status VARCHAR(20) DEFAULT 'Новая'; END IF; END $$; """)) await conn.execute(text(""" UPDATE bot_tickets SET status = 'Новая' WHERE status IS NULL OR status NOT IN ('Новая','В работе','Закрыта'); """)) except Exception as e: logger.warning(f"Migration note: {e}") # BotUser new columns await _add_column_if_not_exists(conn, "bot_users", "patronymic", "VARCHAR(255)") await _add_column_if_not_exists(conn, "bot_users", "email", "VARCHAR(255)") await _add_column_if_not_exists(conn, "bot_users", "organization", "VARCHAR(255)") await _add_column_if_not_exists(conn, "bot_users", "address", "TEXT") await _add_column_if_not_exists(conn, "bot_users", "vcf_raw", "TEXT") await _add_column_if_not_exists(conn, "bot_users", "contact_hash", "VARCHAR(255)") await _add_column_if_not_exists(conn, "bot_users", "phone_verified", "BOOLEAN DEFAULT FALSE") await _add_column_if_not_exists(conn, "bot_users", "email_verified", "BOOLEAN DEFAULT FALSE") await _add_column_if_not_exists(conn, "bot_users", "last_interaction", "TIMESTAMP") await _add_column_if_not_exists(conn, "bot_users", "total_conversations", "INTEGER DEFAULT 0") await _add_column_if_not_exists(conn, "bot_users", "total_tickets", "INTEGER DEFAULT 0") # BotProcessingLog table await _create_table_if_not_exists(conn, "bot_processing_logs", """ CREATE TABLE IF NOT EXISTS bot_processing_logs ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES bot_users(id) ON DELETE CASCADE, conversation_id BIGINT REFERENCES bot_conversations(id) ON DELETE SET NULL, step VARCHAR(50) NOT NULL, status VARCHAR(20) DEFAULT 'ok', message TEXT, detail TEXT, created_at TIMESTAMP DEFAULT now() ) """) async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str): from sqlalchemy import text as sa_text _validate_sql_name(table) _validate_sql_name(column) _validate_sql_type(col_type) try: await conn.execute(sa_text(f""" DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name='{table}' AND column_name='{column}' ) THEN ALTER TABLE "{table}" ADD COLUMN "{column}" {col_type}; END IF; END $$; """)) except Exception as e: logger.warning(f"Migration add column {table}.{column}: {e}") async def _create_table_if_not_exists(conn, table: str, create_sql: str): from sqlalchemy import text as sa_text try: await conn.execute(sa_text(f""" DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_name='{table}' ) THEN {create_sql}; END IF; END $$; """)) except Exception as e: logger.warning(f"Migration create table {table}: {e}") def _validate_sql_name(name: str) -> None: import re if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_ ]*$', name): raise ValueError(f"Invalid SQL identifier: {name}") def _validate_sql_type(col_type: str) -> None: import re allowed = r'^(VARCHAR\(\d+\)|TEXT|INTEGER( DEFAULT \d+)?|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$' if not re.match(allowed, col_type.strip()): raise ValueError(f"Invalid SQL type: {col_type}") async def _log_processing(user_id: int, step: str, status: str = "ok", message: str = None, detail: str = None, conversation_id: int = None): try: async with async_session() as db: db.add(BotProcessingLog( user_id=user_id, conversation_id=conversation_id, step=step, status=status, message=message, detail=detail, )) await db.commit() except Exception as e: logger.warning(f"Failed to write processing log: {e}") async def _seed_default_settings(): defaults = { "assistant_name": "София", "assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности", "support_email": "zakaz@aegisone.ru", "email_subject": "Обращение через Виртуального помощника AegisOne Engineering", "ai_provider": "yandex", } async with async_session() as db: from app.models import BotSetting for key, value in defaults.items(): result = await db.execute(select(BotSetting).where(BotSetting.key == key)) if not result.scalar_one_or_none(): db.add(BotSetting(key=key, value=value)) await db.commit() settings_cache.invalidate() async def _register_webhook(): try: subs = await max_api.get_subscriptions() logger.info(f"Current subscriptions: {subs}") except Exception as e: logger.warning(f"Could not get subscriptions: {e}") try: result = await max_api.subscribe_webhook() logger.info(f"Webhook subscription result: {result}") except Exception as e: logger.error(f"Failed to subscribe webhook: {e}") def _verify_secret(request: Request) -> bool: secret = request.headers.get("X-Max-Bot-Api-Secret", "") if not secret: return True return secret == settings.webhook_secret async def _get_active_conv_id(user_id: int) -> int | None: try: async with async_session() as db: result = await db.execute( select(BotConversation) .where(BotConversation.user_id == user_id) .order_by(BotConversation.id.desc()) .limit(1) ) conv = result.scalar_one_or_none() return conv.id if conv else None except Exception: return None @app.post("/webhook") async def webhook(request: Request): if not _verify_secret(request): logger.warning("Invalid webhook secret") raise HTTPException(status_code=401, detail="Invalid secret") try: body = await request.json() except Exception: raise HTTPException(status_code=400, detail="Invalid JSON") update_type = body.get("update_type", "") logger.info(f"Received webhook: type={update_type}") user_id = None is_test = False try: if update_type == "bot_started": user_id = body.get("user", {}).get("user_id") or body.get("chat_id") payload = body.get("payload") user_info = body.get("user", {}) first_name = user_info.get("first_name", "") last_name = user_info.get("last_name", "") username = user_info.get("username", "") if user_id: is_test = max_api.is_test_user(user_id) if is_test: max_api.mock_replies.clear() conv_id = await _get_active_conv_id(user_id) await _log_processing(user_id, "bot_started", "ok", "Диалог начат", conversation_id=conv_id) await handle_greeting(user_id, payload, first_name, last_name, username) elif update_type == "message_created": message = body.get("message", {}) if not isinstance(message, dict): message = {} sender = message.get("sender", {}) user_id = sender.get("user_id") if isinstance(sender, dict) else None body_data = message.get("body", {}) text = body_data.get("text", "") if isinstance(body_data, dict) else "" logger.info(f"message_created: user_id={user_id} text={text[:80] if text else '(empty)'}") if user_id: is_test = max_api.is_test_user(user_id) if is_test: max_api.mock_replies.clear() conv_id = await _get_active_conv_id(user_id) await _log_processing(user_id, "message_received", "ok", f"Получено сообщение: {text[:100] if text else '(вложение)'}", conversation_id=conv_id) attachments = body_data.get("attachments", []) if isinstance(body_data, dict) else [] for att in attachments: if att.get("type") == "contact": if user_id: vcf_info = att.get("payload", {}).get("vcf_info", "") contact_hash = att.get("payload", {}).get("hash", "") vcf_data = _parse_vcf(vcf_info) phone = vcf_data.get("phone") or _extract_phone_from_vcf(vcf_info) async with async_session() as db: result = await db.execute( select(BotConversation) .where(BotConversation.user_id == user_id) .order_by(BotConversation.id.desc()) .limit(1) ) conv = result.scalar_one_or_none() conv_id = conv.id if conv else 0 await _log_processing(user_id, "contact_share", "ok", f"Получен контакт: {phone}", conversation_id=conv_id) await handle_contact_share(user_id, conv_id, phone, vcf_info, vcf_data, contact_hash) bot_replies = max_api.pop_mock_replies() if is_test else [] return JSONResponse({"ok": True, "bot_replies": bot_replies}) if user_id and text: logger.info(f"message_created: user={user_id} text={text[:80]}") conv_id = await _get_active_conv_id(user_id) await _log_processing(user_id, "intent_analysis", "ok", f"Анализ сообщения: {text[:100]}", conversation_id=conv_id) try: await handle_message(user_id, text) except Exception as e: logger.exception(f"handle_message FAILED: {e}") elif update_type == "dialog_cleared": user_id = body.get("user", {}).get("user_id") or body.get("chat_id") if user_id: conv_id = await _get_active_conv_id(user_id) await _log_processing(user_id, "dialog_cleared", "ok", "Диалог сброшен", conversation_id=conv_id) async with async_session() as db: result = await db.execute( select(BotConversation) .where(BotConversation.user_id == user_id) .order_by(BotConversation.id.desc()) .limit(1) ) conv = result.scalar_one_or_none() if conv: conv.state = "greeting" await db.commit() logger.info(f"Dialog cleared for user {user_id}, state reset") elif update_type == "message_callback": callback_data = body.get("callback", {}) message_data = body.get("message", {}) recipient = message_data.get("recipient", {}) if isinstance(message_data, dict) else {} user_id = recipient.get("user_id") if isinstance(recipient, dict) else None payload = callback_data.get("payload", "") if isinstance(callback_data, dict) else "" callback_id = callback_data.get("callback_id", "") if isinstance(callback_data, dict) else "" logger.info(f"message_callback: user_id={user_id} payload={payload} callback_id={callback_id[:30] if callback_id else ''}") if callback_id and callback_id in processed_callbacks: logger.info(f"message_callback: duplicate callback_id={callback_id[:30]}, skipping") return JSONResponse({"ok": True}) if callback_id: processed_callbacks.add(callback_id) if len(processed_callbacks) > 1000: processed_callbacks.clear() if user_id and payload: is_test = max_api.is_test_user(user_id) if is_test: max_api.mock_replies.clear() conv_id = await _get_active_conv_id(user_id) await _log_processing(user_id, "callback_received", "ok", f"Callback: {payload}", conversation_id=conv_id) await handle_callback(user_id, payload, callback_data) bot_replies = max_api.pop_mock_replies() if is_test else [] return JSONResponse({"ok": True, "bot_replies": bot_replies}) except Exception as e: logger.exception(f"Error processing webhook: {e}") if user_id is not None: await _log_processing(user_id, "error", "error", f"Ошибка обработки: {str(e)[:200]}") return JSONResponse({"ok": False, "error": str(e)}, status_code=500) @app.get("/health") async def health(): return {"status": "ok"} SENSITIVE_KEYS = {"yandex_gpt_key", "yandex_folder_id", "yandex_disk_token", "yandex_disk_root", "yandex_disk_client_id", "yandex_disk_client_secret"} @app.get("/api/bot/settings") async def api_get_settings(): all_settings = await settings_cache.get_all() masked = dict(all_settings) for key in SENSITIVE_KEYS: if key in masked and masked[key]: masked[key] = masked[key][:6] + "..." if len(masked[key]) > 6 else "***" return JSONResponse(masked) @app.post("/api/bot/settings/save") async def api_save_settings(request: Request): try: body = await request.json() except Exception: raise HTTPException(status_code=400, detail="Invalid JSON") yandex_keys = {"yandex_gpt_key", "yandex_folder_id", "yandex_disk_token", "yandex_disk_root", "yandex_disk_client_id", "yandex_disk_client_secret", "assistant_name", "assistant_role", "support_email", "email_subject", "max_bot_id", "ai_provider"} for key, value in body.items(): if key in yandex_keys: await settings_cache.set(key, str(value)) return JSONResponse({"ok": True}) @app.get("/api/bot/kb") async def api_get_kb(): from app.models import BotKnowledgeBase async with async_session() as db: result = await db.execute( select(BotKnowledgeBase).order_by(BotKnowledgeBase.sort_order) ) cards = result.scalars().all() data = [] for c in cards: data.append({ "id": c.id, "category_id": c.category_id, "question": c.question, "answer": c.answer or "", "keywords": c.keywords or [], "is_active": c.is_active, "source_url": c.source_url or "", "sort_order": c.sort_order, }) return JSONResponse(data) @app.post("/api/bot/kb") async def api_create_kb(request: Request): from app.models import BotKnowledgeBase body = await request.json() if not body.get("question") or not body["question"].strip(): return JSONResponse({"ok": False, "error": "Вопрос обязателен"}, status_code=400) async with async_session() as db: card = BotKnowledgeBase( category_id=body.get("category_id"), question=body["question"].strip(), answer=body.get("answer"), keywords=body.get("keywords", []), is_active=body.get("is_active", True), source_url=body.get("source_url"), sort_order=body.get("sort_order", 0), ) db.add(card) await db.commit() return JSONResponse({"ok": True}) @app.delete("/api/bot/kb/{card_id}") async def api_delete_kb(card_id: int): from app.models import BotKnowledgeBase async with async_session() as db: result = await db.execute( select(BotKnowledgeBase).where(BotKnowledgeBase.id == card_id) ) card = result.scalar_one_or_none() if card: await db.delete(card) await db.commit() return JSONResponse({"ok": True}) @app.put("/api/bot/kb/{card_id}") async def api_update_kb(card_id: int, request: Request): from app.models import BotKnowledgeBase body = await request.json() async with async_session() as db: result = await db.execute( select(BotKnowledgeBase).where(BotKnowledgeBase.id == card_id) ) card = result.scalar_one_or_none() if not card: return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) if "category_id" in body: card.category_id = body["category_id"] if "question" in body: card.question = body["question"] if "answer" in body: card.answer = body.get("answer") if "keywords" in body: card.keywords = body["keywords"] if "is_active" in body: card.is_active = body["is_active"] if "source_url" in body: card.source_url = body.get("source_url") if "sort_order" in body: card.sort_order = body["sort_order"] await db.commit() return JSONResponse({"ok": True}) @app.get("/api/bot/unknown-questions") async def api_get_unknown_questions(): from app.models import BotUnknownQuestion status_filter = None async with async_session() as db: query = select(BotUnknownQuestion).order_by(BotUnknownQuestion.id.desc()) result = await db.execute(query) items = result.scalars().all() data = [] for q in items: data.append({ "id": q.id, "question_text": q.question_text, "normalized_text": q.normalized_text or "", "ask_count": q.ask_count, "user_ids": q.user_ids or [], "status": q.status, "generated_answer": q.generated_answer or "", "generated_keywords": q.generated_keywords or [], "generated_category_id": q.generated_category_id, "created_at": q.created_at.isoformat() if q.created_at else "", }) return JSONResponse(data) @app.put("/api/bot/unknown-questions/{qid}/approve") async def api_approve_unknown_question(qid: int, request: Request): from app.models import BotUnknownQuestion, BotKnowledgeBase body = await request.json() async with async_session() as db: result = await db.execute(select(BotUnknownQuestion).where(BotUnknownQuestion.id == qid)) q = result.scalar_one_or_none() if not q: return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) card = BotKnowledgeBase( category_id=body.get("category_id", q.generated_category_id or 1), question=body.get("question", q.normalized_text or q.question_text), answer=body.get("answer", q.generated_answer), keywords=body.get("keywords", q.generated_keywords or []), is_active=True, sort_order=0, ) db.add(card) q.status = "reviewed" q.reviewed_at = datetime.datetime.utcnow() await db.commit() return JSONResponse({"ok": True}) @app.put("/api/bot/unknown-questions/{qid}/edit") async def api_edit_unknown_question(qid: int, request: Request): from app.models import BotUnknownQuestion, BotKnowledgeBase body = await request.json() async with async_session() as db: result = await db.execute(select(BotUnknownQuestion).where(BotUnknownQuestion.id == qid)) q = result.scalar_one_or_none() if not q: return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) card = BotKnowledgeBase( category_id=body.get("category_id", 1), question=body.get("question", q.question_text), answer=body.get("answer"), keywords=body.get("keywords", []), is_active=True, sort_order=0, ) db.add(card) q.status = "reviewed" q.reviewed_at = datetime.datetime.utcnow() q.generated_answer = body.get("answer", q.generated_answer) q.generated_keywords = body.get("keywords", q.generated_keywords) q.generated_category_id = body.get("category_id", q.generated_category_id) await db.commit() return JSONResponse({"ok": True}) @app.put("/api/bot/unknown-questions/{qid}/ignore") async def api_ignore_unknown_question(qid: int): from app.models import BotUnknownQuestion async with async_session() as db: result = await db.execute(select(BotUnknownQuestion).where(BotUnknownQuestion.id == qid)) q = result.scalar_one_or_none() if not q: return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) q.status = "ignored" q.reviewed_at = datetime.datetime.utcnow() await db.commit() return JSONResponse({"ok": True}) @app.get("/api/bot/users/consented") async def api_get_consented_users(): from app.models import BotUser async with async_session() as db: result = await db.execute( select(BotUser).where(BotUser.consent_given == True).order_by(BotUser.consent_date.desc()) ) users = result.scalars().all() data = [] for u in users: data.append({ "id": u.id, "first_name": u.first_name or "", "last_name": u.last_name or "", "patronymic": u.patronymic or "", "username": u.username or "", "phone": u.phone or "", "email": u.email or "", "organization": u.organization or "", "consent_given": u.consent_given, "consent_date": u.consent_date.isoformat() if u.consent_date else "", "phone_verified": u.phone_verified, "last_interaction": u.last_interaction.isoformat() if u.last_interaction else "", "total_conversations": u.total_conversations, "total_tickets": u.total_tickets, "created_at": u.created_at.isoformat() if u.created_at else "", }) return JSONResponse(data) @app.post("/api/bot/broadcast") async def api_broadcast(request: Request): body = await request.json() user_ids = body.get("user_ids", []) text = body.get("text", "") send_all = body.get("all", False) if not text: return JSONResponse({"ok": False, "error": "Text is required"}, status_code=400) if send_all: from app.models import BotUser async with async_session() as db: result = await db.execute(select(BotUser)) users = result.scalars().all() user_ids = [u.id for u in users if u.consent_given] if not user_ids: return JSONResponse({"ok": False, "error": "No recipients"}, status_code=400) sent = 0 errors = 0 import asyncio for i, uid in enumerate(user_ids): try: await max_api.send_message(uid, text) sent += 1 except Exception as e: logger.error(f"Broadcast failed for user {uid}: {e}") errors += 1 if i % 10 == 9: await asyncio.sleep(0.5) return JSONResponse({"ok": True, "sent": sent, "errors": errors}) @app.post("/api/bot/broadcast/preview") async def api_broadcast_preview(request: Request): body = await request.json() text = body.get("text", "") return JSONResponse({"ok": True, "preview": text}) @app.get("/api/bot/tickets") async def api_get_tickets(): from app.models import BotTicket async with async_session() as db: result = await db.execute( select(BotTicket).order_by(BotTicket.id.desc()).limit(100) ) tickets = result.scalars().all() data = [] for t in tickets: data.append({ "id": t.id, "user_id": t.user_id, "title": t.title or "", "description": t.description or "", "priority": t.priority, "status": t.status, "contact_name": t.contact_name or "", "contact_phone": t.contact_phone or "", "contact_email": t.contact_email or "", "created_at": t.created_at.isoformat() if t.created_at else "", }) return JSONResponse(data) @app.patch("/api/bot/tickets/{ticket_id}") async def api_update_ticket(ticket_id: int, request: Request): from app.models import BotTicket, BotTicketStatus body = await request.json() status = body.get("status") if status and status not in ("Новая", "В работе", "Закрыта"): return JSONResponse({"ok": False, "error": "Invalid status"}, status_code=400) async with async_session() as db: result = await db.execute( select(BotTicket).where(BotTicket.id == ticket_id) ) ticket = result.scalar_one_or_none() if not ticket: return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) if status and status != ticket.status: old_status = ticket.status ticket.status = status db.add(BotTicketStatus( ticket_id=ticket_id, old_status=old_status, new_status=status, changed_by="engineer", )) await db.commit() return JSONResponse({"ok": True}) @app.post("/api/bot/test/clear") async def api_clear_test_data(): from sqlalchemy import text as sa_text async with engine.begin() as conn: await conn.execute(sa_text("DELETE FROM bot_processing_logs WHERE user_id >= 999999000")) result = await conn.execute(sa_text("DELETE FROM bot_users WHERE id >= 999999000")) deleted = result.rowcount return JSONResponse({"ok": True, "deleted": deleted}) @app.get("/api/bot/conversations") async def api_get_conversations(): from app.models import BotUser, BotMessage async with async_session() as db: result = await db.execute( select(BotConversation).options( selectinload(BotConversation.user) ).order_by(BotConversation.id.desc()).limit(100) ) convs = result.unique().scalars().all() data = [] for c in convs: msg_count = await db.scalar( select(func.count()).select_from(BotMessage).where(BotMessage.conversation_id == c.id) ) data.append({ "id": c.id, "user_id": c.user_id, "user_name": f"{c.user.first_name or ''} {c.user.last_name or ''}".strip() or str(c.user_id), "state": c.state, "intent": c.intent or "", "inquiry_text": c.inquiry_text or "", "message_count": msg_count or 0, "created_at": c.created_at.isoformat() if c.created_at else "", "updated_at": c.updated_at.isoformat() if c.updated_at else "", }) return JSONResponse(data) @app.get("/api/bot/conversations/{conv_id}/messages") async def api_get_conversation_messages(conv_id: int): from app.models import BotMessage async with async_session() as db: result = await db.execute( select(BotMessage).where(BotMessage.conversation_id == conv_id).order_by(BotMessage.id) ) msgs = result.scalars().all() data = [] for m in msgs: data.append({ "id": m.id, "direction": m.direction, "text": m.text or "", "attachment_json": m.attachment_json or "", "created_at": m.created_at.isoformat() if m.created_at else "", }) return JSONResponse(data) @app.get("/api/bot/conversations/{conv_id}/logs") async def api_get_conversation_logs(conv_id: int): async with async_session() as db: conv = await db.get(BotConversation, conv_id) if not conv: return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) result = await db.execute( select(BotProcessingLog).where(BotProcessingLog.user_id == conv.user_id).order_by(BotProcessingLog.id.desc()).limit(200) ) logs = result.scalars().all() data = [] for l in logs: data.append({ "id": l.id, "step": l.step, "status": l.status, "message": l.message or "", "detail": l.detail or "", "created_at": l.created_at.isoformat() if l.created_at else "", }) return JSONResponse(data) @app.delete("/api/bot/conversations/{conv_id}") async def api_delete_conversation(conv_id: int): from app.models import BotMessage, BotProcessingLog from sqlalchemy import delete as sa_delete async with async_session() as db: conv = await db.get(BotConversation, conv_id) if not conv: return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) await db.execute(sa_delete(BotProcessingLog).where(BotProcessingLog.conversation_id == conv_id)) await db.execute(sa_delete(BotMessage).where(BotMessage.conversation_id == conv_id)) await db.delete(conv) await db.commit() return JSONResponse({"ok": True}) @app.delete("/api/bot/users/{user_id}") async def api_delete_bot_user(user_id: int): from app.models import BotMessage, BotProcessingLog from sqlalchemy import delete as sa_delete async with async_session() as db: user = await db.get(BotUser, user_id) if not user: return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) conv_ids_q = await db.execute( select(BotConversation.id).where(BotConversation.user_id == user_id) ) conv_ids = [r[0] for r in conv_ids_q.all()] if conv_ids: await db.execute(sa_delete(BotProcessingLog).where(BotProcessingLog.conversation_id.in_(conv_ids))) await db.execute(sa_delete(BotMessage).where(BotMessage.conversation_id.in_(conv_ids))) await db.execute(sa_delete(BotConversation).where(BotConversation.id.in_(conv_ids))) await db.delete(user) await db.commit() return JSONResponse({"ok": True}) def _extract_phone_from_vcf(vcf_info: str) -> str: import re match = re.search(r"TEL[^:]*:(\+?\d[\d\s\-()]*)", vcf_info) if match: return re.sub(r"[\s\-()]", "", match.group(1).strip()) return "" def _parse_vcf(vcf_info: str) -> dict: import re result = { "first_name": "", "last_name": "", "patronymic": "", "phone": "", "email": "", "organization": "", "address": "", } # FN: full name (first + last) fn_match = re.search(r"FN:(.+)", vcf_info) if fn_match: full_name = fn_match.group(1).strip() parts = full_name.split(None, 2) if len(parts) >= 2: result["first_name"] = parts[0] result["last_name"] = parts[1] if len(parts) >= 3: result["patronymic"] = parts[2] elif len(parts) == 1: result["first_name"] = parts[0] # N: structured name (last;first;middle;prefix;suffix) n_match = re.search(r"N(?:;[^:]*)?:([^\r\n]+)", vcf_info) if n_match: n_parts = n_match.group(1).split(";") if len(n_parts) >= 1 and n_parts[0]: result["last_name"] = n_parts[0] if len(n_parts) >= 2 and n_parts[1]: result["first_name"] = n_parts[1] if len(n_parts) >= 3 and n_parts[2]: result["patronymic"] = n_parts[2] # TEL: phone tel_match = re.search(r"TEL[^:]*:(\+?\d[\d\s\-()]*)", vcf_info) if tel_match: phone_raw = tel_match.group(1).strip() result["phone"] = re.sub(r"[\s\-()]", "", phone_raw) # EMAIL email_match = re.search(r"EMAIL[^:]*:([^\r\n]+)", vcf_info) if email_match: result["email"] = email_match.group(1).strip() # ORG org_match = re.search(r"ORG[^:]*:([^\r\n]+)", vcf_info) if org_match: result["organization"] = org_match.group(1).strip() # ADR: structured address adr_match = re.search(r"ADR[^:]*:([^\r\n]+)", vcf_info) if adr_match: result["address"] = adr_match.group(1).strip().replace(";", ", ") return result