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