ForcePilot/backend/package/yuxi/channel/extensions/intercom/client.py
Kris 2ddcf1cc55 feat(channel): 添加 Intercom 渠道扩展
新增 Intercom 渠道扩展,支持在 Yuxi 平台中集成 Intercom 客服渠道。

包含以下功能模块:
- client: Intercom API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- streaming: 流式消息处理
- format: 消息格式转换
- outbound: 外发消息管理
- handoff: 转人工会话切换
- pairing: 用户配对与绑定
- security: 安全签名校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session_guard: 会话防护
- types: 类型定义
2026-05-21 11:01:49 +08:00

580 lines
19 KiB
Python

import logging
import httpx
from yuxi.channel.extensions.intercom.constants import (
DEFAULT_DATACENTER,
INTERCOM_API_BASES,
INTERCOM_API_VERSION,
INTERCOM_DEFAULT_TIMEOUT,
)
from yuxi.channel.extensions.intercom.errors import IntercomError, IntercomErrorCode
logger = logging.getLogger(__name__)
class IntercomClient:
def __init__(
self,
access_token: str,
*,
datacenter: str = DEFAULT_DATACENTER,
timeout: float = INTERCOM_DEFAULT_TIMEOUT,
):
self._access_token = access_token
base_url = INTERCOM_API_BASES.get(datacenter, INTERCOM_API_BASES[DEFAULT_DATACENTER])
self._client = httpx.AsyncClient(
base_url=base_url,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"Intercom-Version": INTERCOM_API_VERSION,
},
timeout=httpx.Timeout(timeout),
)
async def get_me(self) -> dict:
resp = await self._request("GET", "/me")
return resp
async def reply_conversation(
self,
conversation_id: str,
body: str,
*,
message_type: str = "comment",
reply_type: str = "admin",
admin_id: str | None = None,
attachment_urls: list[str] | None = None,
reply_options: dict | None = None,
) -> dict:
payload: dict = {
"message_type": message_type,
"type": reply_type,
"body": body,
}
if admin_id:
payload["admin_id"] = admin_id
if attachment_urls:
payload["attachment_urls"] = attachment_urls
if reply_options:
payload["reply_options"] = reply_options
resp = await self._request(
"POST",
f"/conversations/{conversation_id}/reply",
json=payload,
)
return resp
async def get_conversation(self, conversation_id: str) -> dict:
resp = await self._request("GET", f"/conversations/{conversation_id}")
return resp
async def get_conversation_parts(self, conversation_id: str) -> list[dict]:
resp = await self._request(
"GET",
f"/conversations/{conversation_id}",
params={"display_as": "plaintext"},
)
return resp.get("conversation_parts", {}).get("conversation_parts", [])
async def manage_conversation(
self,
conversation_id: str,
action: str,
admin_id: str | None = None,
) -> dict:
payload: dict = {"type": "admin"}
if admin_id:
payload["admin_id"] = admin_id
resp = await self._request(
"POST",
f"/conversations/{conversation_id}/manage",
json=payload,
params={"action": action},
)
return resp
async def create_conversation(
self,
contact_id: str,
body: str,
*,
admin_id: str | None = None,
message_type: str = "comment",
attachment_urls: list[str] | None = None,
) -> dict:
payload: dict = {
"from": {"type": "contact", "id": contact_id},
"body": body,
}
if admin_id:
payload["admin_id"] = admin_id
if message_type:
payload["message_type"] = message_type
if attachment_urls:
payload["attachment_urls"] = attachment_urls
resp = await self._request("POST", "/conversations", json=payload)
return resp
async def search_conversations(
self,
query: dict,
*,
page: int = 1,
per_page: int = 20,
) -> dict:
payload = {
"query": query,
"pagination": {"page": page, "per_page": min(per_page, 150)},
}
resp = await self._request("POST", "/conversations/search", json=payload)
return resp
async def get_contact(self, contact_id: str) -> dict:
resp = await self._request("GET", f"/contacts/{contact_id}")
return resp
async def create_contact(self, email: str, *, name: str | None = None, **attrs) -> dict:
payload: dict = {"email": email}
if name:
payload["name"] = name
payload.update(attrs)
resp = await self._request("POST", "/contacts", json=payload)
return resp
async def update_contact(self, contact_id: str, **attrs) -> dict:
resp = await self._request("PUT", f"/contacts/{contact_id}", json=attrs)
return resp
async def delete_contact(self, contact_id: str) -> dict:
resp = await self._request("DELETE", f"/contacts/{contact_id}")
return resp
async def list_contacts(self, *, page: int = 1, per_page: int = 20, email: str | None = None) -> dict:
params: dict = {"page": page, "per_page": min(per_page, 150)}
if email:
params["email"] = email
resp = await self._request("GET", "/contacts", params=params)
return resp
async def search_contacts(
self,
query: dict,
*,
page: int = 1,
per_page: int = 20,
) -> dict:
payload = {
"query": query,
"pagination": {"page": page, "per_page": min(per_page, 150)},
}
resp = await self._request("POST", "/contacts/search", json=payload)
return resp
async def archive_contact(self, contact_id: str) -> dict:
resp = await self._request("POST", f"/contacts/{contact_id}/archive")
return resp
async def add_conversation_tags(self, conversation_id: str, tags: list[str]) -> dict:
payload = {"tags": [{"id": tag} for tag in tags]}
resp = await self._request(
"POST",
f"/conversations/{conversation_id}/tags",
json=payload,
)
return resp
async def remove_conversation_tag(self, conversation_id: str, tag_id: str) -> dict:
resp = await self._request(
"DELETE",
f"/conversations/{conversation_id}/tags/{tag_id}",
)
return resp
async def list_tags(self) -> list[dict]:
resp = await self._request("GET", "/tags")
return resp.get("data", [])
async def update_conversation(self, conversation_id: str, **kwargs) -> dict:
resp = await self._request(
"PUT",
f"/conversations/{conversation_id}",
json=kwargs,
)
return resp
async def snooze_conversation(self, conversation_id: str, snooze_until: int) -> dict:
return await self.update_conversation(conversation_id, snoozed_until=snooze_until)
async def assign_conversation(self, conversation_id: str, admin_id: str) -> dict:
return await self.update_conversation(conversation_id, assignee_id=admin_id, assignee_type="admin")
async def delete_conversation_part(self, conversation_id: str, part_id: str) -> dict:
resp = await self._request(
"DELETE",
f"/conversations/{conversation_id}/parts/{part_id}",
)
return resp
async def create_ticket(
self,
subject: str,
message_body: str,
contact_id: str,
*,
admin_id: str | None = None,
ticket_type_id: str | None = None,
ticket_attributes: dict | None = None,
) -> dict:
payload: dict = {
"contacts": [{"id": contact_id}],
"subject": subject,
"ticket_type": {"body": message_body},
}
if admin_id:
payload["admin_assignee_id"] = admin_id
if ticket_type_id:
payload["ticket_type_id"] = ticket_type_id
if ticket_attributes:
payload["ticket_attributes"] = ticket_attributes
resp = await self._request("POST", "/tickets", json=payload)
return resp
async def reply_ticket(
self,
ticket_id: str,
body: str,
*,
admin_id: str | None = None,
attachment_urls: list[str] | None = None,
) -> dict:
payload: dict = {
"type": "admin",
"body": body,
}
if admin_id:
payload["admin_id"] = admin_id
if attachment_urls:
payload["attachment_urls"] = attachment_urls
resp = await self._request("POST", f"/tickets/{ticket_id}/reply", json=payload)
return resp
async def get_ticket(self, ticket_id: str) -> dict:
resp = await self._request("GET", f"/tickets/{ticket_id}")
return resp
async def update_ticket(self, ticket_id: str, **kwargs) -> dict:
resp = await self._request("PUT", f"/tickets/{ticket_id}", json=kwargs)
return resp
async def get_company(self, company_id: str) -> dict:
resp = await self._request("GET", f"/companies/{company_id}")
return resp
async def create_company(self, name: str, *, company_id: str | None = None, **attrs) -> dict:
payload: dict = {"name": name}
if company_id:
payload["company_id"] = company_id
payload.update(attrs)
resp = await self._request("POST", "/companies", json=payload)
return resp
async def update_company(self, company_id: str, **attrs) -> dict:
resp = await self._request("PUT", f"/companies/{company_id}", json=attrs)
return resp
async def list_companies(self, *, page: int = 1, per_page: int = 20) -> dict:
resp = await self._request(
"GET",
"/companies",
params={"page": page, "per_page": min(per_page, 150)},
)
return resp
async def search_companies(
self,
query: dict,
*,
page: int = 1,
per_page: int = 20,
) -> dict:
payload = {
"query": query,
"pagination": {"page": page, "per_page": min(per_page, 150)},
}
resp = await self._request("POST", "/companies/search", json=payload)
return resp
async def attach_contact_to_company(self, contact_id: str, company_id: str) -> dict:
resp = await self._request(
"POST",
f"/contacts/{contact_id}/companies",
json={"id": company_id},
)
return resp
async def detach_contact_from_company(self, contact_id: str, company_id: str) -> dict:
resp = await self._request(
"DELETE",
f"/contacts/{contact_id}/companies/{company_id}",
)
return resp
async def list_articles(self, *, page: int = 1, per_page: int = 20, state: str = "published") -> dict:
resp = await self._request(
"GET",
"/articles",
params={"page": page, "per_page": per_page, "state": state},
)
return resp
async def get_article(self, article_id: str) -> dict:
resp = await self._request("GET", f"/articles/{article_id}")
return resp
async def list_brands(self) -> list[dict]:
resp = await self._request("GET", "/brands")
return resp.get("data", [])
async def get_brand(self, brand_id: str) -> dict:
resp = await self._request("GET", f"/brands/{brand_id}")
return resp
async def list_conversations(self, *, page: int = 1, per_page: int = 20) -> dict:
resp = await self._request(
"GET",
"/conversations",
params={"page": page, "per_page": min(per_page, 150)},
)
return resp
async def delete_conversation(self, conversation_id: str) -> dict:
resp = await self._request("DELETE", f"/conversations/{conversation_id}")
return resp
async def redact_conversation_part(self, conversation_id: str, part_id: str) -> dict:
resp = await self._request(
"POST",
f"/conversations/{conversation_id}/parts/{part_id}/redact",
)
return resp
async def convert_conversation_to_ticket(self, conversation_id: str, *, admin_id: str | None = None) -> dict:
payload: dict = {}
if admin_id:
payload["admin_id"] = admin_id
resp = await self._request(
"POST",
f"/conversations/{conversation_id}/convert-to-ticket",
json=payload,
)
return resp
async def run_assignment_rules(self, conversation_id: str) -> dict:
resp = await self._request(
"POST",
f"/conversations/{conversation_id}/run_assignment_rules",
)
return resp
async def attach_contact_to_conversation(self, conversation_id: str, contact_id: str) -> dict:
resp = await self._request(
"POST",
f"/conversations/{conversation_id}/contacts",
json={"id": contact_id},
)
return resp
async def detach_contact_from_conversation(self, conversation_id: str, contact_id: str) -> dict:
resp = await self._request(
"DELETE",
f"/conversations/{conversation_id}/contacts/{contact_id}",
)
return resp
async def list_tickets(self, *, page: int = 1, per_page: int = 20) -> dict:
resp = await self._request(
"GET",
"/tickets",
params={"page": page, "per_page": min(per_page, 150)},
)
return resp
async def search_tickets(
self,
query: dict,
*,
page: int = 1,
per_page: int = 20,
) -> dict:
payload = {
"query": query,
"pagination": {"page": page, "per_page": min(per_page, 150)},
}
resp = await self._request("POST", "/tickets/search", json=payload)
return resp
async def merge_contacts(self, primary_id: str, secondary_id: str) -> dict:
resp = await self._request(
"POST",
"/contacts/merge",
json={
"primary_contact_id": primary_id,
"secondary_contact_id": secondary_id,
},
)
return resp
async def add_contact_tags(self, contact_id: str, tags: list[str]) -> dict:
payload = {"tags": [{"id": tag} for tag in tags]}
resp = await self._request(
"POST",
f"/contacts/{contact_id}/tags",
json=payload,
)
return resp
async def remove_contact_tag(self, contact_id: str, tag_id: str) -> dict:
resp = await self._request(
"DELETE",
f"/contacts/{contact_id}/tags/{tag_id}",
)
return resp
async def get_contact_subscriptions(self, contact_id: str) -> dict:
resp = await self._request("GET", f"/contacts/{contact_id}/subscriptions")
return resp
async def update_contact_subscriptions(self, contact_id: str, subscriptions: list[dict]) -> dict:
resp = await self._request(
"PUT",
f"/contacts/{contact_id}/subscriptions",
json={"subscriptions": subscriptions},
)
return resp
async def send_email(
self,
to: list[str],
subject: str,
body_html: str,
*,
cc: list[str] | None = None,
bcc: list[str] | None = None,
) -> dict:
payload: dict = {
"to": to,
"subject": subject,
"body": body_html,
}
if cc:
payload["cc"] = cc
if bcc:
payload["bcc"] = bcc
resp = await self._request("POST", "/emails", json=payload)
return resp
async def send_message(
self,
contact_id: str,
body: str,
*,
admin_id: str | None = None,
) -> dict:
payload: dict = {
"from": {"type": "admin", "id": admin_id} if admin_id else {"type": "admin"},
"to": {"type": "contact", "id": contact_id},
"body": body,
}
resp = await self._request("POST", "/messages", json=payload)
return resp
async def close(self) -> None:
await self._client.aclose()
async def _paginated_request(self, method: str, path: str, *, params: dict | None = None, **kwargs) -> list[dict]:
params = params or {}
results: list[dict] = []
while True:
resp = await self._request(method, path, params=params, **kwargs)
data = resp.get("data", [])
results.extend(data)
pages_info = resp.get("pages", {})
next_cursor = pages_info.get("next", {}).get("starting_after")
if not next_cursor:
break
params["starting_after"] = next_cursor
return results
async def _request(self, method: str, path: str, **kwargs) -> dict:
try:
resp = await self._client.request(method, path, **kwargs)
except httpx.TimeoutException as e:
raise IntercomError(
IntercomErrorCode.NETWORK_ERROR,
f"Request timeout: {method} {path}",
retryable=True,
) from e
except httpx.ConnectError as e:
raise IntercomError(
IntercomErrorCode.NETWORK_ERROR,
f"Connection error: {method} {path}",
retryable=True,
) from e
except httpx.HTTPError as e:
raise IntercomError(
IntercomErrorCode.NETWORK_ERROR,
f"HTTP error: {method} {path}: {e}",
retryable=True,
) from e
if resp.status_code == 401:
raise IntercomError(
IntercomErrorCode.AUTH_ERROR,
f"Unauthorized: {resp.text}",
retryable=False,
)
if resp.status_code == 403:
raise IntercomError(
IntercomErrorCode.AUTH_ERROR,
f"Forbidden: {resp.text}",
retryable=False,
)
if resp.status_code == 404:
raise IntercomError(
IntercomErrorCode.NOT_FOUND,
f"Not found: {method} {path}",
retryable=False,
)
if resp.status_code == 429:
retry_after = resp.headers.get("Retry-After")
raise IntercomError(
IntercomErrorCode.RATE_LIMITED,
f"Rate limited (Retry-After: {retry_after or 'N/A'}): {resp.text}",
retryable=True,
)
if resp.status_code >= 500:
raise IntercomError(
IntercomErrorCode.NETWORK_ERROR,
f"Server error {resp.status_code}: {resp.text}",
retryable=True,
)
if resp.status_code >= 400:
raise IntercomError(
IntercomErrorCode.UNKNOWN,
f"Client error {resp.status_code}: {resp.text}",
retryable=False,
)
remaining = resp.headers.get("X-RateLimit-Remaining")
limit = resp.headers.get("X-RateLimit-Limit")
if remaining is not None and int(remaining) < 10:
logger.warning("Intercom rate limit low: %s/%s", remaining, limit)
try:
return resp.json()
except Exception:
return {}