ForcePilot/backend/package/yuxi/channel/extensions/intercom/webhook.py

266 lines
10 KiB
Python
Raw Normal View History

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)