import asyncio import hashlib import hmac import logging import uvicorn from yuxi.channel.extensions.webex.monitor import payload_to_unified_message from yuxi.channel.extensions.webex.types import WebexAccount, WebexMessage from yuxi.channel.extensions.webex.webhook import create_webhook_app from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage from yuxi.channel.routing.models import PeerKind logger = logging.getLogger(__name__) class WebexGateway: def __init__(self, account: WebexAccount, ctx, dedupe=None): self._account = account self._ctx = ctx self._api = None self._dedupe = dedupe self._bot_person_id: str = "" self._server_task: asyncio.Task | None = None self._running = False @property def api(self): return self._api @property def bot_person_id(self) -> str: return self._bot_person_id async def start(self) -> asyncio.Task: if self._running: raise RuntimeError("Webex gateway already running") from webexpythonsdk import WebexAPI self._api = WebexAPI(access_token=self._account.bot_access_token) self._running = True me = self._api.people.me() self._bot_person_id = me.id self._account.bot_person_id = me.id self._account.bot_person_email = me.emails[0] if me.emails else "" app = create_webhook_app( verify_signature=self._verify_signature, handle_webhook=self._handle_webhook, webhook_path=self._account.webhook_path, ) config = uvicorn.Config( app, host=self._account.webhook_host, port=self._account.webhook_port, log_level="warning", access_log=False, ) server = uvicorn.Server(config) self._server_task = asyncio.create_task(server.serve()) await self._ensure_webhook_registered() return self._server_task async def stop(self): self._running = False if self._server_task: self._server_task.cancel() try: await self._server_task except asyncio.CancelledError: pass async def _ensure_webhook_registered(self): if not self._account.webhook_host or self._account.webhook_host == "0.0.0.0": logger.warning( "Webhook host is '%s'. Automatic webhook registration is disabled. " "To receive messages in development, set WEBEX_WEBHOOK_HOST to a public URL " "(e.g. via ngrok) or manually create a webhook at " "https://developer.webex.com/docs/webhooks.", self._account.webhook_host, ) return target_url = f"https://{self._account.webhook_host}{self._account.webhook_path}" try: existing = list(self._api.webhooks.list()) except Exception: return my_webhooks = [w for w in existing if w.targetUrl == target_url] if my_webhooks: for w in my_webhooks: if w.status != "active": try: self._api.webhooks.update(w.id, status="active") except Exception: pass self._register_additional_webhooks(existing, target_url) return try: self._api.webhooks.create( name="ForcePilot Webex Webhook", targetUrl=target_url, resource="messages", event="created", secret=self._account.webhook_secret, ) except Exception: pass self._register_additional_webhooks([], target_url) def _register_additional_webhooks(self, existing: list, target_url: str): for resource, event, name_suffix in [ ("attachmentActions", "created", "Attachment Actions"), ("messages", "deleted", "Message Deleted"), ("memberships", "created", "Membership Created"), ("memberships", "deleted", "Membership Deleted"), ("rooms", "created", "Room Created"), ("rooms", "updated", "Room Updated"), ]: try: already = [w for w in existing if w.resource == resource and w.event == event] if not already: self._api.webhooks.create( name=f"ForcePilot Webex {name_suffix}", targetUrl=target_url, resource=resource, event=event, secret=self._account.webhook_secret, ) logger.info("Registered webhook: %s:%s", resource, event) except Exception: logger.debug("Failed to register webhook %s:%s", resource, event) def _verify_signature(self, body: bytes, x_spark_signature: str) -> bool: if not self._account.webhook_secret: return True computed = hmac.new( self._account.webhook_secret.encode(), body, hashlib.sha1, ).hexdigest() return hmac.compare_digest(computed, x_spark_signature) async def _handle_webhook(self, payload: dict) -> dict: resource = payload.get("resource", "") event_type = payload.get("event", "") if resource == "attachmentActions" and event_type == "created": return await self._handle_attachment_action(payload) if resource == "rooms": if event_type == "created": return await self._handle_room_created(payload) if event_type == "updated": return await self._handle_room_updated(payload) return {"status": "ignored", "reason": "event_not_relevant"} if resource == "memberships": if event_type == "created": return await self._handle_membership_created(payload) if event_type == "deleted": return await self._handle_membership_deleted(payload) return {"status": "ignored", "reason": "event_not_relevant"} if resource == "messages" and event_type == "deleted": return await self._handle_message_deleted(payload) if resource != "messages" or event_type != "created": return {"status": "ignored", "reason": "event_not_relevant"} data = payload.get("data", {}) msg_id = data.get("id", "") person_id = data.get("personId", "") if person_id == self._bot_person_id: return {"status": "ignored", "reason": "echo"} if self._dedupe and self._dedupe.is_duplicate(msg_id): return {"status": "ignored", "reason": "duplicate"} webex_msg = WebexMessage( id=data.get("id", ""), room_id=data.get("roomId", ""), room_type=data.get("roomType", ""), person_id=data.get("personId", ""), person_email=data.get("personEmail", ""), text=data.get("text", ""), markdown=data.get("markdown", ""), html=data.get("html", ""), files=data.get("files", []), mentions=data.get("mentions", []), parent_id=data.get("parentId"), created=data.get("created", ""), raw=payload, ) unified_msg = payload_to_unified_message(webex_msg, self._bot_person_id, self._account.account_id) if unified_msg and self._ctx.queue: self._ctx.queue.put_nowait(unified_msg) return {"status": "ok"} async def _handle_attachment_action(self, payload: dict) -> dict: data = payload.get("data", {}) action_id = data.get("id", "") person_id = data.get("personId", "") if person_id == self._bot_person_id: return {"status": "ignored", "reason": "echo"} if self._dedupe and self._dedupe.is_duplicate(f"action:{action_id}"): return {"status": "ignored", "reason": "duplicate"} inputs = data.get("inputs", {}) room_id = data.get("roomId", "") message_id = data.get("messageId", "") action_summary = ", ".join(f"{k}={v}" for k, v in inputs.items()) if inputs else "(no inputs)" content = f"[card_action:submit] inputs: {{{action_summary}}}" unified_msg = UnifiedMessage( msg_id=f"webex:action:{action_id}", channel_type="webex", account_id=self._account.account_id, content=content, sender=PeerInfo( kind=PeerKind.GROUP if data.get("roomType") != "direct" else PeerKind.DIRECT, id=person_id, display_name=data.get("personEmail", ""), ), message_type=MessageType.EVENT, metadata={ "InteractionType": "attachment_action", "ActionType": data.get("type", "submit"), "Inputs": inputs, "room_id": room_id, "message_id": message_id, "debounce_key": f"webex:action:{person_id}:{room_id}", }, raw_payload=payload, ) if self._ctx.queue: self._ctx.queue.put_nowait(unified_msg) return {"status": "ok", "action": "attachment_action"} async def _handle_membership_created(self, payload: dict) -> dict: data = payload.get("data", {}) person_id = data.get("personId", "") room_id = data.get("roomId", "") if person_id != self._bot_person_id: return {"status": "ignored", "reason": "not_self"} logger.info("Bot added to space: %s", room_id) return {"status": "ok", "action": "membership_created", "room_id": room_id} async def _handle_membership_deleted(self, payload: dict) -> dict: data = payload.get("data", {}) person_id = data.get("personId", "") room_id = data.get("roomId", "") if person_id != self._bot_person_id: return {"status": "ignored", "reason": "not_self"} logger.info("Bot removed from space: %s", room_id) return {"status": "ok", "action": "membership_deleted", "room_id": room_id} async def _handle_message_deleted(self, payload: dict) -> dict: data = payload.get("data", {}) msg_id = data.get("id", "") logger.info("Message deleted in Webex: %s", msg_id) return {"status": "ok", "action": "message_deleted", "message_id": msg_id} async def _handle_room_created(self, payload: dict) -> dict: data = payload.get("data", {}) room_id = data.get("id", "") room_title = data.get("title", "") logger.info("Room created in Webex: %s (%s)", room_title, room_id) return {"status": "ok", "action": "room_created", "room_id": room_id} async def _handle_room_updated(self, payload: dict) -> dict: data = payload.get("data", {}) room_id = data.get("id", "") room_title = data.get("title", "") logger.info("Room updated in Webex: %s (%s)", room_title, room_id) return {"status": "ok", "action": "room_updated", "room_id": room_id}