ForcePilot/backend/package/yuxi/channel/extensions/freshdesk/webhook.py
Kris 3e861d6cd1 feat(freshdesk): 新增Freshdesk/Freshchat渠道插件
实现完整的Freshdesk和Freshchat集成支持,包含会话守卫、错误定义、消息去重、配置管理、webhook处理、出站消息发送、状态监控、安全校验、配对功能和流式回复支持
2026-05-21 10:47:20 +08:00

234 lines
8.6 KiB
Python

import json
import logging
from fastapi import APIRouter, HTTPException, Request
from yuxi.channel.extensions.freshdesk.constants import VALID_FRESHCHAT_EVENTS, VALID_FRESHDESK_EVENTS
from yuxi.channel.extensions.freshdesk.format import message_parts_to_text, parse_timestamp
from yuxi.channel.extensions.freshdesk.types import FreshdeskInboundEvent
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/channel/freshdesk", tags=["freshdesk"])
def build_webhook_handler(gateway):
async def _handle(request: Request):
if gateway is None:
raise HTTPException(status_code=503, detail="Gateway not initialized")
raw_body = await request.body()
try:
payload = json.loads(raw_body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON")
source = _detect_webhook_source(request.headers, raw_body)
account_entry = _resolve_account(gateway, payload, source)
if not account_entry:
raise HTTPException(status_code=500, detail="Account not found")
account = account_entry["account"]
webhook_secret = account.webhook_secret
x_freshchat_hmac = request.headers.get("X-Freshchat-Hmac-SHA256", "")
if source == "freshchat":
if not gateway.verify_webhook_signature(raw_body, x_freshchat_hmac, webhook_secret):
logger.warning("Invalid Freshchat webhook signature for account %s", account.account_id)
raise HTTPException(status_code=401, detail="Invalid signature")
elif source == "freshdesk":
token = request.query_params.get("token", "")
if webhook_secret and token != webhook_secret:
logger.warning("Invalid Freshdesk webhook token for account %s", account.account_id)
raise HTTPException(status_code=401, detail="Invalid token")
event_type = _extract_event_type(source, payload)
if source == "freshchat" and event_type not in VALID_FRESHCHAT_EVENTS:
return {"status": "ignored", "reason": f"unknown_freshchat_event: {event_type}"}
if source == "freshdesk" and event_type not in VALID_FRESHDESK_EVENTS:
return {"status": "ignored", "reason": f"unknown_freshdesk_event: {event_type}"}
event_id = _extract_event_id(source, payload)
if gateway.dedupe.is_duplicate(event_id):
return {"status": "duplicate"}
if _is_fin_ai_message(payload):
return {"status": "fin_ai_ignored"}
if _is_own_message(source, payload, account_entry["identity"]):
return {"status": "loop_protected"}
inbound_event = _parse_inbound_event(source, payload)
if inbound_event is None:
return {"status": "ignored", "reason": "parse_failed"}
queue = gateway.queue
if queue:
await queue.put(inbound_event)
return {"status": "ok"}
return _handle
def _resolve_account(gateway, payload: dict | None = None, source: str | None = None):
if not gateway.accounts:
return None
if payload and source and len(gateway.accounts) > 1:
domain_hint = None
if source == "freshdesk":
domain_hint = payload.get("freshdesk_domain") or payload.get("domain")
elif source == "freshchat":
data = payload.get("data", {})
account_id = data.get("account_id")
if account_id:
for entry in gateway.accounts.values():
if entry["identity"].freshchat_account_id == str(account_id):
return entry
domain_hint = payload.get("domain")
if domain_hint:
entry = gateway.resolve_account_by_hint(domain_hint=domain_hint)
if entry:
return entry
if len(gateway.accounts) == 1:
return next(iter(gateway.accounts.values()))
return None
def _detect_webhook_source(headers, body_bytes: bytes) -> str:
if headers.get("X-Freshchat-Hmac-SHA256"):
return "freshchat"
try:
body_str = body_bytes.decode("utf-8", errors="replace")
if '"webhookEvent"' in body_str or '"event"' in body_str:
payload = json.loads(body_bytes)
if payload.get("webhookEvent") or payload.get("event") in ("ticket_created", "ticket_updated"):
return "freshdesk"
if payload.get("action") or payload.get("data", {}).get("message"):
return "freshchat"
except Exception:
pass
return "freshchat"
def _is_fin_ai_message(payload: dict) -> bool:
actor_type = payload.get("actor_type", "")
if actor_type == "ai_agent":
return True
msg = payload.get("data", {}).get("message", {})
if msg.get("actor_type") == "ai_agent":
return True
return False
def _is_own_message(source: str, payload: dict, identity) -> bool:
if source == "freshchat":
msg = payload.get("data", {}).get("message", {})
actor_id = str(msg.get("actor_id", ""))
actor_type = msg.get("actor_type", "")
if actor_type == "agent" and actor_id == identity.freshdesk_agent_id:
return True
return False
def _extract_event_type(source: str, payload: dict) -> str:
if source == "freshchat":
return payload.get("action", "")
return payload.get("event", payload.get("webhookEvent", ""))
def _extract_event_id(source: str, payload: dict) -> str:
if source == "freshchat":
return payload.get("event_id", str(payload.get("timestamp", "")))
return f"{payload.get('ticket_id', '')}_{payload.get('timestamp', '')}"
def _parse_inbound_event(source: str, payload: dict) -> FreshdeskInboundEvent | None:
if source == "freshchat":
data = payload.get("data", {})
msg = data.get("message", {})
conv = data.get("conversation", {})
user = data.get("user", {})
message_parts = msg.get("message_parts", [])
content = message_parts_to_text(message_parts)
attachments = _extract_attachments_from_parts(message_parts)
return FreshdeskInboundEvent(
source="freshchat",
event_id=payload.get("event_id", ""),
event_type=payload.get("action", ""),
conversation_id=str(conv.get("conversation_id", "")),
conversation_status=conv.get("status", "new"),
message_id=str(msg.get("id", msg.get("message_id", ""))),
actor_type=msg.get("actor_type", ""),
actor_id=str(msg.get("actor_id", "")),
actor_name=f"{user.get('first_name', '')} {user.get('last_name', '')}".strip(),
content=content,
content_type="text",
message_parts=message_parts,
attachments=attachments,
ticket_id=None,
created_at=parse_timestamp(str(payload.get("timestamp", ""))),
raw=payload,
)
ticket_id = str(payload.get("ticket_id", ""))
ticket_desc = payload.get("ticket_description", payload.get("body", ""))
content_type = "html" if "<" in str(ticket_desc) else "text"
raw_attachments = payload.get("attachments", [])
attachments = []
for att in raw_attachments:
name = att.get("name", "")
url = att.get("attachment_url", "")
ct = att.get("content_type", "")
if not url:
continue
attachments.append({
"type": "file",
"name": name,
"url": url,
"content_type": ct or "application/octet-stream",
})
return FreshdeskInboundEvent(
source="freshdesk",
event_id=f"{ticket_id}_{payload.get('timestamp', '')}",
event_type=payload.get("event", payload.get("webhookEvent", "")),
conversation_id=None,
conversation_status=None,
message_id=ticket_id,
actor_type="contact",
actor_id=str(payload.get("requester_email", "")),
actor_name=payload.get("requester_name", ""),
content=ticket_desc,
content_type=content_type,
message_parts=[],
attachments=attachments,
ticket_id=ticket_id,
created_at=parse_timestamp(str(payload.get("timestamp", payload.get("created_at", "")))),
raw=payload,
)
def _extract_attachments_from_parts(parts: list[dict]) -> list[dict]:
attachments = []
for part in parts:
if "image" in part:
attachments.append({"type": "image", **part["image"]})
elif "file" in part:
attachments.append({"type": "file", **part["file"]})
elif "card" in part:
card = part["card"]
if card.get("image_url"):
attachments.append({"type": "image", "url": card["image_url"]})
return attachments