from base64 import b64encode import httpx from yuxi.channel.extensions.freshdesk.constants import DEFAULT_TIMEOUT from yuxi.channel.extensions.freshdesk.errors import FreshdeskError, FreshdeskErrorCode FRESHCHAT_API_BASE_TEMPLATE = "https://{domain}.freshchat.com/v2" FRESHDESK_API_BASE_TEMPLATE = "https://{domain}.freshdesk.com/api/v2" class FreshdeskClient: def __init__( self, freshdesk_domain: str, freshdesk_api_key: str, freshchat_domain: str | None = None, freshchat_api_key: str | None = None, timeout: float = DEFAULT_TIMEOUT, ): self._freshdesk_domain = freshdesk_domain self._freshchat_domain = freshchat_domain or freshdesk_domain self._freshchat_api_key = freshchat_api_key or freshdesk_api_key freshdesk_auth = b64encode(f"{freshdesk_api_key}:X".encode()).decode() self._fd_headers = { "Authorization": f"Basic {freshdesk_auth}", "Content-Type": "application/json", } self._fc_headers = { "Authorization": f"Bearer {self._freshchat_api_key}", "Content-Type": "application/json", } self._fd_client = httpx.AsyncClient( base_url=FRESHDESK_API_BASE_TEMPLATE.format(domain=freshdesk_domain), headers=self._fd_headers, timeout=httpx.Timeout(timeout), ) self._fc_client = httpx.AsyncClient( base_url=FRESHCHAT_API_BASE_TEMPLATE.format(domain=self._freshchat_domain), headers=self._fc_headers, timeout=httpx.Timeout(timeout), ) def _with_retry(self, resp): if resp.status_code == 429: retry_after = None try: retry_after = float(resp.headers.get("Retry-After", "5")) except (TypeError, ValueError): retry_after = 5.0 raise FreshdeskError(FreshdeskErrorCode.RATE_LIMITED, "Rate limited", retry_after=retry_after) if resp.status_code == 401: raise FreshdeskError(FreshdeskErrorCode.AUTH_ERROR, f"Auth failed: {resp.text}") if resp.status_code == 404: raise FreshdeskError(FreshdeskErrorCode.NOT_FOUND, f"Not found: {resp.text}") resp.raise_for_status() async def fc_get_me(self) -> dict: resp = await self._fc_client.get("/accounts/configuration") self._with_retry(resp) return resp.json() async def fc_list_conversations(self, **params) -> dict: resp = await self._fc_client.get("/conversations", params=params) self._with_retry(resp) return resp.json() async def fc_get_conversation(self, conversation_id: str) -> dict: resp = await self._fc_client.get(f"/conversations/{conversation_id}") self._with_retry(resp) return resp.json() async def fc_get_messages(self, conversation_id: str) -> list[dict]: resp = await self._fc_client.get(f"/conversations/{conversation_id}/messages") self._with_retry(resp) return resp.json() async def fc_send_message( self, conversation_id: str, message_parts: list[dict], *, message_type: str = "normal", actor_type: str = "agent", actor_id: str | None = None, user_id: str | None = None, ) -> dict: body: dict = { "message_parts": message_parts, "message_type": message_type, } if actor_type: body["actor_type"] = actor_type if actor_id: body["actor_id"] = actor_id if user_id: body["user_id"] = user_id resp = await self._fc_client.post( f"/conversations/{conversation_id}/messages", json=body, ) self._with_retry(resp) return resp.json() async def fc_assign_conversation( self, conversation_id: str, *, agent_id: str | None = None, group_id: str | None = None, ) -> dict: body = {} if agent_id: body["assigned_agent_id"] = agent_id if group_id: body["assigned_group_id"] = group_id resp = await self._fc_client.put( f"/conversations/{conversation_id}/assign", json=body, ) self._with_retry(resp) return resp.json() async def fc_resolve_conversation(self, conversation_id: str) -> dict: resp = await self._fc_client.put(f"/conversations/{conversation_id}/resolve") self._with_retry(resp) return resp.json() async def fc_reopen_conversation(self, conversation_id: str) -> dict: resp = await self._fc_client.put(f"/conversations/{conversation_id}/reopen") self._with_retry(resp) return resp.json() async def fc_get_user(self, user_id: str) -> dict: resp = await self._fc_client.get(f"/users/{user_id}") self._with_retry(resp) return resp.json() async def fc_list_agents(self) -> list[dict]: resp = await self._fc_client.get("/agents") self._with_retry(resp) return resp.json() async def fc_list_channels(self) -> list[dict]: resp = await self._fc_client.get("/channels") self._with_retry(resp) return resp.json() async def fd_get_me(self) -> dict: resp = await self._fd_client.get("/agents/me") self._with_retry(resp) return resp.json() async def fd_list_tickets(self, **params) -> list[dict]: resp = await self._fd_client.get("/tickets", params=params) self._with_retry(resp) return resp.json() async def fd_get_ticket(self, ticket_id: str) -> dict: resp = await self._fd_client.get(f"/tickets/{ticket_id}") self._with_retry(resp) return resp.json() async def fd_create_ticket(self, data: dict) -> dict: resp = await self._fd_client.post("/tickets", json=data) self._with_retry(resp) return resp.json() async def fd_update_ticket(self, ticket_id: str, data: dict) -> dict: resp = await self._fd_client.put(f"/tickets/{ticket_id}", json=data) self._with_retry(resp) return resp.json() async def fd_reply_ticket(self, ticket_id: str, body_html: str, *, private: bool = False) -> dict: if private: resp = await self._fd_client.post( f"/tickets/{ticket_id}/notes", json={"body": body_html, "private": True}, ) else: resp = await self._fd_client.post( f"/tickets/{ticket_id}/reply", json={"body": body_html}, ) self._with_retry(resp) return resp.json() async def fd_get_conversations(self, ticket_id: str) -> list[dict]: resp = await self._fd_client.get(f"/tickets/{ticket_id}/conversations") self._with_retry(resp) return resp.json() async def fd_search_tickets(self, query: str) -> dict: resp = await self._fd_client.get("/search/tickets", params={"query": query}) self._with_retry(resp) return resp.json() async def fd_archive_ticket(self, ticket_id: str) -> None: resp = await self._fd_client.put(f"/tickets/{ticket_id}/archive") self._with_retry(resp) async def fd_restore_ticket(self, ticket_id: str) -> None: resp = await self._fd_client.put(f"/tickets/{ticket_id}/restore") self._with_retry(resp) async def fd_merge_tickets(self, primary_id: str, ticket_ids: list[str]) -> dict: resp = await self._fd_client.post( f"/tickets/{primary_id}/merge", json={"ticket_ids": ticket_ids}, ) self._with_retry(resp) return resp.json() async def fd_forward_ticket(self, ticket_id: str, to_emails: list[str], note: str = "") -> dict: resp = await self._fd_client.post( f"/tickets/{ticket_id}/forward", json={"to_emails": to_emails, "body": note}, ) self._with_retry(resp) return resp.json() async def fd_bulk_update_tickets(self, ticket_ids: list[str], data: dict) -> dict: resp = await self._fd_client.post( "/tickets/bulk_update", json={"ticket_ids": ticket_ids, **data}, ) self._with_retry(resp) return resp.json() async def fd_filter_tickets(self, query: str) -> list[dict]: resp = await self._fd_client.get("/filter/tickets", params={"query": query}) self._with_retry(resp) return resp.json() async def fd_list_contacts(self, **params) -> list[dict]: resp = await self._fd_client.get("/contacts", params=params) self._with_retry(resp) return resp.json() async def fd_get_contact(self, contact_id: str) -> dict: resp = await self._fd_client.get(f"/contacts/{contact_id}") self._with_retry(resp) return resp.json() async def fd_search_contacts(self, query: str) -> dict: resp = await self._fd_client.get("/search/contacts", params={"query": query}) self._with_retry(resp) return resp.json() async def fd_create_contact(self, data: dict) -> dict: resp = await self._fd_client.post("/contacts", json=data) self._with_retry(resp) return resp.json() async def fd_update_contact(self, contact_id: str, data: dict) -> dict: resp = await self._fd_client.put(f"/contacts/{contact_id}", json=data) self._with_retry(resp) return resp.json() async def fd_list_companies(self, **params) -> list[dict]: resp = await self._fd_client.get("/companies", params=params) self._with_retry(resp) return resp.json() async def fd_get_company(self, company_id: str) -> dict: resp = await self._fd_client.get(f"/companies/{company_id}") self._with_retry(resp) return resp.json() async def fd_create_company(self, data: dict) -> dict: resp = await self._fd_client.post("/companies", json=data) self._with_retry(resp) return resp.json() async def fd_update_company(self, company_id: str, data: dict) -> dict: resp = await self._fd_client.put(f"/companies/{company_id}", json=data) self._with_retry(resp) return resp.json() async def fd_search_companies(self, query: str) -> dict: resp = await self._fd_client.get("/search/companies", params={"query": query}) self._with_retry(resp) return resp.json() async def fd_list_groups(self) -> list[dict]: resp = await self._fd_client.get("/groups") self._with_retry(resp) return resp.json() async def fd_get_group(self, group_id: str) -> dict: resp = await self._fd_client.get(f"/groups/{group_id}") self._with_retry(resp) return resp.json() async def fd_list_agents(self) -> list[dict]: resp = await self._fd_client.get("/agents") self._with_retry(resp) return resp.json() async def fd_get_agent(self, agent_id: str) -> dict: resp = await self._fd_client.get(f"/agents/{agent_id}") self._with_retry(resp) return resp.json() async def fd_update_agent(self, agent_id: str, data: dict) -> dict: resp = await self._fd_client.put(f"/agents/{agent_id}", json=data) self._with_retry(resp) return resp.json() async def fd_list_ticket_fields(self) -> list[dict]: resp = await self._fd_client.get("/ticket_fields") self._with_retry(resp) return resp.json() async def fd_list_contact_fields(self) -> list[dict]: resp = await self._fd_client.get("/contact_fields") self._with_retry(resp) return resp.json() async def fd_list_sla_policies(self) -> list[dict]: resp = await self._fd_client.get("/sla_policies") self._with_retry(resp) return resp.json() async def fd_list_business_hours(self) -> list[dict]: resp = await self._fd_client.get("/business_hours") self._with_retry(resp) return resp.json() async def fd_list_products(self) -> list[dict]: resp = await self._fd_client.get("/products") self._with_retry(resp) return resp.json() async def fd_list_surveys(self) -> dict: resp = await self._fd_client.get("/surveys") self._with_retry(resp) return resp.json() async def fd_list_categories(self) -> list[dict]: resp = await self._fd_client.get("/solutions/categories") self._with_retry(resp) return resp.json() async def fd_list_folders(self, category_id: str) -> list[dict]: resp = await self._fd_client.get(f"/solutions/categories/{category_id}/folders") self._with_retry(resp) return resp.json() async def fd_list_articles(self, folder_id: str) -> list[dict]: resp = await self._fd_client.get(f"/solutions/folders/{folder_id}/articles") self._with_retry(resp) return resp.json() async def fd_get_article(self, article_id: str) -> dict: resp = await self._fd_client.get(f"/solutions/articles/{article_id}") self._with_retry(resp) return resp.json() async def fd_search_articles(self, query: str) -> dict: resp = await self._fd_client.get("/search/solutions", params={"term": query}) self._with_retry(resp) return resp.json() async def fd_list_canned_responses(self) -> list[dict]: resp = await self._fd_client.get("/canned_responses") self._with_retry(resp) return resp.json() async def fd_get_canned_response(self, response_id: str) -> dict: resp = await self._fd_client.get(f"/canned_responses/{response_id}") self._with_retry(resp) return resp.json() async def fd_list_canned_response_folders(self) -> list[dict]: resp = await self._fd_client.get("/canned_responses/folders") self._with_retry(resp) return resp.json() async def fd_list_time_entries(self, ticket_id: str | None = None) -> list[dict]: url = f"/tickets/{ticket_id}/time_entries" if ticket_id else "/time_entries" resp = await self._fd_client.get(url) self._with_retry(resp) return resp.json() async def fd_list_email_configs(self) -> list[dict]: resp = await self._fd_client.get("/email_configs") self._with_retry(resp) return resp.json() async def fd_list_automations(self, rule_type: str = "ticket") -> list[dict]: resp = await self._fd_client.get(f"/automations/{rule_type}") self._with_retry(resp) return resp.json() async def fd_get_settings(self) -> dict: resp = await self._fd_client.get("/settings") self._with_retry(resp) return resp.json() async def fc_list_users(self, **params) -> list[dict]: resp = await self._fc_client.get("/users", params=params) self._with_retry(resp) return resp.json() async def fc_create_user(self, data: dict) -> dict: resp = await self._fc_client.post("/users", json=data) self._with_retry(resp) return resp.json() async def fc_update_user(self, user_id: str, data: dict) -> dict: resp = await self._fc_client.put(f"/users/{user_id}", json=data) self._with_retry(resp) return resp.json() async def fc_get_user_conversations(self, user_id: str) -> list[dict]: resp = await self._fc_client.get(f"/users/{user_id}/conversations") self._with_retry(resp) return resp.json() async def fc_list_groups(self) -> list[dict]: resp = await self._fc_client.get("/groups") self._with_retry(resp) return resp.json() async def fc_get_agent_status(self) -> dict: resp = await self._fc_client.get("/agents/status") self._with_retry(resp) return resp.json() async def fd_delete_ticket(self, ticket_id: str) -> None: resp = await self._fd_client.delete(f"/tickets/{ticket_id}") self._with_retry(resp) async def fd_assign_ticket(self, ticket_id: str, *, agent_id: int | None = None, group_id: int | None = None) -> dict: body = {} if agent_id is not None: body["responder_id"] = agent_id if group_id is not None: body["group_id"] = group_id resp = await self._fd_client.put(f"/tickets/{ticket_id}", json=body) self._with_retry(resp) return resp.json() async def fd_bulk_delete_tickets(self, ticket_ids: list[str]) -> dict: resp = await self._fd_client.post( "/tickets/bulk_delete", json={"ticket_ids": ticket_ids}, ) self._with_retry(resp) return resp.json() async def fd_reply_to_forward(self, ticket_id: str, body_html: str, from_email_id: str | None = None) -> dict: payload: dict = {"body": body_html} if from_email_id: payload["from_email_id"] = from_email_id resp = await self._fd_client.post( f"/tickets/{ticket_id}/reply_to_forward", json=payload, ) self._with_retry(resp) return resp.json() async def fd_add_watcher(self, ticket_id: str, watcher_id: int) -> dict: resp = await self._fd_client.post( f"/tickets/{ticket_id}/watchers", json={"user_id": watcher_id}, ) self._with_retry(resp) return resp.json() async def fd_remove_watcher(self, ticket_id: str, watcher_id: int) -> None: resp = await self._fd_client.delete(f"/tickets/{ticket_id}/watchers/{watcher_id}") self._with_retry(resp) async def fd_associate_tickets(self, primary_id: str, ticket_ids: list[str]) -> dict: resp = await self._fd_client.post( f"/tickets/{primary_id}/associations", json={"association_list": [{"id": tid} for tid in ticket_ids]}, ) self._with_retry(resp) return resp.json() async def fd_get_ticket_summary(self, ticket_id: str) -> dict: resp = await self._fd_client.get(f"/tickets/{ticket_id}/summary") self._with_retry(resp) return resp.json() async def fd_update_conversation(self, conversation_id: str, data: dict) -> dict: resp = await self._fd_client.put(f"/conversations/{conversation_id}", json=data) self._with_retry(resp) return resp.json() async def fd_delete_conversation(self, conversation_id: str) -> None: resp = await self._fd_client.delete(f"/conversations/{conversation_id}") self._with_retry(resp) async def fd_delete_contact(self, contact_id: str) -> None: resp = await self._fd_client.delete(f"/contacts/{contact_id}") self._with_retry(resp) async def fd_restore_contact(self, contact_id: str) -> dict: resp = await self._fd_client.put(f"/contacts/{contact_id}/restore") self._with_retry(resp) return resp.json() async def fd_make_agent(self, contact_id: str) -> dict: resp = await self._fd_client.put(f"/contacts/{contact_id}/make_agent") self._with_retry(resp) return resp.json() async def fd_merge_contacts(self, primary_id: str, contact_ids: list[str]) -> dict: resp = await self._fd_client.post( "/contacts/merge", json={"primary_contact_id": primary_id, "secondary_contact_ids": contact_ids}, ) self._with_retry(resp) return resp.json() async def fd_delete_company(self, company_id: str) -> None: resp = await self._fd_client.delete(f"/companies/{company_id}") self._with_retry(resp) async def fd_filter_companies(self, query: str) -> list[dict]: resp = await self._fd_client.get("/filter/companies", params={"query": query}) self._with_retry(resp) return resp.json() async def fd_list_company_fields(self) -> list[dict]: resp = await self._fd_client.get("/company_fields") self._with_retry(resp) return resp.json() async def fd_create_group(self, data: dict) -> dict: resp = await self._fd_client.post("/groups", json=data) self._with_retry(resp) return resp.json() async def fd_update_group(self, group_id: str, data: dict) -> dict: resp = await self._fd_client.put(f"/groups/{group_id}", json=data) self._with_retry(resp) return resp.json() async def fd_delete_group(self, group_id: str) -> None: resp = await self._fd_client.delete(f"/groups/{group_id}") self._with_retry(resp) async def fd_create_category(self, data: dict) -> dict: resp = await self._fd_client.post("/solutions/categories", json=data) self._with_retry(resp) return resp.json() async def fd_update_category(self, category_id: str, data: dict) -> dict: resp = await self._fd_client.put(f"/solutions/categories/{category_id}", json=data) self._with_retry(resp) return resp.json() async def fd_delete_category(self, category_id: str) -> None: resp = await self._fd_client.delete(f"/solutions/categories/{category_id}") self._with_retry(resp) async def fd_create_folder(self, category_id: str, data: dict) -> dict: resp = await self._fd_client.post( f"/solutions/categories/{category_id}/folders", json=data, ) self._with_retry(resp) return resp.json() async def fd_update_folder(self, folder_id: str, data: dict) -> dict: resp = await self._fd_client.put(f"/solutions/folders/{folder_id}", json=data) self._with_retry(resp) return resp.json() async def fd_delete_folder(self, folder_id: str) -> None: resp = await self._fd_client.delete(f"/solutions/folders/{folder_id}") self._with_retry(resp) async def fd_create_article(self, folder_id: str, data: dict) -> dict: resp = await self._fd_client.post( f"/solutions/folders/{folder_id}/articles", json=data, ) self._with_retry(resp) return resp.json() async def fd_update_article(self, article_id: str, data: dict) -> dict: resp = await self._fd_client.put(f"/solutions/articles/{article_id}", json=data) self._with_retry(resp) return resp.json() async def fd_delete_article(self, article_id: str) -> None: resp = await self._fd_client.delete(f"/solutions/articles/{article_id}") self._with_retry(resp) async def fd_create_canned_response(self, data: dict) -> dict: resp = await self._fd_client.post("/canned_responses", json=data) self._with_retry(resp) return resp.json() async def fd_update_canned_response(self, response_id: str, data: dict) -> dict: resp = await self._fd_client.put(f"/canned_responses/{response_id}", json=data) self._with_retry(resp) return resp.json() async def fd_delete_canned_response(self, response_id: str) -> None: resp = await self._fd_client.delete(f"/canned_responses/{response_id}") self._with_retry(resp) async def fd_list_roles(self) -> list[dict]: resp = await self._fd_client.get("/roles") self._with_retry(resp) return resp.json() async def fd_delete_agent(self, agent_id: str) -> None: resp = await self._fd_client.delete(f"/agents/{agent_id}") self._with_retry(resp) async def fc_create_conversation(self, user_id: str, message_parts: list[dict], *, channel_id: str | None = None) -> dict: body: dict = {"users": [{"id": user_id}], "message_parts": message_parts} if channel_id: body["channel_id"] = channel_id resp = await self._fc_client.post("/conversations", json=body) self._with_retry(resp) return resp.json() async def fc_update_conversation(self, conversation_id: str, data: dict) -> dict: resp = await self._fc_client.put(f"/conversations/{conversation_id}", json=data) self._with_retry(resp) return resp.json() async def fc_delete_conversation(self, conversation_id: str) -> None: resp = await self._fc_client.delete(f"/conversations/{conversation_id}") self._with_retry(resp) async def fc_delete_user(self, user_id: str) -> None: resp = await self._fc_client.delete(f"/users/{user_id}") self._with_retry(resp) async def fc_fetch_users(self, user_ids: list[str]) -> dict: resp = await self._fc_client.post("/users/fetch", json={"ids": user_ids}) self._with_retry(resp) return resp.json() async def fc_list_outbound_messages(self, **params) -> dict: resp = await self._fc_client.get("/outbound-messages", params=params) self._with_retry(resp) return resp.json() async def fc_get_report(self, report_id: str) -> dict: resp = await self._fc_client.get(f"/reports/raw/{report_id}") self._with_retry(resp) return resp.json() async def fd_upload_attachment(self, file_content: bytes, file_name: str, content_type: str) -> dict: resp = await self._fd_client.post( "/attachments", files={"file": (file_name, file_content, content_type)}, headers={**self._fd_headers, "Content-Type": None}, ) self._with_retry(resp) return resp.json() async def fc_upload_file(self, file_content: bytes, file_name: str, content_type: str) -> dict: resp = await self._fc_client.post( "/attachments", files={"file": (file_name, file_content, content_type)}, headers={**self._fc_headers, "Content-Type": None}, ) self._with_retry(resp) return resp.json() async def close(self) -> None: await self._fd_client.aclose() await self._fc_client.aclose()