Files
site_aegisone/max_bot/app/main.py
T
angel 4c3026a80f
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
v1.8.2: AGENTS.md, consent revocation, delete bot users, dialogues fix, intent improvements, CI for max_bot
2026-06-02 18:22:45 +03:00

886 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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