from __future__ import annotations import base64 import logging import time from dataclasses import dataclass, field from urllib.parse import urljoin import httpx logger = logging.getLogger(__name__) OCS_CAPABILITIES_PATH = "/ocs/v2.php/cloud/capabilities" PROBE_TIMEOUT = 10.0 @dataclass class NextcloudTalkProbeResult: ok: bool = False base_url: str = "" reachable: bool = False latency_ms: float | None = None nextcloud_version: str | None = None talk_app_installed: bool = False bots_v1_available: bool = False reactions_available: bool = False api_credentials_valid: bool | None = None # --feature response cannot be verified without a valid room token feature_response_checkable: bool = False warnings: list[str] = field(default_factory=list) error: str | None = None async def probe_nextcloud_talk_connectivity(base_url: str) -> dict: if not base_url: return {"reachable": False, "error": "base_url is not configured"} url = urljoin(base_url.rstrip("/") + "/", OCS_CAPABILITIES_PATH) start = time.monotonic() try: async with httpx.AsyncClient(timeout=PROBE_TIMEOUT) as client: resp = await client.get( url, headers={ "OCS-APIRequest": "true", "Accept": "application/json", }, ) latency_ms = (time.monotonic() - start) * 1000 if resp.status_code != 200: return {"reachable": False, "latency_ms": latency_ms, "error": f"HTTP {resp.status_code}"} data = resp.json() ocs_data = data.get("ocs", {}).get("data", {}) version = ocs_data.get("version", {}).get("string") capabilities = ocs_data.get("capabilities", {}) talk_installed = "spreed" in capabilities talk_capabilities = capabilities.get("spreed", {}).get("features", []) return { "reachable": True, "latency_ms": round(latency_ms, 1), "nextcloud_version": version, "talk_app_installed": talk_installed, "bots_v1_available": "bots-v1" in talk_capabilities, "reactions_available": "reactions" in talk_capabilities, } except httpx.TimeoutException: return {"reachable": False, "error": "connection timeout"} except httpx.ConnectError as e: return {"reachable": False, "error": f"connection failed: {e}"} except Exception as e: return {"reachable": False, "error": str(e)} async def probe_nextcloud_talk_api_credentials(base_url: str, api_user: str, api_password: str) -> dict: if not api_user or not api_password: return {"valid": None, "error": "API credentials not configured"} url = urljoin(base_url.rstrip("/") + "/", OCS_CAPABILITIES_PATH) auth = base64.b64encode(f"{api_user}:{api_password}".encode()).decode() try: async with httpx.AsyncClient(timeout=PROBE_TIMEOUT) as client: resp = await client.get( url, headers={ "Authorization": f"Basic {auth}", "OCS-APIRequest": "true", "Accept": "application/json", }, ) if resp.status_code == 200: return {"valid": True} if resp.status_code == 401: return {"valid": False, "error": "API credentials invalid (401)"} return {"valid": False, "error": f"HTTP {resp.status_code}"} except Exception as e: return {"valid": False, "error": str(e)} async def probe_nextcloud_talk(account: dict) -> NextcloudTalkProbeResult: base_url = account.get("base_url", "") api_user = account.get("api_user") api_password = account.get("api_password", "") bot_secret = account.get("bot_secret", "") result = NextcloudTalkProbeResult(base_url=base_url) if not base_url: result.error = "base_url not configured" result.warnings.append("base_url is required") return result conn_result = await probe_nextcloud_talk_connectivity(base_url) result.reachable = conn_result["reachable"] result.latency_ms = conn_result.get("latency_ms") result.nextcloud_version = conn_result.get("nextcloud_version") result.talk_app_installed = conn_result.get("talk_app_installed", False) result.bots_v1_available = conn_result.get("bots_v1_available", False) result.reactions_available = conn_result.get("reactions_available", False) if not result.reachable: result.error = conn_result.get("error", "unreachable") result.warnings.append(f"Nextcloud instance unreachable: {result.error}") return result if not result.talk_app_installed: result.warnings.append("Nextcloud Talk app not detected on this instance") if not result.bots_v1_available: result.warnings.append("bots-v1 capability not available - Nextcloud Talk may be too old for Bot API") if api_user and api_password: cred_result = await probe_nextcloud_talk_api_credentials(base_url, api_user, api_password) result.api_credentials_valid = cred_result["valid"] if not cred_result["valid"]: result.warnings.append(f"API credentials: {cred_result.get('error', 'unknown error')}") if not bot_secret: result.warnings.append("bot_secret not configured - webhook signature verification will fail") result.feature_response_checkable = bool(bot_secret) if not result.feature_response_checkable: result.warnings.append( "Cannot verify --feature response: bot_secret not configured. " "Ensure 'occ talk:bot:install --feature response' was used." ) result.ok = result.reachable and not any(w for w in result.warnings if "unreachable" in w.lower()) return result