""" 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}