v1.6.0: max_bot fixes — feature keys, flush→commit, test-run, categories, broadcast page, proxy error handling, deploy scripts

This commit is contained in:
2026-05-24 07:50:38 +03:00
parent bd048ea23d
commit 493e0b37a1
127 changed files with 6082 additions and 65 deletions
+879
View File
@@ -0,0 +1,879 @@
import json
import logging
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Optional
from fastapi import FastAPI, Request, Depends, Query
from fastapi.responses import JSONResponse, HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from app.config import get_settings
from app.database import get_db, async_session, engine
from app.models.models import Base
from app.max_api import MaxAPIClient
from app.settings_cache import SettingsCache
from app.bot_engine import handle_update
from app.text_renderer import render_template, make_clickable_links
settings = get_settings()
logger = logging.getLogger(__name__)
max_api: Optional[MaxAPIClient] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global max_api
async with engine.begin() as conn:
pass
async with async_session() as db:
await SettingsCache.refresh(db)
token = SettingsCache.get_bot_token()
if token:
max_api = MaxAPIClient(token)
try:
me = await max_api.get_me()
logger.info(f"Bot connected: {me}")
except Exception as e:
logger.error(f"Bot connection failed: {e}")
else:
logger.warning("No bot token configured")
yield
if max_api:
await max_api.close()
app = FastAPI(title="AegisOne Max Bot — София", lifespan=lifespan)
@app.post(settings.WEBHOOK_PATH)
async def webhook(request: Request, db: AsyncSession = Depends(get_db)):
secret_header = request.headers.get("X-Max-Bot-Api-Secret", "")
webhook_secret = settings.WEBHOOK_SECRET
if webhook_secret and secret_header != webhook_secret:
logger.warning("Invalid webhook secret")
return JSONResponse({"error": "Invalid secret"}, status_code=403)
raw = await request.body()
try:
body = json.loads(raw)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON from MAX: {e}, raw={raw[:500]}")
return JSONResponse({"ok": False}, status_code=200)
logger.info(f"Webhook update: {body.get('update_type', 'unknown')}")
global max_api
if not max_api:
token = SettingsCache.get_bot_token()
if token:
max_api = MaxAPIClient(token)
try:
await handle_update(None, body, db, max_api)
await db.commit()
except Exception as e:
logger.error(f"Webhook handler error: {e}", exc_info=True)
await db.rollback()
return JSONResponse({"ok": True})
@app.get("/api/1c/response")
@app.post("/api/1c/response")
async def api_1c_response(request: Request, db: AsyncSession = Depends(get_db)):
try:
body = await request.json()
from app.integrations import _1c_unf as ic_integration
success = await ic_integration.handle_1c_response(db, body)
await db.commit()
if success:
conv_id = body.get("conversation_id")
user_id = body.get("user_id")
if user_id and max_api:
status = body.get("status", "в работе")
engineer = body.get("engineer", "")
from app.conversation import get_template_rendered
text = await get_template_rendered(db, "status_update",
conv_id=conv_id, status=status, engineer=engineer)
await max_api.send_message(user_id, text, format="markdown")
return JSONResponse({"ok": success})
except Exception as e:
logger.error(f"1C response error: {e}", exc_info=True)
await db.rollback()
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@app.get("/health")
async def health():
return {"status": "ok", "bot": "София"}
# ===================== Service Portal API =====================
@app.get("/api/bot/settings")
async def api_bot_settings(db: AsyncSession = Depends(get_db)):
await SettingsCache.refresh(db)
return JSONResponse(SettingsCache.get_all())
@app.post("/api/bot/settings/save")
async def api_bot_settings_save(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotSetting
body = await request.json()
for key, value in body.items():
result = await db.execute(select(BotSetting).where(BotSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = str(value)
else:
db.add(BotSetting(key=key, value=str(value)))
await db.commit()
SettingsCache._last_update = 0
return JSONResponse({"ok": True})
@app.get("/api/bot/templates")
async def api_bot_templates(db: AsyncSession = Depends(get_db)):
await SettingsCache.refresh(db)
return JSONResponse(SettingsCache.get_all_templates())
@app.post("/api/bot/templates/save")
async def api_bot_templates_save(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotResponseTemplate
body = await request.json()
for key, value in body.items():
result = await db.execute(select(BotResponseTemplate).where(BotResponseTemplate.template_key == key))
tmpl = result.scalar_one_or_none()
if tmpl:
tmpl.template_text = value
else:
db.add(BotResponseTemplate(template_key=key, template_text=value))
await db.commit()
SettingsCache._last_update = 0
return JSONResponse({"ok": True})
@app.get("/api/bot/features")
async def api_bot_features(db: AsyncSession = Depends(get_db)):
from app.models.models import BotFeature
result = await db.execute(select(BotFeature).order_by(BotFeature.sort_order))
features = result.scalars().all()
return JSONResponse([
{"key": f.feature_key, "name": f.name, "description": f.description, "enabled": f.enabled}
for f in features
])
@app.post("/api/bot/features/toggle")
async def api_bot_features_toggle(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotFeature
body = await request.json()
key = body.get("feature_key")
enabled = body.get("enabled", False)
result = await db.execute(select(BotFeature).where(BotFeature.feature_key == key))
feature = result.scalar_one_or_none()
if feature:
feature.enabled = enabled
await db.commit()
SettingsCache._last_update = 0
return JSONResponse({"ok": True})
@app.get("/api/bot/categories")
async def api_bot_categories(db: AsyncSession = Depends(get_db)):
from app.models.models import BotCategory
result = await db.execute(select(BotCategory).order_by(BotCategory.sort_order))
cats = result.scalars().all()
return JSONResponse([
{"id": c.id, "name": c.name, "description": c.description, "active": c.active,
"icon_emoji": c.icon_emoji, "sort_order": c.sort_order, "keywords": c.keywords}
for c in cats
])
@app.post("/api/bot/categories/create")
async def api_bot_categories_create(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotCategory
body = await request.json()
db.add(BotCategory(
name=body.get("name", ""), description=body.get("description", ""),
active=body.get("active", True), icon_emoji=body.get("icon_emoji", "📋"),
sort_order=body.get("sort_order", 0), keywords=body.get("keywords", []),
))
await db.commit()
SettingsCache._last_update = 0
return JSONResponse({"ok": True})
@app.post("/api/bot/categories/edit")
async def api_bot_categories_edit(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotCategory
body = await request.json()
result = await db.execute(select(BotCategory).where(BotCategory.id == body.get("id")))
cat = result.scalar_one_or_none()
if cat:
for f in ("name", "description", "active", "icon_emoji", "sort_order", "keywords"):
if f in body:
setattr(cat, f, body[f])
await db.commit()
SettingsCache._last_update = 0
return JSONResponse({"ok": True})
@app.post("/api/bot/categories/delete")
async def api_bot_categories_delete(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotCategory
body = await request.json()
result = await db.execute(select(BotCategory).where(BotCategory.id == body.get("id")))
cat = result.scalar_one_or_none()
if cat:
await db.delete(cat)
await db.commit()
SettingsCache._last_update = 0
return JSONResponse({"ok": True})
@app.get("/api/bot/kb")
async def api_bot_kb(db: AsyncSession = Depends(get_db)):
from app.models.models import BotKnowledgeBase
result = await db.execute(select(BotKnowledgeBase).order_by(BotKnowledgeBase.sort_order))
entries = result.scalars().all()
return JSONResponse([
{"id": e.id, "question": e.question, "answer": e.answer, "category": e.category,
"active": e.active, "sort_order": e.sort_order, "keywords": e.keywords}
for e in entries
])
@app.post("/api/bot/kb/create")
async def api_bot_kb_create(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotKnowledgeBase
body = await request.json()
db.add(BotKnowledgeBase(
question=body.get("question", ""), answer=body.get("answer", ""),
category=body.get("category", ""), active=body.get("active", True),
sort_order=body.get("sort_order", 0), keywords=body.get("keywords", []),
))
await db.commit()
return JSONResponse({"ok": True})
@app.post("/api/bot/kb/edit")
async def api_bot_kb_edit(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotKnowledgeBase
body = await request.json()
result = await db.execute(select(BotKnowledgeBase).where(BotKnowledgeBase.id == body.get("id")))
entry = result.scalar_one_or_none()
if entry:
for f in ("question", "answer", "category", "active", "sort_order", "keywords"):
if f in body:
setattr(entry, f, body[f])
await db.commit()
return JSONResponse({"ok": True})
@app.post("/api/bot/kb/delete")
async def api_bot_kb_delete(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotKnowledgeBase
body = await request.json()
result = await db.execute(select(BotKnowledgeBase).where(BotKnowledgeBase.id == body.get("id")))
entry = result.scalar_one_or_none()
if entry:
await db.delete(entry)
await db.commit()
return JSONResponse({"ok": True})
@app.get("/api/bot/conversations")
async def api_bot_conversations(db: AsyncSession = Depends(get_db),
status: str = Query(None),
page: int = Query(1),
limit: int = Query(20)):
from app.models.models import BotConversation, BotUser
query = select(BotConversation, BotUser.first_name).join(BotUser)
if status:
query = query.where(BotConversation.status == status)
query = query.order_by(BotConversation.created_at.desc()).offset((page - 1) * limit).limit(limit)
result = await db.execute(query)
rows = result.all()
return JSONResponse([
{"id": c.id, "user_name": fn, "status": c.status, "inquiry_text": c.inquiry_text[:100],
"created_at": c.created_at.isoformat() if c.created_at else "", "priority": c.priority}
for c, fn in rows
])
@app.get("/api/bot/users")
async def api_bot_users(db: AsyncSession = Depends(get_db),
page: int = Query(1), limit: int = Query(20),
sort_by: str = Query("created_at"),
sort_dir: str = Query("desc"),
consent: str = Query(None)):
from app.models.models import BotUser
allowed_sort = {"id", "max_user_id", "first_name", "last_name", "phone", "email", "consent_given", "created_at"}
if sort_by not in allowed_sort:
sort_by = "created_at"
sort_col = getattr(BotUser, sort_by)
order = sort_col.desc() if sort_dir == "desc" else sort_col.asc()
filters = []
if consent == "true":
filters.append(BotUser.consent_given == True)
elif consent == "false":
filters.append(BotUser.consent_given == False)
count_q = select(func.count(BotUser.id))
if filters:
count_q = count_q.where(and_(*filters))
total = (await db.execute(count_q)).scalar() or 0
query = select(BotUser).where(*filters).order_by(order).offset((page - 1) * limit).limit(limit)
result = await db.execute(query)
users = result.scalars().all()
return JSONResponse({
"total": total,
"users": [
{"id": u.id, "max_user_id": u.max_user_id, "first_name": u.first_name,
"last_name": u.last_name, "phone": u.phone, "email": u.email,
"consent_given": u.consent_given, "created_at": u.created_at.isoformat() if u.created_at else ""}
for u in users
],
})
@app.get("/api/bot/analytics")
async def api_bot_analytics(db: AsyncSession = Depends(get_db),
days: int = Query(30)):
from app.models.models import BotConversation, BotUser
from datetime import timedelta
since = datetime.now() - timedelta(days=days)
total_consented = await db.execute(
select(func.count(BotUser.id)).where(BotUser.consent_given == True))
total_convs = await db.execute(
select(func.count(BotConversation.id)).where(BotConversation.created_at >= since))
open_convs = await db.execute(select(func.count(BotConversation.id)).where(
BotConversation.status == "open", BotConversation.created_at >= since))
closed_convs = await db.execute(select(func.count(BotConversation.id)).where(
BotConversation.status == "closed", BotConversation.created_at >= since))
return JSONResponse({
"total_users": total_consented.scalar(),
"conversations_period": total_convs.scalar(),
"open_conversations": open_convs.scalar(),
"closed_conversations": closed_convs.scalar(),
"period_days": days,
})
@app.post("/api/bot/test-run")
async def api_bot_test_run(request: Request, db: AsyncSession = Depends(get_db)):
body = await request.json()
scenario = body.get("scenario", "new_dialog")
messages = body.get("messages", [])
settings = SettingsCache.get_all()
features = {k: v.enabled for k, v in SettingsCache.get_enabled_features().items()}
categories = [{"id": c.id, "name": c.name} for c in SettingsCache.get_categories()]
test_api = MaxAPIClient("test_token", test_mode=True)
conversation_log = []
try:
from app.fsm import FSM, BotState
from app.conversation import get_or_create_user, add_message
await SettingsCache.refresh(db)
mock_user_id = body.get("mock_user_id", 999999999)
user = await get_or_create_user(db, mock_user_id)
if not user:
user = await get_or_create_user(db, mock_user_id)
FSM.set_state(mock_user_id, BotState.IDLE)
if scenario == "new_dialog":
update = {
"update_type": "bot_started",
"user": {"user_id": mock_user_id},
}
await handle_update(None, update, db, test_api)
conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages]
test_api.test_messages.clear()
elif scenario == "out_of_hours":
update = {
"update_type": "bot_started",
"user": {"user_id": mock_user_id},
}
await handle_update(None, update, db, test_api)
conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages]
test_api.test_messages.clear()
elif scenario == "returning":
conv = await get_or_create_user(db, mock_user_id)
user = await get_or_create_user(db, mock_user_id)
from app.conversation import create_conversation
from app.models.models import BotConversation
existing = await db.execute(
select(BotConversation).where(BotConversation.user_id == user.id).order_by(BotConversation.created_at.desc())
)
if not existing.scalar_one_or_none():
await create_conversation(db, user.id, "open", "Test inquiry")
await db.commit()
update = {
"update_type": "bot_started",
"user": {"user_id": mock_user_id},
}
await handle_update(None, update, db, test_api)
conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages]
test_api.test_messages.clear()
for msg_text in messages:
update = {
"update_type": "message_created",
"user": {"user_id": mock_user_id},
"message": {
"body": {"text": msg_text},
"attachments": [],
},
}
await handle_update(None, update, db, test_api)
conversation_log.append({"role": "user", "text": msg_text})
for bot_msg in test_api.test_messages:
conversation_log.append({"role": "bot", "text": bot_msg["text"]})
test_api.test_messages.clear()
await db.rollback()
FSM.clear_all()
except Exception as e:
logger.error(f"Test-run error: {e}", exc_info=True)
await db.rollback()
return JSONResponse({
"scenario": scenario,
"conversation": conversation_log,
"settings": settings,
"enabled_features": features,
"categories": categories,
})
@app.get("/api/bot/broadcast/recipients")
async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db),
q: str = Query(None),
page: int = Query(1), limit: int = Query(200)):
from app.models.models import BotUser
filters = [BotUser.consent_given == True]
if q:
like = f"%{q}%"
filters.append(
BotUser.first_name.ilike(like) |
BotUser.last_name.ilike(like) |
BotUser.phone.ilike(like) |
BotUser.email.ilike(like)
)
count_q = select(func.count(BotUser.id)).where(and_(*filters))
total = (await db.execute(count_q)).scalar() or 0
query = select(BotUser).where(and_(*filters)).order_by(BotUser.first_name).offset((page - 1) * limit).limit(limit)
result = await db.execute(query)
users = result.scalars().all()
return JSONResponse({
"total": total,
"users": [
{"id": u.id, "max_user_id": u.max_user_id, "first_name": u.first_name,
"last_name": u.last_name, "phone": u.phone, "email": u.email,
"consent_given": u.consent_given, "created_at": u.created_at.isoformat() if u.created_at else ""}
for u in users
],
})
@app.post("/api/bot/broadcast")
async def api_bot_broadcast(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotBroadcast, BotUser
body = await request.json()
text = body.get("text", "")
if not text:
return JSONResponse({"error": "Text is required"}, status_code=400)
action = body.get("action", "send")
user_ids = body.get("user_ids", None) # None or [] = all consenting users
draft_id = body.get("draft_id")
# Build recipient list: only consenting users
filters = [BotUser.consent_given == True]
if user_ids is not None and len(user_ids) > 0:
filters.append(BotUser.max_user_id.in_(user_ids))
result = await db.execute(select(BotUser.max_user_id, BotUser.first_name, BotUser.last_name).where(and_(*filters)))
recipients = result.all()
recipient_ids = [r[0] for r in recipients]
recipient_names = [f"{r[1]} {r[2]}".strip() or f"id{r[0]}" for r in recipients]
if action == "preview":
# Create or reuse draft
if draft_id:
result = await db.execute(select(BotBroadcast).where(BotBroadcast.id == draft_id))
broadcast = result.scalar_one_or_none()
if not broadcast:
return JSONResponse({"error": "Draft not found"}, status_code=404)
broadcast.text = text
broadcast.recipient_count = len(recipient_ids)
broadcast.recipient_ids = recipient_ids
else:
broadcast = BotBroadcast(text=text, recipient_count=len(recipient_ids),
recipient_ids=recipient_ids, status="draft")
db.add(broadcast)
await db.commit()
return JSONResponse({
"draft_id": broadcast.id,
"text": text,
"recipient_count": len(recipient_ids),
"recipients": recipient_names,
})
# action == "send"
if draft_id:
result = await db.execute(select(BotBroadcast).where(BotBroadcast.id == draft_id))
broadcast = result.scalar_one_or_none()
if broadcast:
broadcast.text = text
broadcast.recipient_count = len(recipient_ids)
broadcast.recipient_ids = recipient_ids
broadcast.status = "sent"
broadcast.sent_at = datetime.now()
else:
return JSONResponse({"error": "Draft not found"}, status_code=404)
else:
broadcast = BotBroadcast(text=text, recipient_count=len(recipient_ids),
recipient_ids=recipient_ids, status="sent", sent_at=datetime.now())
db.add(broadcast)
await db.commit()
sent_count = 0
if max_api:
for uid in recipient_ids:
try:
await max_api.send_message(uid, text, format="markdown")
sent_count += 1
except Exception:
pass
return JSONResponse({"ok": True, "sent_to": sent_count, "total_recipients": len(recipient_ids)})
@app.get("/api/bot/broadcast/history")
async def api_bot_broadcast_history(db: AsyncSession = Depends(get_db),
page: int = Query(1), limit: int = Query(20)):
from app.models.models import BotBroadcast
count_q = select(func.count(BotBroadcast.id)).where(BotBroadcast.status == "sent")
total = (await db.execute(count_q)).scalar() or 0
query = select(BotBroadcast).where(BotBroadcast.status == "sent").order_by(
BotBroadcast.sent_at.desc()).offset((page - 1) * limit).limit(limit)
result = await db.execute(query)
broadcasts = result.scalars().all()
return JSONResponse({
"total": total,
"broadcasts": [
{"id": b.id, "text": b.text[:200], "recipient_count": b.recipient_count,
"sent_at": b.sent_at.isoformat() if b.sent_at else ""}
for b in broadcasts
],
})
# ===================== Risk Questions API =====================
@app.get("/api/bot/risk-questions")
async def api_bot_risk_questions(db: AsyncSession = Depends(get_db)):
from app.models.models import BotRiskQuestion
result = await db.execute(
select(BotRiskQuestion).order_by(BotRiskQuestion.sort_order)
)
questions = result.scalars().all()
return JSONResponse([
{
"id": q.id, "question_key": q.question_key,
"question": q.question, "weight": q.weight,
"sort_order": q.sort_order, "is_active": q.is_active,
}
for q in questions
])
@app.post("/api/bot/risk-questions/create")
async def api_bot_risk_questions_create(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotRiskQuestion
body = await request.json()
q = BotRiskQuestion(
question_key=body["question_key"],
question=body["question"],
weight=int(body.get("weight", 10)),
sort_order=int(body.get("sort_order", 0)),
is_active=body.get("is_active", True),
)
db.add(q)
await db.commit()
return JSONResponse({"ok": True, "id": q.id})
@app.post("/api/bot/risk-questions/edit")
async def api_bot_risk_questions_edit(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotRiskQuestion
body = await request.json()
qid = body.get("id")
if not qid:
return JSONResponse({"ok": False, "error": "id required"}, status_code=400)
q = await db.get(BotRiskQuestion, int(qid))
if not q:
return JSONResponse({"ok": False, "error": "Not found"}, status_code=404)
if "question_key" in body:
q.question_key = body["question_key"]
if "question" in body:
q.question = body["question"]
if "weight" in body:
q.weight = int(body["weight"])
if "sort_order" in body:
q.sort_order = int(body["sort_order"])
if "is_active" in body:
q.is_active = body["is_active"]
await db.commit()
return JSONResponse({"ok": True})
@app.post("/api/bot/risk-questions/toggle")
async def api_bot_risk_questions_toggle(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotRiskQuestion
body = await request.json()
q = await db.get(BotRiskQuestion, int(body["id"]))
if not q:
return JSONResponse({"ok": False, "error": "Not found"}, status_code=404)
q.is_active = not q.is_active
await db.commit()
return JSONResponse({"ok": True})
@app.post("/api/bot/risk-questions/delete")
async def api_bot_risk_questions_delete(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotRiskQuestion
body = await request.json()
q = await db.get(BotRiskQuestion, int(body["id"]))
if not q:
return JSONResponse({"ok": False, "error": "Not found"}, status_code=404)
await db.delete(q)
await db.commit()
return JSONResponse({"ok": True})
# ===================== Tickets API =====================
@app.get("/api/bot/tickets")
async def api_bot_tickets(db: AsyncSession = Depends(get_db),
status: str = Query(None),
priority: str = Query(None)):
from app.models.models import BotTicket, BotUser
query = select(BotTicket, BotUser.first_name, BotUser.last_name).join(
BotUser, BotTicket.user_id == BotUser.id, isouter=True
)
if status:
query = query.where(BotTicket.status == status)
if priority:
query = query.where(BotTicket.priority == priority)
query = query.order_by(BotTicket.created_at.desc())
result = await db.execute(query)
rows = result.all()
return JSONResponse([
{
"id": t.id, "ticket_number": t.ticket_number,
"title": t.title, "description": t.description[:200],
"priority": t.priority, "status": t.status,
"user_name": f"{fn or ''} {ln or ''}".strip() if fn else None,
"object_name": None,
"created_at": t.created_at.isoformat() if t.created_at else "",
}
for t, fn, ln in rows
])
@app.get("/api/bot/tickets/{ticket_id}")
async def api_bot_ticket_get(ticket_id: int, db: AsyncSession = Depends(get_db)):
from app.models.models import BotTicket, BotUser, BotTicketMessage
t = await db.get(BotTicket, ticket_id)
if not t:
return JSONResponse({"error": "Not found"}, status_code=404)
user = await db.get(BotUser, t.user_id) if t.user_id else None
result = await db.execute(
select(BotTicketMessage).where(BotTicketMessage.ticket_id == ticket_id)
.order_by(BotTicketMessage.created_at)
)
messages = result.scalars().all()
return JSONResponse({
"id": t.id, "ticket_number": t.ticket_number,
"title": t.title, "description": t.description,
"priority": t.priority, "status": t.status,
"user_name": f"{user.first_name} {user.last_name}".strip() if user else None,
"object_name": None,
"created_at": t.created_at.isoformat() if t.created_at else "",
"messages": [
{"id": m.id, "direction": m.direction, "text": m.text,
"sender": m.sender,
"timestamp": m.created_at.isoformat() if m.created_at else ""}
for m in messages
],
})
@app.post("/api/bot/tickets/create")
async def api_bot_tickets_create(request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotTicket, BotTicketMessage, BotTicketStatus
from sqlalchemy import func
body = await request.json()
count = (await db.execute(select(func.count(BotTicket.id)))).scalar() or 0
ticket_num = f"T-{count + 1:04d}"
t = BotTicket(
ticket_number=ticket_num,
title=body.get("title", ""),
description=body.get("description", ""),
priority=body.get("priority", "normal"),
status="open",
created_by=body.get("created_by", "portal"),
)
db.add(t)
await db.flush()
db.add(BotTicketMessage(ticket_id=t.id, direction="in", text=body.get("description", ""), sender="client"))
db.add(BotTicketStatus(ticket_id=t.id, old_status="", new_status="open", changed_by="portal"))
await db.commit()
return JSONResponse({"ok": True, "id": t.id, "ticket_number": ticket_num})
@app.post("/api/bot/tickets/{ticket_id}/message")
async def api_bot_tickets_message(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotTicketMessage
body = await request.json()
m = BotTicketMessage(
ticket_id=ticket_id,
direction=body.get("direction", "in"),
text=body.get("text", ""),
sender=body.get("sender", ""),
)
db.add(m)
await db.commit()
return JSONResponse({"ok": True})
@app.post("/api/bot/tickets/{ticket_id}/status")
async def api_bot_tickets_status(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db)):
from app.models.models import BotTicket, BotTicketStatus
body = await request.json()
t = await db.get(BotTicket, ticket_id)
if not t:
return JSONResponse({"ok": False, "error": "Not found"}, status_code=404)
old = t.status
new_status = body.get("status", old)
db.add(BotTicketStatus(ticket_id=t.id, old_status=old, new_status=new_status, changed_by="portal"))
t.status = new_status
await db.commit()
return JSONResponse({"ok": True})
@app.get("/api/bot/engineers")
async def api_bot_engineers(db: AsyncSession = Depends(get_db)):
from app.models.models import User
result = await db.execute(
select(User.id, User.full_name).where(
User.is_active == True,
User.role.in_(["engineer", "owner"])
).order_by(User.full_name)
)
return JSONResponse([{"id": row[0], "full_name": row[1]} for row in result.fetchall()])
@app.get("/api/bot/handoffs")
async def api_bot_handoffs(db: AsyncSession = Depends(get_db)):
from app.models.models import BotConversation, BotUser
query = (
select(BotConversation, BotUser.first_name, BotUser.max_user_id)
.join(BotUser)
.where(BotConversation.status == "handoff")
.order_by(BotConversation.created_at.desc())
)
result = await db.execute(query)
rows = result.all()
return JSONResponse([
{
"id": c.id, "user_name": fn, "max_user_id": muid,
"text": c.inquiry_text[:200] if c.inquiry_text else "",
"status": c.status, "priority": c.priority,
"created_at": c.created_at.isoformat() if c.created_at else "",
}
for c, fn, muid in rows
])
@app.post("/api/bot/handoffs/{handoff_id}/take")
async def api_bot_handoff_take(handoff_id: int, db: AsyncSession = Depends(get_db)):
from app.models.models import BotConversation
conv = await db.get(BotConversation, handoff_id)
if not conv or conv.status != "handoff":
return JSONResponse({"ok": False, "error": "Not found or not handoff"}, status_code=404)
conv.status = "open"
conv.priority = "normal"
await db.commit()
return JSONResponse({"ok": True})
@app.post("/api/bot/handoffs/{handoff_id}/close")
async def api_bot_handoff_close(handoff_id: int, db: AsyncSession = Depends(get_db)):
from app.models.models import BotConversation
from datetime import datetime
conv = await db.get(BotConversation, handoff_id)
if not conv or conv.status != "handoff":
return JSONResponse({"ok": False, "error": "Not found or not handoff"}, status_code=404)
conv.status = "closed"
conv.closed_at = datetime.now()
await db.commit()
return JSONResponse({"ok": True})
@app.get("/api/bot/notifications")
async def api_bot_notifications(db: AsyncSession = Depends(get_db),
page: int = Query(1), limit: int = Query(50)):
from app.models.models import BotNotification
count_q = select(func.count(BotNotification.id))
total = (await db.execute(count_q)).scalar() or 0
query = (
select(BotNotification)
.order_by(BotNotification.created_at.desc())
.offset((page - 1) * limit).limit(limit)
)
result = await db.execute(query)
notifications = result.scalars().all()
return JSONResponse({
"total": total,
"notifications": [
{
"id": n.id, "type": n.type, "recipient_type": n.recipient_type,
"title": n.title, "body": n.body[:200],
"is_read": n.is_read,
"created_at": n.created_at.isoformat() if n.created_at else "",
}
for n in notifications
],
})
@app.post("/api/bot/notifications/{notif_id}/read")
async def api_bot_notification_read(notif_id: int, db: AsyncSession = Depends(get_db)):
from app.models.models import BotNotification
n = await db.get(BotNotification, notif_id)
if not n:
return JSONResponse({"ok": False, "error": "Not found"}, status_code=404)
n.is_read = True
await db.commit()
return JSONResponse({"ok": True})