新增 Intercom 渠道扩展,支持在 Yuxi 平台中集成 Intercom 客服渠道。 包含以下功能模块: - client: Intercom API 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - streaming: 流式消息处理 - format: 消息格式转换 - outbound: 外发消息管理 - handoff: 转人工会话切换 - pairing: 用户配对与绑定 - security: 安全签名校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session_guard: 会话防护 - types: 类型定义
266 lines
10 KiB
Python
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)
|