import asyncio import logging import random from yuxi.channel.extensions.freshdesk.constants import ( FRESHDESK_RETRY_BASE, FRESHDESK_RETRY_MAX, FRESHDESK_RETRY_MAX_DELAY, ) from yuxi.channel.extensions.freshdesk.errors import FreshdeskError, FreshdeskErrorCode from yuxi.channel.extensions.freshdesk.format import markdown_to_html, sanitize_html logger = logging.getLogger(__name__) class FreshdeskOutbound: delivery_mode = "direct" chunker_mode = "length" text_chunk_limit = None 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): self._gateway = gateway 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, account = self._resolve(account_id) source = self._detect_target_source(target_id) if source == "freshchat": await self._retry_with_backoff( lambda: client.fc_send_message( conversation_id=target_id, message_parts=[{"text": {"content": content}}], message_type="normal", actor_type="agent", actor_id=account.agent_id or None, ) ) elif source == "freshdesk": html_body = markdown_to_html(content) html_body = sanitize_html(html_body) await self._retry_with_backoff( lambda: client.fd_reply_ticket( ticket_id=target_id, body_html=html_body, private=False, ) ) 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 = self._resolve(account_id) source = self._detect_target_source(target_id) if source == "freshchat": if media_type.startswith("image/"): part = {"image": {"url": media_url, "content_type": media_type}} else: part = {"file": {"url": media_url, "content_type": media_type}} await self._retry_with_backoff( lambda: client.fc_send_message( conversation_id=target_id, message_parts=[part], message_type="normal", actor_type="agent", ) ) elif source == "freshdesk": if media_type.startswith("image/"): html_body = f'

attachment

' else: html_body = f'

附件

' html_body = sanitize_html(html_body) await self._retry_with_backoff( lambda: client.fd_reply_ticket( ticket_id=target_id, body_html=html_body, private=False, ) ) async def send_quick_replies( self, target_id: str, text: str, options: list[dict], *, account_id: str | None = None, ) -> None: client, account = self._resolve(account_id) parts = [{"text": {"content": text}}] quick_replies_data = [ {"title": opt["title"], "value": opt.get("value", opt["title"])} for opt in options ] parts.append({"quick_replies": {"quick_replies": quick_replies_data}}) await self._retry_with_backoff( lambda: client.fc_send_message( conversation_id=target_id, message_parts=parts, message_type="normal", actor_type="agent", actor_id=account.agent_id or None, ) ) async def send_note( self, target_id: str, content: str, *, account_id: str | None = None, ) -> None: client, account = self._resolve(account_id) source = self._detect_target_source(target_id) if source == "freshchat": note_part = {"private_note": {"content": content}} await self._retry_with_backoff( lambda: client.fc_send_message( conversation_id=target_id, message_parts=[note_part], message_type="private", actor_type="agent", actor_id=account.agent_id or None, ) ) elif source == "freshdesk": html_body = sanitize_html(markdown_to_html(content)) await self._retry_with_backoff( lambda: client.fd_reply_ticket( ticket_id=target_id, body_html=html_body, private=True, ) ) async def handoff_conversation( self, target_id: str, *, group_id: str | None = None, note: str | None = None, account_id: str | None = None, ) -> None: client, account = self._resolve(account_id) source = self._detect_target_source(target_id) if source == "freshchat": await client.fc_assign_conversation( target_id, group_id=group_id, ) if note: await self.send_note( target_id, f"[AI 转接] {note}", account_id=account_id, ) elif source == "freshdesk": update_data: dict = {"status": 3} if group_id and group_id.isdigit(): update_data["group_id"] = int(group_id) await client.fd_update_ticket(target_id, update_data) @staticmethod def _detect_target_source(target_id: str) -> str: if target_id.isdigit(): return "freshdesk" return "freshchat" def _resolve(self, account_id: str | None): if self._gateway is None or not self._gateway.accounts: raise FreshdeskError(FreshdeskErrorCode.CONFIG_ERROR, "Gateway not initialized") if account_id: entry = self._gateway.accounts.get(account_id) else: entry = next(iter(self._gateway.accounts.values()), None) if entry is None: raise FreshdeskError(FreshdeskErrorCode.CONFIG_ERROR, f"Account {account_id or 'default'} not found") return entry["client"], entry["account"] async def _retry_with_backoff(self, operation, max_attempts: int | None = None): max_attempts = max_attempts or FRESHDESK_RETRY_MAX for attempt in range(max_attempts): try: return await operation() except FreshdeskError as e: if e.code == FreshdeskErrorCode.RATE_LIMITED and attempt < max_attempts - 1: delay = e.retry_after or (FRESHDESK_RETRY_BASE * (2 ** attempt)) logger.warning("Freshdesk rate limited, waiting %.1fs", delay) await asyncio.sleep(delay) continue if e.code in (FreshdeskErrorCode.AUTH_ERROR, FreshdeskErrorCode.NOT_FOUND): raise if attempt < max_attempts - 1: delay = min( FRESHDESK_RETRY_BASE * (2 ** attempt) + random.uniform(0, 1), FRESHDESK_RETRY_MAX_DELAY, ) logger.warning("Freshdesk API error, retrying in %.1fs", delay) await asyncio.sleep(delay) continue raise 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