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

251 lines
8.4 KiB
Python

import asyncio
import logging
import random
from yuxi.channel.extensions.freshdesk.constants import (
FRESHDESK_RETRY_BASE,
FRESHDESK_RETRY_MAX,
FRESHDESK_RETRY_MAX_DELAY,
)
from yuxi.channel.extensions.freshdesk.errors import FreshdeskError, FreshdeskErrorCode
from yuxi.channel.extensions.freshdesk.format import markdown_to_html, sanitize_html
logger = logging.getLogger(__name__)
class FreshdeskOutbound:
delivery_mode = "direct"
chunker_mode = "length"
text_chunk_limit = None
poll_max_options = None
supports_poll_duration_seconds = False
supports_anonymous_polls = False
extract_markdown_images = False
presentation_capabilities = None
delivery_capabilities = None
def __init__(self, gateway):
self._gateway = gateway
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
client, account = self._resolve(account_id)
source = self._detect_target_source(target_id)
if source == "freshchat":
await self._retry_with_backoff(
lambda: client.fc_send_message(
conversation_id=target_id,
message_parts=[{"text": {"content": content}}],
message_type="normal",
actor_type="agent",
actor_id=account.agent_id or None,
)
)
elif source == "freshdesk":
html_body = markdown_to_html(content)
html_body = sanitize_html(html_body)
await self._retry_with_backoff(
lambda: client.fd_reply_ticket(
ticket_id=target_id,
body_html=html_body,
private=False,
)
)
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
client, account = self._resolve(account_id)
source = self._detect_target_source(target_id)
if source == "freshchat":
if media_type.startswith("image/"):
part = {"image": {"url": media_url, "content_type": media_type}}
else:
part = {"file": {"url": media_url, "content_type": media_type}}
await self._retry_with_backoff(
lambda: client.fc_send_message(
conversation_id=target_id,
message_parts=[part],
message_type="normal",
actor_type="agent",
)
)
elif source == "freshdesk":
if media_type.startswith("image/"):
html_body = f'<p><img src="{media_url}" alt="attachment" /></p>'
else:
html_body = f'<p><a href="{media_url}">附件</a></p>'
html_body = sanitize_html(html_body)
await self._retry_with_backoff(
lambda: client.fd_reply_ticket(
ticket_id=target_id,
body_html=html_body,
private=False,
)
)
async def send_quick_replies(
self,
target_id: str,
text: str,
options: list[dict],
*,
account_id: str | None = None,
) -> None:
client, account = self._resolve(account_id)
parts = [{"text": {"content": text}}]
quick_replies_data = [
{"title": opt["title"], "value": opt.get("value", opt["title"])}
for opt in options
]
parts.append({"quick_replies": {"quick_replies": quick_replies_data}})
await self._retry_with_backoff(
lambda: client.fc_send_message(
conversation_id=target_id,
message_parts=parts,
message_type="normal",
actor_type="agent",
actor_id=account.agent_id or None,
)
)
async def send_note(
self,
target_id: str,
content: str,
*,
account_id: str | None = None,
) -> None:
client, account = self._resolve(account_id)
source = self._detect_target_source(target_id)
if source == "freshchat":
note_part = {"private_note": {"content": content}}
await self._retry_with_backoff(
lambda: client.fc_send_message(
conversation_id=target_id,
message_parts=[note_part],
message_type="private",
actor_type="agent",
actor_id=account.agent_id or None,
)
)
elif source == "freshdesk":
html_body = sanitize_html(markdown_to_html(content))
await self._retry_with_backoff(
lambda: client.fd_reply_ticket(
ticket_id=target_id,
body_html=html_body,
private=True,
)
)
async def handoff_conversation(
self,
target_id: str,
*,
group_id: str | None = None,
note: str | None = None,
account_id: str | None = None,
) -> None:
client, account = self._resolve(account_id)
source = self._detect_target_source(target_id)
if source == "freshchat":
await client.fc_assign_conversation(
target_id,
group_id=group_id,
)
if note:
await self.send_note(
target_id,
f"[AI 转接] {note}",
account_id=account_id,
)
elif source == "freshdesk":
update_data: dict = {"status": 3}
if group_id and group_id.isdigit():
update_data["group_id"] = int(group_id)
await client.fd_update_ticket(target_id, update_data)
@staticmethod
def _detect_target_source(target_id: str) -> str:
if target_id.isdigit():
return "freshdesk"
return "freshchat"
def _resolve(self, account_id: str | None):
if self._gateway is None or not self._gateway.accounts:
raise FreshdeskError(FreshdeskErrorCode.CONFIG_ERROR, "Gateway not initialized")
if account_id:
entry = self._gateway.accounts.get(account_id)
else:
entry = next(iter(self._gateway.accounts.values()), None)
if entry is None:
raise FreshdeskError(FreshdeskErrorCode.CONFIG_ERROR, f"Account {account_id or 'default'} not found")
return entry["client"], entry["account"]
async def _retry_with_backoff(self, operation, max_attempts: int | None = None):
max_attempts = max_attempts or FRESHDESK_RETRY_MAX
for attempt in range(max_attempts):
try:
return await operation()
except FreshdeskError as e:
if e.code == FreshdeskErrorCode.RATE_LIMITED and attempt < max_attempts - 1:
delay = e.retry_after or (FRESHDESK_RETRY_BASE * (2 ** attempt))
logger.warning("Freshdesk rate limited, waiting %.1fs", delay)
await asyncio.sleep(delay)
continue
if e.code in (FreshdeskErrorCode.AUTH_ERROR, FreshdeskErrorCode.NOT_FOUND):
raise
if attempt < max_attempts - 1:
delay = min(
FRESHDESK_RETRY_BASE * (2 ** attempt) + random.uniform(0, 1),
FRESHDESK_RETRY_MAX_DELAY,
)
logger.warning("Freshdesk API error, retrying in %.1fs", delay)
await asyncio.sleep(delay)
continue
raise
async def send_payload(self, ctx: object) -> object:
return None
async def send_poll(self, ctx: object) -> object:
return None
def sanitize_text(self, text: str, payload: object) -> str:
return text
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
return False
def normalize_payload(self, payload, config, account_id=None):
return payload
def resolve_effective_text_chunk_limit(self, config, account_id=None, fallback_limit=None):
return fallback_limit