test2 / app /admin /router.py
joedown11's picture
Fix 500 error due to NoneType strip and handle URL schemes securely
2c8b1b1
Raw
History Blame Contribute Delete
31.2 kB
"""
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
@admin_router.post("/api/admin/request-otp", include_in_schema=False)
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)
@admin_router.post("/api/admin/verify-otp", include_in_schema=False)
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 ────────────────────────────────────────────────────────────────────
@admin_router.get("/admin", response_class=HTMLResponse, include_in_schema=False)
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.",
)
@admin_router.get("/api/admin/sessions", include_in_schema=False)
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)})
@admin_router.get("/api/admin/metrics", include_in_schema=False)
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)
@admin_router.get("/api/admin/sla-metrics", include_in_schema=False)
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)
@admin_router.get("/api/admin/analytics", include_in_schema=False)
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
})
@admin_router.get("/api/admin/sessions/{session_id}", include_in_schema=False)
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)
@admin_router.delete("/api/admin/sessions/{session_id}", include_in_schema=False)
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})
@admin_router.delete("/api/admin/sessions/{session_id}/chats/{chat_id}", include_in_schema=False)
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
@admin_router.post("/api/admin/sessions/{session_id}/block", include_in_schema=False)
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"}
@admin_router.post("/api/admin/sessions/{session_id}/unblock", include_in_schema=False)
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"}
@admin_router.post("/api/admin/sessions/{session_id}/takeover", include_in_schema=False)
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"}
@admin_router.post("/api/admin/sessions/{session_id}/end_takeover", include_in_schema=False)
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"}
@admin_router.post("/api/admin/sessions/{session_id}/archive", include_in_schema=False)
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"}
@admin_router.post("/api/admin/sessions/{session_id}/unarchive", include_in_schema=False)
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"}
@admin_router.post("/api/admin/sessions/{session_id}/chats/{chat_id}/archive", include_in_schema=False)
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"}
@admin_router.post("/api/admin/sessions/{session_id}/chats/{chat_id}/unarchive", include_in_schema=False)
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"}
@admin_router.post("/api/admin/sessions/{session_id}/end_chat", include_in_schema=False)
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"}
@admin_router.post("/api/admin/sessions/{session_id}/reply", include_in_schema=False)
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 ─────────────────────────────────────────────────────────
@admin_router.get("/api/admin/export/session/{session_id}", include_in_schema=False)
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"}
)
@admin_router.get("/api/admin/export/all", include_in_schema=False)
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 = ""
@admin_router.get("/api/admin/settings/whatsapp", include_in_schema=False)
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"))
}
@admin_router.post("/api/admin/settings/whatsapp", include_in_schema=False)
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"}
@admin_router.post("/api/admin/settings/whatsapp/test", include_in_schema=False)
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)}")
@admin_router.get("/api/admin/settings/whatsapp/baileys/qr", include_in_schema=False)
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)}")