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