ForcePilot/backend/package/yuxi/channel/extensions/jd/outbound.py
Kris bb8ade9e2b feat(channel): 添加京东渠道扩展
新增京东(JD)渠道扩展,支持在 Yuxi 平台中集成京东客服渠道。

包含以下功能模块:
- client: 京东 API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- pairing: 用户配对与绑定
- security: 安全校验
- signature: 请求签名验证
- crypto: 加解密处理
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- business: 业务逻辑处理
- types: 类型定义
2026-05-21 11:05:40 +08:00

148 lines
4.9 KiB
Python

import json
import logging
from yuxi.channel.extensions.jd.client import JOSClient
from yuxi.channel.extensions.jd.types import JD_TEXT_MAX_LENGTH
logger = logging.getLogger(__name__)
class JDOutbound:
def __init__(self, gateway=None):
self._gateway = gateway
self._client: JOSClient | None = None
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,
chat_id: str | None = None,
) -> None:
if not content:
return
if len(content) > JD_TEXT_MAX_LENGTH:
content = content[: JD_TEXT_MAX_LENGTH - 3] + "..."
await self._send_msg(target_id, content, msg_type=1, chat_id=chat_id)
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,
chat_id: str | None = None,
) -> None:
if media_type != "image":
logger.warning("JD only supports image, got media_type=%s", media_type)
return
img_content = json.dumps({"url": media_url}, ensure_ascii=False)
await self._send_msg(target_id, img_content, msg_type=2, chat_id=chat_id)
async def send_image(self, to_user: str, media_url: str, *, chat_id: str | None = None) -> bool:
img_content = json.dumps({"url": media_url}, ensure_ascii=False)
return await self._send_msg(to_user, img_content, msg_type=2, chat_id=chat_id)
async def send_link(
self,
target_id: str,
url: str,
text: str,
*,
chat_id: str | None = None,
) -> bool:
link_content = json.dumps({"url": url, "text": text}, ensure_ascii=False)
return await self._send_msg(target_id, link_content, msg_type=3, chat_id=chat_id)
async def send_product_card(
self,
target_id: str,
url: str,
pid: str,
*,
chat_id: str | None = None,
) -> bool:
card_content = json.dumps({"url": url, "pid": pid}, ensure_ascii=False)
return await self._send_msg(target_id, card_content, msg_type=4, chat_id=chat_id)
async def _send_msg(self, to_user: str, content: str, msg_type: int = 1, *, chat_id: str | None = None) -> bool:
gateway = self._gateway
if gateway is None or not (token := gateway.get_token()) or not (account := gateway.account):
logger.error("No JD gateway/token/account available")
return False
if self._client is None:
self._client = JOSClient(
app_key=account.app_key,
app_secret=account.app_secret,
shop_id=account.shop_id,
)
biz_params = {
"chat_id": chat_id or "",
"from_id": account.shop_id,
"to_id": to_user,
"msg_type": msg_type,
"content": content,
}
try:
data = await self._client.call("jingdong.im.sendMsg", biz_params, token)
result = data.get("jingdong_im_sendMsg_responce", {}).get("result", {})
logger.debug("JD message sent: msg_id=%s to=%s", result.get("msg_id", ""), to_user)
return True
except Exception:
logger.exception("JD send_msg failed for user %s", to_user)
return False
async def recall_msg(self, msg_id: str, chat_id: str | None = None) -> bool:
gateway = self._gateway
if gateway is None or not (token := gateway.get_token()) or not (account := gateway.account):
logger.error("No JD gateway/token/account available for recall")
return False
if self._client is None:
self._client = JOSClient(
app_key=account.app_key,
app_secret=account.app_secret,
shop_id=account.shop_id,
)
biz_params = {"msg_id": msg_id, "chat_id": chat_id or ""}
try:
await self._client.call("jingdong.im.recallMsg", biz_params, token)
logger.debug("JD message recalled: msg_id=%s", msg_id)
return True
except Exception:
logger.exception("JD recall msg failed: msg_id=%s", msg_id)
return False
def chunker(self, text: str, max_chars: int = JD_TEXT_MAX_LENGTH) -> list[str]:
if len(text) <= max_chars:
return [text]
chunks = []
while text:
if len(text) <= max_chars:
chunks.append(text)
break
split_at = text.rfind("\n", 0, max_chars)
if split_at == -1:
split_at = max_chars
chunks.append(text[:split_at])
text = text[split_at:].lstrip()
return chunks
async def close(self):
if self._client:
await self._client.close()
self._client = None