ForcePilot/backend/package/yuxi/channels/adapters/nextcloudtalk/send.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

296 lines
10 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import random
from collections.abc import Callable
from typing import Any
import aiohttp
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelRateLimitError,
DeliveryFailedError,
)
from yuxi.channels.models import DeliveryResult
from .client import NextcloudTalkClient
logger = logging.getLogger(__name__)
def _retry_config(config: dict[str, Any]) -> dict[str, Any]:
retry_cfg = config.get("retry", {})
return {
"max_attempts": retry_cfg.get("attempts", 3),
"min_delay_ms": retry_cfg.get("min_delay_ms", 400),
"max_delay_ms": retry_cfg.get("max_delay_ms", 30000),
"jitter": retry_cfg.get("jitter", 0.1),
}
async def _with_retry(
func: Callable[[], Any],
config: dict[str, Any],
) -> Any:
rc = _retry_config(config)
for attempt in range(rc["max_attempts"]):
try:
return await func()
except aiohttp.ClientResponseError as e:
if e.status == 401:
raise ChannelAuthenticationError() from e
if e.status == 429:
retry_after = int(e.headers.get("Retry-After", "5"))
if attempt < rc["max_attempts"] - 1:
await asyncio.sleep(retry_after)
else:
raise ChannelRateLimitError(retry_after_ms=retry_after * 1000) from e
elif e.status in (404, 403):
return DeliveryResult(success=False, error=f"HTTP {e.status}: {e.message}")
elif attempt >= rc["max_attempts"] - 1:
raise DeliveryFailedError(str(e)) from e
except Exception as e:
if attempt >= rc["max_attempts"] - 1:
raise DeliveryFailedError(str(e)) from e
delay = min(rc["min_delay_ms"] * (2**attempt), rc["max_delay_ms"]) / 1000
delay += delay * rc["jitter"] * (random.random() * 2 - 1)
await asyncio.sleep(delay)
return DeliveryResult(success=False, error="Max retries exceeded")
async def send_text(
client: NextcloudTalkClient,
api_base: str,
token: str,
payload: dict[str, Any],
config: dict[str, Any],
sent_cache: dict[str, str] | None = None,
silent: bool = False,
) -> DeliveryResult:
reference_id = payload.get("referenceId", "")
if sent_cache and reference_id and reference_id in sent_cache:
logger.debug(f"[NextcloudTalk] Duplicate send prevented for ref {reference_id}")
return DeliveryResult(success=True, message_id=sent_cache[reference_id])
async def _do():
json_data = dict(payload)
if silent:
json_data["silent"] = True
result = await client.post(f"{api_base}/chat/{token}", json_data=json_data)
ocs = result.get("ocs", {})
meta = ocs.get("meta", {})
if meta.get("statuscode") == 201:
msg_data = ocs.get("data", {})
msg_id = str(msg_data.get("id", ""))
if sent_cache is not None and reference_id and msg_id:
sent_cache[reference_id] = msg_id
return DeliveryResult(success=True, message_id=msg_id)
return DeliveryResult(success=False, error=meta.get("message", "Unknown error"))
return await _with_retry(_do, config)
async def edit_message(
client: NextcloudTalkClient,
api_base: str,
token: str,
message_id: str,
new_content: str,
config: dict[str, Any] | None = None,
) -> DeliveryResult:
config = config or {}
async def _do():
result = await client.put(
f"{api_base}/chat/{token}/{message_id}",
json_data={"message": new_content},
)
ocs = result.get("ocs", {})
meta = ocs.get("meta", {})
if meta.get("statuscode") in (200, 201):
return DeliveryResult(success=True, message_id=message_id)
return DeliveryResult(success=False, error=meta.get("message", "Unknown error"))
return await _with_retry(_do, config)
async def delete_message(
client: NextcloudTalkClient,
api_base: str,
token: str,
message_id: str,
config: dict[str, Any] | None = None,
) -> DeliveryResult:
config = config or {}
async def _do():
result = await client.delete(f"{api_base}/chat/{token}/{message_id}")
ocs = result.get("ocs", {})
meta = ocs.get("meta", {})
if meta.get("statuscode") in (200, 201):
return DeliveryResult(success=True, message_id=message_id)
return DeliveryResult(success=False, error=meta.get("message", "Unknown error"))
return await _with_retry(_do, config)
async def send_media(
client: NextcloudTalkClient,
api_base: str,
chat_id: str,
media_type: str,
media_data: bytes,
config: dict[str, Any] | None = None,
**kwargs,
) -> DeliveryResult:
config = config or {}
filename = kwargs.get("filename", f"attachment.{media_type.split('/')[-1]}")
caption = kwargs.get("caption", "")
async def _do():
form = aiohttp.FormData()
form.add_field("file", media_data, filename=filename, content_type=media_type)
form.add_field("metaData", json.dumps({"message": caption}))
result = await client.post_form(f"{api_base}/chat/{chat_id}/share", form)
ocs = result.get("ocs", {})
meta = ocs.get("meta", {})
if meta.get("statuscode") == 201:
msg_data = ocs.get("data", {})
return DeliveryResult(success=True, message_id=str(msg_data.get("id", "")))
return DeliveryResult(success=False, error=meta.get("message", "Unknown error"))
return await _with_retry(_do, config)
async def send_media_chunked(
client: NextcloudTalkClient,
api_base: str,
chat_id: str,
media_type: str,
media_data: bytes,
chunk_size: int = 5 * 1024 * 1024,
config: dict[str, Any] | None = None,
on_progress: Callable[[int, int], None] | None = None,
**kwargs,
) -> DeliveryResult:
total_size = len(media_data)
if total_size <= chunk_size:
return await send_media(client, api_base, chat_id, media_type, media_data, config, **kwargs)
config = config or {}
filename = kwargs.get("filename", f"attachment.{media_type.split('/')[-1]}")
caption = kwargs.get("caption", "")
uploaded = 0
async def _do():
nonlocal uploaded
result = await client.put(
f"{api_base}/chat/{chat_id}/upload/init",
json_data={"filename": filename, "size": total_size, "content_type": media_type},
)
ocs = result.get("ocs", {})
if ocs.get("meta", {}).get("statuscode") != 201:
return DeliveryResult(success=False, error="Failed to initialize chunked upload")
upload_id = ocs.get("data", {}).get("id", "")
while uploaded < total_size:
end = min(uploaded + chunk_size, total_size)
chunk = media_data[uploaded:end]
form = aiohttp.FormData()
form.add_field("chunk", chunk, filename=filename, content_type=media_type)
form.add_field("offset", str(uploaded))
result = await client.post_form(f"{api_base}/chat/{chat_id}/upload/{upload_id}/chunk", form)
ocs = result.get("ocs", {})
if ocs.get("meta", {}).get("statuscode") != 201:
return DeliveryResult(success=False, error=f"Chunk upload failed at offset {uploaded}")
uploaded = end
if on_progress:
on_progress(uploaded, total_size)
meta_payload = {"metaData": json.dumps({"message": caption})}
result = await client.post(
f"{api_base}/chat/{chat_id}/upload/{upload_id}/finalize",
json_data=meta_payload,
)
ocs = result.get("ocs", {})
if ocs.get("meta", {}).get("statuscode") == 201:
msg_data = ocs.get("data", {})
return DeliveryResult(success=True, message_id=str(msg_data.get("id", "")))
return DeliveryResult(success=False, error=ocs.get("meta", {}).get("message", "Finalize failed"))
return await _with_retry(_do, config)
async def send_reaction(
client: NextcloudTalkClient,
api_base: str,
token: str,
message_id: str,
emoji: str,
config: dict[str, Any] | None = None,
) -> DeliveryResult:
config = config or {}
async def _do():
result = await client.post(
f"{api_base}/reaction/{token}/{message_id}",
json_data={"reaction": emoji},
)
ocs = result.get("ocs", {})
meta = ocs.get("meta", {})
if meta.get("statuscode") in (200, 201):
return DeliveryResult(success=True, message_id=message_id)
return DeliveryResult(success=False, error=meta.get("message", "Unknown error"))
return await _with_retry(_do, config)
async def send_reaction_delete(
client: NextcloudTalkClient,
api_base: str,
token: str,
message_id: str,
emoji: str,
config: dict[str, Any] | None = None,
) -> DeliveryResult:
config = config or {}
async def _do():
result = await client.delete(
f"{api_base}/reaction/{token}/{message_id}",
json_data={"reaction": emoji},
)
ocs = result.get("ocs", {})
meta = ocs.get("meta", {})
if meta.get("statuscode") in (200, 201):
return DeliveryResult(success=True, message_id=message_id)
return DeliveryResult(success=False, error=meta.get("message", "Unknown error"))
return await _with_retry(_do, config)
async def get_reactions(
client: NextcloudTalkClient,
api_base: str,
token: str,
message_id: str,
config: dict[str, Any] | None = None,
) -> DeliveryResult:
config = config or {}
async def _do():
result = await client.get(f"{api_base}/reaction/{token}/{message_id}")
ocs = result.get("ocs", {})
meta = ocs.get("meta", {})
if meta.get("statuscode") in (200, 201):
data = ocs.get("data", {})
return DeliveryResult(success=True, message_id=message_id, metadata={"reactions": data})
return DeliveryResult(success=False, error=meta.get("message", "Unknown error"))
return await _with_retry(_do, config)