554 lines
20 KiB
Python
554 lines
20 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
|
||
|
||
from app.config import settings
|
||
from app.database import engine, async_session
|
||
from app.models import Base, BotConversation
|
||
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")
|
||
|
||
|
||
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 $$;
|
||
"""))
|
||
|
||
|
||
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|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$'
|
||
if not re.match(allowed, col_type.strip()):
|
||
raise ValueError(f"Invalid SQL type: {col_type}")
|
||
except Exception as e:
|
||
logger.warning(f"Migration add column {table}.{column}: {e}")
|
||
|
||
|
||
async def _seed_default_settings():
|
||
defaults = {
|
||
"assistant_name": "София",
|
||
"assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности",
|
||
"support_email": "zakaz@aegisone.ru",
|
||
"email_subject": "Обращение через Виртуального помощника AegisOne Engineering",
|
||
}
|
||
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", "")
|
||
return secret == settings.webhook_secret
|
||
|
||
|
||
@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}")
|
||
|
||
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:
|
||
await handle_greeting(user_id, payload, first_name, last_name, username)
|
||
|
||
elif update_type == "message_created":
|
||
user_id = body.get("sender", {}).get("user_id") or body.get("chat_id")
|
||
text = body.get("body", {}).get("text", "")
|
||
|
||
attachments = body.get("body", {}).get("attachments", [])
|
||
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 handle_contact_share(user_id, conv_id, phone, vcf_info, vcf_data, contact_hash)
|
||
return JSONResponse({"ok": True})
|
||
|
||
if user_id and text:
|
||
await handle_message(user_id, text)
|
||
|
||
elif update_type == "dialog_cleared":
|
||
user_id = body.get("user", {}).get("user_id") or body.get("chat_id")
|
||
if user_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":
|
||
user_id = body.get("user", {}).get("user_id") or body.get("chat_id")
|
||
callback_id = body.get("callback", {}).get("callback_id", "")
|
||
callback_data = body.get("callback", {})
|
||
|
||
if user_id and callback_id:
|
||
await handle_callback(user_id, callback_id, callback_data)
|
||
|
||
return JSONResponse({"ok": True})
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Error processing webhook: {e}")
|
||
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"}
|
||
|
||
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/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,
|
||
"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})
|
||
|
||
|
||
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
|