完成Help Scout渠道的完整功能实现,包含API客户端、webhook处理、轮询同步、自动回复、工单标签分配、客户资料管理等核心能力,附带完整的配置Schema与插件元信息
432 lines
14 KiB
Python
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
|