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

279 lines
10 KiB
Python

from __future__ import annotations
import asyncio
import logging
import time
import uuid
from datetime import UTC, datetime
from yuxi.channel.extensions.helpscout.auth import HelpScoutAuth
from yuxi.channel.extensions.helpscout.client import HelpScoutClient
from yuxi.channel.extensions.helpscout.config import resolve_account
from yuxi.channel.extensions.helpscout.monitor import parse_webhook_event_to_unified
from yuxi.channel.gateway.routes import webhook_registry
from yuxi.channel.gateway.webhook_security import WebhookGuardConfig
logger = logging.getLogger(__name__)
WEBHOOK_FAILURE_THRESHOLD = 5
class HelpScoutGateway:
def __init__(self):
self._running = False
self._auth: HelpScoutAuth | None = None
self._client: HelpScoutClient | None = None
self._tasks: list[asyncio.Task] = []
self._last_modification_time: str | None = None
self._last_message_at: float | None = None
self._queue: asyncio.Queue | None = None
self._webhook_failure_count = 0
self._webhook_success_count = 0
async def start(self, ctx) -> object:
account_id = getattr(ctx, "account_id", "default")
account = resolve_account(account_id)
if not account.get("app_id") or not account.get("app_secret") or int(account.get("mailbox_id", 0)) <= 0:
logger.warning("Help Scout account not configured, skipping gateway start")
return {"running": False, "reason": "not-configured"}
self._auth = HelpScoutAuth(account["app_id"], account["app_secret"])
await self._auth.ensure_token()
self._client = HelpScoutClient(self._auth)
self._queue = asyncio.Queue(maxsize=1000)
self._running = True
mode = account.get("mode", "webhook")
self._last_modification_time = _utc_now_iso()
if mode == "webhook":
await self._auto_register_webhook(account)
await self._discover_mailboxes(account)
self._register_webhook_handler()
task = asyncio.create_task(self._webhook_loop(account))
self._tasks.append(task)
logger.info(
"Help Scout gateway started: mode=webhook mailbox=%s",
account["mailbox_id"],
)
elif mode == "polling":
await self._discover_mailboxes(account)
task = asyncio.create_task(self._polling_loop(account))
self._tasks.append(task)
logger.info(
"Help Scout gateway started: mode=polling mailbox=%s",
account["mailbox_id"],
)
return {"running": True, "mode": mode, "mailbox_id": account["mailbox_id"]}
async def stop(self, ctx) -> None:
account_id = getattr(ctx, "account_id", "default")
account = resolve_account(account_id)
self._running = False
for task in self._tasks:
task.cancel()
self._tasks.clear()
self._queue = None
if self._client:
await self._auto_unregister_webhook(account)
await self._client.close()
logger.info("Help Scout gateway stopped")
def _register_webhook_handler(self) -> None:
from yuxi.channel.extensions.helpscout.webhook import HelpScoutWebhookHandler
handler = HelpScoutWebhookHandler(queue=self._queue)
guard_config = WebhookGuardConfig(
allowed_methods=["POST"],
max_body_bytes=1_048_576,
)
webhook_registry.register(
"helpscout",
handler.handle,
guard_config=guard_config,
)
async def _auto_register_webhook(self, account: dict) -> None:
config = account
if not config.get("auto_register_webhook"):
return
callback_url = config.get("webhook_url")
if not callback_url:
logger.warning("auto_register_webhook=true but webhook_url is empty")
return
secret = config.get("webhook_secret") or uuid.uuid4().hex[:16]
events = config.get(
"webhook_events",
[
"convo.created",
"convo.customer.reply.created",
"convo.note.created",
"convo.status",
],
)
try:
existing = await self._client.list_webhooks()
webhooks = existing.get("_embedded", {}).get("webhooks", [])
for wh in webhooks:
if wh.get("url") == callback_url:
logger.info("Webhook already registered: id=%s url=%s", wh["id"], callback_url)
return
result = await self._client.create_webhook(
url=callback_url,
events=events,
secret=secret,
mailbox_id=account["mailbox_id"],
)
logger.info("Webhook registered: id=%s url=%s", result.get("id"), callback_url)
except Exception:
logger.exception("Failed to auto-register webhook")
async def _auto_unregister_webhook(self, account: dict) -> None:
config = account
if not config.get("auto_unregister_webhook"):
return
callback_url = config.get("webhook_url")
if not callback_url:
return
try:
existing = await self._client.list_webhooks()
webhooks = existing.get("_embedded", {}).get("webhooks", [])
for wh in webhooks:
if wh.get("url") == callback_url:
await self._client.delete_webhook(wh["id"])
logger.info("Webhook unregistered: id=%s", wh["id"])
except Exception:
logger.exception("Failed to auto-unregister webhook")
async def _discover_mailboxes(self, account: dict) -> list[dict]:
if not account.get("auto_discover_mailbox"):
return []
try:
result = await self._client.list_mailboxes()
mailboxes = result.get("_embedded", {}).get("mailboxes", [])
for mb in mailboxes:
logger.info(
"Discovered mailbox: id=%d name=%s email=%s",
mb["id"],
mb["name"],
mb.get("email", "N/A"),
)
return mailboxes
except Exception:
logger.exception("Failed to discover mailboxes")
return []
async def _webhook_loop(self, account: dict):
while self._running and self._queue is not None:
try:
event_type, payload = await asyncio.wait_for(self._queue.get(), timeout=1.0)
await self._process_webhook_event(event_type, payload, account)
self._webhook_success_count += 1
except TimeoutError:
continue
except asyncio.CancelledError:
break
except Exception:
logger.exception("Error processing Help Scout webhook payload")
self._webhook_failure_count += 1
self._check_webhook_health()
async def _process_webhook_event(self, event_type: str, payload: dict, account: dict):
unified = parse_webhook_event_to_unified(event_type, payload, account)
if unified is None:
return
self._last_message_at = time.monotonic()
logger.debug(
"Help Scout message processed: type=%s conv_id=%s",
event_type,
unified.get("conversation_id", "N/A"),
)
async def _polling_loop(self, account: dict):
poll_interval = account.get("poll_interval", 30)
while self._running:
try:
result = await self._client.list_conversations(
mailbox=account["mailbox_id"],
status="active",
modified_since=self._last_modification_time,
embed="threads",
)
conversations = result.get("_embedded", {}).get("conversations", [])
for conv in conversations:
await self._process_polled_conversation(conv, account)
self._last_modification_time = _utc_now_iso()
except asyncio.CancelledError:
break
except Exception:
logger.exception("Error in Help Scout polling loop")
await asyncio.sleep(poll_interval)
async def _process_polled_conversation(self, conv: dict, account: dict):
threads = conv.get("_embedded", {}).get("threads", [])
for thread in threads:
if thread.get("type") not in ("customer", "note"):
continue
self._last_message_at = time.monotonic()
logger.debug(
"Help Scout polled message: type=%s conv=%s thread=%s",
thread.get("type"),
conv["id"],
thread["id"],
)
def _check_webhook_health(self):
if self._webhook_failure_count >= WEBHOOK_FAILURE_THRESHOLD and self._webhook_success_count == 0:
logger.error(
"Help Scout webhook health ALERT: %d consecutive failures!",
self._webhook_failure_count,
)
if self._webhook_failure_count > 0:
self._webhook_failure_count = 0
self._webhook_success_count = 0
def _utc_now_iso() -> str:
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def _convert_thread_to_unified(conv: dict, thread: dict, account: dict) -> dict | None:
try:
return {
"channel": "helpscout",
"dedup_key": f"poll:{conv['id']}:{thread['id']}",
"conversation_id": str(conv["id"]),
"conversation_status": conv.get("status", "active"),
"conversation_subject": conv.get("subject", ""),
"msg_id": str(thread["id"]),
"msg_type": thread.get("type", "unknown"),
"sender": {
"id": str(thread.get("createdBy", {}).get("id", "")),
"type": thread.get("createdBy", {}).get("type", ""),
"email": thread.get("createdBy", {}).get("email", ""),
},
"text": thread.get("body", ""),
"attachments": thread.get("attachments", []),
"timestamp": thread.get("createdAt", ""),
}
except Exception:
logger.exception("Failed to convert polled thread to unified")
return None