Spaces:
Running
Running
| """ | |
| app/admin/router.py | |
| ─────────────────── | |
| Secure FastAPI router for the Admin Panel. | |
| Security design: | |
| • All /admin/* routes require HTTP Basic Auth. | |
| • Credentials are compared using secrets.compare_digest() to prevent | |
| timing-based attacks — NOT a simple == comparison. | |
| • Wrong credentials always return 401 with WWW-Authenticate header. | |
| • Admin routes are completely isolated from the main app and Gradio UI. | |
| Endpoints: | |
| GET /admin → Serves the premium admin.html SPA | |
| GET /api/admin/sessions → Returns all session summaries (JSON) | |
| GET /api/admin/sessions/{session_id} → Returns full session transcript | |
| DELETE /api/admin/sessions/{session_id} → Deletes a session | |
| """ | |
| import secrets | |
| import logging | |
| import asyncio | |
| import time | |
| import random | |
| import smtplib | |
| import socket | |
| import json as _json | |
| import urllib.parse | |
| from email.mime.text import MIMEText | |
| from email.mime.multipart import MIMEMultipart | |
| from pathlib import Path | |
| from typing import Dict, Optional | |
| import pandas as pd | |
| import io | |
| from fastapi import APIRouter, Depends, HTTPException, status | |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse | |
| from fastapi.security import HTTPBasic, HTTPBasicCredentials | |
| from pydantic import BaseModel | |
| from app.services.session_store import get_all_sessions, get_session, delete_session, _list_json_files, _read_json | |
| from app.core.config import get_settings | |
| logger = logging.getLogger(__name__) | |
| settings = get_settings() | |
| # Credentials are now loaded from settings (environment variables) | |
| # ── Security scheme ─────────────────────────────────────────────────────────── | |
| security = HTTPBasic() | |
| # ── Router ──────────────────────────────────────────────────────────────────── | |
| admin_router = APIRouter(tags=["admin"]) | |
| # ── OTP Store (In-memory for simplicity) ────────────────────────────────────── | |
| # Format: {username: {"code": "123456", "expires": timestamp}} | |
| _otp_store: Dict[str, dict] = {} | |
| OTP_EXPIRY_SECONDS = 300 # 5 minutes | |
| # ── Path to the admin HTML template ────────────────────────────────────────── | |
| _ADMIN_HTML_PATH = Path(__file__).parent / "templates" / "admin.html" | |
| # ── Auth dependency ─────────────────────────────────────────────────────────── | |
| def verify_admin(credentials: HTTPBasicCredentials = Depends(security)) -> str: | |
| """ | |
| Verifies HTTP Basic credentials using constant-time comparison. | |
| Raises 401 for any mismatch. Returns the username on success. | |
| """ | |
| correct_username = secrets.compare_digest( | |
| credentials.username.encode("utf-8"), | |
| settings.admin_user.encode("utf-8"), | |
| ) | |
| correct_password = secrets.compare_digest( | |
| credentials.password.encode("utf-8"), | |
| settings.admin_pass.encode("utf-8"), | |
| ) | |
| if not (correct_username and correct_password): | |
| logger.warning( | |
| "Admin auth failed for user: '%s'", credentials.username | |
| ) | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Invalid admin credentials.", | |
| ) | |
| return credentials.username | |
| # ── OTP Endpoints ───────────────────────────────────────────────────────────── | |
| class LoginRequest(BaseModel): | |
| username: str | |
| password: str | |
| class OTPVerifyRequest(BaseModel): | |
| username: str | |
| password: str | |
| code: str | |
| async def request_otp(req: LoginRequest): | |
| """ | |
| Verifies password and generates a 6-digit OTP. | |
| Sends the OTP to the configured admin email. | |
| """ | |
| if not (secrets.compare_digest(req.username, settings.admin_user) and | |
| secrets.compare_digest(req.password, settings.admin_pass)): | |
| raise HTTPException(status_code=401, detail="Invalid credentials.") | |
| # Generate 6-digit code | |
| code = "".join([str(random.randint(0, 9)) for _ in range(6)]) | |
| _otp_store[req.username] = { | |
| "code": code, | |
| "expires": time.time() + OTP_EXPIRY_SECONDS | |
| } | |
| # Attempt to send email | |
| email_sent = False | |
| if settings.smtp_user and settings.smtp_pass: | |
| try: | |
| # Use to_thread to avoid blocking the event loop during SMTP network I/O | |
| await asyncio.to_thread(send_otp_email, settings.admin_email, code) | |
| email_sent = True | |
| logger.info("OTP email sent to %s", settings.admin_email) | |
| except Exception as e: | |
| logger.error("Failed to send OTP email: %s", e) | |
| else: | |
| logger.warning("SMTP credentials not configured. OTP will only be in logs.") | |
| # FALLBACK/LOG: Always log to console so the user is never locked out | |
| print("\n" + "="*50) | |
| print(f" ADMIN OTP FOR {req.username.upper()}: {code}") | |
| print(f" SENDING TO: {settings.admin_email} ({'SUCCESS' if email_sent else 'FAILED/SKIPPED'})") | |
| print(" EXPIRES IN 5 MINUTES") | |
| print("="*50 + "\n") | |
| return { | |
| "message": "OTP sent successfully.", | |
| "email_status": "sent" if email_sent else "logged_to_console_only" | |
| } | |
| def send_otp_email(to_email: str, otp_code: str): | |
| """Sends a professional OTP email via SMTP. Run in thread.""" | |
| msg = MIMEMultipart() | |
| msg['From'] = settings.smtp_user | |
| msg['To'] = to_email | |
| msg['Subject'] = f"Your Admin Access Code: {otp_code}" | |
| body = f""" | |
| <html> | |
| <body style="font-family: sans-serif; padding: 20px; color: #333;"> | |
| <h2 style="color: #3b82f6;">Martechsol Admin Panel</h2> | |
| <p>Your one-time security code is:</p> | |
| <div style="font-size: 32px; font-weight: bold; letter-spacing: 5px; padding: 15px; background: #f3f4f6; border-radius: 8px; display: inline-block; margin: 10px 0;"> | |
| {otp_code} | |
| </div> | |
| <p style="color: #666; font-size: 14px;">This code will expire in 5 minutes.</p> | |
| <hr style="border: 0; border-top: 1px solid #eee; margin-top: 20px;"> | |
| <p style="font-size: 12px; color: #999;">If you did not request this code, please secure your account.</p> | |
| </body> | |
| </html> | |
| """ | |
| msg.attach(MIMEText(body, 'html')) | |
| # Force IPv4 resolution to avoid "Network unreachable" IPv6 issues | |
| try: | |
| ais = socket.getaddrinfo(settings.smtp_server, settings.smtp_port, socket.AF_INET) | |
| target_ip = ais[0][4][0] | |
| logger.info("Resolved %s to IPv4: %s", settings.smtp_server, target_ip) | |
| except Exception as e: | |
| logger.error("DNS Resolution failed: %s", e) | |
| target_ip = settings.smtp_server | |
| logger.info("Attempting SMTP_SSL connection to %s:%d (via %s)", | |
| settings.smtp_server, settings.smtp_port, target_ip) | |
| # Using the IP directly can sometimes bypass DNS/routing quirks | |
| with smtplib.SMTP_SSL(target_ip, settings.smtp_port, timeout=15) as server: | |
| server.login(settings.smtp_user, settings.smtp_pass) | |
| server.send_message(msg) | |
| async def verify_otp(req: OTPVerifyRequest): | |
| """Verifies both the password and the OTP code.""" | |
| # 1. Verify password first | |
| if not (secrets.compare_digest(req.username, settings.admin_user) and | |
| secrets.compare_digest(req.password, settings.admin_pass)): | |
| raise HTTPException(status_code=401, detail="Invalid credentials.") | |
| # 2. Verify OTP | |
| stored = _otp_store.get(req.username) | |
| if not stored: | |
| raise HTTPException(status_code=400, detail="No OTP requested for this user.") | |
| if time.time() > stored["expires"]: | |
| del _otp_store[req.username] | |
| raise HTTPException(status_code=400, detail="OTP has expired. Please request a new one.") | |
| if not secrets.compare_digest(req.code, stored["code"]): | |
| raise HTTPException(status_code=401, detail="Invalid OTP code.") | |
| # Success — clear OTP and return | |
| del _otp_store[req.username] | |
| return {"message": "OTP verified."} | |
| # ── Routes ──────────────────────────────────────────────────────────────────── | |
| async def admin_panel(): | |
| """Serves the admin panel SPA.""" | |
| try: | |
| html_content = _ADMIN_HTML_PATH.read_text(encoding="utf-8") | |
| return HTMLResponse( | |
| content=html_content, | |
| status_code=200, | |
| headers={ | |
| "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", | |
| "Pragma": "no-cache", | |
| "Expires": "0", | |
| } | |
| ) | |
| except FileNotFoundError: | |
| logger.error("admin.html template not found at %s", _ADMIN_HTML_PATH) | |
| raise HTTPException( | |
| status_code=500, | |
| detail="Admin panel template is missing. Please redeploy.", | |
| ) | |
| async def list_sessions( | |
| search: str = "", | |
| filter: str = "", | |
| skip: int = 0, | |
| limit: int = 50, | |
| username: str = Depends(verify_admin) | |
| ): | |
| """Returns paginated session summaries sorted by most recent activity.""" | |
| sessions = await get_all_sessions(search=search, filter_type=filter, skip=skip, limit=limit) | |
| return JSONResponse(content={"sessions": sessions, "total": len(sessions)}) | |
| async def admin_metrics_endpoint(username: str = Depends(verify_admin)): | |
| from app.services.session_store import get_metrics | |
| metrics = await get_metrics() | |
| return JSONResponse(content=metrics) | |
| async def admin_sla_metrics_endpoint(username: str = Depends(verify_admin)): | |
| from app.services.session_store import get_sla_metrics | |
| metrics = await get_sla_metrics() | |
| return JSONResponse(content=metrics) | |
| async def admin_analytics(username: str = Depends(verify_admin)): | |
| """Returns aggregated analytics for the dashboard.""" | |
| from datetime import datetime, timedelta, timezone | |
| files = await asyncio.to_thread(_list_json_files) | |
| total_sessions = len(files) | |
| total_messages = 0 | |
| now_utc = datetime.now(timezone.utc) | |
| today_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) | |
| today_sessions = 0 | |
| days_data = {} | |
| for i in range(6, -1, -1): | |
| d = (today_start - timedelta(days=i)).strftime("%Y-%m-%d") | |
| days_data[d] = {"sessions": 0, "messages": 0} | |
| for fpath in files: | |
| try: | |
| data = await asyncio.to_thread(_read_json, fpath) | |
| msg_count = data.get("message_count", 0) | |
| total_messages += msg_count | |
| created_str = data.get("created_at", "") | |
| if created_str: | |
| try: | |
| created_dt = datetime.fromisoformat(created_str.replace('Z', '+00:00')) | |
| if created_dt >= today_start: | |
| today_sessions += 1 | |
| date_str = created_dt.strftime("%Y-%m-%d") | |
| if date_str in days_data: | |
| days_data[date_str]["sessions"] += 1 | |
| days_data[date_str]["messages"] += msg_count | |
| except ValueError: | |
| pass | |
| except Exception as e: | |
| logger.warning("Analytics: Failed to read %s: %s", fpath, e) | |
| trends = [{"date": k, "sessions": v["sessions"], "messages": v["messages"]} for k, v in days_data.items()] | |
| return JSONResponse(content={ | |
| "total_sessions": total_sessions, | |
| "total_messages": total_messages, | |
| "today_sessions": today_sessions, | |
| "trends": trends | |
| }) | |
| async def get_session_detail( | |
| session_id: str, username: str = Depends(verify_admin) | |
| ): | |
| """Returns the full Q&A transcript for a specific session.""" | |
| session_id = urllib.parse.unquote(session_id) | |
| if ".." in session_id or "/" in session_id or "\\" in session_id: | |
| raise HTTPException(status_code=400, detail="Invalid session ID format.") | |
| data = await get_session(session_id) | |
| if not data: | |
| raise HTTPException( | |
| status_code=404, | |
| detail=f"Session '{session_id}' not found.", | |
| ) | |
| return JSONResponse(content=data) | |
| async def remove_session( | |
| session_id: str, username: str = Depends(verify_admin) | |
| ): | |
| """Deletes a session file permanently.""" | |
| session_id = urllib.parse.unquote(session_id) | |
| if ".." in session_id or "/" in session_id or "\\" in session_id: | |
| raise HTTPException(status_code=400, detail="Invalid session ID format.") | |
| success = await delete_session(session_id) | |
| if not success: | |
| raise HTTPException(status_code=500, detail="Failed to delete session.") | |
| return JSONResponse(content={"deleted": session_id}) | |
| async def delete_chat_thread( | |
| session_id: str, chat_id: int, username: str = Depends(verify_admin) | |
| ): | |
| """Deletes a specific chat thread from a session.""" | |
| from app.services.session_store import _get_lock, _get_session_path, _write_json | |
| session_id = urllib.parse.unquote(session_id) | |
| if ".." in session_id or "/" in session_id or "\\" in session_id: | |
| raise HTTPException(status_code=400, detail="Invalid session ID format.") | |
| lock = await _get_lock(session_id) | |
| path = _get_session_path(session_id) | |
| async with lock: | |
| if path.exists(): | |
| data = await asyncio.to_thread(_read_json, path) | |
| data["messages"] = [m for m in data.get("messages", []) if m.get("chat_id", 1) != chat_id] | |
| data["message_count"] = len(data["messages"]) | |
| await asyncio.to_thread(_write_json, path, data) | |
| return {"status": "ok"} | |
| class ReplyRequest(BaseModel): | |
| message: str | |
| chat_id: Optional[int] = None | |
| async def block_session(session_id: str, username: str = Depends(verify_admin)): | |
| from app.services.session_store import update_session_field | |
| await update_session_field(session_id, "blocked", True) | |
| return {"status": "blocked"} | |
| async def unblock_session(session_id: str, username: str = Depends(verify_admin)): | |
| from app.services.session_store import update_session_field | |
| await update_session_field(session_id, "blocked", False) | |
| return {"status": "unblocked"} | |
| async def takeover_session( | |
| session_id: str, username: str = Depends(verify_admin) | |
| ): | |
| """Admin takes over the chat.""" | |
| from app.services.session_store import update_session_field | |
| session_id = urllib.parse.unquote(session_id) | |
| success = await update_session_field(session_id, "status", "HUMAN_TAKEOVER") | |
| if not success: | |
| raise HTTPException(status_code=404, detail="Session not found") | |
| return {"status": "success"} | |
| async def end_takeover_session( | |
| session_id: str, username: str = Depends(verify_admin) | |
| ): | |
| """Admin returns control to AI.""" | |
| from app.services.session_store import update_session_field | |
| session_id = urllib.parse.unquote(session_id) | |
| success = await update_session_field(session_id, "status", "AI") | |
| if not success: | |
| raise HTTPException(status_code=404, detail="Session not found") | |
| return {"status": "success"} | |
| async def archive_session_endpoint(session_id: str, username: str = Depends(verify_admin)): | |
| from app.services.session_store import update_session_field | |
| session_id = urllib.parse.unquote(session_id) | |
| success = await update_session_field(session_id, "archived", True) | |
| if not success: | |
| raise HTTPException(status_code=404, detail="Session not found") | |
| return {"status": "archived"} | |
| async def unarchive_session_endpoint(session_id: str, username: str = Depends(verify_admin)): | |
| from app.services.session_store import update_session_field | |
| session_id = urllib.parse.unquote(session_id) | |
| success = await update_session_field(session_id, "archived", False) | |
| if not success: | |
| raise HTTPException(status_code=404, detail="Session not found") | |
| return {"status": "unarchived"} | |
| async def archive_chat_thread_endpoint(session_id: str, chat_id: int, username: str = Depends(verify_admin)): | |
| from app.services.session_store import archive_chat_thread, get_session | |
| session_id = urllib.parse.unquote(session_id) | |
| # Rule: Only allow archiving if chat is ended. | |
| # A chat is ended if it's an old thread (chat_id < current_chat_id) OR if the whole session is ENDED. | |
| data = await get_session(session_id) | |
| if not data: | |
| raise HTTPException(status_code=404, detail="Session not found") | |
| current_chat_id = data.get("current_chat_id", 1) | |
| session_status = data.get("status", "AI") | |
| is_ended = (chat_id < current_chat_id) or (session_status == "ENDED") | |
| if not is_ended: | |
| raise HTTPException(status_code=400, detail="Cannot archive an active chat. End the chat first.") | |
| success = await archive_chat_thread(session_id, chat_id, True) | |
| if not success: | |
| raise HTTPException(status_code=500, detail="Failed to archive chat thread.") | |
| return {"status": "archived"} | |
| async def unarchive_chat_thread_endpoint(session_id: str, chat_id: int, username: str = Depends(verify_admin)): | |
| from app.services.session_store import archive_chat_thread | |
| session_id = urllib.parse.unquote(session_id) | |
| success = await archive_chat_thread(session_id, chat_id, False) | |
| if not success: | |
| raise HTTPException(status_code=500, detail="Failed to unarchive chat thread.") | |
| return {"status": "unarchived"} | |
| async def end_chat_completely( | |
| session_id: str, username: str = Depends(verify_admin) | |
| ): | |
| """Admin completely ends the chat session.""" | |
| from app.services.session_store import update_session_field | |
| session_id = urllib.parse.unquote(session_id) | |
| success = await update_session_field(session_id, "status", "ENDED") | |
| if not success: | |
| raise HTTPException(status_code=404, detail="Session not found") | |
| return {"status": "success"} | |
| async def reply_session(session_id: str, req: ReplyRequest, username: str = Depends(verify_admin)): | |
| from app.services.session_store import save_message | |
| # Save with empty question so admin reply renders as 'Human Agent' message (no question field) | |
| # Pass chat_id so admin reply goes to the specific chat thread being viewed | |
| await save_message(session_id, "", req.message, chat_id=req.chat_id) | |
| return {"status": "sent"} | |
| # ── Export Endpoints ───────────────────────────────────────────────────────── | |
| async def export_session( | |
| session_id: str, format: str = "excel", username: str = Depends(verify_admin) | |
| ): | |
| """Exports a single session's transcript in Excel or JSON format.""" | |
| data = await get_session(session_id) | |
| if not data: | |
| raise HTTPException(status_code=404, detail="Session not found.") | |
| messages = data.get("messages", []) | |
| export_data = [] | |
| for msg in messages: | |
| export_data.append({ | |
| "Timestamp": msg.get("timestamp", ""), | |
| "Question": msg.get("question", ""), | |
| "Answer": msg.get("answer", "") | |
| }) | |
| if format.lower() == "json": | |
| return JSONResponse( | |
| content=export_data, | |
| headers={"Content-Disposition": f"attachment; filename=session_{session_id}.json"} | |
| ) | |
| # Excel Export | |
| df = pd.DataFrame(export_data) | |
| output = io.BytesIO() | |
| with pd.ExcelWriter(output, engine='openpyxl') as writer: | |
| df.to_excel(writer, index=False, sheet_name='Transcript') | |
| output.seek(0) | |
| return StreamingResponse( | |
| output, | |
| media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| headers={"Content-Disposition": f"attachment; filename=session_{session_id}.xlsx"} | |
| ) | |
| async def export_all_sessions( | |
| format: str = "excel", username: str = Depends(verify_admin) | |
| ): | |
| """Exports all sessions' transcripts in a single Excel or JSON file.""" | |
| files = await asyncio.to_thread(_list_json_files) | |
| all_messages = [] | |
| for fpath in files: | |
| try: | |
| data = await asyncio.to_thread(_read_json, fpath) | |
| session_id = data.get("session_id", fpath.stem) | |
| for msg in data.get("messages", []): | |
| all_messages.append({ | |
| "Session ID": session_id, | |
| "Timestamp": msg.get("timestamp", ""), | |
| "Question": msg.get("question", ""), | |
| "Answer": msg.get("answer", ""), | |
| "current_chat_id": data.get("current_chat_id", 1) | |
| }) | |
| except Exception as e: | |
| logger.warning("Failed to read %s for export: %s", fpath, e) | |
| if format.lower() == "json": | |
| return JSONResponse( | |
| content=all_messages, | |
| headers={"Content-Disposition": "attachment; filename=all_chats_export.json"} | |
| ) | |
| # Excel Export | |
| df = pd.DataFrame(all_messages) | |
| output = io.BytesIO() | |
| with pd.ExcelWriter(output, engine='openpyxl') as writer: | |
| df.to_excel(writer, index=False, sheet_name='All Chats') | |
| output.seek(0) | |
| return StreamingResponse( | |
| output, | |
| media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| headers={"Content-Disposition": "attachment; filename=all_chats_export.xlsx"} | |
| ) | |
| # ── WhatsApp Settings Endpoints ────────────────────────────────────────────── | |
| def _get_whatsapp_settings_path() -> Path: | |
| """Returns the path to the WhatsApp settings JSON file.""" | |
| settings = get_settings() | |
| base = Path("/data") if Path("/data").exists() else Path("data") | |
| base.mkdir(parents=True, exist_ok=True) | |
| return base / "whatsapp_settings.json" | |
| def get_whatsapp_settings() -> dict: | |
| """Reads WhatsApp settings from persistent JSON file, falling back to env vars.""" | |
| path = _get_whatsapp_settings_path() | |
| settings = get_settings() | |
| defaults = { | |
| "provider": "meta", | |
| "admin_number": settings.meta_whatsapp_admin_number, | |
| "phone_id": settings.meta_whatsapp_phone_id, | |
| "token": settings.meta_whatsapp_token, | |
| "baileys_url": "", | |
| "baileys_token": "" | |
| } | |
| if path.exists(): | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| saved = _json.load(f) | |
| # Merge: saved values override defaults if present | |
| for k in defaults: | |
| if saved.get(k): | |
| defaults[k] = saved[k] | |
| except Exception: | |
| pass | |
| return defaults | |
| def _save_whatsapp_settings(data: dict) -> None: | |
| path = _get_whatsapp_settings_path() | |
| with open(path, "w", encoding="utf-8") as f: | |
| _json.dump(data, f, indent=2) | |
| class WhatsAppSettingsRequest(BaseModel): | |
| provider: str = "meta" | |
| admin_number: str = "" | |
| phone_id: str = "" | |
| token: str = "" | |
| baileys_url: str = "" | |
| baileys_token: str = "" | |
| async def get_whatsapp_config(username: str = Depends(verify_admin)): | |
| ws = get_whatsapp_settings() | |
| return { | |
| "provider": ws.get("provider", "meta"), | |
| "admin_number": ws.get("admin_number", ""), | |
| "phone_id": ws.get("phone_id", ""), | |
| "token": bool(ws.get("token")), # Don't expose token, just indicate if set | |
| "baileys_url": ws.get("baileys_url", ""), | |
| "baileys_token": bool(ws.get("baileys_token")) | |
| } | |
| async def save_whatsapp_config(req: WhatsAppSettingsRequest, username: str = Depends(verify_admin)): | |
| current = get_whatsapp_settings() | |
| updated = { | |
| "provider": req.provider.strip() if req.provider else current.get("provider", "meta"), | |
| "admin_number": req.admin_number.strip() if req.admin_number else current.get("admin_number", ""), | |
| "phone_id": req.phone_id.strip() if req.phone_id else current.get("phone_id", ""), | |
| "token": req.token.strip() if req.token else current.get("token", ""), | |
| "baileys_url": req.baileys_url.strip() if req.baileys_url else current.get("baileys_url", ""), | |
| "baileys_token": req.baileys_token.strip() if req.baileys_token else current.get("baileys_token", ""), | |
| } | |
| _save_whatsapp_settings(updated) | |
| return {"status": "saved"} | |
| async def test_whatsapp(username: str = Depends(verify_admin)): | |
| ws = get_whatsapp_settings() | |
| admin_number = ws.get("admin_number", "") | |
| admin_numbers = [n.strip() for n in admin_number.split(",") if n.strip()] | |
| if not admin_numbers: | |
| raise HTTPException(status_code=400, detail="No valid WhatsApp numbers configured.") | |
| from app.services.whatsapp import ProviderFactory | |
| provider = ProviderFactory.get_provider(ws) | |
| def _send_sync(): | |
| text = "✅ Test notification from Martechsol Admin Panel.\nWhatsApp integration is working correctly!" | |
| errors = provider.send_message(admin_numbers, text) | |
| return errors | |
| try: | |
| import asyncio | |
| errors = await asyncio.to_thread(_send_sync) | |
| if errors: | |
| raise HTTPException(status_code=400, detail=" | ".join(errors)) | |
| return {"status": "sent"} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Unexpected error: {type(e).__name__} - {str(e)}") | |
| async def get_baileys_qr(username: str = Depends(verify_admin)): | |
| import urllib.request | |
| import urllib.error | |
| import json | |
| import ssl | |
| ws = get_whatsapp_settings() | |
| baileys_url = str(ws.get("baileys_url") or "").strip() | |
| baileys_token = str(ws.get("baileys_token") or "").strip() | |
| if not baileys_url: | |
| raise HTTPException(status_code=400, detail="Baileys API URL is not configured. Please save settings first.") | |
| if not baileys_url.startswith("http://") and not baileys_url.startswith("https://"): | |
| baileys_url = "https://" + baileys_url | |
| # Derive the QR URL from the configured Baileys URL | |
| qr_url = baileys_url | |
| if qr_url.endswith("/api/send"): | |
| qr_url = qr_url.replace("/api/send", "/api/qr") | |
| elif not qr_url.endswith("/api/qr"): | |
| if qr_url.endswith("/"): | |
| qr_url += "api/qr" | |
| else: | |
| qr_url += "/api/qr" | |
| headers = {} | |
| if baileys_token: | |
| headers["Authorization"] = f"Bearer {baileys_token}" | |
| headers["apikey"] = baileys_token # For generic wrapper support | |
| ctx = ssl.create_default_context() | |
| ctx.check_hostname = False | |
| ctx.verify_mode = ssl.CERT_NONE | |
| req = urllib.request.Request(qr_url, headers=headers, method='GET') | |
| try: | |
| def _fetch_qr(): | |
| with urllib.request.urlopen(req, timeout=15.0, context=ctx) as response: | |
| body = response.read().decode('utf-8') | |
| try: | |
| return json.loads(body) | |
| except Exception: | |
| if "<html" in body.lower(): | |
| return {"status": "generating", "message": "Space is booting up."} | |
| raise Exception("Invalid JSON response from Baileys server") | |
| import asyncio | |
| data = await asyncio.to_thread(_fetch_qr) | |
| return data | |
| except urllib.error.HTTPError as e: | |
| err_msg = e.read().decode('utf-8') | |
| try: | |
| parsed = json.loads(err_msg) | |
| if "status" in parsed: | |
| from fastapi.responses import JSONResponse | |
| return JSONResponse(status_code=200, content=parsed) | |
| detail = parsed.get("error", err_msg) | |
| except Exception: | |
| detail = err_msg | |
| raise HTTPException(status_code=e.code, detail=f"Baileys Error: {detail}") | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Network Error contacting Baileys: {str(e)}") | |