ForcePilot/backend/package/yuxi/channels/adapters/nextcloudtalk/client.py
Kris b018ad25da feat(backend): add Nextcloud Talk channel adapter implementation
实现了完整的Nextcloud Talk聊天适配器,包含以下核心功能:
1. 基础客户端通信与认证
2. 会话路由与聊天类型解析
3. 消息分块与格式转换
4. 用户配对与权限校验
5. 轮询式消息监听
6. 配置验证与诊断工具
7. 安全策略与去重缓存
8. 指标统计与状态快照

新增配套的配对用户缓存文件与模块导出入口。
2026-05-12 00:47:06 +08:00

122 lines
4.8 KiB
Python

from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
from typing import Any
import aiohttp
from yuxi.channels.exceptions import ChannelNotConnectedError
logger = logging.getLogger(__name__)
def _compute_hmac_signature(body: bytes, secret: str) -> str:
return base64.b64encode(hmac.digest(secret.encode(), body, hashlib.sha256)).decode()
def verify_hmac_signature(body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = _compute_hmac_signature(body, secret)
return hmac.compare_digest(expected, signature_header)
class NextcloudTalkClient:
def __init__(
self,
server_url: str,
bot_user: str,
app_password: str,
proxy: str | None = None,
bot_secret: str | None = None,
):
self.server_url = server_url.rstrip("/")
self.bot_user = bot_user
self.basic_auth = base64.b64encode(f"{bot_user}:{app_password}".encode()).decode()
self.proxy = proxy
self.bot_secret = bot_secret
self._session: aiohttp.ClientSession | None = None
async def start(self) -> None:
if self._session is None:
connector_kwargs: dict[str, Any] = {"limit": 10, "ttl_dns_cache": 300}
timeout = aiohttp.ClientTimeout(total=60)
session_kwargs: dict[str, Any] = {
"connector": aiohttp.TCPConnector(**connector_kwargs),
"timeout": timeout,
"headers": {"User-Agent": "ForcePilot-NextcloudTalk-Adapter/1.0"},
}
if self.proxy:
session_kwargs["proxy"] = self.proxy
self._session = aiohttp.ClientSession(**session_kwargs)
async def stop(self) -> None:
if self._session:
await self._session.close()
self._session = None
@property
def session(self) -> aiohttp.ClientSession:
if self._session is None:
raise ChannelNotConnectedError()
return self._session
def auth_headers(self) -> dict[str, str]:
return {"Authorization": f"Basic {self.basic_auth}"}
def api_url(self, path: str) -> str:
return f"{self.server_url}{path}"
def _sign_body(self, body_bytes: bytes) -> dict[str, str]:
if not self.bot_secret:
return {}
return {"X-Nextcloud-Talk-SHA256": _compute_hmac_signature(body_bytes, self.bot_secret)}
async def get(self, path: str, **kwargs) -> dict[str, Any]:
url = self.api_url(path)
headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"}
headers.update(kwargs.pop("headers", {}))
async with self.session.get(url, headers=headers, **kwargs) as resp:
resp.raise_for_status()
return await resp.json()
async def post(self, path: str, json_data: dict[str, Any] | None = None, **kwargs) -> dict[str, Any]:
url = self.api_url(path)
headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"}
body_bytes = json.dumps(json_data).encode() if json_data else b""
headers.update(self._sign_body(body_bytes))
headers.update(kwargs.pop("headers", {}))
async with self.session.post(url, headers=headers, json=json_data, **kwargs) as resp:
resp.raise_for_status()
return await resp.json()
async def post_form(self, path: str, form: aiohttp.FormData, **kwargs) -> dict[str, Any]:
url = self.api_url(path)
headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"}
headers.update(kwargs.pop("headers", {}))
async with self.session.post(url, headers=headers, data=form, **kwargs) as resp:
resp.raise_for_status()
return await resp.json()
async def put(self, path: str, json_data: dict[str, Any] | None = None, **kwargs) -> dict[str, Any]:
url = self.api_url(path)
headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"}
body_bytes = json.dumps(json_data).encode() if json_data else b""
headers.update(self._sign_body(body_bytes))
headers.update(kwargs.pop("headers", {}))
async with self.session.put(url, headers=headers, json=json_data, **kwargs) as resp:
resp.raise_for_status()
return await resp.json()
async def delete(self, path: str, **kwargs) -> dict[str, Any]:
url = self.api_url(path)
headers = {**self.auth_headers(), "OCS-APIRequest": "true", "Accept": "application/json"}
headers.update(kwargs.pop("headers", {}))
async with self.session.delete(url, headers=headers, **kwargs) as resp:
resp.raise_for_status()
return await resp.json()