ForcePilot/backend/package/yuxi/channel/extensions/freshdesk/client.py
Kris 3e861d6cd1 feat(freshdesk): 新增Freshdesk/Freshchat渠道插件
实现完整的Freshdesk和Freshchat集成支持,包含会话守卫、错误定义、消息去重、配置管理、webhook处理、出站消息发送、状态监控、安全校验、配对功能和流式回复支持
2026-05-21 10:47:20 +08:00

680 lines
25 KiB
Python

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()