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