from __future__ import annotations import asyncio import random from collections.abc import AsyncIterator from typing import Any, TYPE_CHECKING import httpx from yuxi.channels.exceptions import ChannelAuthenticationError from yuxi.channels.models import ChannelResponse, DeliveryResult from yuxi.utils.logging_config import logger from .auth import refresh_session_if_needed from .format import build_channel_path, build_chat_action_payload, build_poke_payload if TYPE_CHECKING: from .client import UrbitClient _DEFAULT_RETRY_CONFIG: dict[str, Any] = { "attempts": 3, "min_delay_ms": 500, "max_delay_ms": 30000, "jitter": 0.1, } async def _retry_with_backoff( max_retries: int, min_delay_ms: int, max_delay_ms: int, jitter: float, operation_name: str, ) -> AsyncIterator[int]: for attempt in range(max_retries): yield attempt if attempt < max_retries - 1: delay = min(max_delay_ms / 1000, (min_delay_ms / 1000) * (2**attempt)) delay += random.uniform(0, delay * jitter) logger.warning(f"[Urbit] {operation_name} retry {attempt + 1}/{max_retries} after {delay:.1f}s") await asyncio.sleep(delay) async def _handle_401_if_needed( r: httpx.Response, attempt: int, max_retries: int, client: UrbitClient, ship_code: str, payload: dict[str, Any], ) -> bool: if r.status_code == 401 and attempt < max_retries - 1: if ship_code: await refresh_session_if_needed(client, ship_code) payload["ship"] = client.ship_name return True return False async def send_poke( client: UrbitClient, response: ChannelResponse, retry_config: dict[str, Any] | None = None, *, ship_code: str = "", ) -> DeliveryResult: cfg = {**_DEFAULT_RETRY_CONFIG, **(retry_config or {})} max_retries = cfg["attempts"] min_delay_ms = cfg["min_delay_ms"] max_delay_ms = cfg["max_delay_ms"] jitter = cfg["jitter"] chat_type = response.metadata.get("chat_type", "group") channel_path = build_channel_path(**{**response.metadata, "chat_type": chat_type}) host_ship = client.ship_name payload = build_poke_payload( host_ship=host_ship, content=response.content, channel_path=channel_path, continuation=response.metadata.get("continuation", False), reply_to=response.reply_to_message_id, chat_type=chat_type, ) last_error = None async for attempt in _retry_with_backoff(max_retries, min_delay_ms, max_delay_ms, jitter, "Send"): try: r = await client.put( f"/~/channel/{_build_channel_id(response)}", json=payload, timeout=15.0, ) if await _handle_401_if_needed(r, attempt, max_retries, client, ship_code, payload): payload["json"]["envelope"]["author"] = f"~{client.ship_name}" continue r.raise_for_status() return DeliveryResult(success=True) except httpx.HTTPStatusError as e: last_error = f"Urbit poke failed ({e.response.status_code})" if e.response.status_code in (404, 403): return DeliveryResult(success=False, error=last_error) except httpx.RequestError as e: last_error = f"Urbit Ship unreachable: {e}" except ChannelAuthenticationError: last_error = "Session recovery failed" return DeliveryResult(success=False, error=last_error or "Send failed after retries") async def send_reaction_poke( client: UrbitClient, chat_id: str, msg_id: str, emoji: str, metadata: dict[str, Any] | None = None, retry_config: dict[str, Any] | None = None, *, ship_code: str = "", ) -> DeliveryResult: return await _send_chat_action( client=client, chat_id=chat_id, action="add-react", msg_id=msg_id, metadata=metadata, retry_config=retry_config, emoji=emoji, ship_code=ship_code, ) async def send_del_reaction_poke( client: UrbitClient, chat_id: str, msg_id: str, emoji: str, metadata: dict[str, Any] | None = None, retry_config: dict[str, Any] | None = None, *, ship_code: str = "", ) -> DeliveryResult: return await _send_chat_action( client=client, chat_id=chat_id, action="del-react", msg_id=msg_id, metadata=metadata, retry_config=retry_config, emoji=emoji, ship_code=ship_code, ) async def send_edit_poke( client: UrbitClient, chat_id: str, msg_id: str, content: str, metadata: dict[str, Any] | None = None, retry_config: dict[str, Any] | None = None, *, ship_code: str = "", ) -> DeliveryResult: return await _send_chat_action( client=client, chat_id=chat_id, action="edit", msg_id=msg_id, metadata=metadata, retry_config=retry_config, content=content, ship_code=ship_code, ) async def send_delete_poke( client: UrbitClient, chat_id: str, msg_id: str, metadata: dict[str, Any] | None = None, retry_config: dict[str, Any] | None = None, *, ship_code: str = "", ) -> DeliveryResult: return await _send_chat_action( client=client, chat_id=chat_id, action="del", msg_id=msg_id, metadata=metadata, retry_config=retry_config, ship_code=ship_code, ) async def send_block_ship_poke( client: UrbitClient, ship: str, *, ship_code: str = "", ) -> DeliveryResult: target = ship.lstrip("~") payload = { "action": "poke", "ship": client.ship_name, "app": "chat", "mark": "chat-block-ship", "json": {"ship": f"~{target}"}, } try: r = await client.put( "/~/channel/chat-0", json=payload, timeout=10.0, ) if r.status_code == 401 and ship_code: from .auth import refresh_session_if_needed await refresh_session_if_needed(client, ship_code) payload["ship"] = client.ship_name r = await client.put( "/~/channel/chat-0", json=payload, timeout=10.0, ) r.raise_for_status() logger.info(f"[Urbit] Ship blocked via poke: ~{target}") return DeliveryResult(success=True) except httpx.HTTPStatusError as e: return DeliveryResult(success=False, error=f"block-ship failed ({e.response.status_code})") except httpx.RequestError as e: return DeliveryResult(success=False, error=f"Urbit Ship unreachable: {e}") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_unblock_ship_poke( client: UrbitClient, ship: str, *, ship_code: str = "", ) -> DeliveryResult: target = ship.lstrip("~") payload = { "action": "poke", "ship": client.ship_name, "app": "chat", "mark": "chat-unblock-ship", "json": {"ship": f"~{target}"}, } try: r = await client.put( "/~/channel/chat-0", json=payload, timeout=10.0, ) if r.status_code == 401 and ship_code: from .auth import refresh_session_if_needed await refresh_session_if_needed(client, ship_code) payload["ship"] = client.ship_name r = await client.put( "/~/channel/chat-0", json=payload, timeout=10.0, ) r.raise_for_status() logger.info(f"[Urbit] Ship unblocked via poke: ~{target}") return DeliveryResult(success=True) except httpx.HTTPStatusError as e: return DeliveryResult(success=False, error=f"unblock-ship failed ({e.response.status_code})") except httpx.RequestError as e: return DeliveryResult(success=False, error=f"Urbit Ship unreachable: {e}") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def _send_chat_action( *, client: UrbitClient, chat_id: str, action: str, msg_id: str, metadata: dict[str, Any] | None = None, retry_config: dict[str, Any] | None = None, emoji: str | None = None, content: str | None = None, ship_code: str = "", ) -> DeliveryResult: cfg = {**_DEFAULT_RETRY_CONFIG, **(retry_config or {})} max_retries = cfg["attempts"] min_delay_ms = cfg["min_delay_ms"] max_delay_ms = cfg["max_delay_ms"] jitter = cfg["jitter"] meta = metadata or {} chat_type = meta.get("chat_type", "group") channel_path = build_channel_path(**{**meta, "chat_type": chat_type}) host_ship = client.ship_name payload = build_chat_action_payload( host_ship=host_ship, channel_path=channel_path, action=action, msg_id=msg_id, emoji=emoji, content=content, ) last_error = None async for attempt in _retry_with_backoff( max_retries, min_delay_ms, max_delay_ms, jitter, f"Chat action '{action}'" ): try: r = await client.put( f"/~/channel/{chat_id}", json=payload, timeout=10.0, ) if await _handle_401_if_needed(r, attempt, max_retries, client, ship_code, payload): continue r.raise_for_status() return DeliveryResult(success=True) except httpx.HTTPStatusError as e: last_error = f"Urbit chat action '{action}' failed ({e.response.status_code})" if e.response.status_code in (404, 403): return DeliveryResult(success=False, error=last_error) except httpx.RequestError as e: last_error = f"Urbit Ship unreachable: {e}" except ChannelAuthenticationError: last_error = "Session recovery failed" return DeliveryResult(success=False, error=last_error or f"Chat action '{action}' failed after retries") async def _send_media_poke( client: UrbitClient, response: ChannelResponse, payload: dict[str, Any], retry_config: dict[str, Any] | None = None, *, ship_code: str = "", ) -> DeliveryResult: cfg = {**_DEFAULT_RETRY_CONFIG, **(retry_config or {})} max_retries = cfg["attempts"] min_delay_ms = cfg["min_delay_ms"] max_delay_ms = cfg["max_delay_ms"] jitter = cfg["jitter"] last_error = None async for attempt in _retry_with_backoff(max_retries, min_delay_ms, max_delay_ms, jitter, "Media send"): try: r = await client.put( f"/~/channel/{_build_channel_id(response)}", json=payload, timeout=15.0, ) if await _handle_401_if_needed(r, attempt, max_retries, client, ship_code, payload): continue r.raise_for_status() return DeliveryResult(success=True) except httpx.HTTPStatusError as e: last_error = f"Urbit media send failed ({e.response.status_code})" if e.response.status_code in (404, 403): return DeliveryResult(success=False, error=last_error) except httpx.RequestError as e: last_error = f"Urbit Ship unreachable: {e}" except ChannelAuthenticationError: last_error = "Session recovery failed" return DeliveryResult(success=False, error=last_error or "Media send failed after retries") async def send_chat_action( client: UrbitClient, chat_id: str, metadata: dict[str, Any], action: str = "typing", *, ship_code: str = "", ) -> DeliveryResult: meta = metadata or {} chat_type = meta.get("chat_type", "group") channel_path = build_channel_path(**{**meta, "chat_type": chat_type}) host_ship = client.ship_name payload = build_chat_action_payload( host_ship=host_ship, channel_path=channel_path, action=action, msg_id="", ) last_error = None async for attempt in _retry_with_backoff(3, 500, 30000, 0.1, f"Chat action '{action}'"): try: r = await client.put( f"/~/channel/{chat_id}", json=payload, timeout=10.0, ) if await _handle_401_if_needed(r, attempt, 3, client, ship_code, payload): continue r.raise_for_status() return DeliveryResult(success=True) except httpx.HTTPStatusError as e: last_error = f"Chat action '{action}' failed ({e.response.status_code})" return DeliveryResult(success=False, error=last_error) except httpx.RequestError as e: last_error = f"Urbit Ship unreachable: {e}" except ChannelAuthenticationError: last_error = "Session recovery failed" return DeliveryResult(success=False, error=last_error or f"Chat action '{action}' failed after retries") def _build_channel_id(response: ChannelResponse) -> str: meta = response.metadata chat_type = meta.get("chat_type", "group") if chat_type == "direct": target = meta.get("target_ship", "").lstrip("~") host = meta.get("host_ship", "").lstrip("~") return f"~{target}/dm--{host}" host = meta.get("host_ship", "").lstrip("~") group = meta.get("group_name", "unknown") ch_type = meta.get("channel_type", "chat") return f"~{host}/{group}/{ch_type}"