import logging import httpx from yuxi.channel.extensions.intercom.constants import ( DEFAULT_DATACENTER, INTERCOM_API_BASES, INTERCOM_API_VERSION, INTERCOM_DEFAULT_TIMEOUT, ) from yuxi.channel.extensions.intercom.errors import IntercomError, IntercomErrorCode logger = logging.getLogger(__name__) class IntercomClient: def __init__( self, access_token: str, *, datacenter: str = DEFAULT_DATACENTER, timeout: float = INTERCOM_DEFAULT_TIMEOUT, ): self._access_token = access_token base_url = INTERCOM_API_BASES.get(datacenter, INTERCOM_API_BASES[DEFAULT_DATACENTER]) self._client = httpx.AsyncClient( base_url=base_url, headers={ "Authorization": f"Bearer {access_token}", "Accept": "application/json", "Intercom-Version": INTERCOM_API_VERSION, }, timeout=httpx.Timeout(timeout), ) async def get_me(self) -> dict: resp = await self._request("GET", "/me") return resp async def reply_conversation( self, conversation_id: str, body: str, *, message_type: str = "comment", reply_type: str = "admin", admin_id: str | None = None, attachment_urls: list[str] | None = None, reply_options: dict | None = None, ) -> dict: payload: dict = { "message_type": message_type, "type": reply_type, "body": body, } if admin_id: payload["admin_id"] = admin_id if attachment_urls: payload["attachment_urls"] = attachment_urls if reply_options: payload["reply_options"] = reply_options resp = await self._request( "POST", f"/conversations/{conversation_id}/reply", json=payload, ) return resp async def get_conversation(self, conversation_id: str) -> dict: resp = await self._request("GET", f"/conversations/{conversation_id}") return resp async def get_conversation_parts(self, conversation_id: str) -> list[dict]: resp = await self._request( "GET", f"/conversations/{conversation_id}", params={"display_as": "plaintext"}, ) return resp.get("conversation_parts", {}).get("conversation_parts", []) async def manage_conversation( self, conversation_id: str, action: str, admin_id: str | None = None, ) -> dict: payload: dict = {"type": "admin"} if admin_id: payload["admin_id"] = admin_id resp = await self._request( "POST", f"/conversations/{conversation_id}/manage", json=payload, params={"action": action}, ) return resp async def create_conversation( self, contact_id: str, body: str, *, admin_id: str | None = None, message_type: str = "comment", attachment_urls: list[str] | None = None, ) -> dict: payload: dict = { "from": {"type": "contact", "id": contact_id}, "body": body, } if admin_id: payload["admin_id"] = admin_id if message_type: payload["message_type"] = message_type if attachment_urls: payload["attachment_urls"] = attachment_urls resp = await self._request("POST", "/conversations", json=payload) return resp async def search_conversations( self, query: dict, *, page: int = 1, per_page: int = 20, ) -> dict: payload = { "query": query, "pagination": {"page": page, "per_page": min(per_page, 150)}, } resp = await self._request("POST", "/conversations/search", json=payload) return resp async def get_contact(self, contact_id: str) -> dict: resp = await self._request("GET", f"/contacts/{contact_id}") return resp async def create_contact(self, email: str, *, name: str | None = None, **attrs) -> dict: payload: dict = {"email": email} if name: payload["name"] = name payload.update(attrs) resp = await self._request("POST", "/contacts", json=payload) return resp async def update_contact(self, contact_id: str, **attrs) -> dict: resp = await self._request("PUT", f"/contacts/{contact_id}", json=attrs) return resp async def delete_contact(self, contact_id: str) -> dict: resp = await self._request("DELETE", f"/contacts/{contact_id}") return resp async def list_contacts(self, *, page: int = 1, per_page: int = 20, email: str | None = None) -> dict: params: dict = {"page": page, "per_page": min(per_page, 150)} if email: params["email"] = email resp = await self._request("GET", "/contacts", params=params) return resp async def search_contacts( self, query: dict, *, page: int = 1, per_page: int = 20, ) -> dict: payload = { "query": query, "pagination": {"page": page, "per_page": min(per_page, 150)}, } resp = await self._request("POST", "/contacts/search", json=payload) return resp async def archive_contact(self, contact_id: str) -> dict: resp = await self._request("POST", f"/contacts/{contact_id}/archive") return resp async def add_conversation_tags(self, conversation_id: str, tags: list[str]) -> dict: payload = {"tags": [{"id": tag} for tag in tags]} resp = await self._request( "POST", f"/conversations/{conversation_id}/tags", json=payload, ) return resp async def remove_conversation_tag(self, conversation_id: str, tag_id: str) -> dict: resp = await self._request( "DELETE", f"/conversations/{conversation_id}/tags/{tag_id}", ) return resp async def list_tags(self) -> list[dict]: resp = await self._request("GET", "/tags") return resp.get("data", []) async def update_conversation(self, conversation_id: str, **kwargs) -> dict: resp = await self._request( "PUT", f"/conversations/{conversation_id}", json=kwargs, ) return resp async def snooze_conversation(self, conversation_id: str, snooze_until: int) -> dict: return await self.update_conversation(conversation_id, snoozed_until=snooze_until) async def assign_conversation(self, conversation_id: str, admin_id: str) -> dict: return await self.update_conversation(conversation_id, assignee_id=admin_id, assignee_type="admin") async def delete_conversation_part(self, conversation_id: str, part_id: str) -> dict: resp = await self._request( "DELETE", f"/conversations/{conversation_id}/parts/{part_id}", ) return resp async def create_ticket( self, subject: str, message_body: str, contact_id: str, *, admin_id: str | None = None, ticket_type_id: str | None = None, ticket_attributes: dict | None = None, ) -> dict: payload: dict = { "contacts": [{"id": contact_id}], "subject": subject, "ticket_type": {"body": message_body}, } if admin_id: payload["admin_assignee_id"] = admin_id if ticket_type_id: payload["ticket_type_id"] = ticket_type_id if ticket_attributes: payload["ticket_attributes"] = ticket_attributes resp = await self._request("POST", "/tickets", json=payload) return resp async def reply_ticket( self, ticket_id: str, body: str, *, admin_id: str | None = None, attachment_urls: list[str] | None = None, ) -> dict: payload: dict = { "type": "admin", "body": body, } if admin_id: payload["admin_id"] = admin_id if attachment_urls: payload["attachment_urls"] = attachment_urls resp = await self._request("POST", f"/tickets/{ticket_id}/reply", json=payload) return resp async def get_ticket(self, ticket_id: str) -> dict: resp = await self._request("GET", f"/tickets/{ticket_id}") return resp async def update_ticket(self, ticket_id: str, **kwargs) -> dict: resp = await self._request("PUT", f"/tickets/{ticket_id}", json=kwargs) return resp async def get_company(self, company_id: str) -> dict: resp = await self._request("GET", f"/companies/{company_id}") return resp async def create_company(self, name: str, *, company_id: str | None = None, **attrs) -> dict: payload: dict = {"name": name} if company_id: payload["company_id"] = company_id payload.update(attrs) resp = await self._request("POST", "/companies", json=payload) return resp async def update_company(self, company_id: str, **attrs) -> dict: resp = await self._request("PUT", f"/companies/{company_id}", json=attrs) return resp async def list_companies(self, *, page: int = 1, per_page: int = 20) -> dict: resp = await self._request( "GET", "/companies", params={"page": page, "per_page": min(per_page, 150)}, ) return resp async def search_companies( self, query: dict, *, page: int = 1, per_page: int = 20, ) -> dict: payload = { "query": query, "pagination": {"page": page, "per_page": min(per_page, 150)}, } resp = await self._request("POST", "/companies/search", json=payload) return resp async def attach_contact_to_company(self, contact_id: str, company_id: str) -> dict: resp = await self._request( "POST", f"/contacts/{contact_id}/companies", json={"id": company_id}, ) return resp async def detach_contact_from_company(self, contact_id: str, company_id: str) -> dict: resp = await self._request( "DELETE", f"/contacts/{contact_id}/companies/{company_id}", ) return resp async def list_articles(self, *, page: int = 1, per_page: int = 20, state: str = "published") -> dict: resp = await self._request( "GET", "/articles", params={"page": page, "per_page": per_page, "state": state}, ) return resp async def get_article(self, article_id: str) -> dict: resp = await self._request("GET", f"/articles/{article_id}") return resp async def list_brands(self) -> list[dict]: resp = await self._request("GET", "/brands") return resp.get("data", []) async def get_brand(self, brand_id: str) -> dict: resp = await self._request("GET", f"/brands/{brand_id}") return resp async def list_conversations(self, *, page: int = 1, per_page: int = 20) -> dict: resp = await self._request( "GET", "/conversations", params={"page": page, "per_page": min(per_page, 150)}, ) return resp async def delete_conversation(self, conversation_id: str) -> dict: resp = await self._request("DELETE", f"/conversations/{conversation_id}") return resp async def redact_conversation_part(self, conversation_id: str, part_id: str) -> dict: resp = await self._request( "POST", f"/conversations/{conversation_id}/parts/{part_id}/redact", ) return resp async def convert_conversation_to_ticket(self, conversation_id: str, *, admin_id: str | None = None) -> dict: payload: dict = {} if admin_id: payload["admin_id"] = admin_id resp = await self._request( "POST", f"/conversations/{conversation_id}/convert-to-ticket", json=payload, ) return resp async def run_assignment_rules(self, conversation_id: str) -> dict: resp = await self._request( "POST", f"/conversations/{conversation_id}/run_assignment_rules", ) return resp async def attach_contact_to_conversation(self, conversation_id: str, contact_id: str) -> dict: resp = await self._request( "POST", f"/conversations/{conversation_id}/contacts", json={"id": contact_id}, ) return resp async def detach_contact_from_conversation(self, conversation_id: str, contact_id: str) -> dict: resp = await self._request( "DELETE", f"/conversations/{conversation_id}/contacts/{contact_id}", ) return resp async def list_tickets(self, *, page: int = 1, per_page: int = 20) -> dict: resp = await self._request( "GET", "/tickets", params={"page": page, "per_page": min(per_page, 150)}, ) return resp async def search_tickets( self, query: dict, *, page: int = 1, per_page: int = 20, ) -> dict: payload = { "query": query, "pagination": {"page": page, "per_page": min(per_page, 150)}, } resp = await self._request("POST", "/tickets/search", json=payload) return resp async def merge_contacts(self, primary_id: str, secondary_id: str) -> dict: resp = await self._request( "POST", "/contacts/merge", json={ "primary_contact_id": primary_id, "secondary_contact_id": secondary_id, }, ) return resp async def add_contact_tags(self, contact_id: str, tags: list[str]) -> dict: payload = {"tags": [{"id": tag} for tag in tags]} resp = await self._request( "POST", f"/contacts/{contact_id}/tags", json=payload, ) return resp async def remove_contact_tag(self, contact_id: str, tag_id: str) -> dict: resp = await self._request( "DELETE", f"/contacts/{contact_id}/tags/{tag_id}", ) return resp async def get_contact_subscriptions(self, contact_id: str) -> dict: resp = await self._request("GET", f"/contacts/{contact_id}/subscriptions") return resp async def update_contact_subscriptions(self, contact_id: str, subscriptions: list[dict]) -> dict: resp = await self._request( "PUT", f"/contacts/{contact_id}/subscriptions", json={"subscriptions": subscriptions}, ) return resp async def send_email( self, to: list[str], subject: str, body_html: str, *, cc: list[str] | None = None, bcc: list[str] | None = None, ) -> dict: payload: dict = { "to": to, "subject": subject, "body": body_html, } if cc: payload["cc"] = cc if bcc: payload["bcc"] = bcc resp = await self._request("POST", "/emails", json=payload) return resp async def send_message( self, contact_id: str, body: str, *, admin_id: str | None = None, ) -> dict: payload: dict = { "from": {"type": "admin", "id": admin_id} if admin_id else {"type": "admin"}, "to": {"type": "contact", "id": contact_id}, "body": body, } resp = await self._request("POST", "/messages", json=payload) return resp async def close(self) -> None: await self._client.aclose() async def _paginated_request(self, method: str, path: str, *, params: dict | None = None, **kwargs) -> list[dict]: params = params or {} results: list[dict] = [] while True: resp = await self._request(method, path, params=params, **kwargs) data = resp.get("data", []) results.extend(data) pages_info = resp.get("pages", {}) next_cursor = pages_info.get("next", {}).get("starting_after") if not next_cursor: break params["starting_after"] = next_cursor return results async def _request(self, method: str, path: str, **kwargs) -> dict: try: resp = await self._client.request(method, path, **kwargs) except httpx.TimeoutException as e: raise IntercomError( IntercomErrorCode.NETWORK_ERROR, f"Request timeout: {method} {path}", retryable=True, ) from e except httpx.ConnectError as e: raise IntercomError( IntercomErrorCode.NETWORK_ERROR, f"Connection error: {method} {path}", retryable=True, ) from e except httpx.HTTPError as e: raise IntercomError( IntercomErrorCode.NETWORK_ERROR, f"HTTP error: {method} {path}: {e}", retryable=True, ) from e if resp.status_code == 401: raise IntercomError( IntercomErrorCode.AUTH_ERROR, f"Unauthorized: {resp.text}", retryable=False, ) if resp.status_code == 403: raise IntercomError( IntercomErrorCode.AUTH_ERROR, f"Forbidden: {resp.text}", retryable=False, ) if resp.status_code == 404: raise IntercomError( IntercomErrorCode.NOT_FOUND, f"Not found: {method} {path}", retryable=False, ) if resp.status_code == 429: retry_after = resp.headers.get("Retry-After") raise IntercomError( IntercomErrorCode.RATE_LIMITED, f"Rate limited (Retry-After: {retry_after or 'N/A'}): {resp.text}", retryable=True, ) if resp.status_code >= 500: raise IntercomError( IntercomErrorCode.NETWORK_ERROR, f"Server error {resp.status_code}: {resp.text}", retryable=True, ) if resp.status_code >= 400: raise IntercomError( IntercomErrorCode.UNKNOWN, f"Client error {resp.status_code}: {resp.text}", retryable=False, ) remaining = resp.headers.get("X-RateLimit-Remaining") limit = resp.headers.get("X-RateLimit-Limit") if remaining is not None and int(remaining) < 10: logger.warning("Intercom rate limit low: %s/%s", remaining, limit) try: return resp.json() except Exception: return {}