from __future__ import annotations import asyncio import logging import time import httpx from yuxi.channel.extensions.helpscout.auth import HelpScoutAuth logger = logging.getLogger(__name__) BASE_URL = "https://api.helpscout.net/v2" DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0) KEEPALIVE_LIMITS = httpx.Limits( max_keepalive_connections=5, max_connections=10, keepalive_expiry=30.0, ) class RateLimitTracker: def __init__(self): self._remaining = 200 self._limit = 200 self._last_updated = 0.0 def update(self, headers: httpx.Headers) -> None: remaining = headers.get("X-RateLimit-Remaining-Minutely") limit = headers.get("X-RateLimit-Limit-Minutely") if remaining is not None: self._remaining = int(remaining) if limit is not None: self._limit = int(limit) self._last_updated = time.monotonic() @property def usage_pct(self) -> float: if self._limit == 0: return 0.0 return (self._limit - self._remaining) / self._limit * 100 def should_throttle(self) -> bool: return self._remaining < self._limit * 0.2 class HelpScoutClient: def __init__(self, auth: HelpScoutAuth): self._auth = auth self._client: httpx.AsyncClient | None = None self._rate_limiter = RateLimitTracker() async def _get_client(self) -> httpx.AsyncClient: if self._client is None: self._client = httpx.AsyncClient( base_url=BASE_URL, timeout=DEFAULT_TIMEOUT, limits=KEEPALIVE_LIMITS, http2=True, ) return self._client async def _request( self, method: str, path: str, *, json_data: dict | None = None, data: dict | None = None, params: dict | None = None, content: bytes | None = None, headers_extra: dict | None = None, ) -> httpx.Response: await self._auth.ensure_token() client = await self._get_client() headers = {**self._auth.auth_header} if json_data is not None: headers["Content-Type"] = "application/json" if headers_extra: headers.update(headers_extra) max_retries = 3 resp = None for attempt in range(max_retries): resp = await client.request( method, path, json=json_data, data=data, params=params, content=content, headers=headers, ) self._rate_limiter.update(resp.headers) if resp.status_code == 401 and attempt == 0: await self._auth.force_refresh() headers["Authorization"] = f"Bearer {self._auth.access_token}" continue if resp.status_code == 429: retry_after = int(resp.headers.get("X-RateLimit-Retry-After", "5")) logger.warning("Help Scout rate limited (429), retry after %ds", retry_after) await asyncio.sleep(retry_after) continue if self._rate_limiter.should_throttle() and attempt < max_retries - 1: logger.warning( "Help Scout rate limit low: %d remaining, throttling...", self._rate_limiter._remaining, ) await asyncio.sleep(1.0) return resp raise httpx.HTTPStatusError("Max retries exceeded", request=resp.request, response=resp) async def list_conversations( self, mailbox: int, status: str | None = None, modified_since: str | None = None, embed: str | None = None, page: int = 1, query: str | None = None, ) -> dict: params: dict = {"mailbox": mailbox, "page": page} if status: params["status"] = status if modified_since: params["modifiedSince"] = modified_since if embed: params["embed"] = embed if query: params["query"] = query resp = await self._request("GET", "/conversations", params=params) return resp.json() async def get_conversation(self, conversation_id: int, embed: str | None = None) -> dict: params = {} if embed: params["embed"] = embed resp = await self._request("GET", f"/conversations/{conversation_id}", params=params) return resp.json() async def update_conversation(self, conversation_id: int, updates: dict) -> dict: resp = await self._request("PUT", f"/conversations/{conversation_id}", json_data=updates) return resp.json() async def list_threads(self, conversation_id: int, page: int = 1) -> dict: resp = await self._request( "GET", f"/conversations/{conversation_id}/threads", params={"page": page}, ) return resp.json() async def reply_conversation( self, conversation_id: int, text: str, html: str | None = None, draft: bool = True, attachments: list[int] | None = None, customer_id: int | None = None, ) -> dict: payload: dict = { "text": text, "draft": draft, } if html: payload["html"] = html if attachments: payload["attachments"] = [int(a) for a in attachments] if customer_id: payload["customer"] = {"id": customer_id} resp = await self._request( "POST", f"/conversations/{conversation_id}/reply", json_data=payload, ) return resp.json() async def update_draft(self, conversation_id: int, text: str) -> dict: resp = await self._request( "PUT", f"/conversations/{conversation_id}/draft", json_data={"text": text}, ) return resp.json() async def create_note(self, conversation_id: int, text: str) -> dict: resp = await self._request( "POST", f"/conversations/{conversation_id}/notes", json_data={"text": text}, ) return resp.json() async def upload_attachment(self, file_content: bytes, file_name: str, mime_type: str) -> dict: await self._auth.ensure_token() client = await self._get_client() resp = await client.post( f"{BASE_URL}/attachments", files={"file": (file_name, file_content, mime_type)}, headers={**self._auth.auth_header}, ) resp.raise_for_status() return resp.json() async def download_attachment(self, attachment_id: str, conversation_id: int) -> bytes: path = f"/conversations/{conversation_id}/attachments/{attachment_id}/download" resp = await self._request("POST", path) return resp.content async def list_webhooks(self) -> dict: resp = await self._request("GET", "/webhooks") return resp.json() async def create_webhook( self, url: str, events: list[str], secret: str, mailbox_id: int, ) -> dict: payload = { "url": url, "events": events, "secret": secret, "mailboxId": mailbox_id, } resp = await self._request("POST", "/webhooks", json_data=payload) return resp.json() async def delete_webhook(self, webhook_id: str) -> None: await self._request("DELETE", f"/webhooks/{webhook_id}") async def list_tags(self, mailbox_id: int) -> list[dict]: resp = await self._request("GET", "/tags", params={"mailbox": mailbox_id}) data = resp.json() return data.get("_embedded", {}).get("tags", []) async def get_mailbox(self, mailbox_id: int) -> dict: resp = await self._request("GET", f"/mailboxes/{mailbox_id}") return resp.json() async def get_customer(self, customer_id: int) -> dict: resp = await self._request("GET", f"/customers/{customer_id}") return resp.json() async def list_customers( self, mailbox: int | None = None, email: str | None = None, first_name: str | None = None, last_name: str | None = None, page: int = 1, ) -> dict: params: dict = {"page": page} if mailbox is not None: params["mailbox"] = mailbox if email: params["email"] = email if first_name: params["firstName"] = first_name if last_name: params["lastName"] = last_name resp = await self._request("GET", "/customers", params=params) return resp.json() async def create_conversation( self, mailbox_id: int, subject: str, customer: dict, type: str = "email", threads: list[dict] | None = None, tags: list[str] | None = None, status: str = "active", assign_to: int | None = None, ) -> dict: payload: dict = { "mailboxId": mailbox_id, "type": type, "subject": subject, "customer": customer, "status": status, } if threads: payload["threads"] = threads if tags: payload["tags"] = tags if assign_to is not None: payload["assignTo"] = assign_to resp = await self._request("POST", "/conversations", json_data=payload) return resp.json() async def list_users( self, mailbox: int | None = None, status: str | None = None, page: int = 1, ) -> dict: params: dict = {"page": page} if mailbox is not None: params["mailbox"] = mailbox if status: params["status"] = status resp = await self._request("GET", "/users", params=params) return resp.json() async def list_teams(self, mailbox: int | None = None, page: int = 1) -> dict: params: dict = {"page": page} if mailbox is not None: params["mailbox"] = mailbox resp = await self._request("GET", "/teams", params=params) return resp.json() async def get_user(self, user_id: int) -> dict: resp = await self._request("GET", f"/users/{user_id}") return resp.json() async def get_team(self, team_id: int) -> dict: resp = await self._request("GET", f"/teams/{team_id}") return resp.json() async def list_team_members(self, team_id: int, page: int = 1) -> dict: resp = await self._request("GET", f"/teams/{team_id}/members", params={"page": page}) return resp.json() async def list_mailboxes(self, page: int = 1) -> dict: resp = await self._request("GET", "/mailboxes", params={"page": page}) return resp.json() async def create_customer( self, first_name: str, last_name: str, email: str, phone: str | None = None, photo_url: str | None = None, job_title: str | None = None, organization: str | None = None, ) -> dict: payload: dict = { "firstName": first_name, "lastName": last_name, "emails": [{"location": "work", "value": email}], } if phone: payload["phones"] = [{"location": "work", "value": phone}] if photo_url: payload["photoUrl"] = photo_url if job_title: payload["jobTitle"] = job_title if organization: payload["organization"] = organization resp = await self._request("POST", "/customers", json_data=payload) return resp.json() async def update_customer(self, customer_id: int, data: dict) -> dict: resp = await self._request("PUT", f"/customers/{customer_id}", json_data=data) return resp.json() async def get_webhook(self, webhook_id: int) -> dict: resp = await self._request("GET", f"/webhooks/{webhook_id}") return resp.json() async def update_webhook(self, webhook_id: int, data: dict) -> dict: resp = await self._request("PUT", f"/webhooks/{webhook_id}", json_data=data) return resp.json() async def list_workflows(self, mailbox_id: int, page: int = 1) -> dict: resp = await self._request("GET", "/workflows", params={"mailboxId": mailbox_id, "page": page}) return resp.json() async def run_workflow(self, workflow_id: int) -> dict: resp = await self._request("POST", f"/workflows/{workflow_id}/run") return resp.json() async def get_workflow(self, workflow_id: int) -> dict: resp = await self._request("GET", f"/workflows/{workflow_id}") return resp.json() async def update_workflow(self, workflow_id: int, data: dict) -> dict: resp = await self._request("PUT", f"/workflows/{workflow_id}", json_data=data) return resp.json() async def get_conversations_report( self, start: str, end: str, mailbox: int | None = None, user: int | None = None, tag: str | None = None, ) -> dict: params: dict = {"start": start, "end": end} if mailbox is not None: params["mailboxes"] = mailbox if user is not None: params["user"] = user if tag: params["tags"] = tag resp = await self._request("GET", "/reports/conversations", params=params) return resp.json() async def get_satisfaction_report( self, start: str, end: str, mailbox: int | None = None, user: int | None = None, ) -> dict: params: dict = {"start": start, "end": end} if mailbox is not None: params["mailboxes"] = mailbox if user is not None: params["user"] = user resp = await self._request("GET", "/reports/happiness", params=params) return resp.json() async def close(self) -> None: if self._client: await self._client.aclose() self._client = None