""" web/routes.py — REST API routes for credentials, audit log, agents, settings, etc. All database calls are async (asyncpg). """ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from ..agents import tasks as agent_store from ..agents.runner import agent_runner from ..audit import audit_log from ..config import settings from ..database import credential_store, email_whitelist_store, filesystem_whitelist_store, web_whitelist_store router = APIRouter() # ── Credentials ─────────────────────────────────────────────────────────────── class CredentialIn(BaseModel): key: str value: str description: str = "" @router.get("/credentials") async def list_credentials(request: Request): _require_admin(request) return await credential_store.list_keys() @router.get("/credentials/{key:path}") async def get_credential(request: Request, key: str): _require_admin(request) value = await credential_store.get(key) if value is None: raise HTTPException(status_code=404, detail=f"Credential '{key}' not found.") return {"key": key, "value": value} @router.post("/credentials") async def set_credential(request: Request, body: CredentialIn): _require_admin(request) await credential_store.set(body.key, body.value, body.description) return {"ok": True, "key": body.key} @router.delete("/credentials/{key:path}") async def delete_credential(request: Request, key: str): _require_admin(request) if key == "system:paused": raise HTTPException(status_code=400, detail="Cannot delete kill-switch key via this endpoint.") deleted = await credential_store.delete(key) if not deleted: raise HTTPException(status_code=404, detail=f"Credential '{key}' not found.") return {"ok": True, "key": key} # ── Audit log ───────────────────────────────────────────────────────────────── @router.get("/audit") async def query_audit( request: Request, start: str = "", end: str = "", tool_name: str = "", session_id: str = "", task_id: str = "", confirmed_only: bool = False, page: int = 1, per_page: int = 50, ): user = _require_auth(request) # Non-admins can only see their own audit entries force_user_id = None if user.is_admin else user.id offset = (page - 1) * per_page entries, total = await asyncio_gather( audit_log.query( start=start or None, end=end or None, tool_name=tool_name or None, session_id=session_id or None, task_id=task_id or None, confirmed_only=confirmed_only, user_id=force_user_id, limit=per_page, offset=offset, ), audit_log.count( start=start or None, end=end or None, tool_name=tool_name or None, task_id=task_id or None, session_id=session_id or None, confirmed_only=confirmed_only, user_id=force_user_id, ), ) return { "entries": [ { "id": e.id, "timestamp": e.timestamp, "session_id": e.session_id, "tool_name": e.tool_name, "arguments": e.arguments, "result_summary": e.result_summary, "confirmed": e.confirmed, "task_id": e.task_id, } for e in entries ], "total": total, "page": page, "per_page": per_page, "pages": max(1, -(-total // per_page)), } @router.delete("/audit") async def clear_audit(request: Request, older_than_days: int = 0): _require_admin(request) deleted = await audit_log.purge(older_than_days=older_than_days if older_than_days > 0 else None) return {"deleted": deleted} @router.get("/settings/audit-retention") async def get_audit_retention(request: Request): _require_admin(request) days = await credential_store.get("system:audit_retention_days") return {"days": int(days) if days else 0} @router.post("/settings/audit-retention") async def set_audit_retention(request: Request, body: dict): _require_admin(request) days = int(body.get("days", 0)) if days > 0: await credential_store.set( "system:audit_retention_days", str(days), description="Audit log retention in days (0 = keep forever)", ) else: await credential_store.delete("system:audit_retention_days") return {"days": days} # ── Email whitelist ──────────────────────────────────────────────────────────── class EmailWhitelistEntry(BaseModel): email: str daily_limit: int = 0 @router.get("/email-whitelist") async def list_email_whitelist(request: Request): _require_admin(request) return await email_whitelist_store.list() @router.post("/email-whitelist") async def add_email_whitelist(request: Request, body: EmailWhitelistEntry): _require_admin(request) email = body.email.strip().lower() if not email or "@" not in email: raise HTTPException(status_code=400, detail="Invalid email address") if body.daily_limit < 0: raise HTTPException(status_code=400, detail="daily_limit must be >= 0") await email_whitelist_store.add(email, body.daily_limit) return {"ok": True, "email": email, "daily_limit": body.daily_limit} @router.delete("/email-whitelist/{email:path}") async def delete_email_whitelist(request: Request, email: str): _require_admin(request) deleted = await email_whitelist_store.remove(email) if not deleted: raise HTTPException(status_code=404, detail=f"Email '{email}' not in whitelist") return {"ok": True, "email": email} # ── Web whitelist ───────────────────────────────────────────────────────────── class WebWhitelistEntry(BaseModel): domain: str note: str = "" @router.get("/web-whitelist") async def list_web_whitelist(request: Request): _require_admin(request) return await web_whitelist_store.list() @router.post("/web-whitelist") async def add_web_whitelist(request: Request, body: WebWhitelistEntry): _require_admin(request) domain = body.domain.strip() if not domain: raise HTTPException(status_code=400, detail="domain is required") await web_whitelist_store.add(domain, body.note) return {"ok": True, "domain": domain} @router.delete("/web-whitelist/{domain:path}") async def delete_web_whitelist(request: Request, domain: str): _require_admin(request) deleted = await web_whitelist_store.remove(domain) if not deleted: raise HTTPException(status_code=404, detail=f"Domain '{domain}' not in whitelist") return {"ok": True, "domain": domain} # ── Filesystem whitelist ────────────────────────────────────────────────────── class FilesystemWhitelistEntry(BaseModel): path: str note: str = "" @router.get("/filesystem-whitelist") async def list_filesystem_whitelist(request: Request): _require_admin(request) return await filesystem_whitelist_store.list() @router.post("/filesystem-whitelist") async def add_filesystem_whitelist(request: Request, body: FilesystemWhitelistEntry): _require_admin(request) from pathlib import Path path = body.path.strip() if not path: raise HTTPException(status_code=400, detail="path is required") if not Path(path).is_absolute(): raise HTTPException(status_code=400, detail="path must be absolute (e.g. /home/rune/documents)") if not Path(path).exists(): raise HTTPException(status_code=400, detail=f"path does not exist: {path}") if not Path(path).is_dir(): raise HTTPException(status_code=400, detail=f"path is not a directory: {path}") await filesystem_whitelist_store.add(path, body.note) return {"ok": True, "path": path} @router.delete("/filesystem-whitelist/{path:path}") async def delete_filesystem_whitelist(request: Request, path: str): _require_admin(request) deleted = await filesystem_whitelist_store.remove(path) if not deleted: raise HTTPException(status_code=404, detail=f"Path '{path}' not in whitelist") return {"ok": True, "path": path} @router.get("/filesystem-browser") async def filesystem_browser(request: Request, path: str = "/"): _require_admin(request) from pathlib import Path p = Path(path) if not p.is_absolute(): raise HTTPException(status_code=400, detail="path must be absolute") if not p.exists() or not p.is_dir(): raise HTTPException(status_code=404, detail=f"Directory not found: {path}") entries = [] try: for entry in sorted(p.iterdir()): try: if entry.is_dir() and not entry.is_symlink(): entries.append({"name": entry.name, "path": str(entry)}) except PermissionError: continue except PermissionError: pass parent = str(p.parent) if p != p.parent else None return {"path": str(p), "parent": parent, "entries": entries[:300]} # ── Models ──────────────────────────────────────────────────────────────────── @router.get("/models") async def list_models(request: Request): from ..providers.models import get_available_models, get_access_tier from ..providers.registry import get_available_providers user = request.state.current_user user_id = user.id if user else None is_admin = user.is_admin if user else True models, default = await get_available_models(user_id=user_id, is_admin=is_admin) access = await get_access_tier(user_id=user_id, is_admin=is_admin) return { "models": models, "default": default, "providers": await get_available_providers(user_id=user_id), "access": access, } @router.get("/models/info") async def models_info(request: Request): from ..providers.models import get_models_info user = request.state.current_user user_id = user.id if user else None is_admin = user.is_admin if user else True return await get_models_info(user_id=user_id, is_admin=is_admin) # ── Runtime limits ──────────────────────────────────────────────────────────── class LimitsIn(BaseModel): max_tool_calls: Optional[int] = None max_autonomous_runs_per_hour: Optional[int] = None max_concurrent_runs: Optional[int] = None class ProxyTrustIn(BaseModel): trusted_ips: str @router.get("/settings/limits") async def get_limits(request: Request): _require_admin(request) async def _get(key: str, default: int) -> int: v = await credential_store.get(key) try: return int(v) if v else default except (ValueError, TypeError): return default mtc, mar, mcr = await asyncio_gather( _get("system:max_tool_calls", settings.max_tool_calls), _get("system:max_autonomous_runs_per_hour", settings.max_autonomous_runs_per_hour), _get("system:max_concurrent_runs", 3), ) return { "max_tool_calls": mtc, "max_autonomous_runs_per_hour": mar, "max_concurrent_runs": mcr, "defaults": { "max_tool_calls": settings.max_tool_calls, "max_autonomous_runs_per_hour": settings.max_autonomous_runs_per_hour, "max_concurrent_runs": 3, }, } @router.post("/settings/limits") async def set_limits(request: Request, body: LimitsIn): _require_admin(request) if body.max_tool_calls is not None: if body.max_tool_calls < 1: raise HTTPException(status_code=400, detail="max_tool_calls must be >= 1") await credential_store.set( "system:max_tool_calls", str(body.max_tool_calls), "Max tool calls per agent run", ) if body.max_autonomous_runs_per_hour is not None: if body.max_autonomous_runs_per_hour < 1: raise HTTPException(status_code=400, detail="max_autonomous_runs_per_hour must be >= 1") await credential_store.set( "system:max_autonomous_runs_per_hour", str(body.max_autonomous_runs_per_hour), "Max autonomous scheduler runs per hour", ) if body.max_concurrent_runs is not None: if body.max_concurrent_runs < 1: raise HTTPException(status_code=400, detail="max_concurrent_runs must be >= 1") await credential_store.set( "system:max_concurrent_runs", str(body.max_concurrent_runs), "Max concurrent agent runs", ) return await get_limits() @router.get("/queue") async def get_queue_status(request: Request): """Return current run queue status.""" _require_auth(request) return agent_runner.queue_status @router.get("/settings/default-models") async def get_default_models(request: Request): _require_admin(request) return { "default_model": await credential_store.get("system:default_chat_model") or "", "free_tier_model": await credential_store.get("system:default_chat_model_free") or "", } class DefaultModelsIn(BaseModel): default_model: Optional[str] = None free_tier_model: Optional[str] = None @router.post("/settings/default-models") async def set_default_models(request: Request, body: DefaultModelsIn): _require_admin(request) if body.default_model is not None: if body.default_model.strip(): await credential_store.set( "system:default_chat_model", body.default_model.strip(), "Default chat model for all users", ) else: await credential_store.delete("system:default_chat_model") if body.free_tier_model is not None: if body.free_tier_model.strip(): await credential_store.set( "system:default_chat_model_free", body.free_tier_model.strip(), "Default chat model for free-tier users (no own API key)", ) else: await credential_store.delete("system:default_chat_model_free") return await get_default_models() @router.get("/settings/proxy-trust") async def get_proxy_trust(request: Request): _require_admin(request) return { "trusted_ips": await credential_store.get("system:trusted_proxy_ips") or "127.0.0.1", } @router.post("/settings/proxy-trust") async def set_proxy_trust(request: Request, body: ProxyTrustIn): _require_admin(request) await credential_store.set( "system:trusted_proxy_ips", body.trusted_ips.strip(), "Trusted reverse proxy IPs for X-Forwarded-For headers", ) return {"trusted_ips": body.trusted_ips.strip()} class UserBaseFolderIn(BaseModel): path: str = "" @router.get("/settings/users-base-folder") async def get_users_base_folder(request: Request): _require_admin(request) return {"path": await credential_store.get("system:users_base_folder") or ""} @router.post("/settings/users-base-folder") async def set_users_base_folder(request: Request, body: UserBaseFolderIn): _require_admin(request) path = body.path.strip() if path: from pathlib import Path as _Path p = _Path(path) if not p.is_absolute(): raise HTTPException(status_code=400, detail="Path must be absolute.") if not p.exists(): raise HTTPException(status_code=400, detail="Path does not exist on the server.") await credential_store.set("system:users_base_folder", path, "Base folder for per-user subfolders") # Provision folders for all existing users from ..users import list_users, _provision_user_folder for u in await list_users(): await _provision_user_folder(u["username"]) else: await credential_store.delete("system:users_base_folder") return {"path": path} # ── Admin CalDAV / CardDAV settings ────────────────────────────────────────── @router.get("/settings/caldav") async def get_admin_caldav(request: Request): _require_admin(request) get = credential_store.get return { "host": await get("mailcow_host") or "", "username": await get("mailcow_username") or "", "password_set": bool(await get("mailcow_password")), "calendar_name": await get("caldav_calendar_name") or "", "contacts_allow_write": (await get("contacts:allow_write")) == "1", "carddav_same_as_caldav": (await get("carddav_same_as_caldav")) == "1", "carddav_url": await get("carddav_url") or "", "carddav_username": await get("carddav_username") or "", "carddav_password_set": bool(await get("carddav_password")), "imap_host": await get("mailcow_imap_host") or "", "smtp_host": await get("mailcow_smtp_host") or "", "smtp_port": await get("mailcow_smtp_port") or "", } @router.post("/settings/caldav") async def set_admin_caldav(request: Request): _require_admin(request) body = await request.json() async def _set(key, val, desc=""): val = (val or "").strip() if val: await credential_store.set(key, val, desc) else: await credential_store.delete(key) async def _set_bool(key, val): if val: await credential_store.set(key, "1") else: await credential_store.delete(key) await _set("mailcow_host", body.get("host"), "Mailcow hostname") await _set("mailcow_username", body.get("username"), "Mailcow username") # Only update password if a new value is provided pwd = (body.get("password") or "").strip() if pwd: await credential_store.set("mailcow_password", pwd, "Mailcow password") await _set("caldav_calendar_name", body.get("calendar_name"), "Default calendar name") await _set_bool("contacts:allow_write", body.get("contacts_allow_write")) same = bool(body.get("carddav_same_as_caldav")) await _set_bool("carddav_same_as_caldav", same) if same: for k in ("carddav_url", "carddav_username", "carddav_password"): await credential_store.delete(k) else: await _set("carddav_url", body.get("carddav_url"), "CardDAV server URL") await _set("carddav_username", body.get("carddav_username"), "CardDAV username") cpwd = (body.get("carddav_password") or "").strip() if cpwd: await credential_store.set("carddav_password", cpwd, "CardDAV password") # Email tool overrides (optional host overrides for IMAP/SMTP) await _set("mailcow_imap_host", body.get("imap_host"), "IMAP host override") await _set("mailcow_smtp_host", body.get("smtp_host"), "SMTP host override") await _set("mailcow_smtp_port", body.get("smtp_port"), "SMTP port override") return {"ok": True} # ── Admin Pushover settings ─────────────────────────────────────────────────── @router.get("/settings/pushover") async def get_admin_pushover(request: Request): _require_admin(request) return { "app_token_set": bool(await credential_store.get("pushover_app_token")), "user_key_set": bool(await credential_store.get("pushover_user_key")), } @router.post("/settings/pushover") async def set_admin_pushover(request: Request): _require_admin(request) body = await request.json() for field, key in [("app_token", "pushover_app_token"), ("user_key", "pushover_user_key")]: val = (body.get(field) or "").strip() if val: await credential_store.set(key, val) # Never clear on empty — must explicitly use delete endpoint return {"ok": True} # ── User Pushover settings ──────────────────────────────────────────────────── @router.get("/my/pushover") async def get_my_pushover(request: Request): _require_auth(request) user = request.state.current_user app_ok = bool(await credential_store.get("pushover_app_token")) user_key = await _user_settings_store.get(user["id"], "pushover_user_key") return {"app_token_configured": app_ok, "user_key_set": bool(user_key)} @router.post("/my/pushover") async def set_my_pushover(request: Request): _require_auth(request) user = request.state.current_user body = await request.json() key = (body.get("user_key") or "").strip() if key: await _user_settings_store.set(user["id"], "pushover_user_key", key) return {"ok": True} @router.delete("/my/pushover") async def delete_my_pushover(request: Request): _require_auth(request) user = request.state.current_user await _user_settings_store.delete(user["id"], "pushover_user_key") return {"ok": True} # ── Agents ──────────────────────────────────────────────────────────────────── class AgentIn(BaseModel): name: str prompt: str model: str description: str = "" can_create_subagents: bool = False allowed_tools: Optional[list[str]] = None schedule: Optional[str] = None enabled: bool = True max_tool_calls: Optional[int] = None prompt_mode: str = "combined" class AgentUpdate(BaseModel): name: Optional[str] = None prompt: Optional[str] = None model: Optional[str] = None description: Optional[str] = None can_create_subagents: Optional[bool] = None allowed_tools: Optional[list[str]] = None schedule: Optional[str] = None enabled: Optional[bool] = None max_tool_calls: Optional[int] = None prompt_mode: Optional[str] = None @router.get("/agents") async def list_agents(request: Request): user = _require_auth(request) owner_filter = None if user.is_admin else user.id agents = await agent_store.list_agents(owner_user_id=owner_filter) # Exclude email handling agents — they are managed via Email Accounts settings from ..database import get_pool as _gp pool = await _gp() handler_ids = {str(r["agent_id"]) for r in await pool.fetch( "SELECT agent_id FROM email_accounts WHERE agent_id IS NOT NULL" )} return [a for a in agents if a["id"] not in handler_ids] @router.post("/agents", status_code=201) async def create_agent(request: Request, body: AgentIn): user = _require_auth(request) # Stamp owner; synthetic API-key admin gets NULL (no real user row) from ..auth import SYNTHETIC_API_ADMIN owner_user_id = None if user.id == SYNTHETIC_API_ADMIN.id else user.id agent = await agent_store.create_agent( name=body.name, prompt=body.prompt, model=body.model, description=body.description, can_create_subagents=body.can_create_subagents, allowed_tools=body.allowed_tools or [], schedule=body.schedule, enabled=body.enabled, max_tool_calls=body.max_tool_calls, prompt_mode=body.prompt_mode, owner_user_id=owner_user_id, ) agent_runner.reschedule(agent) return agent @router.get("/agents/{agent_id}") async def get_agent(request: Request, agent_id: str): user = _require_auth(request) agent = await agent_store.get_agent(agent_id) return _check_agent_access(agent, user) @router.put("/agents/{agent_id}") async def update_agent(request: Request, agent_id: str, body: AgentUpdate): user = _require_auth(request) _check_agent_access(await agent_store.get_agent(agent_id), user) fields = {k: v for k, v in body.model_dump().items() if v is not None or k == "max_tool_calls"} agent = await agent_store.update_agent(agent_id, **fields) if not agent: raise HTTPException(status_code=404, detail="Agent not found") agent_runner.reschedule(agent) return agent @router.delete("/agents/{agent_id}") async def delete_agent(request: Request, agent_id: str): user = _require_auth(request) _check_agent_access(await agent_store.get_agent(agent_id), user) deleted = await agent_store.delete_agent(agent_id) if not deleted: raise HTTPException(status_code=404, detail="Agent not found") agent_runner.remove(agent_id) return {"ok": True} @router.post("/agents/{agent_id}/toggle") async def toggle_agent(request: Request, agent_id: str): user = _require_auth(request) agent = _check_agent_access(await agent_store.get_agent(agent_id), user) updated = await agent_store.update_agent(agent_id, enabled=not agent["enabled"]) agent_runner.reschedule(updated) return updated @router.post("/agents/{agent_id}/run") async def run_agent_now(request: Request, agent_id: str): user = _require_auth(request) _check_agent_access(await agent_store.get_agent(agent_id), user) run = await agent_runner.run_agent_now(agent_id) return {"ok": True, "run": run} @router.post("/agents/{agent_id}/stop") async def stop_agent(request: Request, agent_id: str): user = _require_auth(request) _check_agent_access(await agent_store.get_agent(agent_id), user) run_id = await agent_runner.find_active_run(agent_id) if not run_id: raise HTTPException(status_code=404, detail="No active run found for this agent") stopped = agent_runner.stop_run(run_id) return {"ok": stopped, "run_id": run_id} @router.get("/agents/{agent_id}/runs") async def get_agent_runs(request: Request, agent_id: str): user = _require_auth(request) _check_agent_access(await agent_store.get_agent(agent_id), user) return await agent_store.list_runs(agent_id=agent_id) # ── Agent runs (cross-agent) ────────────────────────────────────────────────── @router.get("/agent-runs") async def list_all_runs( request: Request, since: str = "7d", start: str = "", end: str = "", status: str = "", ): user = _require_auth(request) now = datetime.now(timezone.utc) since_dt: str | None = None if start: since_dt = start elif since == "today": since_dt = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() elif since == "7d": since_dt = (now - timedelta(days=7)).isoformat() elif since == "30d": since_dt = (now - timedelta(days=30)).isoformat() runs = await agent_store.list_runs(since=since_dt, status=status or None) # Exclude email handler agents (managed via Email Accounts, not the Agents page) from ..database import get_pool as _gp _pool = await _gp() handler_ids = {str(r["agent_id"]) for r in await _pool.fetch( "SELECT agent_id FROM email_accounts WHERE agent_id IS NOT NULL" )} runs = [r for r in runs if r["agent_id"] not in handler_ids] # Non-admins see only runs for their own agents if not user.is_admin: own_agents = await agent_store.list_agents(owner_user_id=user.id) own_ids = {a["id"] for a in own_agents} runs = [r for r in runs if r["agent_id"] in own_ids] agents_cache: dict[str, dict] = {} for run in runs: aid = run["agent_id"] if aid not in agents_cache: a = await agent_store.get_agent(aid) agents_cache[aid] = a or {} run["agent_name"] = agents_cache[aid].get("name", "—") return runs @router.get("/agent-runs/{run_id}") async def get_agent_run(request: Request, run_id: str): _require_auth(request) from ..agents.tasks import get_run run = await get_run(run_id) if not run: raise HTTPException(status_code=404, detail="Run not found") return run @router.post("/agent-runs/{run_id}/stop") async def stop_run(request: Request, run_id: str): _require_admin(request) stopped = agent_runner.stop_run(run_id) if not stopped: raise HTTPException(status_code=404, detail="Run not found or already stopped") return {"ok": True, "run_id": run_id} # ── Inbox triggers ──────────────────────────────────────────────────────────── class InboxTriggerIn(BaseModel): trigger_word: str agent_id: str description: str = "" enabled: bool = True @router.get("/inbox-triggers") async def list_inbox_triggers(request: Request): _require_admin(request) from ..inbox.triggers import list_triggers return await list_triggers() @router.post("/inbox-triggers", status_code=201) async def create_inbox_trigger(request: Request, body: InboxTriggerIn): _require_admin(request) from ..inbox.triggers import create_trigger return await create_trigger(body.trigger_word, body.agent_id, body.description, body.enabled) @router.put("/inbox-triggers/{id}") async def update_inbox_trigger(request: Request, id: str, body: InboxTriggerIn): _require_admin(request) from ..inbox.triggers import update_trigger ok = await update_trigger( id, trigger_word=body.trigger_word, agent_id=body.agent_id, description=body.description, enabled=body.enabled, ) if not ok: raise HTTPException(status_code=404, detail="Trigger not found") return {"ok": True} @router.delete("/inbox-triggers/{id}") async def delete_inbox_trigger(request: Request, id: str): _require_admin(request) from ..inbox.triggers import delete_trigger if not await delete_trigger(id): raise HTTPException(status_code=404, detail="Trigger not found") return {"ok": True} @router.post("/inbox-triggers/{id}/toggle") async def toggle_inbox_trigger(request: Request, id: str): _require_admin(request) from ..inbox.triggers import toggle_trigger await toggle_trigger(id) return {"ok": True} @router.get("/inbox/status") async def inbox_status(request: Request): _require_admin(request) from ..inbox.listener import inbox_listener return { "global": inbox_listener.status, "all": inbox_listener.all_statuses(), } @router.post("/inbox/reconnect") async def inbox_reconnect(request: Request): _require_admin(request) from ..inbox.listener import inbox_listener inbox_listener.reconnect() return {"ok": True} @router.post("/inbox/disconnect") async def inbox_disconnect(request: Request): _require_admin(request) from ..inbox.listener import inbox_listener inbox_listener.stop() return {"ok": True} # ── Telegram ────────────────────────────────────────────────────────────────── @router.get("/telegram/status") async def telegram_status(request: Request): _require_admin(request) from ..telegram.listener import telegram_listener return telegram_listener.status @router.post("/telegram/reconnect") async def telegram_reconnect(request: Request): _require_admin(request) from ..telegram.listener import telegram_listener telegram_listener.reconnect() return {"ok": True} @router.post("/telegram/disconnect") async def telegram_disconnect(request: Request): _require_admin(request) from ..telegram.listener import telegram_listener telegram_listener.stop() return {"ok": True} @router.get("/telegram-whitelist") async def list_telegram_whitelist(request: Request): _require_admin(request) from ..telegram.triggers import list_whitelist return await list_whitelist() @router.post("/telegram-whitelist") async def add_telegram_whitelist(request: Request, body: dict): _require_admin(request) from ..telegram.triggers import add_to_whitelist chat_id = body.get("chat_id", "").strip() if not chat_id: raise HTTPException(status_code=400, detail="chat_id required") return await add_to_whitelist(chat_id, body.get("label", "")) @router.delete("/telegram-whitelist/{chat_id}") async def remove_telegram_whitelist(request: Request, chat_id: str): _require_admin(request) from ..telegram.triggers import remove_from_whitelist if not await remove_from_whitelist(chat_id): raise HTTPException(status_code=404, detail="chat_id not found") return {"ok": True} @router.get("/telegram-triggers") async def list_telegram_triggers(request: Request): _require_admin(request) from ..telegram.triggers import list_triggers return await list_triggers() @router.post("/telegram-triggers") async def create_telegram_trigger(request: Request, body: dict): _require_admin(request) from ..telegram.triggers import create_trigger trigger_word = body.get("trigger_word", "").strip() agent_id = body.get("agent_id", "").strip() if not trigger_word or not agent_id: raise HTTPException(status_code=400, detail="trigger_word and agent_id required") return await create_trigger( trigger_word=trigger_word, agent_id=agent_id, description=body.get("description", ""), enabled=body.get("enabled", True), ) @router.put("/telegram-triggers/{id}") async def update_telegram_trigger(request: Request, id: str, body: dict): _require_admin(request) from ..telegram.triggers import update_trigger _ALLOWED = {"trigger_word", "agent_id", "description", "enabled"} safe_body = {k: v for k, v in body.items() if k in _ALLOWED} if not await update_trigger(id, **safe_body): raise HTTPException(status_code=404, detail="Trigger not found") return {"ok": True} @router.delete("/telegram-triggers/{id}") async def delete_telegram_trigger(request: Request, id: str): _require_admin(request) from ..telegram.triggers import delete_trigger if not await delete_trigger(id): raise HTTPException(status_code=404, detail="Trigger not found") return {"ok": True} @router.post("/telegram-triggers/{id}/toggle") async def toggle_telegram_trigger(request: Request, id: str): _require_admin(request) from ..telegram.triggers import toggle_trigger await toggle_trigger(id) return {"ok": True} @router.get("/telegram/default-agent") async def get_telegram_default_agent(request: Request): _require_admin(request) agent_id = await credential_store.get("telegram:default_agent_id") or "" return {"agent_id": agent_id} @router.post("/telegram/default-agent") async def set_telegram_default_agent(request: Request, body: dict): _require_admin(request) agent_id = body.get("agent_id", "").strip() if agent_id: await credential_store.set("telegram:default_agent_id", agent_id, description="Default Telegram agent (no trigger match)") else: await credential_store.delete("telegram:default_agent_id") return {"ok": True} # ── 2nd Brain ───────────────────────────────────────────────────────────────── class CaptureIn(BaseModel): content: str @router.get("/brain/status") async def get_brain_status(request: Request): _require_admin(request) from ..brain.database import get_pool, get_stats, browse_thoughts if get_pool() is None: return {"connected": False} try: stats = await get_stats() recent = await browse_thoughts(limit=10) return {"connected": True, "stats": stats, "recent": recent} except Exception as e: return {"connected": False, "error": str(e)} @router.get("/brain/search") async def search_brain(request: Request, q: str = "", limit: int = 20): _require_admin(request) from ..brain.database import get_pool from ..brain.search import semantic_search if get_pool() is None: raise HTTPException(status_code=503, detail="Brain DB not available") if not q.strip(): raise HTTPException(status_code=400, detail="Query required") results = await semantic_search(q.strip(), threshold=0.3, limit=limit) return {"results": results} @router.post("/brain/capture") async def capture_thought(request: Request, body: CaptureIn): _require_admin(request) from ..brain.database import get_pool from ..brain.ingest import ingest_thought if get_pool() is None: raise HTTPException(status_code=503, detail="Brain DB not available") result = await ingest_thought(body.content) return {"ok": True, "id": result["id"], "confirmation": result["confirmation"]} # ── System prompt files (SOUL.md / USER.md) ─────────────────────────────────── _PROMPT_FILES = { "soul": "SOUL.md", "user": "USER.md", } def _prompt_file_path(key: str): from pathlib import Path filename = _PROMPT_FILES.get(key) if not filename: raise HTTPException(status_code=404, detail="Unknown prompt file") return Path(__file__).parent.parent.parent / filename @router.get("/system-prompt/{key}") async def get_system_prompt(request: Request, key: str): _require_admin(request) path = _prompt_file_path(key) content = path.read_text(encoding="utf-8") if path.exists() else "" return {"content": content} @router.post("/system-prompt/{key}") async def save_system_prompt(request: Request, key: str, body: dict): _require_admin(request) path = _prompt_file_path(key) content = body.get("content", "") path.write_text(content, encoding="utf-8") return {"ok": True} # ── Tool list ────────────────────────────────────────────────────────────────── @router.get("/tools") async def list_tools(request: Request): user = _require_auth(request) from ..main import _registry if _registry is None: return [] tools = _registry.all_tools() result = [] for t in tools: if t.name == "bash" and not user.is_admin: continue is_mcp = t.name.startswith("mcp__") # Non-admins don't see global MCP tools — they get their own below if is_mcp and not user.is_admin: continue result.append({ "name": t.name, "is_mcp": is_mcp, "server": t.name.split("__")[1] if is_mcp else None, "server_display_name": getattr(t, "_server_display_name", None), }) # Inject per-user MCP tools for non-admin users if not user.is_admin: from ..mcp_client.manager import discover_user_mcp_tools user_mcp_tools = await discover_user_mcp_tools(user.id) for t in user_mcp_tools: result.append({ "name": t.name, "is_mcp": True, "server": t.name.split("__")[1] if "__" in t.name else None, "server_display_name": getattr(t, "_server_display_name", getattr(t, "server_name", None)), }) return result # ── MCP servers ──────────────────────────────────────────────────────────────── class McpServerIn(BaseModel): name: str url: str transport: str = "sse" api_key: str = "" headers: Optional[dict] = None enabled: bool = True class McpServerUpdate(BaseModel): name: Optional[str] = None url: Optional[str] = None transport: Optional[str] = None api_key: Optional[str] = None headers: Optional[dict] = None enabled: Optional[bool] = None @router.get("/mcp-servers") async def list_mcp_servers(request: Request): _require_admin(request) from ..mcp_client.store import list_servers return await list_servers(include_secrets=False, user_id="GLOBAL") @router.post("/mcp-servers", status_code=201) async def create_mcp_server(request: Request, body: McpServerIn): _require_admin(request) from ..mcp_client.store import list_servers, create_server from ..mcp_client.manager import reload_server_tools from ..main import _registry existing = await list_servers(user_id="GLOBAL") if any(s["name"] == body.name for s in existing): raise HTTPException(status_code=400, detail="A server with that name already exists") server = await create_server( name=body.name, url=body.url, transport=body.transport, api_key=body.api_key, headers=body.headers, enabled=body.enabled, ) if _registry: reload_server_tools(_registry) return server @router.get("/mcp-servers/{server_id}") async def get_mcp_server(request: Request, server_id: str): _require_admin(request) from ..mcp_client.store import get_server server = await get_server(server_id, include_secrets=True) if not server: raise HTTPException(status_code=404, detail="MCP server not found") return server @router.put("/mcp-servers/{server_id}") async def update_mcp_server(request: Request, server_id: str, body: McpServerUpdate): _require_admin(request) from ..mcp_client.store import update_server from ..mcp_client.manager import reload_server_tools from ..main import _registry fields = {k: v for k, v in body.model_dump().items() if v is not None} server = await update_server(server_id, **fields) if not server: raise HTTPException(status_code=404, detail="MCP server not found") if _registry: reload_server_tools(_registry) return server @router.delete("/mcp-servers/{server_id}") async def delete_mcp_server(request: Request, server_id: str): _require_admin(request) from ..mcp_client.store import delete_server from ..mcp_client.manager import reload_server_tools from ..main import _registry deleted = await delete_server(server_id) if not deleted: raise HTTPException(status_code=404, detail="MCP server not found") if _registry: reload_server_tools(_registry) return {"ok": True} @router.post("/mcp-servers/{server_id}/toggle") async def toggle_mcp_server(request: Request, server_id: str): _require_admin(request) from ..mcp_client.store import get_server, update_server from ..mcp_client.manager import reload_server_tools from ..main import _registry server = await get_server(server_id) if not server: raise HTTPException(status_code=404, detail="MCP server not found") updated = await update_server(server_id, enabled=not server["enabled"]) if _registry: reload_server_tools(_registry) return updated @router.post("/mcp-servers/{server_id}/refresh") async def refresh_mcp_server(request: Request, server_id: str): _require_admin(request) from ..mcp_client.store import get_server from ..mcp_client.manager import discover_tools, reload_server_tools from ..main import _registry server = await get_server(server_id, include_secrets=True) if not server: raise HTTPException(status_code=404, detail="MCP server not found") tools = await discover_tools(server) if _registry: reload_server_tools(_registry) return {"tool_count": len(tools), "tools": [t["tool_name"] for t in tools]} # ── Security settings ───────────────────────────────────────────────────────── class SecuritySettingsIn(BaseModel): sanitize_enhanced: Optional[bool] = None canary_enabled: Optional[bool] = None output_validation_enabled: Optional[bool] = None llm_screen_enabled: Optional[bool] = None llm_screen_model: Optional[str] = None llm_screen_block: Optional[bool] = None truncation_enabled: Optional[bool] = None max_web_chars: Optional[int] = None max_email_chars: Optional[int] = None max_file_chars: Optional[int] = None max_subject_chars: Optional[int] = None @router.get("/settings/security") async def get_security_settings(request: Request): _require_admin(request) async def _bool(key: str) -> bool: return await credential_store.get(key) == "1" async def _int(key: str, default: int) -> int: raw = await credential_store.get(key) try: return int(raw) if raw else default except (ValueError, TypeError): return default ( sanitize_enhanced, canary_enabled, output_validation_enabled, truncation_enabled, llm_screen_enabled, llm_screen_block, max_web_chars, max_email_chars, max_file_chars, max_subject_chars, llm_screen_model, ) = await asyncio_gather( _bool("system:security_sanitize_enhanced"), _bool("system:security_canary_enabled"), _bool("system:security_output_validation_enabled"), _bool("system:security_truncation_enabled"), _bool("system:security_llm_screen_enabled"), _bool("system:security_llm_screen_block"), _int("system:security_max_web_chars", 20000), _int("system:security_max_email_chars", 6000), _int("system:security_max_file_chars", 20000), _int("system:security_max_subject_chars", 200), credential_store.get("system:security_llm_screen_model"), ) return { "sanitize_enhanced": sanitize_enhanced, "canary_enabled": canary_enabled, "output_validation_enabled": output_validation_enabled, "truncation_enabled": truncation_enabled, "max_web_chars": max_web_chars, "max_email_chars": max_email_chars, "max_file_chars": max_file_chars, "max_subject_chars": max_subject_chars, "llm_screen_enabled": llm_screen_enabled, "llm_screen_model": llm_screen_model or "google/gemini-flash-1.5", "llm_screen_block": llm_screen_block, } @router.post("/settings/security") async def save_security_settings(request: Request, body: SecuritySettingsIn): _require_admin(request) from ..security_screening import _invalidate_toggle_cache, _limit_cache ops = [] if body.sanitize_enhanced is not None: ops.append(credential_store.set("system:security_sanitize_enhanced", "1" if body.sanitize_enhanced else "0", "Enhanced prompt injection sanitization (Option 1)")) _invalidate_toggle_cache("system:security_sanitize_enhanced") if body.canary_enabled is not None: ops.append(credential_store.set("system:security_canary_enabled", "1" if body.canary_enabled else "0", "Canary token injection detection (Option 2)")) _invalidate_toggle_cache("system:security_canary_enabled") if body.output_validation_enabled is not None: ops.append(credential_store.set("system:security_output_validation_enabled", "1" if body.output_validation_enabled else "0", "Output validation for external-origin sessions (Option 4)")) _invalidate_toggle_cache("system:security_output_validation_enabled") if body.truncation_enabled is not None: ops.append(credential_store.set("system:security_truncation_enabled", "1" if body.truncation_enabled else "0", "Configurable content truncation limits (Option 5)")) _invalidate_toggle_cache("system:security_truncation_enabled") if body.max_web_chars is not None: ops.append(credential_store.set("system:security_max_web_chars", str(body.max_web_chars), "Max web page chars returned to agent")) _limit_cache.pop("system:security_max_web_chars", None) if body.max_email_chars is not None: ops.append(credential_store.set("system:security_max_email_chars", str(body.max_email_chars), "Max email body chars returned to agent")) _limit_cache.pop("system:security_max_email_chars", None) if body.max_file_chars is not None: ops.append(credential_store.set("system:security_max_file_chars", str(body.max_file_chars), "Max file content chars returned to agent")) _limit_cache.pop("system:security_max_file_chars", None) if body.max_subject_chars is not None: ops.append(credential_store.set("system:security_max_subject_chars", str(body.max_subject_chars), "Max email subject chars returned to agent")) _limit_cache.pop("system:security_max_subject_chars", None) if body.llm_screen_enabled is not None: ops.append(credential_store.set("system:security_llm_screen_enabled", "1" if body.llm_screen_enabled else "0", "LLM content screening for external tool results (Option 3)")) _invalidate_toggle_cache("system:security_llm_screen_enabled") if body.llm_screen_model is not None: ops.append(credential_store.set("system:security_llm_screen_model", body.llm_screen_model, "Model used for LLM content screening")) if body.llm_screen_block is not None: ops.append(credential_store.set("system:security_llm_screen_block", "1" if body.llm_screen_block else "0", "LLM screening block mode (vs flag mode)")) _invalidate_toggle_cache("system:security_llm_screen_block") if ops: await asyncio_gather(*ops) return {"ok": True} # ── Login lockouts ──────────────────────────────────────────────────────────── @router.get("/settings/login-lockouts") async def get_login_lockouts(request: Request): _require_admin(request) from ..login_limiter import list_locked return list_locked() @router.delete("/settings/login-lockouts") async def unlock_all_ips(request: Request): _require_admin(request) from ..login_limiter import unlock_all count = unlock_all() return {"ok": True, "unlocked": count} @router.delete("/settings/login-lockouts/{ip:path}") async def unlock_ip(request: Request, ip: str): _require_admin(request) from ..login_limiter import unlock if not unlock(ip): raise HTTPException(status_code=404, detail=f"IP {ip!r} not found in lockout list") return {"ok": True, "ip": ip} # ── Branding ─────────────────────────────────────────────────────────────────── @router.get("/settings/branding") async def get_branding(request: Request): _require_admin(request) from pathlib import Path from ..main import BASE_DIR brand_name, logo_filename = await asyncio_gather( credential_store.get("system:brand_name"), credential_store.get("system:brand_logo_filename"), ) brand_name = brand_name or "" logo_filename = logo_filename or "" has_custom_logo = bool(logo_filename and (BASE_DIR / "web" / "static" / logo_filename).exists()) return { "brand_name": brand_name, "has_custom_logo": has_custom_logo, "logo_filename": logo_filename if has_custom_logo else "", } class BrandingNameIn(BaseModel): brand_name: str @router.post("/settings/branding") async def save_branding(request: Request, body: BrandingNameIn): _require_admin(request) from ..main import _refresh_brand_globals if body.brand_name.strip(): await credential_store.set("system:brand_name", body.brand_name.strip(), "Custom brand name shown in sidebar") else: await credential_store.delete("system:brand_name") await _refresh_brand_globals() return {"ok": True} @router.post("/settings/branding/logo") async def upload_brand_logo(): raise HTTPException(status_code=405, detail="Use multipart POST") _MAX_LOGO_BYTES = 2 * 1024 * 1024 # 2 MB @router.post("/settings/branding/logo/upload") async def upload_brand_logo_file(request: Request): _require_admin(request) from pathlib import Path from ..main import BASE_DIR, _refresh_brand_globals form = await request.form() file = form.get("file") if not file: raise HTTPException(status_code=400, detail="No file provided") ext = Path(file.filename).suffix.lower() or ".png" # SVG excluded: can embed JavaScript and execute when served directly if ext not in {".png", ".jpg", ".jpeg", ".gif", ".webp"}: raise HTTPException(status_code=400, detail="Unsupported image type (png, jpg, gif, webp only)") data = await file.read() if len(data) > _MAX_LOGO_BYTES: raise HTTPException(status_code=413, detail="File too large (max 2 MB)") filename = f"logo_custom{ext}" dest = BASE_DIR / "web" / "static" / filename old_filename = await credential_store.get("system:brand_logo_filename") or "" if old_filename and old_filename != filename: old_path = BASE_DIR / "web" / "static" / old_filename if old_path.exists(): old_path.unlink(missing_ok=True) with dest.open("wb") as f: f.write(data) await credential_store.set("system:brand_logo_filename", filename, "Custom sidebar logo filename") await _refresh_brand_globals() return {"ok": True, "filename": filename} @router.delete("/settings/branding/logo") async def delete_brand_logo(request: Request): _require_admin(request) from pathlib import Path from ..main import BASE_DIR, _refresh_brand_globals filename = await credential_store.get("system:brand_logo_filename") or "" if filename: path = BASE_DIR / "web" / "static" / filename path.unlink(missing_ok=True) await credential_store.delete("system:brand_logo_filename") await _refresh_brand_globals() return {"ok": True} # ── API Key ──────────────────────────────────────────────────────────────────── @router.get("/settings/api-key") async def get_api_key_status(request: Request): _require_admin(request) hash_val, created_at = await asyncio_gather( credential_store.get("system:api_key_hash"), credential_store.get("system:api_key_created_at"), ) return {"configured": hash_val is not None, "created_at": created_at} @router.post("/settings/api-key") async def generate_api_key(request: Request): _require_admin(request) import hashlib import secrets raw_key = "oai_" + secrets.token_urlsafe(32) key_hash = hashlib.sha256(raw_key.encode()).hexdigest() now = datetime.now(timezone.utc).isoformat() await asyncio_gather( credential_store.set("system:api_key_hash", key_hash, description="API key hash (SHA-256)"), credential_store.set("system:api_key_created_at", now, description="API key creation timestamp"), ) return {"key": raw_key} @router.delete("/settings/api-key") async def revoke_api_key(request: Request): _require_admin(request) await asyncio_gather( credential_store.delete("system:api_key_hash"), credential_store.delete("system:api_key_created_at"), ) return {"ok": True} # ── Provider API keys (admin — global) ──────────────────────────────────────── _VALID_PROVIDERS = frozenset({"anthropic", "openrouter", "openai"}) _PROVIDER_LABELS = { "anthropic": "Anthropic API key", "openrouter": "OpenRouter API key", "openai": "OpenAI API key", } class ProviderKeyIn(BaseModel): provider: str # "anthropic", "openrouter", or "openai" key: str @router.get("/settings/provider-keys") async def get_provider_keys(request: Request): _require_admin(request) ant = await credential_store.get("system:anthropic_api_key") orr = await credential_store.get("system:openrouter_api_key") oai = await credential_store.get("system:openai_api_key") return {"anthropic_set": bool(ant), "openrouter_set": bool(orr), "openai_set": bool(oai)} @router.post("/settings/provider-keys") async def set_provider_key(request: Request, body: ProviderKeyIn): _require_admin(request) if body.provider not in _VALID_PROVIDERS: raise HTTPException(status_code=400, detail="provider must be 'anthropic', 'openrouter', or 'openai'") if not body.key.strip(): raise HTTPException(status_code=400, detail="key must not be empty") label = _PROVIDER_LABELS[body.provider] await credential_store.set(f"system:{body.provider}_api_key", body.key.strip(), label) return {"ok": True} @router.delete("/settings/provider-keys/{provider}") async def delete_provider_key(request: Request, provider: str): _require_admin(request) if provider not in _VALID_PROVIDERS: raise HTTPException(status_code=400, detail="provider must be 'anthropic', 'openrouter', or 'openai'") await credential_store.delete(f"system:{provider}_api_key") return {"ok": True} # ── Per-user API keys (/api/my/provider-keys) ───────────────────────────────── from ..database import user_settings_store as _user_settings_store class MyProviderKeyIn(BaseModel): provider: str key: str @router.get("/my/provider-keys") async def get_my_provider_keys(request: Request): user = _require_auth(request) ant = await _user_settings_store.get(user.id, "anthropic_api_key") orr = await _user_settings_store.get(user.id, "openrouter_api_key") oai = await _user_settings_store.get(user.id, "openai_api_key") # Determine access tier based on global keys global_ant = await credential_store.get("system:anthropic_api_key") global_or = await credential_store.get("system:openrouter_api_key") # Admin-granted full access bypasses all restrictions use_admin_keys = not user.is_admin and bool(await _user_settings_store.get(user.id, "use_admin_keys")) return { "anthropic_set": bool(ant), "openrouter_set": bool(orr), "openai_set": bool(oai), # Non-admin access restrictions (lifted if use_admin_keys is granted) "anthropic_blocked": not use_admin_keys and not bool(ant) and bool(global_ant) and not user.is_admin, "openrouter_free_only": not use_admin_keys and not bool(orr) and bool(global_or) and not user.is_admin, "openai_blocked": not use_admin_keys and not user.is_admin, } @router.post("/my/provider-keys") async def set_my_provider_key(request: Request, body: MyProviderKeyIn): user = _require_auth(request) if body.provider not in _VALID_PROVIDERS: raise HTTPException(status_code=400, detail="provider must be 'anthropic', 'openrouter', or 'openai'") if not body.key.strip(): raise HTTPException(status_code=400, detail="key must not be empty") await _user_settings_store.set(user.id, f"{body.provider}_api_key", body.key.strip()) return {"ok": True} @router.delete("/my/provider-keys/{provider}") async def delete_my_provider_key(request: Request, provider: str): user = _require_auth(request) if provider not in _VALID_PROVIDERS: raise HTTPException(status_code=400, detail="provider must be 'anthropic', 'openrouter', or 'openai'") await _user_settings_store.delete(user.id, f"{provider}_api_key") return {"ok": True} # ── Admin: grant/revoke user admin key access ───────────────────────────────── class AdminKeysAccessIn(BaseModel): enabled: bool @router.get("/users/{user_id}/admin-keys") async def get_user_admin_keys_access(request: Request, user_id: str): _require_admin(request) val = await _user_settings_store.get(user_id, "use_admin_keys") return {"enabled": bool(val)} @router.post("/users/{user_id}/admin-keys") async def set_user_admin_keys_access(request: Request, user_id: str, body: AdminKeysAccessIn): _require_admin(request) from ..users import get_user_by_id if not await get_user_by_id(user_id): raise HTTPException(status_code=404, detail="User not found") if body.enabled: await _user_settings_store.set(user_id, "use_admin_keys", "1") else: await _user_settings_store.delete(user_id, "use_admin_keys") return {"ok": True} # ── Per-user personality (/api/my/personality) ──────────────────────────────── class MyPersonalityIn(BaseModel): soul: Optional[str] = None user_context: Optional[str] = None @router.get("/my/personality") async def get_my_personality(request: Request): user = _require_auth(request) soul = await _user_settings_store.get(user.id, "personality_soul") or "" user_ctx = await _user_settings_store.get(user.id, "personality_user") or "" return {"soul": soul, "user_context": user_ctx} @router.post("/my/personality") async def set_my_personality(request: Request, body: MyPersonalityIn): user = _require_auth(request) if body.soul is not None: if body.soul.strip(): await _user_settings_store.set(user.id, "personality_soul", body.soul.strip()) else: await _user_settings_store.delete(user.id, "personality_soul") if body.user_context is not None: if body.user_context.strip(): await _user_settings_store.set(user.id, "personality_user", body.user_context.strip()) else: await _user_settings_store.delete(user.id, "personality_user") # Mark profile setup as done when user explicitly saves await _user_settings_store.set(user.id, "personality_setup_done", "1") return {"ok": True} @router.post("/my/personality/dismiss-nag") async def dismiss_personality_nag(request: Request): user = _require_auth(request) await _user_settings_store.set(user.id, "personality_setup_done", "1") return {"ok": True} # ── Per-user brain settings (/api/my/brain-settings) ───────────────────────── @router.get("/my/brain-settings") async def get_my_brain_settings(request: Request): user = _require_auth(request) val = await _user_settings_store.get(user.id, "brain_auto_approve") return {"brain_auto_approve": bool(val)} @router.post("/my/brain-settings") async def set_my_brain_settings(request: Request, body: dict): user = _require_auth(request) enabled = body.get("brain_auto_approve", False) if enabled: await _user_settings_store.set(user.id, "brain_auto_approve", "1") else: await _user_settings_store.delete(user.id, "brain_auto_approve") return {"ok": True} # ── Per-user MCP servers (/api/my/mcp-servers) ─────────────────────────────── class MyMcpServerIn(BaseModel): name: str url: str transport: str = "sse" api_key: Optional[str] = None headers: Optional[dict] = None enabled: bool = True class MyMcpServerUpdate(BaseModel): name: Optional[str] = None url: Optional[str] = None transport: Optional[str] = None api_key: Optional[str] = None headers: Optional[dict] = None enabled: Optional[bool] = None @router.get("/my/mcp-servers") async def list_my_mcp_servers(request: Request): user = _require_auth(request) from ..mcp_client.store import list_servers return await list_servers(include_secrets=False, user_id=user.id) @router.post("/my/mcp-servers", status_code=201) async def create_my_mcp_server(request: Request, body: MyMcpServerIn): user = _require_auth(request) from ..mcp_client.store import list_servers, create_server existing = await list_servers(user_id=user.id) if any(s["name"] == body.name for s in existing): raise HTTPException(status_code=400, detail="A server with that name already exists") server = await create_server( name=body.name, url=body.url, transport=body.transport, api_key=body.api_key or "", headers=body.headers, enabled=body.enabled, user_id=user.id, ) return server @router.get("/my/mcp-servers/{server_id}") async def get_my_mcp_server(request: Request, server_id: str): user = _require_auth(request) from ..mcp_client.store import get_server server = await get_server(server_id, include_secrets=False) if not server or server.get("user_id") != user.id: raise HTTPException(status_code=404, detail="MCP server not found") return server @router.put("/my/mcp-servers/{server_id}") async def update_my_mcp_server(request: Request, server_id: str, body: MyMcpServerUpdate): user = _require_auth(request) from ..mcp_client.store import get_server, update_server server = await get_server(server_id) if not server or server.get("user_id") != user.id: raise HTTPException(status_code=404, detail="MCP server not found") fields = {k: v for k, v in body.model_dump().items() if v is not None} updated = await update_server(server_id, **fields) return updated @router.delete("/my/mcp-servers/{server_id}") async def delete_my_mcp_server(request: Request, server_id: str): user = _require_auth(request) from ..mcp_client.store import get_server, delete_server server = await get_server(server_id) if not server or server.get("user_id") != user.id: raise HTTPException(status_code=404, detail="MCP server not found") await delete_server(server_id) return {"ok": True} @router.post("/my/mcp-servers/{server_id}/toggle") async def toggle_my_mcp_server(request: Request, server_id: str): user = _require_auth(request) from ..mcp_client.store import get_server, update_server server = await get_server(server_id) if not server or server.get("user_id") != user.id: raise HTTPException(status_code=404, detail="MCP server not found") updated = await update_server(server_id, enabled=not server["enabled"]) return updated # ── Per-user inbox (/api/my/inbox) ──────────────────────────────────────────── _INBOX_CRED_KEYS = [ "imap_host", "imap_port", "imap_username", "imap_password", "smtp_host", "smtp_port", "smtp_username", "smtp_password", ] @router.get("/my/inbox/config") async def get_my_inbox_config(request: Request): user = _require_auth(request) result = {} for key in _INBOX_CRED_KEYS: val = await _user_settings_store.get(user.id, f"inbox_{key}") result[key] = bool(val) if "password" in key else (val or "") return result @router.post("/my/inbox/config") async def set_my_inbox_config(request: Request): user = _require_auth(request) body = await request.json() for key in _INBOX_CRED_KEYS: if key in body: val = str(body[key]).strip() if body[key] else "" if val: await _user_settings_store.set(user.id, f"inbox_{key}", val) else: await _user_settings_store.delete(user.id, f"inbox_{key}") return {"ok": True} @router.post("/my/inbox/reconnect") async def reconnect_my_inbox(request: Request): user = _require_auth(request) from ..inbox.listener import inbox_listener inbox_listener.start_for_user(user.id) return {"ok": True} @router.get("/my/inbox/status") async def get_my_inbox_status(request: Request): user = _require_auth(request) from ..inbox.listener import inbox_listener statuses = inbox_listener.all_statuses() # all_statuses() now returns a list; find entries for this user user_statuses = [s for s in statuses if s.get("user_id") == user.id] if user_statuses: return user_statuses[0] return {"configured": False, "connected": False, "error": None} class MyInboxTriggerIn(BaseModel): trigger_word: str agent_id: str description: Optional[str] = "" enabled: bool = True @router.get("/my/inbox/triggers") async def list_my_inbox_triggers(request: Request): user = _require_auth(request) from ..inbox.triggers import list_triggers return await list_triggers(user_id=user.id) @router.post("/my/inbox/triggers", status_code=201) async def create_my_inbox_trigger(request: Request, body: MyInboxTriggerIn): user = _require_auth(request) from ..inbox.triggers import create_trigger return await create_trigger( trigger_word=body.trigger_word, agent_id=body.agent_id, description=body.description or "", enabled=body.enabled, user_id=user.id, ) @router.put("/my/inbox/triggers/{trigger_id}") async def update_my_inbox_trigger(request: Request, trigger_id: str, body: MyInboxTriggerIn): user = _require_auth(request) from ..inbox.triggers import update_trigger, list_triggers triggers = await list_triggers(user_id=user.id) if not any(t["id"] == trigger_id for t in triggers): raise HTTPException(status_code=404, detail="Trigger not found") fields = {k: v for k, v in body.model_dump().items() if v is not None} await update_trigger(trigger_id, **fields) return {"ok": True} @router.delete("/my/inbox/triggers/{trigger_id}") async def delete_my_inbox_trigger(request: Request, trigger_id: str): user = _require_auth(request) from ..inbox.triggers import delete_trigger, list_triggers triggers = await list_triggers(user_id=user.id) if not any(t["id"] == trigger_id for t in triggers): raise HTTPException(status_code=404, detail="Trigger not found") await delete_trigger(trigger_id) return {"ok": True} @router.post("/my/inbox/triggers/{trigger_id}/toggle") async def toggle_my_inbox_trigger(request: Request, trigger_id: str): user = _require_auth(request) from ..inbox.triggers import toggle_trigger, list_triggers triggers = await list_triggers(user_id=user.id) if not any(t["id"] == trigger_id for t in triggers): raise HTTPException(status_code=404, detail="Trigger not found") await toggle_trigger(trigger_id) return {"ok": True} # ── Per-user Telegram (/api/my/telegram) ────────────────────────────────────── @router.get("/my/telegram/config") async def get_my_telegram_config(request: Request): user = _require_auth(request) token = await _user_settings_store.get(user.id, "telegram_bot_token") return {"bot_token_set": bool(token)} @router.post("/my/telegram/config") async def set_my_telegram_config(request: Request): user = _require_auth(request) body = await request.json() token = str(body.get("bot_token", "")).strip() if token: await _user_settings_store.set(user.id, "telegram_bot_token", token) else: await _user_settings_store.delete(user.id, "telegram_bot_token") return {"ok": True} @router.delete("/my/telegram/config") async def delete_my_telegram_config(request: Request): user = _require_auth(request) await _user_settings_store.delete(user.id, "telegram_bot_token") from ..telegram.listener import telegram_listener telegram_listener.stop_for_user(user.id) return {"ok": True} @router.post("/my/telegram/reconnect") async def reconnect_my_telegram(request: Request): user = _require_auth(request) from ..telegram.listener import telegram_listener telegram_listener.start_for_user(user.id) return {"ok": True} @router.get("/my/telegram/status") async def get_my_telegram_status(request: Request): user = _require_auth(request) from ..telegram.listener import telegram_listener statuses = telegram_listener.all_statuses() return statuses.get(user.id, {"configured": False, "running": False, "error": None}) @router.get("/my/telegram-whitelist") async def list_my_telegram_whitelist(request: Request): user = _require_auth(request) from ..telegram.triggers import list_whitelist return await list_whitelist(user_id=user.id) @router.post("/my/telegram-whitelist") async def add_my_telegram_whitelist(request: Request): user = _require_auth(request) body = await request.json() chat_id = str(body.get("chat_id", "")).strip() label = str(body.get("label", "")).strip() if not chat_id: raise HTTPException(status_code=400, detail="chat_id is required") from ..telegram.triggers import add_to_whitelist return await add_to_whitelist(chat_id, label=label, user_id=user.id) @router.delete("/my/telegram-whitelist/{chat_id}") async def remove_my_telegram_whitelist(request: Request, chat_id: str): user = _require_auth(request) from ..telegram.triggers import remove_from_whitelist, list_whitelist entries = await list_whitelist(user_id=user.id) if not any(e["chat_id"] == chat_id for e in entries): raise HTTPException(status_code=404, detail="Chat ID not found in your whitelist") await remove_from_whitelist(chat_id, user_id=user.id) return {"ok": True} class MyTelegramTriggerIn(BaseModel): trigger_word: str agent_id: str description: Optional[str] = "" enabled: bool = True @router.get("/my/telegram-triggers") async def list_my_telegram_triggers(request: Request): user = _require_auth(request) from ..telegram.triggers import list_triggers return await list_triggers(user_id=user.id) @router.post("/my/telegram-triggers", status_code=201) async def create_my_telegram_trigger(request: Request, body: MyTelegramTriggerIn): user = _require_auth(request) from ..telegram.triggers import create_trigger return await create_trigger( trigger_word=body.trigger_word, agent_id=body.agent_id, description=body.description or "", enabled=body.enabled, user_id=user.id, ) @router.put("/my/telegram-triggers/{trigger_id}") async def update_my_telegram_trigger( request: Request, trigger_id: str, body: MyTelegramTriggerIn ): user = _require_auth(request) from ..telegram.triggers import update_trigger, list_triggers triggers = await list_triggers(user_id=user.id) if not any(t["id"] == trigger_id for t in triggers): raise HTTPException(status_code=404, detail="Trigger not found") fields = {k: v for k, v in body.model_dump().items() if v is not None} await update_trigger(trigger_id, **fields) return {"ok": True} @router.delete("/my/telegram-triggers/{trigger_id}") async def delete_my_telegram_trigger(request: Request, trigger_id: str): user = _require_auth(request) from ..telegram.triggers import delete_trigger, list_triggers triggers = await list_triggers(user_id=user.id) if not any(t["id"] == trigger_id for t in triggers): raise HTTPException(status_code=404, detail="Trigger not found") await delete_trigger(trigger_id) return {"ok": True} @router.post("/my/telegram-triggers/{trigger_id}/toggle") async def toggle_my_telegram_trigger(request: Request, trigger_id: str): user = _require_auth(request) from ..telegram.triggers import toggle_trigger, list_triggers triggers = await list_triggers(user_id=user.id) if not any(t["id"] == trigger_id for t in triggers): raise HTTPException(status_code=404, detail="Trigger not found") await toggle_trigger(trigger_id) return {"ok": True} # ── Conversations (chat history) ────────────────────────────────────────────── class ConversationRenameIn(BaseModel): title: str @router.get("/conversations") async def list_conversations( request: Request, page: int = 1, per_page: int = 40, q: str = "", ): user = _require_auth(request) from ..database import get_pool pool = await get_pool() offset = (page - 1) * per_page # Build WHERE conditions with correct parameter indices conditions = ["task_id IS NULL"] filter_params: list = [] # Always scope to the requesting user — admins are not special here filter_params.append(user.id) conditions.append(f"user_id = ${len(filter_params)}") if q: filter_params.append(f"%{q}%") conditions.append(f"title ILIKE ${len(filter_params)}") where = "WHERE " + " AND ".join(conditions) n = len(filter_params) total = await pool.fetchval( f"SELECT COUNT(*) FROM conversations {where}", *filter_params, ) rows = await pool.fetch( f"""SELECT id, title, model, started_at, ended_at, task_id, CASE WHEN jsonb_typeof(messages) = 'array' THEN jsonb_array_length(messages) ELSE 0 END AS message_count FROM conversations {where} ORDER BY ended_at DESC NULLS LAST LIMIT ${n + 1} OFFSET ${n + 2}""", *filter_params, per_page, offset, ) return {"conversations": [dict(r) for r in rows], "total": total, "page": page, "per_page": per_page} @router.patch("/conversations/{conv_id}") async def rename_conversation(request: Request, conv_id: str, body: ConversationRenameIn): user = _require_auth(request) from ..database import get_pool pool = await get_pool() row = await pool.fetchrow("SELECT user_id FROM conversations WHERE id = $1", conv_id) if not row: raise HTTPException(status_code=404, detail="Conversation not found") if not user.is_admin and row["user_id"] != user.id: raise HTTPException(status_code=403, detail="Not your conversation") title = body.title.strip()[:120] or "Chat" await pool.execute("UPDATE conversations SET title = $1 WHERE id = $2", title, conv_id) return {"ok": True, "title": title} @router.delete("/conversations/{conv_id}") async def delete_conversation(request: Request, conv_id: str): user = _require_auth(request) from ..database import get_pool pool = await get_pool() row = await pool.fetchrow("SELECT user_id FROM conversations WHERE id = $1", conv_id) if not row: raise HTTPException(status_code=404, detail="Conversation not found") if not user.is_admin and row["user_id"] != user.id: raise HTTPException(status_code=403, detail="Not your conversation") await pool.execute("DELETE FROM conversations WHERE id = $1", conv_id) return {"ok": True} @router.get("/conversations/{conv_id}/export") async def export_conversation(request: Request, conv_id: str, format: str = "markdown"): """Download a conversation as Markdown or JSON.""" import json as _json from fastapi.responses import Response from datetime import datetime, timezone user = _require_auth(request) from ..database import get_pool pool = await get_pool() row = await pool.fetchrow( "SELECT * FROM conversations WHERE id = $1", conv_id ) if not row: raise HTTPException(status_code=404, detail="Conversation not found") if not user.is_admin and row.get("user_id") != user.id: raise HTTPException(status_code=403, detail="Not your conversation") messages = row.get("messages") or [] if isinstance(messages, str): try: messages = _json.loads(messages) except Exception: messages = [] started_at = row.get("started_at") or "" title = row.get("title") or "Conversation" model = row.get("model") or "" from ..config import settings as _settings agent_name = _settings.agent_name if format == "json": content = _json.dumps({ "id": str(row["id"]), "title": title, "model": model, "started_at": str(started_at), "messages": messages, }, indent=2, default=str) filename = f"conversation-{conv_id[:8]}.json" media_type = "application/json" else: # Markdown lines = [f"# {title}", ""] if model: lines.append(f"**Model:** {model} ") if started_at: lines.append(f"**Date:** {str(started_at)[:19]} ") lines += ["", "---", ""] for msg in messages: role = msg.get("role", "") content_parts = msg.get("content", "") if isinstance(content_parts, list): text = " ".join( p.get("text", "") for p in content_parts if isinstance(p, dict) and p.get("type") == "text" ) else: text = str(content_parts) if not text.strip(): continue speaker = "You" if role == "user" else agent_name lines += [f"**{speaker}**", "", text, "", "---", ""] content = "\n".join(lines) filename = f"conversation-{conv_id[:8]}.md" media_type = "text/markdown" return Response( content=content, media_type=media_type, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) # ── Agent templates ──────────────────────────────────────────────────────────── @router.get("/agent-templates") async def list_agent_templates(): from ..agent_templates import list_templates return list_templates() @router.get("/agent-templates/{template_id}") async def get_agent_template(template_id: str): from ..agent_templates import get_template t = get_template(template_id) if not t: raise HTTPException(status_code=404, detail="Template not found") return t # ── User management (admin only) ─────────────────────────────────────────────── class UserCreateIn(BaseModel): username: str password: str email: str role: str = "user" class UserUpdateIn(BaseModel): role: Optional[str] = None is_active: Optional[bool] = None password: Optional[str] = None email: Optional[str] = None class ChangePasswordIn(BaseModel): current_password: str new_password: str def _require_admin(request: Request): """Raise 403 if the current request is not from an admin.""" u = getattr(request.state, "current_user", None) if u is None or u.role != "admin": raise HTTPException(status_code=403, detail="Admin access required") return u def _require_auth(request: Request): """Raise 401 if not authenticated.""" u = getattr(request.state, "current_user", None) if u is None: raise HTTPException(status_code=401, detail="Authentication required") return u def _check_agent_access(agent: dict | None, user) -> dict: """Raise 404 if the agent doesn't exist or the non-admin user doesn't own it. Always returns 404 (not 403) to avoid leaking existence of other users' agents.""" if agent is None: raise HTTPException(status_code=404, detail="Agent not found") if not user.is_admin and agent.get("owner_user_id") != user.id: raise HTTPException(status_code=404, detail="Agent not found") return agent @router.get("/users") async def list_users_api(request: Request): _require_admin(request) from ..users import list_users return await list_users() @router.post("/users", status_code=201) async def create_user_api(request: Request, body: UserCreateIn): _require_admin(request) if len(body.password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters") if body.role not in ("admin", "user"): raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'") email = body.email.strip().lower() if not email or "@" not in email: raise HTTPException(status_code=400, detail="A valid email address is required") from ..users import create_user, get_user_by_username if await get_user_by_username(body.username): raise HTTPException(status_code=409, detail="Username already exists") return await create_user(body.username, body.password, body.role, email) @router.put("/users/{user_id}") async def update_user_api(request: Request, user_id: str, body: UserUpdateIn): admin = _require_admin(request) if user_id == admin.id and body.is_active is False: raise HTTPException(status_code=400, detail="Cannot deactivate your own account") from ..users import update_user, get_user_by_id if not await get_user_by_id(user_id): raise HTTPException(status_code=404, detail="User not found") fields = {} if body.role is not None: if body.role not in ("admin", "user"): raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'") fields["role"] = body.role if body.is_active is not None: fields["is_active"] = body.is_active if body.password is not None: if len(body.password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters") fields["password"] = body.password if body.email is not None: email = body.email.strip().lower() if email and "@" not in email: raise HTTPException(status_code=400, detail="Invalid email address") fields["email"] = email or None if not fields: raise HTTPException(status_code=400, detail="No fields to update") await update_user(user_id, **fields) # Stop or restart automated listeners when is_active changes if "is_active" in fields: from ..inbox.listener import inbox_listener from ..telegram.listener import telegram_listener if fields["is_active"]: inbox_listener.start_for_user(user_id) telegram_listener.start_for_user(user_id) else: inbox_listener.stop_for_user(user_id) telegram_listener.stop_for_user(user_id) from ..users import get_user_by_id return await get_user_by_id(user_id) @router.delete("/users/{user_id}") async def delete_user_api(request: Request, user_id: str): admin = _require_admin(request) if user_id == admin.id: raise HTTPException(status_code=400, detail="Cannot delete your own account") from ..users import delete_user if not await delete_user(user_id): raise HTTPException(status_code=404, detail="User not found") return {"ok": True} @router.post("/users/me/password") async def change_own_password(request: Request, body: ChangePasswordIn): u = _require_auth(request) # sets u from request.state from ..auth import verify_password from ..users import get_user_by_username, update_user full = await get_user_by_username(u.username) if not full or not verify_password(body.current_password, full["password_hash"]): raise HTTPException(status_code=400, detail="Current password is incorrect") if len(body.new_password) < 8: raise HTTPException(status_code=400, detail="New password must be at least 8 characters") await update_user(u.id, password=body.new_password) return {"ok": True} # ── Helper: thin wrapper around asyncio.gather for use inside routes ─────────── # ── Email accounts (per-user handling + trigger accounts) ───────────────────── @router.get("/my/email-accounts") async def list_my_email_accounts(request: Request): user = _require_auth(request) from ..inbox.accounts import list_accounts, mask_account accounts = await list_accounts(user_id=user.id) return [mask_account(a) for a in accounts] @router.post("/my/email-accounts", status_code=201) async def create_my_email_account(request: Request): user = _require_auth(request) body = await request.json() from ..inbox.accounts import create_account, mask_account from ..agents.tasks import create_agent label = body.get("label", "") agent_model = body.get("agent_model", "").strip() agent_prompt = body.get("agent_prompt", "").strip() # Auto-create a dedicated agent for this account agent = await create_agent( name=f"Email Handler: {label}", prompt=agent_prompt, model=agent_model, allowed_tools=["email"], created_by="user", owner_user_id=user.id, ) agent_id = agent["id"] acct = await create_account( label=label, account_type="handling", imap_host=body.get("imap_host", ""), imap_port=int(body.get("imap_port") or 993), imap_username=body.get("imap_username", ""), imap_password=body.get("imap_password", ""), smtp_host=body.get("smtp_host") or None, smtp_port=int(body.get("smtp_port")) if body.get("smtp_port") else None, smtp_username=body.get("smtp_username") or None, smtp_password=body.get("smtp_password") or None, agent_id=agent_id, user_id=user.id, initial_load_limit=int(body.get("initial_load_limit") or 200), monitored_folders=body.get("monitored_folders") or ["INBOX"], extra_tools=body.get("extra_tools") or [], telegram_chat_id=body.get("telegram_chat_id") or None, telegram_keyword=body.get("telegram_keyword") or None, enabled=body.get("enabled", True), ) return mask_account(acct) @router.put("/my/email-accounts/{account_id}") async def update_my_email_account(request: Request, account_id: str): user = _require_auth(request) from ..inbox.accounts import get_account, update_account, mask_account from ..agents.tasks import update_agent acct = await get_account(account_id) if not acct or str(acct.get("user_id")) != user.id: raise HTTPException(status_code=404, detail="Account not found") body = await request.json() # Update the linked agent's model and prompt if provided if acct.get("agent_id"): agent_fields = {} if "agent_model" in body: agent_fields["model"] = body["agent_model"] if "agent_prompt" in body: agent_fields["prompt"] = body["agent_prompt"] if "label" in body: agent_fields["name"] = f"Email Handler: {body['label']}" if agent_fields: await update_agent(acct["agent_id"], **agent_fields) allowed_fields = { "label", "imap_host", "imap_port", "imap_username", "imap_password", "smtp_host", "smtp_port", "smtp_username", "smtp_password", "enabled", "initial_load_limit", "monitored_folders", "extra_tools", "telegram_chat_id", "telegram_keyword", } updates = {k: v for k, v in body.items() if k in allowed_fields} await update_account(account_id, **updates) updated = await get_account(account_id) return mask_account(updated) @router.delete("/my/email-accounts/{account_id}") async def delete_my_email_account(request: Request, account_id: str): user = _require_auth(request) from ..inbox.accounts import get_account, delete_account from ..inbox.listener import inbox_listener from ..agents.tasks import delete_agent acct = await get_account(account_id) if not acct or str(acct.get("user_id")) != user.id: raise HTTPException(status_code=404, detail="Account not found") inbox_listener.stop_account(account_id) linked_agent_id = acct.get("agent_id") await delete_account(account_id) if linked_agent_id: await delete_agent(linked_agent_id) return {"ok": True} @router.post("/my/email-accounts/{account_id}/toggle") async def toggle_my_email_account(request: Request, account_id: str): user = _require_auth(request) from ..inbox.accounts import get_account, toggle_account, mask_account from ..inbox.listener import inbox_listener acct = await get_account(account_id) if not acct or str(acct.get("user_id")) != user.id: raise HTTPException(status_code=404, detail="Account not found") await toggle_account(account_id) updated = await get_account(account_id) if updated.get("enabled"): inbox_listener.start_account(account_id, updated) else: inbox_listener.stop_account(account_id) return mask_account(updated) @router.post("/my/email-accounts/{account_id}/reconnect") async def reconnect_my_email_account(request: Request, account_id: str): user = _require_auth(request) from ..inbox.accounts import get_account from ..inbox.listener import inbox_listener acct = await get_account(account_id) if not acct or str(acct.get("user_id")) != user.id: raise HTTPException(status_code=404, detail="Account not found") inbox_listener.restart_account(account_id, acct) return {"ok": True} @router.post("/my/email-accounts/{account_id}/pause") async def pause_my_email_account(request: Request, account_id: str): user = _require_auth(request) from ..inbox.accounts import get_account, pause_account from ..inbox.listener import inbox_listener acct = await get_account(account_id) if not acct or str(acct.get("user_id")) != user.id: raise HTTPException(status_code=404, detail="Account not found") await pause_account(account_id) inbox_listener.stop_account(account_id) return {"ok": True, "paused": True} @router.post("/my/email-accounts/{account_id}/resume") async def resume_my_email_account(request: Request, account_id: str): user = _require_auth(request) from ..inbox.accounts import get_account, resume_account from ..inbox.listener import inbox_listener acct = await get_account(account_id) if not acct or str(acct.get("user_id")) != user.id: raise HTTPException(status_code=404, detail="Account not found") await resume_account(account_id) updated = await get_account(account_id) if updated and updated.get("enabled"): inbox_listener.start_account(account_id, updated) return {"ok": True, "paused": False} # ── Per-user Brain MCP key ──────────────────────────────────────────────────── import secrets as _secrets async def _get_or_create_brain_key(user_id: str) -> str: """Return the user's brain MCP key, generating one if it doesn't exist.""" key = await _user_settings_store.get(user_id, "brain_mcp_key") if not key: key = _secrets.token_hex(32) await _user_settings_store.set(user_id, "brain_mcp_key", key) return key @router.get("/my/brain/key") async def get_my_brain_key(request: Request): user = _require_auth(request) key = await _get_or_create_brain_key(user.id) return {"key": key} @router.post("/my/brain/key/regenerate") async def regenerate_my_brain_key(request: Request): user = _require_auth(request) key = _secrets.token_hex(32) await _user_settings_store.set(user.id, "brain_mcp_key", key) return {"key": key} @router.get("/my/data-folder") async def get_my_data_folder(request: Request): """Return the auto-provisioned user folder path (read-only, derived from system:users_base_folder).""" user = _require_auth(request) from ..users import get_user_folder folder = await get_user_folder(user.id) return {"data_folder": folder or ""} # ── Per-user file browser ────────────────────────────────────────────────────── import io as _io import os as _os import zipfile as _zipfile from datetime import datetime as _dt, timezone as _tz from fastapi.responses import FileResponse, StreamingResponse def _resolve_user_path(base: str, rel_path: str) -> str: """Resolve rel_path within base; raise 403 on path traversal attempts.""" real_base = _os.path.realpath(base) joined = _os.path.join(real_base, rel_path.lstrip("/\\")) if rel_path else real_base real_target = _os.path.realpath(joined) if real_target != real_base and not real_target.startswith(real_base + _os.sep): raise HTTPException(status_code=403, detail="Access denied") return real_target def _fmt_size(n: int) -> str: for unit in ("B", "KB", "MB", "GB"): if n < 1024 or unit == "GB": return f"{n:.0f} {unit}" if unit == "B" else f"{n/1024:.1f} {unit}" n /= 1024 return str(n) @router.get("/my/files") async def list_my_files(request: Request, path: str = ""): user = _require_auth(request) from ..users import get_user_folder base = await get_user_folder(user.id) if not base: raise HTTPException(status_code=404, detail="no_folder") if not _os.path.isdir(base): raise HTTPException(status_code=404, detail="folder_missing") target = _resolve_user_path(base, path) if not _os.path.exists(target): raise HTTPException(status_code=404, detail="Path not found") if not _os.path.isdir(target): raise HTTPException(status_code=400, detail="Path is not a directory") real_base = _os.path.realpath(base) entries = [] try: items = sorted(_os.scandir(target), key=lambda e: (not e.is_dir(), e.name.lower())) for entry in items: try: stat = entry.stat(follow_symlinks=False) rel = _os.path.relpath(entry.path, real_base) entries.append({ "name": entry.name, "path": rel, "is_dir": entry.is_dir(), "size": stat.st_size if not entry.is_dir() else None, "modified": _dt.fromtimestamp(stat.st_mtime, tz=_tz.utc).isoformat(), }) except OSError: continue except PermissionError: pass rel_current = _os.path.relpath(target, real_base) if target == real_base: rel_parent = None else: p = _os.path.relpath(_os.path.dirname(target), real_base) rel_parent = "" if p == "." else p return { "path": "" if rel_current == "." else rel_current, "parent": rel_parent, "entries": entries, } @router.get("/my/files/download") async def download_my_file(request: Request, path: str): user = _require_auth(request) from ..users import get_user_folder base = await get_user_folder(user.id) if not base: raise HTTPException(status_code=404, detail="No files folder configured") target = _resolve_user_path(base, path) if not _os.path.isfile(target): raise HTTPException(status_code=404, detail="File not found") return FileResponse( target, filename=_os.path.basename(target), media_type="application/octet-stream", ) @router.get("/my/files/download-zip") async def download_my_zip(request: Request, path: str = ""): user = _require_auth(request) from ..users import get_user_folder base = await get_user_folder(user.id) if not base: raise HTTPException(status_code=404, detail="No files folder configured") target = _resolve_user_path(base, path) if not _os.path.isdir(target): raise HTTPException(status_code=400, detail="Path is not a directory") folder_name = _os.path.basename(target) or user.username def _make_zip() -> bytes: buf = _io.BytesIO() with _zipfile.ZipFile(buf, "w", _zipfile.ZIP_DEFLATED) as zf: for root, dirs, files in _os.walk(target): dirs[:] = sorted(dirs) for fname in sorted(files): fpath = _os.path.join(root, fname) arcname = _os.path.relpath(fpath, target) try: zf.write(fpath, arcname) except OSError: pass return buf.getvalue() import asyncio as _asyncio2 zip_bytes = await _asyncio2.get_event_loop().run_in_executor(None, _make_zip) return StreamingResponse( iter([zip_bytes]), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{folder_name}.zip"'}, ) @router.delete("/my/files") async def delete_my_file(request: Request, path: str): """Delete a file from the user's data folder. Protected files (memory_*, reasoning_*) can never be deleted via this endpoint. TODO: replace hard-coded prefixes with a .jarvisignore-style mechanism. """ user = _require_auth(request) from ..users import get_user_folder base = await get_user_folder(user.id) if not base: raise HTTPException(status_code=404, detail="No files folder configured") target = _resolve_user_path(base, path) name = _os.path.basename(target) if name.startswith("memory_") or name.startswith("reasoning_"): raise HTTPException(status_code=403, detail=f"'{name}' is a protected file and cannot be deleted") if not _os.path.exists(target): raise HTTPException(status_code=404, detail="File not found") if _os.path.isdir(target): raise HTTPException(status_code=400, detail="Use a dedicated endpoint to delete directories") _os.remove(target) return {"ok": True} @router.get("/my/email-accounts/available-extra-tools") async def available_extra_tools(request: Request): """Return which notification tools are configured and available for email handling accounts.""" user = _require_auth(request) available = [] # Telegram: check user's own per-user token; admins also see global token tg_token = await _user_settings_store.get(user.id, "telegram_bot_token") if not tg_token and user.role == "admin": tg_token = await credential_store.get("telegram:bot_token") if tg_token: available.append({"id": "telegram", "label": "Telegram", "description": "Send Telegram messages to whitelisted chats"}) # Pushover: global admin credential — only available to admin users if user.role == "admin": pv_key = await credential_store.get("pushover_app_token") pv_user = await credential_store.get("pushover_user_key") if pv_key and pv_user: available.append({"id": "pushover", "label": "Pushover", "description": "Send push notifications to your phone"}) return available @router.get("/my/telegram/whitelisted-chats") async def my_whitelisted_telegram_chats(request: Request): """Return Telegram chat IDs the current user has whitelisted (for email account binding).""" user = _require_auth(request) from ..telegram.triggers import list_whitelist user_chats = await list_whitelist(user.id) global_chats = await list_whitelist("GLOBAL") # Merge, deduplicate by chat_id, user-specific first seen: set[str] = set() result = [] for row in user_chats + global_chats: if row["chat_id"] not in seen: seen.add(row["chat_id"]) result.append({"chat_id": row["chat_id"], "label": row.get("label") or row["chat_id"]}) return result @router.post("/my/email-accounts/list-folders-preview") async def list_folders_preview(request: Request): """Load folder list using credentials supplied directly (for new accounts).""" _require_auth(request) body = await request.json() host = body.get("imap_host", "").strip() port = int(body.get("imap_port") or 993) username = body.get("imap_username", "").strip() password = body.get("imap_password", "") if not host or not username or not password: raise HTTPException(status_code=400, detail="host, username and password are required") from ..tools.email_handling_tool import EmailHandlingTool tool = EmailHandlingTool(account={ "imap_host": host, "imap_port": port, "imap_username": username, "imap_password": password, "monitored_folders": None, # show all folders for picker }) result = await tool._list_folders() if not result.success: raise HTTPException(status_code=400, detail=result.error) return result.data @router.post("/my/email-accounts/{account_id}/list-folders") async def list_folders_for_account(request: Request, account_id: str): """Open a temporary IMAP connection and return the folder tree.""" user = _require_auth(request) from ..inbox.accounts import get_account from ..tools.email_handling_tool import EmailHandlingTool acct = await get_account(account_id) if not acct or str(acct.get("user_id")) != user.id: raise HTTPException(status_code=404, detail="Account not found") # Show all server folders for the picker (not filtered to current selection) tool = EmailHandlingTool(account=dict(acct, monitored_folders=None)) result = await tool._list_folders() if not result.success: raise HTTPException(status_code=400, detail=result.error) return result.data # ── Per-user CalDAV config ───────────────────────────────────────────────────── @router.get("/my/caldav/config") async def get_my_caldav_config(request: Request): user = _require_auth(request) get = lambda k: _user_settings_store.get(user.id, k) url = await get("caldav_url") username = await get("caldav_username") password = await get("caldav_password") calendar_name = await get("caldav_calendar_name") carddav_same = await get("carddav_same_as_caldav") carddav_url = await get("carddav_url") carddav_user = await get("carddav_username") carddav_pass = await get("carddav_password") allow_write = await get("contacts_allow_write") return { "url": url or "", "username": username or "", "password": password or "", "calendar_name": calendar_name or "", "carddav_same_as_caldav": carddav_same == "1", "carddav_url": carddav_url or "", "carddav_username": carddav_user or "", "carddav_password": carddav_pass or "", "contacts_allow_write": allow_write == "1", } @router.post("/my/caldav/config") async def set_my_caldav_config(request: Request): user = _require_auth(request) body = await request.json() # CalDAV fields for key, setting_key in [ ("url", "caldav_url"), ("username", "caldav_username"), ("password", "caldav_password"), ("calendar_name", "caldav_calendar_name"), ]: val = (body.get(key) or "").strip() if val: await _user_settings_store.set(user.id, setting_key, val) elif key != "password": await _user_settings_store.delete(user.id, setting_key) # CardDAV fields same = bool(body.get("carddav_same_as_caldav")) if same: await _user_settings_store.set(user.id, "carddav_same_as_caldav", "1") # Clear separate CardDAV creds — not needed when using same server for k in ("carddav_url", "carddav_username", "carddav_password"): await _user_settings_store.delete(user.id, k) else: await _user_settings_store.delete(user.id, "carddav_same_as_caldav") for key, setting_key in [ ("carddav_url", "carddav_url"), ("carddav_username", "carddav_username"), ("carddav_password", "carddav_password"), ]: val = (body.get(key) or "").strip() if val: await _user_settings_store.set(user.id, setting_key, val) elif key != "carddav_password": await _user_settings_store.delete(user.id, setting_key) # Per-user contacts write permission allow_write = bool(body.get("contacts_allow_write")) if allow_write: await _user_settings_store.set(user.id, "contacts_allow_write", "1") else: await _user_settings_store.delete(user.id, "contacts_allow_write") return {"ok": True} @router.post("/my/caldav/test") async def test_my_caldav_config(request: Request): user = _require_auth(request) from ..tools.caldav_tool import _get_caldav_config cfg = await _get_caldav_config(user_id=user.id) if not cfg.get("url") or not cfg.get("username") or not cfg.get("password"): return {"success": False, "message": "CalDAV credentials not configured"} try: import caldav url = cfg["url"] if not url.startswith(("http://", "https://")): url = "https://" + url if "/SOGo/dav/" not in url: url = f"{url.rstrip('/')}/SOGo/dav/{cfg['username']}/" client = caldav.DAVClient(url=url, username=cfg["username"], password=cfg["password"]) principal = client.principal() calendars = principal.calendars() return {"success": True, "message": f"Connected — {len(calendars)} calendar(s) found"} except Exception as e: return {"success": False, "message": str(e)} @router.post("/my/caldav/test-carddav") async def test_my_carddav_config(request: Request): user = _require_auth(request) from ..tools.contacts_tool import _get_carddav_config, _sogo_carddav_url cfg = await _get_carddav_config(user_id=user.id) if not cfg.get("url") or not cfg.get("username") or not cfg.get("password"): return {"success": False, "message": "CardDAV credentials not configured"} try: import httpx abook_url = _sogo_carddav_url(cfg["url"], cfg["username"]) body = ( '' '' ) async with httpx.AsyncClient( auth=(cfg["username"], cfg["password"]), timeout=10 ) as client: r = await client.request( "PROPFIND", abook_url, content=body, headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"}, ) if r.status_code in (200, 207): return {"success": True, "message": f"Connected to {abook_url}"} return {"success": False, "message": f"Server returned HTTP {r.status_code}"} except Exception as e: return {"success": False, "message": str(e)} @router.delete("/my/caldav/config") async def delete_my_caldav_config(request: Request): user = _require_auth(request) for key in ( "caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "carddav_same_as_caldav", "carddav_url", "carddav_username", "carddav_password", "contacts_allow_write", ): await _user_settings_store.delete(user.id, key) return {"ok": True} # ── Inbox all-accounts status (admin) ───────────────────────────────────────── @router.get("/inbox/accounts") async def list_inbox_accounts(request: Request): _require_admin(request) from ..inbox.accounts import list_accounts, mask_account accounts = await list_accounts() return [mask_account(a) for a in accounts] import asyncio as _asyncio async def asyncio_gather(*coros): return await _asyncio.gather(*coros) # ── MFA (TOTP) setup & management ───────────────────────────────────────────── # # Pending secrets are held in memory until confirmed; never written to DB until # the user successfully verifies their first TOTP code. import time as _time _pending_mfa: dict[str, tuple[str, float]] = {} # user_id → (secret, expiry_epoch) _MFA_SETUP_TTL = 600 # 10 minutes to confirm @router.get("/my/mfa/status") async def mfa_status(request: Request): user = _require_auth(request) from ..users import get_user_by_id u = await get_user_by_id(user.id) return {"enabled": bool(u and u.get("totp_secret"))} @router.post("/my/mfa/setup/begin") async def mfa_setup_begin(request: Request): user = _require_auth(request) from ..auth import generate_totp_secret, make_totp_provisioning_uri, make_totp_qr_png_b64 secret = generate_totp_secret() _pending_mfa[user.id] = (secret, _time.time() + _MFA_SETUP_TTL) uri = make_totp_provisioning_uri(secret, user.username) qr = make_totp_qr_png_b64(uri) return {"qr": qr, "secret": secret} class MfaConfirmIn(BaseModel): code: str @router.post("/my/mfa/setup/confirm") async def mfa_setup_confirm(request: Request, body: MfaConfirmIn): user = _require_auth(request) entry = _pending_mfa.get(user.id) if not entry or _time.time() > entry[1]: _pending_mfa.pop(user.id, None) raise HTTPException(status_code=400, detail="Setup session expired. Please start again.") secret, _ = entry from ..auth import verify_totp if not verify_totp(secret, body.code.strip()): raise HTTPException(status_code=400, detail="Invalid code. Try again.") from ..users import update_user await update_user(user.id, totp_secret=secret) _pending_mfa.pop(user.id, None) return {"ok": True} class MfaDisableIn(BaseModel): password: str code: str @router.delete("/my/mfa/disable") async def mfa_disable(request: Request, body: MfaDisableIn): user = _require_auth(request) from ..auth import verify_password, verify_totp from ..users import get_user_by_username, update_user full = await get_user_by_username(user.username) if not full or not verify_password(body.password, full["password_hash"]): raise HTTPException(status_code=400, detail="Password is incorrect") if not full.get("totp_secret"): raise HTTPException(status_code=400, detail="MFA is not enabled") if not verify_totp(full["totp_secret"], body.code.strip()): raise HTTPException(status_code=400, detail="Invalid TOTP code") await update_user(user.id, totp_secret=None) return {"ok": True} @router.delete("/users/{user_id}/mfa") async def admin_clear_mfa(request: Request, user_id: str): _require_admin(request) from ..users import get_user_by_id, update_user if not await get_user_by_id(user_id): raise HTTPException(status_code=404, detail="User not found") await update_user(user_id, totp_secret=None) return {"ok": True} # ── Theme ────────────────────────────────────────────────────────────────────── @router.get("/my/theme") async def get_my_theme(request: Request): user = _require_auth(request) from ..web.themes import DEFAULT_THEME, theme_list theme_id = await _user_settings_store.get(user.id, "theme") or DEFAULT_THEME return {"active": theme_id, "themes": theme_list()} class ThemeIn(BaseModel): theme_id: str @router.post("/my/theme") async def set_my_theme(request: Request, body: ThemeIn): user = _require_auth(request) from ..web.themes import THEMES, DEFAULT_THEME if body.theme_id not in THEMES: raise HTTPException(status_code=400, detail="Unknown theme") if body.theme_id == DEFAULT_THEME: await _user_settings_store.delete(user.id, "theme") else: await _user_settings_store.set(user.id, "theme", body.theme_id) return {"ok": True} # ── User profile (display name) ──────────────────────────────────────────────── @router.get("/my/profile") async def get_my_profile(request: Request): user = _require_auth(request) from ..users import get_user_by_id u = await get_user_by_id(user.id) if not u: raise HTTPException(status_code=404, detail="User not found") return { "username": u["username"], "email": u.get("email") or "", "display_name": u.get("display_name") or "", } class ProfileUpdateIn(BaseModel): display_name: str = "" @router.post("/my/profile") async def update_my_profile(request: Request, body: ProfileUpdateIn): user = _require_auth(request) from ..users import update_user await update_user(user.id, display_name=body.display_name.strip() or None) return {"ok": True} # ── Webhook endpoints (inbound triggers) ────────────────────────────────────── class WebhookEndpointIn(BaseModel): name: str agent_id: str = "" description: str = "" allow_get: bool = True class WebhookEndpointUpdate(BaseModel): name: Optional[str] = None agent_id: Optional[str] = None description: Optional[str] = None allow_get: Optional[bool] = None enabled: Optional[bool] = None @router.get("/webhooks") async def list_webhooks(request: Request): _require_admin(request) from ..webhooks.endpoints import list_endpoints return await list_endpoints() @router.post("/webhooks", status_code=201) async def create_webhook(request: Request, body: WebhookEndpointIn): _require_admin(request) from ..webhooks.endpoints import create_endpoint if not body.name.strip(): raise HTTPException(status_code=400, detail="Name is required") ep = await create_endpoint( name=body.name.strip(), agent_id=body.agent_id or "", description=body.description, allow_get=body.allow_get, ) return ep # includes token — only time it's returned @router.put("/webhooks/{endpoint_id}") async def update_webhook(request: Request, endpoint_id: str, body: WebhookEndpointUpdate): _require_admin(request) from ..webhooks.endpoints import update_endpoint fields = {k: v for k, v in body.model_dump().items() if v is not None} ep = await update_endpoint(endpoint_id, **fields) if ep is None: raise HTTPException(status_code=404, detail="Webhook not found") return ep @router.delete("/webhooks/{endpoint_id}") async def delete_webhook(request: Request, endpoint_id: str): _require_admin(request) from ..webhooks.endpoints import delete_endpoint deleted = await delete_endpoint(endpoint_id) if not deleted: raise HTTPException(status_code=404, detail="Webhook not found") return {"ok": True} @router.post("/webhooks/{endpoint_id}/rotate") async def rotate_webhook_token(request: Request, endpoint_id: str): _require_admin(request) from ..webhooks.endpoints import get_endpoint, rotate_token ep = await get_endpoint(endpoint_id) if ep is None: raise HTTPException(status_code=404, detail="Webhook not found") new_token = await rotate_token(endpoint_id) return {"ok": True, "token": new_token} # ── User-scoped webhook endpoints (non-admin) ───────────────────────────────── @router.get("/my/webhooks") async def list_my_webhooks(request: Request): _require_auth(request) user_id = request.state.current_user["id"] from ..webhooks.endpoints import list_endpoints return await list_endpoints(owner_user_id=user_id) @router.post("/my/webhooks", status_code=201) async def create_my_webhook(request: Request, body: WebhookEndpointIn): _require_auth(request) user_id = request.state.current_user["id"] from ..webhooks.endpoints import create_endpoint if not body.name.strip(): raise HTTPException(status_code=400, detail="Name is required") ep = await create_endpoint( name=body.name.strip(), agent_id=body.agent_id or "", description=body.description, allow_get=body.allow_get, owner_user_id=user_id, ) return ep # includes token — only time it's returned @router.put("/my/webhooks/{endpoint_id}") async def update_my_webhook(request: Request, endpoint_id: str, body: WebhookEndpointUpdate): _require_auth(request) user_id = request.state.current_user["id"] from ..webhooks.endpoints import update_endpoint, get_endpoint ep = await get_endpoint(endpoint_id, owner_user_id=user_id) if ep is None: raise HTTPException(status_code=404, detail="Webhook not found") fields = {k: v for k, v in body.model_dump().items() if v is not None} updated = await update_endpoint(endpoint_id, **fields) return updated @router.delete("/my/webhooks/{endpoint_id}") async def delete_my_webhook(request: Request, endpoint_id: str): _require_auth(request) user_id = request.state.current_user["id"] from ..webhooks.endpoints import delete_endpoint deleted = await delete_endpoint(endpoint_id, owner_user_id=user_id) if not deleted: raise HTTPException(status_code=404, detail="Webhook not found") return {"ok": True} @router.post("/my/webhooks/{endpoint_id}/rotate") async def rotate_my_webhook_token(request: Request, endpoint_id: str): _require_auth(request) user_id = request.state.current_user["id"] from ..webhooks.endpoints import get_endpoint, rotate_token ep = await get_endpoint(endpoint_id, owner_user_id=user_id) if ep is None: raise HTTPException(status_code=404, detail="Webhook not found") new_token = await rotate_token(endpoint_id) return {"ok": True, "token": new_token} # ── Webhook targets (outbound) ──────────────────────────────────────────────── class WebhookTargetIn(BaseModel): name: str url: str secret_header: str = "" class WebhookTargetUpdate(BaseModel): name: Optional[str] = None url: Optional[str] = None secret_header: Optional[str] = None enabled: Optional[bool] = None @router.get("/webhook-targets") async def list_webhook_targets(request: Request): _require_admin(request) from ..database import get_pool pool = await get_pool() rows = await pool.fetch("SELECT * FROM webhook_targets ORDER BY name") return [dict(r) for r in rows] @router.post("/webhook-targets", status_code=201) async def create_webhook_target(request: Request, body: WebhookTargetIn): _require_admin(request) if not body.name.strip(): raise HTTPException(status_code=400, detail="Name is required") if not body.url.strip(): raise HTTPException(status_code=400, detail="URL is required") from ..database import get_pool from datetime import datetime, timezone pool = await get_pool() now = datetime.now(timezone.utc).isoformat() try: row = await pool.fetchrow( """ INSERT INTO webhook_targets (name, url, secret_header, created_at) VALUES ($1, $2, $3, $4) RETURNING * """, body.name.strip(), body.url.strip(), body.secret_header or None, now, ) except Exception as e: if "unique" in str(e).lower(): raise HTTPException(status_code=409, detail="A target with that name already exists") raise return dict(row) @router.put("/webhook-targets/{target_id}") async def update_webhook_target(request: Request, target_id: str, body: WebhookTargetUpdate): _require_admin(request) fields = {k: v for k, v in body.model_dump().items() if v is not None} if not fields: raise HTTPException(status_code=400, detail="No fields to update") from ..database import get_pool pool = await get_pool() set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(fields)) await pool.execute( f"UPDATE webhook_targets SET {set_clauses} WHERE id = $1::uuid", target_id, *fields.values(), ) row = await pool.fetchrow("SELECT * FROM webhook_targets WHERE id = $1::uuid", target_id) if not row: raise HTTPException(status_code=404, detail="Target not found") return dict(row) @router.delete("/webhook-targets/{target_id}") async def delete_webhook_target(request: Request, target_id: str): _require_admin(request) from ..database import get_pool, _rowcount pool = await get_pool() status = await pool.execute( "DELETE FROM webhook_targets WHERE id = $1::uuid", target_id ) if _rowcount(status) == 0: raise HTTPException(status_code=404, detail="Target not found") return {"ok": True} # ── User-scoped webhook targets (non-admin) ─────────────────────────────────── @router.get("/my/webhook-targets") async def list_my_webhook_targets(request: Request): _require_auth(request) user_id = request.state.current_user["id"] from ..database import get_pool pool = await get_pool() rows = await pool.fetch( "SELECT * FROM webhook_targets WHERE owner_user_id = $1 ORDER BY name", user_id ) return [dict(r) for r in rows] @router.post("/my/webhook-targets", status_code=201) async def create_my_webhook_target(request: Request, body: WebhookTargetIn): _require_auth(request) user_id = request.state.current_user["id"] if not body.name.strip(): raise HTTPException(status_code=400, detail="Name is required") if not body.url.strip(): raise HTTPException(status_code=400, detail="URL is required") from ..database import get_pool from datetime import datetime, timezone pool = await get_pool() now = datetime.now(timezone.utc).isoformat() try: row = await pool.fetchrow( """ INSERT INTO webhook_targets (name, url, secret_header, owner_user_id, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING * """, body.name.strip(), body.url.strip(), body.secret_header or None, user_id, now, ) except Exception as e: if "unique" in str(e).lower(): raise HTTPException(status_code=409, detail="A target with that name already exists") raise return dict(row) @router.put("/my/webhook-targets/{target_id}") async def update_my_webhook_target(request: Request, target_id: str, body: WebhookTargetUpdate): _require_auth(request) user_id = request.state.current_user["id"] fields = {k: v for k, v in body.model_dump().items() if v is not None} if not fields: raise HTTPException(status_code=400, detail="No fields to update") from ..database import get_pool pool = await get_pool() # Verify ownership first existing = await pool.fetchrow( "SELECT id FROM webhook_targets WHERE id = $1::uuid AND owner_user_id = $2", target_id, user_id, ) if not existing: raise HTTPException(status_code=404, detail="Target not found") set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(fields)) await pool.execute( f"UPDATE webhook_targets SET {set_clauses} WHERE id = $1::uuid", target_id, *fields.values(), ) row = await pool.fetchrow("SELECT * FROM webhook_targets WHERE id = $1::uuid", target_id) return dict(row) @router.delete("/my/webhook-targets/{target_id}") async def delete_my_webhook_target(request: Request, target_id: str): _require_auth(request) user_id = request.state.current_user["id"] from ..database import get_pool, _rowcount pool = await get_pool() status = await pool.execute( "DELETE FROM webhook_targets WHERE id = $1::uuid AND owner_user_id = $2", target_id, user_id, ) if _rowcount(status) == 0: raise HTTPException(status_code=404, detail="Target not found") return {"ok": True} # ── Page Change Monitors ─────────────────────────────────────────────────────── class WatchedPageIn(BaseModel): name: str url: str schedule: str = "0 * * * *" css_selector: Optional[str] = None agent_id: Optional[str] = None notification_mode: str = "agent" class WatchedPageUpdate(BaseModel): name: Optional[str] = None url: Optional[str] = None schedule: Optional[str] = None css_selector: Optional[str] = None agent_id: Optional[str] = None notification_mode: Optional[str] = None enabled: Optional[bool] = None @router.get("/watched-pages") async def list_watched_pages(request: Request): user = _require_auth(request) from ..monitors.store import list_watched_pages as _list owner = None if user.is_admin else user.id return await _list(owner_user_id=owner) @router.post("/watched-pages", status_code=201) async def create_watched_page(request: Request, body: WatchedPageIn): user = _require_auth(request) if not body.name.strip() or not body.url.strip(): raise HTTPException(status_code=400, detail="Name and URL are required") from ..monitors.store import create_watched_page as _create from ..monitors.page_monitor import page_monitor page = await _create( name=body.name.strip(), url=body.url.strip(), schedule=body.schedule, css_selector=body.css_selector or None, agent_id=body.agent_id or None, notification_mode=body.notification_mode, owner_user_id=user.id, ) page_monitor.reschedule(page) return page @router.put("/watched-pages/{page_id}") async def update_watched_page(request: Request, page_id: str, body: WatchedPageUpdate): user = _require_auth(request) from ..monitors.store import get_watched_page, update_watched_page as _update page = await get_watched_page(page_id) if not page: raise HTTPException(status_code=404, detail="Page not found") if not user.is_admin and str(page.get("owner_user_id")) != user.id: raise HTTPException(status_code=404, detail="Page not found") fields = {k: v for k, v in body.model_dump().items() if v is not None} updated = await _update(page_id, **fields) from ..monitors.page_monitor import page_monitor page_monitor.reschedule(updated) return updated @router.delete("/watched-pages/{page_id}") async def delete_watched_page(request: Request, page_id: str): user = _require_auth(request) from ..monitors.store import get_watched_page, delete_watched_page as _delete page = await get_watched_page(page_id) if not page: raise HTTPException(status_code=404, detail="Page not found") if not user.is_admin and str(page.get("owner_user_id")) != user.id: raise HTTPException(status_code=404, detail="Page not found") await _delete(page_id) from ..monitors.page_monitor import page_monitor page_monitor.remove(page_id) return {"ok": True} @router.post("/watched-pages/{page_id}/check-now") async def check_page_now(request: Request, page_id: str): user = _require_auth(request) from ..monitors.store import get_watched_page page = await get_watched_page(page_id) if not page: raise HTTPException(status_code=404, detail="Page not found") if not user.is_admin and str(page.get("owner_user_id")) != user.id: raise HTTPException(status_code=404, detail="Page not found") from ..monitors.page_monitor import page_monitor result = await page_monitor.check_now(page_id) return result # ── RSS Feed Monitors ────────────────────────────────────────────────────────── class RssFeedIn(BaseModel): name: str url: str schedule: str = "0 */4 * * *" agent_id: Optional[str] = None notification_mode: str = "agent" max_items_per_run: int = 5 class RssFeedUpdate(BaseModel): name: Optional[str] = None url: Optional[str] = None schedule: Optional[str] = None agent_id: Optional[str] = None notification_mode: Optional[str] = None max_items_per_run: Optional[int] = None enabled: Optional[bool] = None @router.get("/rss-feeds") async def list_rss_feeds(request: Request): user = _require_auth(request) from ..monitors.store import list_rss_feeds as _list owner = None if user.is_admin else user.id return await _list(owner_user_id=owner) @router.post("/rss-feeds", status_code=201) async def create_rss_feed(request: Request, body: RssFeedIn): user = _require_auth(request) if not body.name.strip() or not body.url.strip(): raise HTTPException(status_code=400, detail="Name and URL are required") from ..monitors.store import create_rss_feed as _create from ..monitors.rss_monitor import rss_monitor feed = await _create( name=body.name.strip(), url=body.url.strip(), schedule=body.schedule, agent_id=body.agent_id or None, notification_mode=body.notification_mode, max_items_per_run=body.max_items_per_run, owner_user_id=user.id, ) rss_monitor.reschedule(feed) return feed @router.put("/rss-feeds/{feed_id}") async def update_rss_feed(request: Request, feed_id: str, body: RssFeedUpdate): user = _require_auth(request) from ..monitors.store import get_rss_feed, update_rss_feed as _update feed = await get_rss_feed(feed_id) if not feed: raise HTTPException(status_code=404, detail="Feed not found") if not user.is_admin and str(feed.get("owner_user_id")) != user.id: raise HTTPException(status_code=404, detail="Feed not found") fields = {k: v for k, v in body.model_dump().items() if v is not None} updated = await _update(feed_id, **fields) from ..monitors.rss_monitor import rss_monitor rss_monitor.reschedule(updated) return updated @router.delete("/rss-feeds/{feed_id}") async def delete_rss_feed(request: Request, feed_id: str): user = _require_auth(request) from ..monitors.store import get_rss_feed, delete_rss_feed as _delete feed = await get_rss_feed(feed_id) if not feed: raise HTTPException(status_code=404, detail="Feed not found") if not user.is_admin and str(feed.get("owner_user_id")) != user.id: raise HTTPException(status_code=404, detail="Feed not found") await _delete(feed_id) from ..monitors.rss_monitor import rss_monitor rss_monitor.remove(feed_id) return {"ok": True} @router.post("/rss-feeds/{feed_id}/fetch-now") async def fetch_feed_now(request: Request, feed_id: str): user = _require_auth(request) from ..monitors.store import get_rss_feed feed = await get_rss_feed(feed_id) if not feed: raise HTTPException(status_code=404, detail="Feed not found") if not user.is_admin and str(feed.get("owner_user_id")) != user.id: raise HTTPException(status_code=404, detail="Feed not found") from ..monitors.rss_monitor import rss_monitor result = await rss_monitor.fetch_now(feed_id) return result