import json import os import uuid import logging import re from typing import Dict, List, Optional, Any import httpx from core.atomic_io import atomic_write_json from core.platform_compat import safe_chmod from src.secret_storage import decrypt, encrypt, is_encrypted log = logging.getLogger(__name__) DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "integrations.json") # --------------------------------------------------------------------------- # Presets # --------------------------------------------------------------------------- INTEGRATION_PRESETS: Dict[str, Dict[str, Any]] = { "miniflux": { "name": "Miniflux", "auth_type": "header", "auth_header": "X-Auth-Token", "description": ( "Miniflux RSS reader (v1 API). Key endpoints:\n" " GET /v1/feeds — list all feeds\n" " GET /v1/feeds/{id} — get feed details\n" " POST /v1/feeds — create feed {\"feed_url\": \"...\", \"category_id\": N}\n" " PUT /v1/feeds/{id} — update feed\n" " DELETE /v1/feeds/{id} — delete feed\n" " GET /v1/feeds/{id}/entries — list entries for feed\n" " GET /v1/entries — list all entries (params: status, limit, order, direction, category_id)\n" " GET /v1/entries/{id} — get single entry\n" " PUT /v1/entries — update entries {\"entry_ids\": [...], \"status\": \"read|unread\"}\n" " GET /v1/categories — list categories\n" " POST /v1/categories — create category {\"title\": \"...\"}\n" " GET /v1/feeds/{id}/icon — get feed icon\n" " PUT /v1/entries/{id}/bookmark — toggle bookmark" ), }, "gitea": { "name": "Gitea", "auth_type": "header", "auth_header": "Authorization", "description": ( "Gitea git forge API (v1). Auth header value format: 'token YOUR_TOKEN'. Key endpoints:\n" " GET /api/v1/repos/search — search repositories\n" " GET /api/v1/repos/{owner}/{repo} — get repo details\n" " GET /api/v1/repos/{owner}/{repo}/issues — list issues\n" " POST /api/v1/repos/{owner}/{repo}/issues — create issue {\"title\": \"...\"}\n" " GET /api/v1/repos/{owner}/{repo}/pulls — list pull requests\n" " GET /api/v1/repos/{owner}/{repo}/commits — list commits\n" " GET /api/v1/user/repos — list your repos\n" " GET /api/v1/orgs — list organizations\n" " GET /api/v1/repos/{owner}/{repo}/contents/{filepath} — get file content" ), }, "linkding": { "name": "Linkding", "auth_type": "header", "auth_header": "Authorization", "description": ( "Linkding bookmark manager API. Auth header value format: 'Token YOUR_TOKEN'. Key endpoints:\n" " GET /api/bookmarks/ — list bookmarks (params: q, limit, offset)\n" " GET /api/bookmarks/{id}/ — get bookmark\n" " POST /api/bookmarks/ — create bookmark {\"url\": \"...\", \"title\": \"...\", \"tag_names\": [...]}\n" " PUT /api/bookmarks/{id}/ — update bookmark\n" " DELETE /api/bookmarks/{id}/ — delete bookmark\n" " GET /api/bookmarks/archived/ — list archived bookmarks\n" " GET /api/tags/ — list tags" ), }, "homeassistant": { "name": "Home Assistant", "auth_type": "bearer", "description": ( "Home Assistant smart home API. Key endpoints:\n" " GET /api/ — API status check\n" " GET /api/states — list all entity states\n" " GET /api/states/{entity_id} — get state of entity\n" " POST /api/states/{entity_id} — update entity state\n" " POST /api/services/{domain}/{service} — call service (e.g. light/turn_on)\n" " GET /api/history/period/{timestamp} — get state history\n" " GET /api/logbook/{timestamp} — get logbook entries\n" " POST /api/events/{event_type} — fire event\n" " GET /api/config — get configuration" ), }, "ntfy": { "name": "ntfy", "auth_type": "none", "description": ( "ntfy push notification service. Key endpoints:\n" " POST /{topic} — send notification. Body is the message text.\n" " Headers: Title (notification title), Priority (1-5), Tags (comma-separated emoji tags)\n" " POST / — send JSON notification {\"topic\": \"...\", \"message\": \"...\", \"title\": \"...\", \"priority\": N}\n" " GET /{topic}/json?poll=1 — poll for messages" ), }, "vaultwarden": { "name": "Vaultwarden", "auth_type": "header", "auth_header": "Authorization", "description": ( "Vaultwarden (Bitwarden-compatible) password manager API. Auth header value format: 'Bearer ACCESS_TOKEN'.\n" "To get an access token: POST /identity/connect/token with grant_type=client_credentials&client_id=...&client_secret=...\n" "Key endpoints:\n" " GET /api/ciphers — list all vault items (logins, notes, cards, identities)\n" " GET /api/ciphers/{id} — get a single vault item\n" " POST /api/ciphers — create vault item {\"type\": 1, \"name\": \"...\", \"login\": {\"uri\": \"...\", \"username\": \"...\", \"password\": \"...\"}}\n" " PUT /api/ciphers/{id} — update vault item\n" " DELETE /api/ciphers/{id} — delete vault item\n" " GET /api/folders — list folders\n" " POST /api/folders — create folder {\"name\": \"...\"}\n" " GET /api/collections — list collections (org vaults)\n" " POST /api/ciphers/{id}/password-history — get password history\n" " GET /api/sends — list Bitwarden Send items\n" " POST /api/sends — create a Send (secure sharing)\n" " Note: Vault data is end-to-end encrypted. The API returns encrypted fields\n" " that must be decrypted client-side with the user's master key." ), }, "freshrss": { "name": "FreshRSS", "auth_type": "header", "auth_header": "Authorization", "description": ( "FreshRSS RSS reader (GReader API). Auth header value format: 'GoogleLogin auth=YOUR_TOKEN'. Key endpoints:\n" " GET /api/greader.php/reader/api/0/subscription/list?output=json — list feeds\n" " GET /api/greader.php/reader/api/0/stream/contents/feed/{feed_id}?output=json&n=20 — get entries\n" " GET /api/greader.php/reader/api/0/tag/list?output=json — list tags/categories\n" " POST /api/greader.php/reader/api/0/edit-tag — mark read/starred\n" " GET /api/greader.php/reader/api/0/unread-count?output=json — unread counts" ), }, } # --------------------------------------------------------------------------- # Storage # --------------------------------------------------------------------------- def _ensure_data_dir() -> None: os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True) def _encrypt_integration_secrets(integrations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Return storage-safe copies with API keys encrypted at rest.""" safe: List[Dict[str, Any]] = [] for item in integrations: copy = dict(item) api_key = copy.get("api_key", "") if api_key: copy["api_key"] = encrypt(str(api_key)) safe.append(copy) return safe def _decrypt_integration_secrets(integrations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Return runtime copies with API keys decrypted for callers.""" decoded: List[Dict[str, Any]] = [] for item in integrations: copy = dict(item) api_key = copy.get("api_key", "") if api_key: copy["api_key"] = decrypt(str(api_key)) decoded.append(copy) return decoded def _has_plaintext_api_key(integrations: List[Dict[str, Any]]) -> bool: return any( bool(item.get("api_key")) and not is_encrypted(str(item.get("api_key"))) for item in integrations ) def mask_integration_secret(integration: Dict[str, Any]) -> Dict[str, Any]: """Return a copy safe for API responses.""" safe = dict(integration) api_key = safe.get("api_key", "") if api_key: safe["api_key"] = f"{str(api_key)[:4]}****" return safe def load_integrations() -> List[Dict[str, Any]]: """Load all integrations from disk with secrets decrypted for runtime use.""" if not os.path.exists(DATA_FILE): return [] try: with open(DATA_FILE, "r", encoding="utf-8") as f: integrations = json.load(f) if not isinstance(integrations, list): log.error("Invalid integrations file shape: expected a list") return [] valid_integrations = [item for item in integrations if isinstance(item, dict)] if len(valid_integrations) != len(integrations): log.error("Invalid integrations file rows: ignored non-object entries") integrations = valid_integrations if _has_plaintext_api_key(integrations): save_integrations(_decrypt_integration_secrets(integrations)) return _decrypt_integration_secrets(integrations) except (json.JSONDecodeError, IOError) as exc: log.error("Failed to load integrations: %s", exc) return [] def save_integrations(integrations: List[Dict[str, Any]]) -> None: """Persist integrations list to disk with API keys encrypted at rest.""" _ensure_data_dir() atomic_write_json(DATA_FILE, _encrypt_integration_secrets(integrations), indent=2) safe_chmod(DATA_FILE, 0o600) def get_integration(integration_id: str) -> Optional[Dict[str, Any]]: """Get a single integration by id.""" for item in load_integrations(): if item.get("id") == integration_id: return item return None def add_integration(data: Dict[str, Any]) -> Dict[str, Any]: """Add a new integration. If 'preset' is given, merge preset defaults first.""" integration: Dict[str, Any] = {} preset_key = data.get("preset") if preset_key and preset_key in INTEGRATION_PRESETS: integration.update(INTEGRATION_PRESETS[preset_key]) integration["preset"] = preset_key integration.update(data) integration.setdefault("id", uuid.uuid4().hex[:12]) integration.setdefault("enabled", True) integration.setdefault("auth_type", "none") integration.setdefault("auth_header", "") integration.setdefault("auth_param", "") integration.setdefault("description", "") integration.setdefault("api_key", "") integration.setdefault("name", "") integration.setdefault("base_url", "") integrations = load_integrations() integrations.append(integration) save_integrations(integrations) return integration def update_integration(integration_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Update fields on an existing integration. Returns updated integration or None.""" integrations = load_integrations() for item in integrations: if item.get("id") == integration_id: data.pop("id", None) # prevent id change item.update(data) save_integrations(integrations) return item return None def delete_integration(integration_id: str) -> bool: """Delete an integration by id. Returns True if found and deleted.""" integrations = load_integrations() original_len = len(integrations) integrations = [i for i in integrations if i.get("id") != integration_id] if len(integrations) < original_len: save_integrations(integrations) return True return False # --------------------------------------------------------------------------- # API execution # --------------------------------------------------------------------------- def _strip_html_tags(html: str) -> str: """Rough HTML tag stripping.""" text = re.sub(r"<[^>]+>", "", html) text = re.sub(r"\s+", " ", text).strip() return text def _find_integration(identifier: str) -> Optional[Dict[str, Any]]: """Find integration by id or name (case-insensitive).""" integrations = load_integrations() # try id first for item in integrations: if item.get("id") == identifier: return item # try name lower = identifier.lower() for item in integrations: if item.get("name", "").lower() == lower: return item return None async def execute_api_call( integration_id: str, method: str, path: str, params: Optional[Dict[str, Any]] = None, body: Optional[Any] = None, extra_headers: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: """Execute an HTTP request against a registered integration.""" integration = _find_integration(integration_id) if not integration: return {"error": f"Integration not found: {integration_id}", "exit_code": 1} if not integration.get("enabled", True): return {"error": f"Integration '{integration.get('name')}' is disabled", "exit_code": 1} base_url = integration.get("base_url", "").rstrip("/") if not base_url: return {"error": "Integration has no base_url configured", "exit_code": 1} # Strip common API path suffixes users might accidentally include # (e.g. "http://host/v1/" → "http://host"). The integration's preset # endpoints include the full path, so the base should be bare. preset = (integration.get("preset") or integration.get("name", "")).lower() strip_suffixes = { "miniflux": ["/v1"], "gitea": ["/api/v1", "/api"], "linkding": ["/api"], "homeassistant": ["/api"], } for suf in strip_suffixes.get(preset, []): if base_url.endswith(suf): base_url = base_url[: -len(suf)] break # Validate path if not path.startswith("/"): return {"error": "Path must start with /", "exit_code": 1} if re.search(r"^https?://", path) or "://" in path: return {"error": "Path must not contain a protocol scheme", "exit_code": 1} url = base_url + path method = method.upper() # Build headers headers: Dict[str, str] = {} if extra_headers: headers.update(extra_headers) api_key = integration.get("api_key", "") auth_type = integration.get("auth_type", "none") if auth_type == "header" and api_key: # Fall back based on preset/name when auth_header is unset or empty header_name = integration.get("auth_header") or "" if not header_name: preset = (integration.get("preset") or integration.get("name", "")).lower() header_defaults = { "miniflux": "X-Auth-Token", "linkding": "Authorization", "gitea": "Authorization", } header_name = header_defaults.get(preset, "Authorization") headers[header_name] = api_key elif auth_type == "bearer" and api_key: headers["Authorization"] = f"Bearer {api_key}" elif auth_type == "query" and api_key: if params is None: params = {} param_name = integration.get("auth_param", "api_key") params[param_name] = api_key # auth_type == "basic" — expects api_key as "user:password" auth = None if auth_type == "basic" and api_key: parts = api_key.split(":", 1) if len(parts) == 2: auth = httpx.BasicAuth(parts[0], parts[1]) try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.request( method, url, params=params, json=body if body is not None else None, headers=headers, auth=auth, ) content_type = response.headers.get("content-type", "") status = response.status_code # Format response body if "application/json" in content_type: try: data = response.json() formatted = json.dumps(data, indent=2, ensure_ascii=False) except (json.JSONDecodeError, ValueError): formatted = response.text elif "text/html" in content_type: formatted = _strip_html_tags(response.text) else: formatted = response.text # Truncate if len(formatted) > 12000: formatted = formatted[:12000] + "\n... (truncated)" output = f"HTTP {status}\n{formatted}" if status >= 400: return {"error": output, "exit_code": 1} return {"output": output, "exit_code": 0} except httpx.TimeoutException: return {"error": f"Request to {integration.get('name')} timed out", "exit_code": 1} except httpx.RequestError as exc: return {"error": f"Request failed: {exc}", "exit_code": 1} except Exception as exc: log.exception("Unexpected error in execute_api_call") return {"error": f"Unexpected error: {exc}", "exit_code": 1} # --------------------------------------------------------------------------- # System prompt helper # --------------------------------------------------------------------------- def get_integrations_prompt() -> str: """Return a string describing all enabled integrations for system prompt injection. Returns empty string if no integrations are enabled. """ integrations = load_integrations() enabled = [i for i in integrations if i.get("enabled", True)] if not enabled: return "" lines = ["You have access to the following API integrations via the api_call tool:\n"] for integ in enabled: name = integ.get("name", integ.get("id", "unknown")) lines.append(f"## {name} (id: {integ['id']})") desc = integ.get("description", "") if desc: lines.append(desc) lines.append("") return "\n".join(lines) # --------------------------------------------------------------------------- # Migration # --------------------------------------------------------------------------- def migrate_from_settings() -> None: """If data/settings.json has miniflux_url and miniflux_api_key, create a Miniflux integration and clear those keys from settings.""" settings_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "settings.json") if not os.path.exists(settings_path): return try: with open(settings_path, "r", encoding="utf-8") as f: settings = json.load(f) except (json.JSONDecodeError, IOError): return miniflux_url = settings.get("miniflux_url", "") miniflux_key = settings.get("miniflux_api_key", "") if not miniflux_url or not miniflux_key: return # Check if a miniflux integration already exists existing = load_integrations() for item in existing: if item.get("preset") == "miniflux": log.info("Miniflux integration already exists, skipping migration") return add_integration({ "preset": "miniflux", "base_url": miniflux_url.rstrip("/"), "api_key": miniflux_key, }) # Clear migrated keys settings.pop("miniflux_url", None) settings.pop("miniflux_api_key", None) with open(settings_path, "w", encoding="utf-8") as f: json.dump(settings, f, indent=2) log.info("Migrated Miniflux integration from settings.json")