ForcePilot/backend/package/yuxi/channel/extensions/intercom/webhook.py
Kris 2ddcf1cc55 feat(channel): 添加 Intercom 渠道扩展
新增 Intercom 渠道扩展,支持在 Yuxi 平台中集成 Intercom 客服渠道。

包含以下功能模块:
- client: Intercom API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- streaming: 流式消息处理
- format: 消息格式转换
- outbound: 外发消息管理
- handoff: 转人工会话切换
- pairing: 用户配对与绑定
- security: 安全签名校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session_guard: 会话防护
- types: 类型定义
2026-05-21 11:01:49 +08:00

266 lines
10 KiB
Python

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)