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:
@@ -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})
|
||||
Reference in New Issue
Block a user