实现了完整的Nextcloud Talk聊天适配器,包含以下核心功能: 1. 基础客户端通信与认证 2. 会话路由与聊天类型解析 3. 消息分块与格式转换 4. 用户配对与权限校验 5. 轮询式消息监听 6. 配置验证与诊断工具 7. 安全策略与去重缓存 8. 指标统计与状态快照 新增配套的配对用户缓存文件与模块导出入口。
296 lines
10 KiB
Python
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)
|