from __future__ import annotations import asyncio import logging from yuxi.channel.extensions.ringcentral.types import RingCentralEvent, ResolvedRingCentralAccount logger = logging.getLogger(__name__) WEBHOOK_TIMEOUT = 120.0 def _needs_event_body_parsing(event_type: str) -> bool: return event_type in ("PostAdded", "PostChanged", "PostRemoved") def _is_card_action_event(event_type: str) -> bool: return event_type in ("adaptiveCard/action", "adaptiveCard/action") def _is_group_event(event_type: str) -> bool: return event_type in ("GroupAdded", "GroupChanged", "GroupRemoved") class RingCentralWebhookHandler: def __init__(self): self._targets: dict[str, ResolvedRingCentralAccount] = {} def register_target(self, webhook_path: str, account: ResolvedRingCentralAccount) -> None: self._targets[webhook_path] = account def unregister_target(self, webhook_path: str) -> None: self._targets.pop(webhook_path, None) def resolve_target(self, webhook_path: str) -> ResolvedRingCentralAccount | None: return self._targets.get(webhook_path) async def handle_webhook( self, body: dict, account: ResolvedRingCentralAccount | None = None, ) -> dict: raw_event_type = body.get("event", "") logger.debug("RingCentral webhook event: %s", raw_event_type) if _is_card_action_event(raw_event_type): return await self._handle_card_action(body, account) if _is_group_event(raw_event_type): return await self._handle_group_event(body, raw_event_type, account) if _needs_event_body_parsing(raw_event_type): return await self._handle_post_event(body, account) return {"status": "ignored", "reason": f"unhandled event type: {raw_event_type}"} async def _handle_card_action( self, payload: dict, account: ResolvedRingCentralAccount | None, ) -> dict: action_data = payload.get("body", {}) action = action_data.get("action", {}) card = action_data.get("card", {}) logger.info( "RingCentral adaptive card action: action_type=%s, card_id=%s", action.get("type"), card.get("id"), ) from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage from yuxi.channel.routing.models import PeerKind sender_id = action_data.get("userId", "") or action_data.get("creatorId", "") unified_msg = UnifiedMessage( msg_id=payload.get("uuid", f"rc-ca:{payload.get('timestamp', '')}"), channel_type="ringcentral", account_id=account.account_id if account else "default", content=action.get("title", "") or action.get("data", {}).get("value", ""), sender=PeerInfo(kind=PeerKind.GROUP, id=sender_id, display_name=None), message_type=MessageType.EVENT, group=None, raw_payload=payload, metadata={ "card_action": True, "card_id": card.get("id"), "action_type": action.get("type"), "action_data": action.get("data"), }, ) from yuxi.channel.runtime.manager import gateway processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None) if processor: await asyncio.wait_for(processor.process(unified_msg), timeout=WEBHOOK_TIMEOUT) return {"status": "processed", "event_type": "card_action"} async def _handle_group_event( self, payload: dict, event_type: str, account: ResolvedRingCentralAccount | None, ) -> dict: group_data = payload.get("body", {}) logger.info( "RingCentral group event: type=%s, group_id=%s, name=%s", event_type, group_data.get("id"), group_data.get("name"), ) return {"status": "acknowledged", "event_type": event_type, "group_id": group_data.get("id")} async def _handle_post_event( self, body: dict, account: ResolvedRingCentralAccount | None, ) -> dict: event = RingCentralEvent.from_payload(body) if not event.body: return {"status": "ignored", "reason": "no event body"} if account and account.bot_person_id and event.body.creator_id == account.bot_person_id: return {"status": "ignored", "reason": "self message"} text = event.body.text or "" attachments = getattr(event.body, "attachments", []) or [] if not text and not attachments: return {"status": "ignored", "reason": "empty text and no attachments"} return await self._process_post_event(body, event, account) async def _process_post_event( self, raw_payload: dict, event: RingCentralEvent, account: ResolvedRingCentralAccount | None, ) -> dict: event_type = event.event if event_type == "PostAdded": return await self._handle_post_added(raw_payload, event, account) if event_type == "PostChanged": return await self._handle_post_changed(raw_payload, event, account) if event_type == "PostRemoved": return await self._handle_post_removed(raw_payload, event, account) return {"status": "ignored", "reason": f"unhandled post event: {event_type}"} async def _handle_post_added( self, raw_payload: dict, event: RingCentralEvent, account: ResolvedRingCentralAccount | None, ) -> dict: try: from yuxi.channel.extensions.ringcentral.monitor import RingCentralMonitor monitor = RingCentralMonitor() unified_msg = await monitor.event_to_unified_message(raw_payload, event, account=account) if unified_msg is None: return {"status": "filtered"} from yuxi.channel.runtime.manager import gateway processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None) if processor: await asyncio.wait_for(processor.process(unified_msg), timeout=WEBHOOK_TIMEOUT) return {"status": "processed"} except TimeoutError: logger.error("Agent response timeout for RingCentral message") return {"status": "timeout"} except Exception: logger.exception("Failed to process RingCentral message") return {"status": "error"} async def _handle_post_changed( self, raw_payload: dict, event: RingCentralEvent, account: ResolvedRingCentralAccount | None, ) -> dict: from yuxi.channel.extensions.ringcentral.monitor import RingCentralMonitor monitor = RingCentralMonitor() unified_msg = await monitor.event_to_unified_message(raw_payload, event, account=account) if unified_msg is None: return {"status": "filtered"} if _contains_reaction_change(event): unified_msg.metadata["reaction_change"] = True logger.debug("RingCentral reaction change detected in PostChanged") unified_msg.metadata["edit_event"] = True from yuxi.channel.runtime.manager import gateway processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None) if processor: await asyncio.wait_for(processor.process(unified_msg), timeout=WEBHOOK_TIMEOUT) return {"status": "processed"} async def _handle_post_removed( self, raw_payload: dict, event: RingCentralEvent, account: ResolvedRingCentralAccount | None, ) -> dict: from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage from yuxi.channel.routing.models import PeerKind body = event.body unified_msg = UnifiedMessage( msg_id=f"rc-del:{event.uuid}", channel_type="ringcentral", account_id=account.account_id if account else "default", content="", sender=PeerInfo(kind=PeerKind.GROUP, id="system", display_name="System"), message_type=MessageType.EVENT, group=None, raw_payload=raw_payload, metadata={ "delete_event": True, "deleted_post_id": body.id if body else "", "event_type": event.event, }, ) from yuxi.channel.runtime.manager import gateway processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None) if processor: await asyncio.wait_for(processor.process(unified_msg), timeout=WEBHOOK_TIMEOUT) return {"status": "processed"} def _contains_reaction_change(event: RingCentralEvent) -> bool: if not event.body: return False reactions = getattr(event.body, "reactions", None) if reactions is None: return False return len(reactions) > 0