实现完整的Freshdesk和Freshchat集成支持,包含会话守卫、错误定义、消息去重、配置管理、webhook处理、出站消息发送、状态监控、安全校验、配对功能和流式回复支持
234 lines
8.6 KiB
Python
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
|