from __future__ import annotations import asyncio import logging from yuxi.channel.extensions.ringcentral.errors import ( RingCentralRateLimitError, is_retryable_error, ) from yuxi.channel.extensions.ringcentral.formatting import chunk_text, sanitize_text from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient from yuxi.channel.extensions.ringcentral.types import ResolvedRingCentralAccount from yuxi.channel.protocols import ( OutboundDeliveryCapabilities, OutboundDeliveryMode, OutboundPresentationCapabilities, ) logger = logging.getLogger(__name__) class RingCentralOutboundAdapter: delivery_mode = OutboundDeliveryMode.DIRECT chunker_mode = "markdown" text_chunk_limit: int = 1000 max_retries: int = 3 retry_base_delay: float = 1.0 presentation_capabilities = OutboundPresentationCapabilities(supported=False) delivery_capabilities = OutboundDeliveryCapabilities( durable_final_text=True, durable_final_media=False, ) def _get_account(self, account_id: str | None) -> ResolvedRingCentralAccount | None: if account_id: from yuxi.channel.plugins.registry import ChannelPluginRegistry plugin = ChannelPluginRegistry.get("ringcentral") if plugin and hasattr(plugin, "_config"): import asyncio loop = asyncio.get_event_loop() account_dict = loop.run_until_complete(plugin._config.resolve_account(account_id)) return account_dict.get("resolved") return None def _get_client(self, account_id: str | None = None) -> AsyncRingCentralClient | None: if account_id: from yuxi.channel.plugins.registry import ChannelPluginRegistry plugin = ChannelPluginRegistry.get("ringcentral") if plugin and hasattr(plugin, "_gateway"): return plugin._gateway.get_client(account_id) return None def _require_client(self, account_id: str | None = None) -> AsyncRingCentralClient: client = self._get_client(account_id) if not client: raise RuntimeError(f"RingCentral client not found for account: {account_id}") return client async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: client = self._get_client(account_id) if not client: raise RuntimeError(f"RingCentral client not found for account: {account_id}") safe = sanitize_text(content) if not safe: return account = self._get_account(account_id) limit = account.text_chunk_limit if account else self.text_chunk_limit chunks = chunk_text(safe, limit) for chunk in chunks: await self._send_post_with_retry(client, target_id, chunk) async def _send_post_with_retry( self, client: AsyncRingCentralClient, chat_id: str, text: str, ) -> str | None: last_error = None for attempt in range(self.max_retries): try: result = await client.post( f"/restapi/v1.0/glip/chats/{chat_id}/posts", body={"text": text}, ) return result.get("id", "") except RingCentralRateLimitError as e: delay = max(e.retry_after, self.retry_base_delay * (2**attempt)) logger.warning("RingCentral rate limited, retrying in %ss (attempt %d)", delay, attempt + 1) await asyncio.sleep(delay) last_error = e except Exception as e: if not is_retryable_error(getattr(e, "status_code", 500)): raise delay = self.retry_base_delay * (2**attempt) await asyncio.sleep(delay) last_error = e if last_error: raise last_error return None async def send_media( self, target_id: str, media_url: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: client = self._get_client(account_id) if not client: raise RuntimeError(f"RingCentral client not found for account: {account_id}") from yuxi.channel.extensions.ringcentral.media import send_media_from_url await send_media_from_url(client, target_id, media_url, media_type) async def edit_message( self, target_id: str, message_id: str, content: str, *, thread_id: str | None = None, account_id: str | None = None, ) -> str | None: client = self._get_client(account_id) if not client: return None safe = sanitize_text(content) result = await client.patch( f"/restapi/v1.0/glip/chats/{target_id}/posts/{message_id}", body={"text": safe}, ) return result.get("id") if result else None async def delete_message( self, chat_id: str, post_id: str, *, account_id: str | None = None, ) -> None: client = self._get_client(account_id) if not client: return await client.delete(f"/restapi/v1.0/glip/chats/{chat_id}/posts/{post_id}") async def send_reaction( self, post_id: str, emoji: str, *, account_id: str | None = None, ) -> dict | None: client = self._get_client(account_id) if not client: return None return await client.post( f"/restapi/v1.0/glip/posts/{post_id}/reactions", body={"reaction": emoji}, ) async def remove_reaction( self, post_id: str, emoji: str, *, account_id: str | None = None, ) -> None: client = self._get_client(account_id) if not client: return await client.delete( f"/restapi/v1.0/glip/posts/{post_id}/reactions/{emoji}", ) def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: return chunk_text(text, limit) async def fetch_posts( self, chat_id: str, *, page: int = 1, per_page: int = 20, account_id: str | None = None, ) -> dict: client = self._require_client(account_id) return await client.get( f"/restapi/v1.0/glip/chats/{chat_id}/posts", params={"page": page, "perPage": per_page}, ) async def get_post( self, chat_id: str, post_id: str, *, account_id: str | None = None, ) -> dict: client = self._require_client(account_id) return await client.get( f"/restapi/v1.0/glip/chats/{chat_id}/posts/{post_id}", ) async def fetch_person( self, person_id: str, *, account_id: str | None = None, ) -> dict: client = self._require_client(account_id) return await client.get(f"/restapi/v1.0/glip/persons/{person_id}") async def fetch_me( self, *, account_id: str | None = None, ) -> dict: return await self.fetch_person("~", account_id=account_id) async def mark_chat_read( self, chat_id: str, *, account_id: str | None = None, ) -> None: client = self._require_client(account_id) await client.post(f"/restapi/v1.0/glip/chats/{chat_id}/read") async def favorite_post( self, post_id: str, *, account_id: str | None = None, ) -> None: client = self._require_client(account_id) await client.post(f"/restapi/v1.0/glip/posts/{post_id}/favorite") async def unfavorite_post( self, post_id: str, *, account_id: str | None = None, ) -> None: client = self._require_client(account_id) await client.delete(f"/restapi/v1.0/glip/posts/{post_id}/favorite") async def list_chats( self, *, page: int = 1, per_page: int = 20, account_id: str | None = None, ) -> dict: client = self._require_client(account_id) return await client.get( "/restapi/v1.0/glip/chats", params={"page": page, "perPage": per_page}, ) async def get_chat( self, chat_id: str, *, account_id: str | None = None, ) -> dict: client = self._require_client(account_id) return await client.get(f"/restapi/v1.0/glip/chats/{chat_id}") async def list_groups( self, *, page: int = 1, per_page: int = 20, account_id: str | None = None, ) -> dict: client = self._require_client(account_id) return await client.get( "/restapi/v1.0/glip/groups", params={"page": page, "perPage": per_page}, ) async def get_group( self, group_id: str, *, account_id: str | None = None, ) -> dict: client = self._require_client(account_id) return await client.get(f"/restapi/v1.0/glip/groups/{group_id}") def sanitize_text_out(self, text: str, payload: object) -> str: return sanitize_text(text) def resolve_effective_text_chunk_limit( self, config: dict, account_id: str | None = None, fallback_limit: int | None = None, ) -> int | None: return self.text_chunk_limit def should_treat_delivered_text_as_visible(self, kind: str, text: str | None = None) -> bool: return True