ForcePilot/backend/package/yuxi/channel/extensions/helpscout/client.py
Kris 432d5db970 feat(helpscout): 新增Help Scout客服工单渠道插件
完成Help Scout渠道的完整功能实现,包含API客户端、webhook处理、轮询同步、自动回复、工单标签分配、客户资料管理等核心能力,附带完整的配置Schema与插件元信息
2026-05-21 10:49:56 +08:00

432 lines
14 KiB
Python

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