import asyncio import json import logging import time from fastapi import APIRouter, Request, Response from fastapi.responses import JSONResponse from yuxi.channel.extensions.intercom.constants import INTERCOM_WEBHOOK_TOPICS_HANDLED from yuxi.channel.extensions.intercom.format import html_to_text, markdown_to_html from yuxi.channel.extensions.intercom.gateway import IntercomGateway from yuxi.channel.extensions.intercom.handoff import HandoffDetector from yuxi.channel.extensions.intercom.monitor import IntercomMonitor from yuxi.channel.extensions.intercom.security import IntercomSecurity from yuxi.channel.extensions.intercom.session_guard import ConversationStateGuard from yuxi.channel.extensions.intercom.types import IntercomInboundEvent from yuxi.channel.runtime.manager import gateway from yuxi.channel.message.models import UnifiedMessage logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/intercom", tags=["intercom"]) _gateway_instance: IntercomGateway | None = None _security = IntercomSecurity() _session_guard = ConversationStateGuard() _handoff = HandoffDetector() def set_gateway_instance(gw: IntercomGateway) -> None: global _gateway_instance _gateway_instance = gw def _parse_inbound_event(payload: dict) -> IntercomInboundEvent | None: data = payload.get("data", {}) item = data.get("item", {}) topic = payload.get("topic", "") # Admin / operator events have different payload structure admin_topics = { "conversation.admin.closed", "conversation.admin.opened", "conversation.admin.snoozed", "conversation.admin.unsnoozed", "conversation.admin.assigned", "conversation.admin.replied", "conversation.admin.single.created", "conversation.operator.replied", } if topic in admin_topics: return _parse_admin_event(payload) # State-only events (deleted, priority updated, rating added, read) state_only_topics = { "conversation.deleted", "conversation.priority.updated", "conversation.rating.added", "conversation.read", "conversation_part.redacted", "conversation_part.tag.created", } if topic in state_only_topics: return _parse_state_event(payload) conversation_message = item.get("conversation_message") or item.get("source") or {} author = conversation_message.get("author", {}) if not conversation_message and item.get("conversation_parts"): parts = item["conversation_parts"].get("conversation_parts", []) if parts: last_part = parts[-1] conversation_message = last_part author = last_part.get("author", {}) if not author.get("id"): return None return IntercomInboundEvent( event_id=payload.get("id", ""), topic=topic, conversation_id=str(item.get("id", "")), conversation_state=item.get("state", "open"), message_id=str(conversation_message.get("id", "")), author_type=author.get("type", ""), author_id=str(author.get("id", "")), author_name=author.get("name", ""), author_email=author.get("email", ""), body_html=conversation_message.get("body", ""), attachments=conversation_message.get("attachments", []), created_at=conversation_message.get("created_at", 0), raw=payload, ) def _parse_admin_event(payload: dict) -> IntercomInboundEvent | None: data = payload.get("data", {}) item = data.get("item", {}) topic = payload.get("topic", "") admin = item.get("admin", {}) or item.get("assignee", {}) or item.get("operator", {}) return IntercomInboundEvent( event_id=payload.get("id", ""), topic=topic, conversation_id=str(item.get("id", "")), conversation_state=item.get("state", "open"), message_id="", author_type="admin", author_id=str(admin.get("id", "")), author_name=admin.get("name", ""), author_email=admin.get("email", ""), body_html="", attachments=[], created_at=0, raw=payload, ) def _parse_state_event(payload: dict) -> IntercomInboundEvent | None: data = payload.get("data", {}) item = data.get("item", {}) return IntercomInboundEvent( event_id=payload.get("id", ""), topic=payload.get("topic", ""), conversation_id=str(item.get("id", "")), conversation_state=item.get("state", "open"), message_id="", author_type="", author_id="", body_html="", raw=payload, ) @router.post("/{account_id}") async def intercom_webhook( request: Request, account_id: str = "default", ) -> Response: if _gateway_instance is None: return JSONResponse({"error": "Gateway not initialized"}, status_code=503) raw_body = await request.body() entry = _gateway_instance.get_account(account_id) if not entry: return JSONResponse({"error": "Account not found"}, status_code=404) account = entry["account"] webhook_secret = account.get("webhook_secret", "") x_hub_signature = request.headers.get("X-Hub-Signature", "") if not _gateway_instance.verify_webhook_signature(raw_body, x_hub_signature, webhook_secret): logger.warning("Invalid webhook signature for account %s", account_id) return JSONResponse({"error": "Invalid signature"}, status_code=401) try: payload = json.loads(raw_body) except Exception: return JSONResponse({"error": "Invalid JSON"}, status_code=400) topic = payload.get("topic", "") if topic not in INTERCOM_WEBHOOK_TOPICS_HANDLED: return JSONResponse({"status": "ignored", "topic": topic}) # Handle state change events state_change_topics = { "conversation.admin.closed", "conversation.admin.opened", "conversation.admin.snoozed", "conversation.admin.unsnoozed", } if topic in state_change_topics: item = payload.get("data", {}).get("item", {}) logger.info("Conversation state change: %s for conv %s", topic, item.get("id")) return JSONResponse({"status": "state_updated"}) event_id = payload.get("id", "") if _gateway_instance.is_duplicate(event_id): return JSONResponse({"status": "duplicate"}) inbound_event = _parse_inbound_event(payload) if inbound_event is None: return JSONResponse({"status": "ignored", "reason": "parse_failed"}) should_respond, reason = _session_guard.should_respond(inbound_event, account) if not should_respond: logger.info("Skipping event %s: %s (state=%s)", event_id, reason, inbound_event.conversation_state) return JSONResponse({"status": "skipped", "reason": reason}) identity = _gateway_instance.get_identity(account_id) if identity and IntercomMonitor.is_echo(inbound_event, identity): return JSONResponse({"status": "ignored", "reason": "echo"}) allowed, auth_reason = _security.authorize_dm(account, inbound_event.author_id) if not allowed: logger.info("DM blocked for contact %s: %s", inbound_event.author_id, auth_reason) return JSONResponse({"status": "blocked", "reason": auth_reason}, status_code=403) handoff_policy = account.get("handoff_policy", "ignore") client = _gateway_instance.get_client(account_id) bot_admin_id = identity.admin_id if identity else "" is_takeover = False if client and bot_admin_id: is_takeover = await _handoff.is_human_takeover(client, inbound_event.conversation_id, bot_admin_id) if not is_takeover: is_takeover = await _handoff.check_recent_admin_reply(client, inbound_event.conversation_id, bot_admin_id) if is_takeover: if handoff_policy == "ignore": logger.info("Handoff detected for conv %s, policy=ignore, skipping", inbound_event.conversation_id) return JSONResponse({"status": "skipped", "reason": "handoff_detected"}) if handoff_policy == "note_only": logger.info("Handoff detected for conv %s, policy=note_only, sending note", inbound_event.conversation_id) if client: try: note_body = markdown_to_html( f"AI 建议回复(人工接管中):\n\n{html_to_text(inbound_event.body_html)}" ) await client.reply_conversation( inbound_event.conversation_id, note_body, message_type="note", reply_type="admin", admin_id=bot_admin_id, ) except Exception: logger.warning( "Failed to send handoff note for conv %s", inbound_event.conversation_id, exc_info=True ) return JSONResponse({"status": "note_only", "reason": "handoff_detected"}) if handoff_policy == "continue": logger.info( "Handoff detected for conv %s, policy=continue, still responding", inbound_event.conversation_id, ) last_ts = await _handoff.get_last_admin_reply_time(client, inbound_event.conversation_id, bot_admin_id) if last_ts and time.time() - last_ts < 30: logger.info("Recent human reply in conv %s, delaying AI response by 10s", inbound_event.conversation_id) await asyncio.sleep(10) processor = gateway._processor if processor is not None: msg = IntercomMonitor.convert_event(inbound_event, account_id) if msg is not None: asyncio.create_task( _dispatch_to_agent(processor, msg), name=f"intercom-agent-{account_id}-{inbound_event.conversation_id}", ) else: logger.warning("Message processor not available, cannot dispatch") return JSONResponse({"status": "ok"}) async def _dispatch_to_agent(processor, msg: UnifiedMessage) -> None: try: await asyncio.wait_for(processor.process(msg), timeout=120) except TimeoutError: logger.error("Agent response timeout for intercom:%s", msg.msg_id) except Exception: logger.exception("Failed to process Intercom message %s", msg.msg_id)