ForcePilot/backend/package/yuxi/channel/extensions/helpscout/outbound.py
Kris 432d5db970 feat(helpscout): 新增Help Scout客服工单渠道插件
完成Help Scout渠道的完整功能实现,包含API客户端、webhook处理、轮询同步、自动回复、工单标签分配、客户资料管理等核心能力,附带完整的配置Schema与插件元信息
2026-05-21 10:49:56 +08:00

155 lines
5.5 KiB
Python

from __future__ import annotations
import logging
from yuxi.channel.extensions.helpscout.auth import HelpScoutAuth
from yuxi.channel.extensions.helpscout.client import HelpScoutClient
from yuxi.channel.extensions.helpscout.config import resolve_account
from yuxi.channel.extensions.helpscout.template import text_to_html_body
from yuxi.channel.extensions.helpscout.types import OutboundResult
logger = logging.getLogger(__name__)
class HelpScoutOutbound:
delivery_mode = "direct"
chunker_mode = "length"
text_chunk_limit: int = 10000
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,
) -> OutboundResult:
account = resolve_account(account_id or "default")
if not account.get("app_id") or not account.get("app_secret") or int(account.get("mailbox_id", 0)) <= 0:
logger.error("Help Scout outbound: account not configured")
return OutboundResult(success=False, error="account_not_configured")
auto_reply_mode = account.get("auto_reply_mode", "draft")
if auto_reply_mode == "off":
logger.debug("Help Scout outbound: auto_reply_mode=off, skipping send")
return OutboundResult(success=False, error="auto_reply_disabled")
conversation_id = int(target_id.split(":")[-1])
client = self._build_client(account)
is_draft = auto_reply_mode == "draft"
html_content = text_to_html_body(content)
try:
result = await client.reply_conversation(
conversation_id=conversation_id,
text=content,
html=html_content,
draft=is_draft,
)
reply_id = result.get("id", "")
logger.info(
"Help Scout reply %s: conv=%d reply_id=%s",
"drafted" if is_draft else "sent",
conversation_id,
reply_id,
)
return OutboundResult(success=True, message_id=str(reply_id))
except Exception as e:
logger.exception("Help Scout send_text failed: conv=%d", conversation_id)
return OutboundResult(success=False, error="exception", detail=str(e))
finally:
await client.close()
async def send_draft(
self,
conversation_id: int,
content: str,
account_id: str | None = None,
) -> OutboundResult:
account = resolve_account(account_id or "default")
client = self._build_client(account)
try:
result = await client.reply_conversation(
conversation_id=conversation_id,
text=content,
draft=True,
)
return OutboundResult(success=True, message_id=str(result.get("id", "")))
except Exception as e:
logger.exception("Help Scout send_draft failed: conv=%d", conversation_id)
return OutboundResult(success=False, error="exception", detail=str(e))
finally:
await client.close()
async def add_note(
self,
conversation_id: int,
text: str,
account_id: str | None = None,
) -> OutboundResult:
account = resolve_account(account_id or "default")
client = self._build_client(account)
try:
result = await client.create_note(
conversation_id=conversation_id,
text=text,
)
return OutboundResult(success=True, message_id=str(result.get("id", "")))
except Exception as e:
logger.exception("Help Scout add_note failed: conv=%d", conversation_id)
return OutboundResult(success=False, error="exception", detail=str(e))
finally:
await client.close()
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
if len(text) <= limit:
return [text]
chunks = []
while len(text) > limit:
break_pos = text.rfind("\n", 0, limit)
if break_pos == -1:
break_pos = limit
chunks.append(text[:break_pos])
text = text[break_pos:].lstrip("\n")
if text:
chunks.append(text)
return chunks
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,
) -> OutboundResult:
account = resolve_account("default")
conversation_id = int(target_id.split(":")[-1])
client = self._build_client(account)
try:
msg = f"[{media_type.upper()}] {media_url}"
result = await client.reply_conversation(
conversation_id=conversation_id,
text=msg,
draft=True,
)
return OutboundResult(success=True, message_id=str(result.get("id", "")))
except Exception as e:
logger.exception("Help Scout send_media failed: conv=%d", conversation_id)
return OutboundResult(success=False, error="exception", detail=str(e))
finally:
await client.close()
def sanitize_text(self, text: str, payload: object) -> str:
return text
@staticmethod
def _build_client(account: dict) -> HelpScoutClient:
auth = HelpScoutAuth(account["app_id"], account["app_secret"])
return HelpScoutClient(auth)