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