886 lines
34 KiB
Python
886 lines
34 KiB
Python
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
|