- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
837 lines
30 KiB
Python
837 lines
30 KiB
Python
"""
|
|
Darkroom Admin Extensions Router
|
|
=================================
|
|
Backend endpoints for the darkroom admin panel:
|
|
- Telegram bot stats/messages/send/broadcast
|
|
- Ghost CMS drafts/tags/analytics/publish
|
|
- Cron job management (reads from Hermes cron system)
|
|
- Dify AI advisor proxy
|
|
- Server processes/disk/network deep-dive
|
|
- Webmail folders/sent/drafts
|
|
|
|
All endpoints require admin auth via X-Admin-Key header.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import UTC, datetime
|
|
|
|
import psutil
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/v1/admin", tags=["admin-extensions"])
|
|
|
|
|
|
# ── Auth helper ──
|
|
async def _verify_admin(request: Request):
|
|
"""Verify admin access with API key."""
|
|
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
|
|
key = request.headers.get("X-Admin-Key", "")
|
|
if key != admin_key:
|
|
raise HTTPException(status_code=401, detail="Invalid admin key")
|
|
return True
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# TELEGRAM BOT
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.get("/telegram/stats")
|
|
async def telegram_stats(request: Request, _=Depends(_verify_admin)):
|
|
"""Get Telegram bot statistics."""
|
|
# Try to read from bot state if available
|
|
bot_state_file = "/tmp/telegram_bot_state.json"
|
|
stats = {
|
|
"total_users": 0,
|
|
"active_chats": 0,
|
|
"messages_sent": 0,
|
|
"messages_received": 0,
|
|
"commands_processed": 0,
|
|
"alerts_sent": 0,
|
|
"uptime_seconds": 0,
|
|
"status": "unknown",
|
|
}
|
|
|
|
if os.path.exists(bot_state_file):
|
|
try:
|
|
with open(bot_state_file) as f:
|
|
data = json.load(f)
|
|
stats.update(data)
|
|
except Exception:
|
|
pass
|
|
|
|
# Check if bot process is running
|
|
try:
|
|
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
|
|
cmdline = proc.info.get("cmdline") or []
|
|
if any("telegram" in str(c).lower() for c in cmdline):
|
|
stats["status"] = "running"
|
|
stats["pid"] = proc.info["pid"]
|
|
break
|
|
else:
|
|
stats["status"] = "stopped"
|
|
except Exception:
|
|
stats["status"] = "unknown"
|
|
|
|
return stats
|
|
|
|
|
|
@router.get("/telegram/users")
|
|
async def telegram_users(request: Request, _=Depends(_verify_admin), limit: int = Query(50, le=200)):
|
|
"""Get Telegram bot users."""
|
|
users_file = "/tmp/telegram_users.json"
|
|
users = []
|
|
if os.path.exists(users_file):
|
|
try:
|
|
with open(users_file) as f:
|
|
data = json.load(f)
|
|
users = data.get("users", [])[:limit]
|
|
except Exception:
|
|
pass
|
|
|
|
if not users:
|
|
# Return placeholder structure
|
|
users = []
|
|
|
|
return {"users": users, "total": len(users)}
|
|
|
|
|
|
@router.get("/telegram/messages")
|
|
async def telegram_messages(request: Request, _=Depends(_verify_admin), limit: int = Query(20, le=100)):
|
|
"""Get recent Telegram messages."""
|
|
messages_file = "/tmp/telegram_messages.json"
|
|
messages = []
|
|
if os.path.exists(messages_file):
|
|
try:
|
|
with open(messages_file) as f:
|
|
data = json.load(f)
|
|
messages = data.get("messages", [])[:limit]
|
|
except Exception:
|
|
pass
|
|
|
|
return {"messages": messages, "total": len(messages)}
|
|
|
|
|
|
class TelegramSendRequest(BaseModel):
|
|
chat_id: str
|
|
message: str
|
|
|
|
|
|
@router.post("/telegram/send")
|
|
async def telegram_send(request: Request, req: TelegramSendRequest, _=Depends(_verify_admin)):
|
|
"""Send a message via Telegram bot."""
|
|
bot_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
|
if not bot_token:
|
|
return {"status": "failed", "error": "TELEGRAM_BOT_TOKEN not configured"}
|
|
|
|
try:
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
f"https://api.telegram.org/bot{bot_token}/sendMessage",
|
|
json={"chat_id": req.chat_id, "text": req.message, "parse_mode": "HTML"},
|
|
)
|
|
data = resp.json()
|
|
if data.get("ok"):
|
|
return {"status": "sent", "message_id": data["result"]["message_id"]}
|
|
return {"status": "failed", "error": data.get("description", "Unknown error")}
|
|
except Exception as e:
|
|
return {"status": "failed", "error": str(e)}
|
|
|
|
|
|
class TelegramBroadcastRequest(BaseModel):
|
|
message: str
|
|
|
|
|
|
@router.post("/telegram/broadcast")
|
|
async def telegram_broadcast(request: Request, req: TelegramBroadcastRequest, _=Depends(_verify_admin)):
|
|
"""Broadcast a message to all Telegram bot users."""
|
|
bot_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
|
if not bot_token:
|
|
return {"status": "failed", "error": "TELEGRAM_BOT_TOKEN not configured"}
|
|
|
|
# Get users
|
|
users_file = "/tmp/telegram_users.json"
|
|
chat_ids = []
|
|
if os.path.exists(users_file):
|
|
try:
|
|
with open(users_file) as f:
|
|
data = json.load(f)
|
|
chat_ids = [
|
|
u.get("chat_id") or u.get("id") for u in data.get("users", []) if u.get("chat_id") or u.get("id")
|
|
]
|
|
except Exception:
|
|
pass
|
|
|
|
if not chat_ids:
|
|
return {"status": "failed", "error": "No users to broadcast to"}
|
|
|
|
sent = 0
|
|
failed = 0
|
|
try:
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
for chat_id in chat_ids[:100]: # Limit to 100
|
|
try:
|
|
resp = await client.post(
|
|
f"https://api.telegram.org/bot{bot_token}/sendMessage",
|
|
json={"chat_id": chat_id, "text": req.message, "parse_mode": "HTML"},
|
|
)
|
|
if resp.json().get("ok"):
|
|
sent += 1
|
|
else:
|
|
failed += 1
|
|
except Exception:
|
|
failed += 1
|
|
except Exception as e:
|
|
return {"status": "partial", "sent": sent, "failed": failed, "error": str(e)}
|
|
|
|
return {"status": "completed", "sent": sent, "failed": failed, "total": len(chat_ids)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# GHOST CMS EXTENSIONS
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.get("/content/drafts")
|
|
async def ghost_drafts(request: Request, _=Depends(_verify_admin), limit: int = Query(20, le=100)):
|
|
"""Get Ghost drafts."""
|
|
try:
|
|
import httpx
|
|
|
|
from app.content_platform import GHOST_API, GHOST_CONTENT_KEY
|
|
|
|
if not GHOST_CONTENT_KEY:
|
|
return {"posts": [], "error": "Ghost content key not configured"}
|
|
|
|
params = {
|
|
"key": GHOST_CONTENT_KEY,
|
|
"limit": limit,
|
|
"filter": "status:draft",
|
|
"include": "tags,authors",
|
|
"order": "updated_at DESC",
|
|
}
|
|
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(
|
|
f"{GHOST_API}/ghost/api/content/posts/",
|
|
params=params,
|
|
headers={"Accept-Version": "v5.0"},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
posts = []
|
|
for p in data.get("posts", []):
|
|
posts.append(
|
|
{
|
|
"id": p["id"],
|
|
"title": p["title"],
|
|
"excerpt": p.get("excerpt", "")[:200],
|
|
"status": "draft",
|
|
"updated_at": p.get("updated_at", ""),
|
|
"tags": [t["name"] for t in p.get("tags", [])],
|
|
}
|
|
)
|
|
return {"posts": posts, "total": len(posts)}
|
|
return {"posts": [], "error": f"Ghost HTTP {resp.status_code}"}
|
|
except Exception as e:
|
|
return {"posts": [], "error": str(e)}
|
|
|
|
|
|
@router.get("/content/tags")
|
|
async def ghost_tags(request: Request, _=Depends(_verify_admin)):
|
|
"""Get Ghost tags."""
|
|
try:
|
|
import httpx
|
|
|
|
from app.content_platform import GHOST_API, GHOST_CONTENT_KEY
|
|
|
|
if not GHOST_CONTENT_KEY:
|
|
return {"tags": [], "error": "Ghost content key not configured"}
|
|
|
|
params = {"key": GHOST_CONTENT_KEY, "limit": 100}
|
|
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(
|
|
f"{GHOST_API}/ghost/api/content/tags/",
|
|
params=params,
|
|
headers={"Accept-Version": "v5.0"},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
tags = []
|
|
for t in data.get("tags", []):
|
|
tags.append(
|
|
{
|
|
"id": t["id"],
|
|
"name": t["name"],
|
|
"slug": t.get("slug", ""),
|
|
"post_count": t.get("count", {}).get("posts", 0) if isinstance(t.get("count"), dict) else 0,
|
|
}
|
|
)
|
|
return {"tags": tags, "total": len(tags)}
|
|
return {"tags": [], "error": f"Ghost HTTP {resp.status_code}"}
|
|
except Exception as e:
|
|
return {"tags": [], "error": str(e)}
|
|
|
|
|
|
@router.get("/content/analytics")
|
|
async def ghost_analytics(request: Request, _=Depends(_verify_admin)):
|
|
"""Get Ghost analytics computed from local data - no Ghost Pro required."""
|
|
try:
|
|
import httpx
|
|
|
|
from app.content_platform import GHOST_API, GHOST_CONTENT_KEY
|
|
|
|
total_posts = 0
|
|
posts_this_month = 0
|
|
|
|
# Fetch all posts to compute analytics
|
|
if GHOST_CONTENT_KEY:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.get(
|
|
f"{GHOST_API}/ghost/api/content/posts/",
|
|
params={
|
|
"key": GHOST_CONTENT_KEY,
|
|
"limit": 100,
|
|
"include": "tags",
|
|
"order": "published_at DESC",
|
|
},
|
|
headers={"Accept-Version": "v5.0"},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
posts = data.get("posts", [])
|
|
total_posts = len(posts)
|
|
|
|
# Count posts this month
|
|
now = datetime.now(UTC)
|
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
for p in posts:
|
|
pub_at = p.get("published_at") or p.get("created_at", "")
|
|
if pub_at:
|
|
try:
|
|
pub_dt = datetime.fromisoformat(pub_at.replace("Z", "+00:00"))
|
|
if pub_dt >= month_start:
|
|
posts_this_month += 1
|
|
except Exception:
|
|
pass
|
|
|
|
# Try to get member count from Ghost admin API
|
|
member_count = 0
|
|
|
|
return {
|
|
"total_posts": total_posts,
|
|
"posts_this_month": posts_this_month,
|
|
"member_count": member_count,
|
|
"avg_read_time": 0,
|
|
"views_today": 0,
|
|
"views_this_month": 0,
|
|
"source": "ghost_content_api",
|
|
"note": "Analytics computed from Ghost Content API - no Ghost Pro required",
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
@router.post("/content/post/{post_id}/publish")
|
|
async def ghost_publish(request: Request, post_id: str, _=Depends(_verify_admin)):
|
|
"""Publish a Ghost draft post."""
|
|
try:
|
|
import httpx
|
|
|
|
from app.content_platform import GHOST_API, _get_session_cookie
|
|
|
|
session = _get_session_cookie()
|
|
if not session:
|
|
return {"error": "No Ghost session cookie - login at /ghost first"}
|
|
|
|
cookie_header = session.split("\t")
|
|
cookie = f"{cookie_header[5]}={cookie_header[6]}" if len(cookie_header) > 6 else session
|
|
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.put(
|
|
f"{GHOST_API}/ghost/api/admin/posts/{post_id}/",
|
|
headers={"Content-Type": "application/json", "Origin": GHOST_API, "Cookie": cookie},
|
|
json={"posts": [{"status": "published"}]},
|
|
)
|
|
if resp.status_code in (200, 201):
|
|
p = resp.json()["posts"][0]
|
|
return {
|
|
"success": True,
|
|
"id": p["id"],
|
|
"status": p["status"],
|
|
"url": p.get("url", ""),
|
|
}
|
|
return {"error": f"Ghost HTTP {resp.status_code}", "detail": resp.text[:300]}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# CRON JOB MANAGEMENT (Hermes cron system)
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.get("/cron/jobs")
|
|
async def cron_jobs(request: Request, _=Depends(_verify_admin)):
|
|
"""List all Hermes cron jobs."""
|
|
jobs = []
|
|
try:
|
|
# Try to read from Hermes cron directory
|
|
cron_dir = os.path.expanduser("~/.hermes/cron")
|
|
if os.path.isdir(cron_dir):
|
|
for f in os.listdir(cron_dir):
|
|
if f.endswith(".json"):
|
|
try:
|
|
with open(os.path.join(cron_dir, f)) as fp:
|
|
job = json.load(fp)
|
|
jobs.append(
|
|
{
|
|
"id": job.get("id", f.replace(".json", "")),
|
|
"name": job.get("name", "Unnamed"),
|
|
"schedule": job.get("schedule", "unknown"),
|
|
"status": job.get("status", "unknown"),
|
|
"last_run": job.get("last_run", None),
|
|
"next_run": job.get("next_run", None),
|
|
"enabled": job.get("enabled", True),
|
|
"script": job.get("script", ""),
|
|
"prompt": job.get("prompt", "")[:100],
|
|
}
|
|
)
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Cron jobs error: {e}")
|
|
|
|
if not jobs:
|
|
# Return placeholder jobs based on known system crons
|
|
jobs = [
|
|
{
|
|
"id": "ghost-daily",
|
|
"name": "Ghost Daily Post",
|
|
"schedule": "0 9 * * *",
|
|
"status": "active",
|
|
"enabled": True,
|
|
},
|
|
{
|
|
"id": "ghost-weekly",
|
|
"name": "Ghost Weekly Newsletter",
|
|
"schedule": "0 10 * * 1",
|
|
"status": "active",
|
|
"enabled": True,
|
|
},
|
|
{
|
|
"id": "git-sync",
|
|
"name": "Git Sync",
|
|
"schedule": "every 4h",
|
|
"status": "active",
|
|
"enabled": True,
|
|
},
|
|
{
|
|
"id": "health-check",
|
|
"name": "Health Check",
|
|
"schedule": "*/15 * * * *",
|
|
"status": "active",
|
|
"enabled": True,
|
|
},
|
|
]
|
|
|
|
return {"jobs": jobs, "total": len(jobs)}
|
|
|
|
|
|
@router.get("/cron/logs")
|
|
async def cron_logs(request: Request, _=Depends(_verify_admin), limit: int = Query(50, le=200)):
|
|
"""Get cron execution logs."""
|
|
logs = []
|
|
log_file = os.path.expanduser("~/.hermes/cron.log")
|
|
if os.path.exists(log_file):
|
|
try:
|
|
with open(log_file) as f:
|
|
lines = f.readlines()[-limit:]
|
|
for line in lines:
|
|
line = line.strip()
|
|
if line:
|
|
logs.append({"timestamp": line[:19] if len(line) > 19 else "", "message": line})
|
|
except Exception:
|
|
pass
|
|
|
|
return {"logs": logs, "total": len(logs)}
|
|
|
|
|
|
@router.post("/cron/{job_id}/run")
|
|
async def cron_run(request: Request, job_id: str, _=Depends(_verify_admin)):
|
|
"""Trigger a cron job manually."""
|
|
return {"status": "queued", "job_id": job_id, "message": "Job execution queued"}
|
|
|
|
|
|
@router.post("/cron/{job_id}/pause")
|
|
async def cron_pause(request: Request, job_id: str, _=Depends(_verify_admin)):
|
|
"""Pause a cron job."""
|
|
return {"status": "paused", "job_id": job_id}
|
|
|
|
|
|
@router.post("/cron/{job_id}/resume")
|
|
async def cron_resume(request: Request, job_id: str, _=Depends(_verify_admin)):
|
|
"""Resume a cron job."""
|
|
return {"status": "resumed", "job_id": job_id}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# DIFY AI ADVISOR PROXY
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _get_brightdata_proxy():
|
|
"""Return Bright Data residential proxy config for Dify access."""
|
|
return {
|
|
"proxy": "http://brd-customer-hl_c51d0c6f-zone-residential_proxy1-country-us:lig96713z47s@brd.superproxy.io:33335",
|
|
"proxy_auth": None, # auth is embedded in the URL
|
|
}
|
|
|
|
|
|
def _brightdata_client(timeout: int = 30):
|
|
"""Create an httpx client routed through Bright Data residential proxy."""
|
|
import httpx
|
|
|
|
proxy_url = (
|
|
"http://brd-customer-hl_c51d0c6f-zone-residential_proxy1-country-us:lig96713z47s@brd.superproxy.io:33335"
|
|
)
|
|
return httpx.AsyncClient(
|
|
timeout=timeout,
|
|
proxy=proxy_url,
|
|
verify=False, # Bright Data handles TLS termination
|
|
)
|
|
|
|
|
|
@router.get("/dify/status")
|
|
async def dify_status(request: Request, _=Depends(_verify_admin)):
|
|
"""Check Dify connection status."""
|
|
# Hardcoded - Docker DNS resolves unreliably in gunicorn workers
|
|
dify_url = "http://172.23.0.4:5001"
|
|
dify_key = os.getenv("DIFY_APP_KEY", "app-nIpKWvytqS3JGNNmD0Iuz0cG")
|
|
|
|
try:
|
|
import asyncio
|
|
import json as _json
|
|
|
|
loop = asyncio.get_event_loop()
|
|
test_payload = _json.dumps(
|
|
{"inputs": {}, "query": "ping", "response_mode": "blocking", "user": "health-check"}
|
|
).encode()
|
|
|
|
def _check():
|
|
import urllib.request as ur
|
|
|
|
req = ur.Request(
|
|
f"{dify_url}/v1/chat-messages",
|
|
data=test_payload,
|
|
headers={"Authorization": f"Bearer {dify_key}", "Content-Type": "application/json"},
|
|
)
|
|
return ur.urlopen(req, timeout=5).status
|
|
|
|
status = await loop.run_in_executor(None, _check)
|
|
if status < 500:
|
|
return {
|
|
"status": "connected",
|
|
"url": dify_url,
|
|
"key_configured": bool(dify_key),
|
|
"connected": True,
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"status": "disconnected",
|
|
"url": dify_url,
|
|
"key_configured": bool(dify_key),
|
|
"error": str(e),
|
|
"connected": False,
|
|
}
|
|
|
|
return {
|
|
"status": "disconnected",
|
|
"url": dify_url,
|
|
"key_configured": bool(dify_key),
|
|
"connected": False,
|
|
}
|
|
|
|
|
|
@router.get("/dify/conversations")
|
|
async def dify_conversations(
|
|
request: Request,
|
|
_=Depends(_verify_admin),
|
|
limit: int = Query(20, le=100),
|
|
use_proxy: bool = False,
|
|
):
|
|
"""Get Dify conversations. Auto-fallback to Bright Data proxy on failure."""
|
|
dify_url = os.getenv("DIFY_API_URL", "http://127.0.0.1:8899")
|
|
dify_key = os.getenv("DIFY_APP_KEY", "")
|
|
|
|
if not dify_key:
|
|
return {"conversations": [], "error": "DIFY_APP_KEY not configured"}
|
|
|
|
try:
|
|
import httpx
|
|
|
|
client = _brightdata_client(timeout=10) if use_proxy else httpx.AsyncClient(timeout=10)
|
|
async with client as c:
|
|
resp = await c.get(
|
|
f"{dify_url}/v1/conversations",
|
|
headers={"Authorization": f"Bearer {dify_key}"},
|
|
params={"limit": limit},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
return {
|
|
"conversations": data.get("data", []),
|
|
"total": data.get("total", 0),
|
|
"proxy": use_proxy,
|
|
}
|
|
return {
|
|
"conversations": [],
|
|
"error": f"Dify HTTP {resp.status_code}",
|
|
"proxy": use_proxy,
|
|
}
|
|
except Exception as e:
|
|
if not use_proxy:
|
|
return await dify_conversations(request, _, limit, use_proxy=True)
|
|
return {"conversations": [], "error": str(e), "proxy": use_proxy}
|
|
|
|
|
|
@router.get("/dify/apps")
|
|
async def dify_apps(request: Request, _=Depends(_verify_admin), use_proxy: bool = False):
|
|
"""Get Dify apps. Auto-fallback to Bright Data proxy."""
|
|
dify_url = os.getenv("DIFY_API_URL", "http://127.0.0.1:8899")
|
|
dify_key = os.getenv("DIFY_APP_KEY", "")
|
|
|
|
if not dify_key:
|
|
return {"apps": [], "error": "DIFY_APP_KEY not configured"}
|
|
|
|
try:
|
|
import httpx
|
|
|
|
client = _brightdata_client(timeout=10) if use_proxy else httpx.AsyncClient(timeout=10)
|
|
async with client as c:
|
|
resp = await c.get(f"{dify_url}/v1/apps", headers={"Authorization": f"Bearer {dify_key}"})
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
return {
|
|
"apps": data.get("data", []),
|
|
"total": data.get("total", 0),
|
|
"proxy": use_proxy,
|
|
}
|
|
return {"apps": [], "error": f"Dify HTTP {resp.status_code}", "proxy": use_proxy}
|
|
except Exception as e:
|
|
if not use_proxy:
|
|
return await dify_apps(request, _, use_proxy=True)
|
|
return {"apps": [], "error": str(e), "proxy": use_proxy}
|
|
|
|
|
|
@router.get("/dify/conversations/{conversation_id}/messages")
|
|
async def dify_messages(request: Request, conversation_id: str, _=Depends(_verify_admin), use_proxy: bool = False):
|
|
"""Get messages for a Dify conversation. Auto-fallback to Bright Data proxy."""
|
|
dify_url = os.getenv("DIFY_API_URL", "http://127.0.0.1:8899")
|
|
dify_key = os.getenv("DIFY_APP_KEY", "")
|
|
|
|
if not dify_key:
|
|
return {"messages": [], "error": "DIFY_APP_KEY not configured"}
|
|
|
|
try:
|
|
import httpx
|
|
|
|
client = _brightdata_client(timeout=10) if use_proxy else httpx.AsyncClient(timeout=10)
|
|
async with client as c:
|
|
resp = await c.get(
|
|
f"{dify_url}/v1/messages",
|
|
headers={"Authorization": f"Bearer {dify_key}"},
|
|
params={"conversation_id": conversation_id, "limit": 100},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
return {
|
|
"messages": data.get("data", []),
|
|
"total": data.get("total", 0),
|
|
"proxy": use_proxy,
|
|
}
|
|
return {"messages": [], "error": f"Dify HTTP {resp.status_code}", "proxy": use_proxy}
|
|
except Exception as e:
|
|
if not use_proxy:
|
|
return await dify_messages(request, conversation_id, _, use_proxy=True)
|
|
return {"messages": [], "error": str(e), "proxy": use_proxy}
|
|
|
|
|
|
class DifyChatRequest(BaseModel):
|
|
message: str
|
|
conversation_id: str | None = None
|
|
app_id: str | None = None
|
|
|
|
|
|
@router.post("/dify/chat")
|
|
async def dify_chat(request: Request, req: DifyChatRequest, _=Depends(_verify_admin)):
|
|
"""Send a message to Dify advisor."""
|
|
# Hardcoded - Docker DNS resolves unreliably in gunicorn workers
|
|
dify_url = "http://172.23.0.4:5001"
|
|
dify_key = os.getenv("DIFY_APP_KEY", "app-nIpKWvytqS3JGNNmD0Iuz0cG")
|
|
|
|
if not dify_key:
|
|
return {"error": "DIFY_APP_KEY not configured"}
|
|
|
|
try:
|
|
import asyncio
|
|
|
|
loop = asyncio.get_event_loop()
|
|
payload = json.dumps(
|
|
{
|
|
"inputs": {},
|
|
"query": req.message,
|
|
"response_mode": "blocking",
|
|
"conversation_id": req.conversation_id or "",
|
|
"user": "darkroom_admin",
|
|
}
|
|
).encode()
|
|
|
|
# Run blocking urllib in thread pool to avoid blocking event loop
|
|
def _call_dify():
|
|
import urllib.request as ur
|
|
|
|
http_req = ur.Request(
|
|
f"{dify_url}/v1/chat-messages",
|
|
data=payload,
|
|
headers={"Authorization": f"Bearer {dify_key}", "Content-Type": "application/json"},
|
|
)
|
|
resp = ur.urlopen(http_req, timeout=60)
|
|
return json.loads(resp.read())
|
|
|
|
data = await loop.run_in_executor(None, _call_dify)
|
|
return {
|
|
"answer": data.get("answer", ""),
|
|
"conversation_id": data.get("conversation_id", ""),
|
|
"message_id": data.get("message_id", ""),
|
|
"status": "completed",
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)[:300]}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# SERVER METRICS DEEP-DIVE
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.get("/processes")
|
|
async def admin_processes(request: Request, _=Depends(_verify_admin)):
|
|
"""Get running processes."""
|
|
processes = []
|
|
try:
|
|
for proc in psutil.process_iter(["pid", "name", "cpu_percent", "memory_info", "num_threads", "status"]):
|
|
try:
|
|
info = proc.info
|
|
mem_mb = info.get("memory_info", type("", (), {"rss": 0})())
|
|
processes.append(
|
|
{
|
|
"pid": info["pid"],
|
|
"name": info["name"],
|
|
"cpu_percent": info.get("cpu_percent", 0),
|
|
"memory_mb": round(mem_mb.rss / (1024 * 1024), 2) if hasattr(mem_mb, "rss") else 0,
|
|
"threads": info.get("num_threads", 0),
|
|
"status": info.get("status", "unknown"),
|
|
}
|
|
)
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
# Sort by memory usage
|
|
processes.sort(key=lambda x: x.get("memory_mb", 0), reverse=True)
|
|
return {"processes": processes[:50], "total": len(processes)}
|
|
|
|
|
|
@router.get("/disk")
|
|
async def admin_disk(request: Request, _=Depends(_verify_admin)):
|
|
"""Get detailed disk usage."""
|
|
disks = []
|
|
try:
|
|
for part in psutil.disk_partitions(all=False):
|
|
try:
|
|
usage = psutil.disk_usage(part.mountpoint)
|
|
disks.append(
|
|
{
|
|
"device": part.device,
|
|
"mountpoint": part.mountpoint,
|
|
"fstype": part.fstype,
|
|
"total_gb": round(usage.total / (1024**3), 2),
|
|
"used_gb": round(usage.used / (1024**3), 2),
|
|
"free_gb": round(usage.free / (1024**3), 2),
|
|
"percent": round(usage.used / usage.total * 100, 1),
|
|
}
|
|
)
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
return {"disks": disks}
|
|
|
|
|
|
@router.get("/network")
|
|
async def admin_network(request: Request, _=Depends(_verify_admin)):
|
|
"""Get network interface stats."""
|
|
interfaces = []
|
|
try:
|
|
stats = psutil.net_io_counters(pernic=True)
|
|
for name, stat in stats.items():
|
|
interfaces.append(
|
|
{
|
|
"name": name,
|
|
"bytes_sent_mb": round(stat.bytes_sent / (1024**2), 2),
|
|
"bytes_recv_mb": round(stat.bytes_recv / (1024**2), 2),
|
|
"packets_sent": stat.packets_sent,
|
|
"packets_recv": stat.packets_recv,
|
|
"errors_in": stat.errin,
|
|
"errors_out": stat.errout,
|
|
"drops_in": stat.dropin,
|
|
"drops_out": stat.dropout,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
return {"interfaces": interfaces}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# WEBMAIL FOLDERS
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.get("/mail/sent")
|
|
async def mail_sent(request: Request, _=Depends(_verify_admin), limit: int = Query(20, le=100)):
|
|
"""Get sent emails (placeholder - requires SMTP sent tracking)."""
|
|
return {"emails": [], "total": 0, "note": "Sent folder requires SMTP sent tracking"}
|
|
|
|
|
|
@router.get("/mail/folders")
|
|
async def mail_folders(request: Request, _=Depends(_verify_admin)):
|
|
"""Get email folders."""
|
|
return {
|
|
"folders": [
|
|
{"name": "INBOX", "count": 0, "unread": 0},
|
|
{"name": "Sent", "count": 0, "unread": 0},
|
|
{"name": "Drafts", "count": 0, "unread": 0},
|
|
{"name": "Spam", "count": 0, "unread": 0},
|
|
{"name": "Trash", "count": 0, "unread": 0},
|
|
]
|
|
}
|
|
|
|
|
|
@router.delete("/mail/{msg_id}")
|
|
async def mail_delete(request: Request, msg_id: str, _=Depends(_verify_admin)):
|
|
"""Delete an email (placeholder)."""
|
|
return {"status": "deleted", "id": msg_id}
|