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)