ForcePilot/backend/package/yuxi/channel/extensions/nextcloud_talk/probe.py
Kris 6fc9a4a94c feat(channel): 添加 Nextcloud Talk 渠道扩展
新增 Nextcloud Talk 渠道扩展,支持在 Yuxi 平台中集成 Nextcloud Talk 即时通讯渠道。

包含以下功能模块:
- accounts: 账户管理
- bot_admin: Bot 管理
- chat_api: 聊天 API 封装
- config_schema: 配置模式
- conversation: 会话管理
- gateway: SSE/WebSocket 网关接入
- inbound: 入站消息处理
- send: 消息发送
- webhook_server: Webhook 服务
- format: 消息格式转换
- normalize: 消息规范化
- poll_api: 投票 API
- reaction_api: 表情反应 API
- policy: 安全策略
- probe: 健康探测
- replay_guard: 重放防护
- signature: 签名验证
- room_info: 房间信息
- types: 类型定义
2026-05-21 11:29:56 +08:00

155 lines
5.7 KiB
Python

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