新增 Intercom 渠道扩展,支持在 Yuxi 平台中集成 Intercom 客服渠道。 包含以下功能模块: - client: Intercom API 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - streaming: 流式消息处理 - format: 消息格式转换 - outbound: 外发消息管理 - handoff: 转人工会话切换 - pairing: 用户配对与绑定 - security: 安全签名校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session_guard: 会话防护 - types: 类型定义
580 lines
19 KiB
Python
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 {}
|