import asyncio import logging import random from yuxi.channel.extensions.intercom.client import IntercomClient from yuxi.channel.extensions.intercom.config import IntercomConfigAdapter from yuxi.channel.extensions.intercom.constants import ( INTERCOM_MAX_RETRIES, INTERCOM_RETRY_BASE_DELAY, INTERCOM_RETRY_MAX_DELAY, INTERCOM_TEXT_CHUNK_LIMIT, ) from yuxi.channel.extensions.intercom.errors import IntercomError, IntercomErrorCode logger = logging.getLogger(__name__) class IntercomOutbound: delivery_mode = "direct" chunker_mode = "length" text_chunk_limit = INTERCOM_TEXT_CHUNK_LIMIT poll_max_options = None supports_poll_duration_seconds = False supports_anonymous_polls = False extract_markdown_images = False presentation_capabilities = None delivery_capabilities = None def __init__(self, gateway=None): self._gateway = gateway def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: if not text: return [] if len(text) <= limit: return [text] chunks: list[str] = [] paragraphs = text.split("\n\n") for para in paragraphs: if not para: continue if len(para) <= limit: chunks.append(para) else: sentences = para.split("\n") for sentence in sentences: if len(sentence) <= limit: chunks.append(sentence) else: while sentence: chunks.append(sentence[:limit]) sentence = sentence[limit:] merged: list[str] = [] for chunk in chunks: if not merged: merged.append(chunk) continue last = merged[-1] candidate = last + "\n\n" + chunk if len(candidate) <= limit: merged[-1] = candidate else: merged.append(chunk) return merged async def _get_client(self, account_id: str | None) -> tuple[IntercomClient, dict]: if self._gateway: entry = self._gateway.get_account(account_id or "default") if entry and entry.get("client"): return entry["client"], entry["account"] adapter = IntercomConfigAdapter() config = {} account = await adapter.resolve_account(account_id or "default", config) client = IntercomClient(account["access_token"], datacenter=account.get("datacenter", "us")) return client, account def _owns_client(self, client: IntercomClient, account_id: str | None) -> bool: if not self._gateway: return True entry = self._gateway.get_account(account_id or "default") return not (entry and entry.get("client") is client) async def _retry_with_backoff(self, operation, max_attempts: int | None = None): max_attempts = max_attempts or INTERCOM_MAX_RETRIES for attempt in range(max_attempts): try: return await operation() except IntercomError as e: if e.code == IntercomErrorCode.RATE_LIMITED and attempt < max_attempts - 1: delay = random.uniform(1, 4) * (2**attempt) logger.warning("Intercom rate limited, waiting %.1fs", delay) await asyncio.sleep(delay) continue if e.code in (IntercomErrorCode.AUTH_ERROR, IntercomErrorCode.NOT_FOUND): raise if attempt < max_attempts - 1: delay = min( INTERCOM_RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1), INTERCOM_RETRY_MAX_DELAY, ) logger.warning("Intercom API error, retrying in %.1fs", delay) await asyncio.sleep(delay) continue raise 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: from yuxi.channel.extensions.intercom.format import markdown_to_html html_body = markdown_to_html(content) client, account = await self._get_client(account_id) owns_client = self._owns_client(client, account_id) try: await self._retry_with_backoff( lambda: client.reply_conversation( conversation_id=target_id, body=html_body, message_type="comment", reply_type="admin", ) ) finally: if owns_client: await client.close() 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, account = await self._get_client(account_id) owns_client = self._owns_client(client, account_id) attachment_urls = [media_url] if media_url else [] try: await self._retry_with_backoff( lambda: client.reply_conversation( conversation_id=target_id, body="", message_type="comment", reply_type="admin", attachment_urls=attachment_urls, ) ) finally: if owns_client: await client.close() async def send_note( self, target_id: str, content: str, *, account_id: str | None = None, ) -> None: from yuxi.channel.extensions.intercom.format import markdown_to_html html_body = markdown_to_html(content) client, account = await self._get_client(account_id) owns_client = self._owns_client(client, account_id) try: await self._retry_with_backoff( lambda: client.reply_conversation( conversation_id=target_id, body=html_body, message_type="note", reply_type="admin", ) ) finally: if owns_client: await client.close() async def send_quick_replies( self, target_id: str, content: str, options: list[dict], *, account_id: str | None = None, ) -> None: from yuxi.channel.extensions.intercom.format import markdown_to_html html_body = markdown_to_html(content) client, account = await self._get_client(account_id) owns_client = self._owns_client(client, account_id) try: reply_options = { "type": "quick_replies", "quick_reply_type": options[0].get("type", "button") if options else "button", "quick_replies": [ {"label": opt["label"], "value": opt.get("value", opt["label"])} for opt in options[:10] ], } await self._retry_with_backoff( lambda: client.reply_conversation( conversation_id=target_id, body=html_body, message_type="comment", reply_type="admin", reply_options=reply_options, ) ) finally: if owns_client: await client.close() async def add_tags( self, target_id: str, tags: list[str], *, account_id: str | None = None, ) -> None: client, account = await self._get_client(account_id) owns_client = self._owns_client(client, account_id) try: await self._retry_with_backoff(lambda: client.add_conversation_tags(target_id, tags)) finally: if owns_client: await client.close() async def remove_tags( self, target_id: str, tag_ids: list[str], *, account_id: str | None = None, ) -> None: client, account = await self._get_client(account_id) owns_client = self._owns_client(client, account_id) try: for tag_id in tag_ids: await self._retry_with_backoff(lambda tid=tag_id: client.remove_conversation_tag(target_id, tid)) finally: if owns_client: await client.close() async def close_conversation( self, target_id: str, *, account_id: str | None = None, ) -> bool: client, account = await self._get_client(account_id) owns_client = self._owns_client(client, account_id) try: await self._retry_with_backoff( lambda: client.manage_conversation(target_id, "close", admin_id=account.get("admin_id")) ) return True except Exception: logger.exception("Failed to close conversation %s", target_id) return False finally: if owns_client: await client.close() async def open_conversation( self, target_id: str, *, account_id: str | None = None, ) -> bool: client, account = await self._get_client(account_id) owns_client = self._owns_client(client, account_id) try: await self._retry_with_backoff( lambda: client.manage_conversation(target_id, "open", admin_id=account.get("admin_id")) ) return True except Exception: logger.exception("Failed to open conversation %s", target_id) return False finally: if owns_client: await client.close() async def create_conversation( self, contact_id: str, body: str, *, account_id: str | None = None, ) -> dict | None: from yuxi.channel.extensions.intercom.format import markdown_to_html html_body = markdown_to_html(body) client, account = await self._get_client(account_id) owns_client = self._owns_client(client, account_id) try: result = await self._retry_with_backoff( lambda: client.create_conversation( contact_id=contact_id, body=html_body, admin_id=account.get("admin_id"), ) ) return result except Exception: logger.exception("Failed to create conversation for contact %s", contact_id) return None finally: if owns_client: await client.close() async def convert_to_ticket( self, conversation_id: str, *, account_id: str | None = None, ) -> dict | None: client, account = await self._get_client(account_id) owns_client = self._owns_client(client, account_id) try: result = await self._retry_with_backoff( lambda: client.convert_conversation_to_ticket( conversation_id, admin_id=account.get("admin_id"), ) ) return result except Exception: logger.exception("Failed to convert conversation %s to ticket", conversation_id) return None finally: if owns_client: await client.close() async def send_payload(self, ctx: object) -> object: return None async def send_poll(self, ctx: object) -> object: return None def sanitize_text(self, text: str, payload: object) -> str: return text def should_skip_plain_text_sanitization(self, payload: object) -> bool: return False def normalize_payload(self, payload, config, account_id=None): return payload def resolve_effective_text_chunk_limit(self, config, account_id=None, fallback_limit=None): return fallback_limit or INTERCOM_TEXT_CHUNK_LIMIT