feat(channel): 添加京东渠道扩展
新增京东(JD)渠道扩展,支持在 Yuxi 平台中集成京东客服渠道。 包含以下功能模块: - client: 京东 API 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - pairing: 用户配对与绑定 - security: 安全校验 - signature: 请求签名验证 - crypto: 加解密处理 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - business: 业务逻辑处理 - types: 类型定义
This commit is contained in:
parent
2ddcf1cc55
commit
bb8ade9e2b
403
backend/package/yuxi/channel/extensions/jd/__init__.py
Normal file
403
backend/package/yuxi/channel/extensions/jd/__init__.py
Normal file
@ -0,0 +1,403 @@
|
||||
import logging
|
||||
|
||||
from yuxi.channel.capabilities import ChannelCapabilities
|
||||
from yuxi.channel.context import ChannelContext
|
||||
from yuxi.channel.extensions.base import BaseChannelPlugin
|
||||
from yuxi.channel.extensions.jd.business import JDBusiness
|
||||
from yuxi.channel.extensions.jd.client import JOSClient
|
||||
from yuxi.channel.extensions.jd.config import JDConfig
|
||||
from yuxi.channel.extensions.jd.dedupe import MessageDeduplicator
|
||||
from yuxi.channel.extensions.jd.gateway import JDGateway
|
||||
from yuxi.channel.extensions.jd.monitor import JDMonitor
|
||||
from yuxi.channel.extensions.jd.outbound import JDOutbound
|
||||
from yuxi.channel.extensions.jd.pairing import JDPairing
|
||||
from yuxi.channel.extensions.jd.security import JDSecurity
|
||||
from yuxi.channel.extensions.jd.session import JDSession
|
||||
from yuxi.channel.extensions.jd.status import JDStatus
|
||||
from yuxi.channel.extensions.jd.webhook import set_plugin
|
||||
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
||||
from yuxi.channel.protocols import ChannelAccountSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JDPlugin(BaseChannelPlugin):
|
||||
id = "jd"
|
||||
name = "京东/咚咚"
|
||||
order = 90
|
||||
label = "京东咚咚 (Webhook + JOS API)"
|
||||
aliases = ["jd", "dongdong", "京东", "京麦"]
|
||||
resolve_reply_to_mode = "off"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._config_adapter = JDConfig()
|
||||
self._gateway: JDGateway | None = None
|
||||
self._outbound: JDOutbound | None = None
|
||||
self._status: JDStatus | None = None
|
||||
self._security: JDSecurity | None = None
|
||||
self._pairing = JDPairing()
|
||||
self._business: JDBusiness | None = None
|
||||
self._session: JDSession | None = None
|
||||
self._deduplicator = MessageDeduplicator()
|
||||
self._monitor = JDMonitor()
|
||||
|
||||
@property
|
||||
def capabilities(self) -> ChannelCapabilities:
|
||||
return ChannelCapabilities(
|
||||
chat_types=["direct"],
|
||||
message_types=["text", "image", "link", "product_card"],
|
||||
reactions=False,
|
||||
typing_indicator=False,
|
||||
threads=False,
|
||||
edit=False,
|
||||
unsend=True,
|
||||
reply=False,
|
||||
media=True,
|
||||
effects=False,
|
||||
native_commands=False,
|
||||
polls=False,
|
||||
group_management=False,
|
||||
streaming=False,
|
||||
streaming_mode=None,
|
||||
block_streaming=False,
|
||||
tts=None,
|
||||
)
|
||||
|
||||
def list_account_ids(self, config: dict | None = None) -> list[str]:
|
||||
return self._config_adapter.list_account_ids(config)
|
||||
|
||||
async def resolve_account(self, account_id: str = "default") -> dict:
|
||||
account = self._config_adapter.resolve_account(account_id)
|
||||
return {
|
||||
"account_id": account.account_id,
|
||||
"app_key": account.app_key,
|
||||
"app_secret": account.app_secret,
|
||||
"shop_id": account.shop_id,
|
||||
"access_token": account.access_token,
|
||||
"refresh_token": account.refresh_token,
|
||||
"dm_policy": account.dm_policy,
|
||||
"allow_from": account.allow_from,
|
||||
}
|
||||
|
||||
def is_configured(self, account: dict | None = None) -> bool:
|
||||
return self._config_adapter.is_configured(account)
|
||||
|
||||
def config_schema(self) -> dict:
|
||||
return self._config_adapter.config_schema()
|
||||
|
||||
async def start(self, ctx) -> object:
|
||||
self._gateway = JDGateway()
|
||||
result = await self._gateway.start(ctx)
|
||||
self._outbound = JDOutbound(self._gateway)
|
||||
self._status = JDStatus(self._gateway)
|
||||
|
||||
account = self._config_adapter.resolve_account()
|
||||
self._security = JDSecurity(account)
|
||||
|
||||
client = JOSClient(
|
||||
app_key=account.app_key,
|
||||
app_secret=account.app_secret,
|
||||
shop_id=account.shop_id,
|
||||
)
|
||||
self._business = JDBusiness(client, account.shop_id)
|
||||
self._session = JDSession(client)
|
||||
await self._session.start()
|
||||
|
||||
set_plugin(self)
|
||||
return result
|
||||
|
||||
async def stop(self, ctx) -> None:
|
||||
if self._session:
|
||||
await self._session.stop()
|
||||
self._session = None
|
||||
if self._business:
|
||||
if hasattr(self._business, "_client") and self._business._client:
|
||||
await self._business._client.close()
|
||||
self._business = None
|
||||
if self._gateway:
|
||||
await self._gateway.stop(ctx)
|
||||
self._gateway = None
|
||||
self._outbound = None
|
||||
self._status = None
|
||||
|
||||
async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None:
|
||||
if prev_cfg != next_cfg:
|
||||
logger.info("JD config changed, reloading...")
|
||||
ctx = ChannelContext(channel_type="jd", account_id=account_id, config=next_cfg)
|
||||
await self.stop(ctx)
|
||||
await self.start(ctx)
|
||||
|
||||
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 chat_id is None and self._gateway:
|
||||
chat_id = self._gateway.get_cached_chat_id(target_id)
|
||||
|
||||
if self._outbound:
|
||||
await self._outbound.send_text(
|
||||
target_id, content, reply_to_id=reply_to_id, thread_id=thread_id, account_id=account_id, 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 chat_id is None and self._gateway:
|
||||
chat_id = self._gateway.get_cached_chat_id(target_id)
|
||||
|
||||
if self._outbound:
|
||||
await self._outbound.send_media(
|
||||
target_id, media_url, media_type, reply_to_id=reply_to_id, thread_id=thread_id, chat_id=chat_id
|
||||
)
|
||||
|
||||
async def send_link(
|
||||
self,
|
||||
target_id: str,
|
||||
url: str,
|
||||
text: str,
|
||||
*,
|
||||
chat_id: str | None = None,
|
||||
) -> None:
|
||||
if chat_id is None and self._gateway:
|
||||
chat_id = self._gateway.get_cached_chat_id(target_id)
|
||||
|
||||
if self._outbound:
|
||||
await self._outbound.send_link(target_id, url, text, chat_id=chat_id)
|
||||
|
||||
async def send_product_card(
|
||||
self,
|
||||
target_id: str,
|
||||
url: str,
|
||||
pid: str,
|
||||
*,
|
||||
chat_id: str | None = None,
|
||||
) -> None:
|
||||
if chat_id is None and self._gateway:
|
||||
chat_id = self._gateway.get_cached_chat_id(target_id)
|
||||
|
||||
if self._outbound:
|
||||
await self._outbound.send_product_card(target_id, url, pid, chat_id=chat_id)
|
||||
|
||||
def build_system_prompt(self, context) -> str | None:
|
||||
return (
|
||||
"你是京东店铺的 AI 客服助手,当前对话通过京东咚咚平台进行。\n"
|
||||
"\n"
|
||||
"重要约束:\n"
|
||||
"- 你只能回复纯文本消息,无法直接发送链接/按钮/富媒体格式(但可使用推荐工具发送商品链接和卡片)\n"
|
||||
"- 请使用礼貌、专业的客服语气\n"
|
||||
"- 回复简洁清晰,避免过长的段落(用户使用手机阅读)\n"
|
||||
"- 你可以直接查询订单状态、物流信息、商品详情和用户评价\n"
|
||||
"- 你可以通过工具向用户发送商品推荐链接或商品卡片\n"
|
||||
"- 对于复杂售后问题,建议转接人工客服处理\n"
|
||||
"\n"
|
||||
"你可以帮助用户:\n"
|
||||
"- 回答商品相关咨询(规格、价格、优惠活动、用户评价)\n"
|
||||
"- 查询订单状态(提供订单号即可)\n"
|
||||
"- 查询物流信息(提供订单号即可)\n"
|
||||
"- 了解售后/退款进度\n"
|
||||
"- 根据用户需求推荐商品并发送链接/卡片"
|
||||
)
|
||||
|
||||
@property
|
||||
def channel_format_instructions(self) -> str | None:
|
||||
return (
|
||||
"京东咚咚支持纯文本消息(最大 4096 字符)、图片消息、链接消息和商品卡片消息。"
|
||||
"请移除 Markdown 格式(加粗、斜体、代码块),转为纯文本发送。"
|
||||
"图片通过 URL 发送,链接和商品推荐通过专用工具发送。"
|
||||
"用户使用手机阅读,请保持回复简洁。"
|
||||
)
|
||||
|
||||
async def probe(self, account: dict | None = None) -> bool:
|
||||
if self._status:
|
||||
return await self._status.probe(account)
|
||||
return False
|
||||
|
||||
def build_summary(self, snapshot: object) -> dict:
|
||||
if self._status:
|
||||
return self._status.build_summary(snapshot)
|
||||
return {}
|
||||
|
||||
def build_account_snapshot(
|
||||
self,
|
||||
account: dict,
|
||||
config: dict,
|
||||
runtime: ChannelAccountSnapshot | None = None,
|
||||
probe_result: object | None = None,
|
||||
audit: object | None = None,
|
||||
) -> ChannelAccountSnapshot:
|
||||
if self._status:
|
||||
return self._status.build_account_snapshot(account, config, runtime, probe_result, audit)
|
||||
return ChannelAccountSnapshot(account_id="default")
|
||||
|
||||
def resolve_dm_policy(self) -> dict:
|
||||
if self._security:
|
||||
return {"mode": self._security.resolve_dm_policy(), "allow_from": []}
|
||||
return {"mode": "open", "allow_from": []}
|
||||
|
||||
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
|
||||
if self._security:
|
||||
return self._security.check_allowlist(peer_id, channel_type)
|
||||
return True
|
||||
|
||||
async def generate_code(self, peer_id: str) -> str:
|
||||
code = self._pairing.generate_code(peer_id)
|
||||
return code or ""
|
||||
|
||||
async def verify_code(self, peer_id: str, code: str) -> bool:
|
||||
return self._pairing.verify(peer_id, code)
|
||||
|
||||
def get_agent_tools(self) -> list:
|
||||
return [
|
||||
{
|
||||
"name": "jd_query_order",
|
||||
"description": "查询京东店铺订单。可按订单号精确查询,或按时间范围搜索最近的订单列表。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "string", "description": "订单号(精确查询)"},
|
||||
"start_time": {"type": "string", "description": "查询起始时间,格式 YYYY-MM-DD"},
|
||||
"end_time": {"type": "string", "description": "查询结束时间,格式 YYYY-MM-DD"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "jd_query_logistics",
|
||||
"description": "查询京东订单的物流轨迹信息。需要提供订单号。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "string", "description": "订单号"},
|
||||
},
|
||||
"required": ["order_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "jd_query_product",
|
||||
"description": "查询京东店铺商品的规格、价格等信息。需要提供商品 SKU ID。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sku_id": {"type": "string", "description": "商品 SKU ID(可从商品链接中获取)"},
|
||||
},
|
||||
"required": ["sku_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "jd_query_refund",
|
||||
"description": "查询京东订单的售后/退款申请状态。需要提供订单号。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "string", "description": "订单号"},
|
||||
},
|
||||
"required": ["order_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "jd_query_comments",
|
||||
"description": "查询京东店铺商品的好评率、用户评价等信息。需要提供商品 SKU ID。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sku_id": {"type": "string", "description": "商品 SKU ID"},
|
||||
},
|
||||
"required": ["sku_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "jd_send_product_recommend",
|
||||
"description": "向用户发送商品推荐。可选择发送商品链接消息或商品卡片消息。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "用户咚咚 ID"},
|
||||
"url": {"type": "string", "description": "商品详情页链接"},
|
||||
"text": {"type": "string", "description": "链接描述文字(仅 link 类型需要)"},
|
||||
"sku_id": {"type": "string", "description": "商品 SKU ID(仅 card 类型需要)"},
|
||||
"msg_type": {
|
||||
"type": "string",
|
||||
"enum": ["link", "card"],
|
||||
"description": "消息类型:link=文字链接, card=商品卡片",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "url", "msg_type"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "jd_evaluate_cs",
|
||||
"description": "邀请用户对当前客服服务进行满意度评价。需要评估分数 1-5 分。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chat_id": {"type": "string", "description": "当前会话 chat_id"},
|
||||
"score": {"type": "integer", "description": "评价分数 1-5"},
|
||||
},
|
||||
"required": ["chat_id", "score"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async def execute_agent_tool(self, tool_name: str, params: dict, context: dict) -> dict:
|
||||
if self._business is None:
|
||||
return {"success": False, "error": "JD 业务模块未初始化"}
|
||||
|
||||
token = self._gateway.get_token() if self._gateway else None
|
||||
if not token:
|
||||
return {"success": False, "error": "JD Token 不可用"}
|
||||
|
||||
if tool_name == "jd_query_order":
|
||||
order_id = params.get("order_id", "")
|
||||
if order_id:
|
||||
return await self._business.get_order_detail(order_id, token)
|
||||
else:
|
||||
return await self._business.query_order(
|
||||
start_time=params.get("start_time"),
|
||||
end_time=params.get("end_time"),
|
||||
access_token=token,
|
||||
)
|
||||
|
||||
if tool_name == "jd_query_logistics":
|
||||
return await self._business.query_logistics(params.get("order_id", ""), token)
|
||||
|
||||
if tool_name == "jd_query_product":
|
||||
return await self._business.query_product(params.get("sku_id", ""), token)
|
||||
|
||||
if tool_name == "jd_query_refund":
|
||||
return await self._business.query_refund(params.get("order_id", ""), token)
|
||||
|
||||
if tool_name == "jd_query_comments":
|
||||
return await self._business.query_comments(params.get("sku_id", ""), access_token=token)
|
||||
|
||||
if tool_name == "jd_send_product_recommend":
|
||||
user_id = params.get("user_id", "")
|
||||
msg_type = params.get("msg_type", "link")
|
||||
url = params.get("url", "")
|
||||
if msg_type == "link":
|
||||
await self.send_link(user_id, url, params.get("text", ""))
|
||||
elif msg_type == "card":
|
||||
await self.send_product_card(user_id, url, params.get("sku_id", ""))
|
||||
return {"success": True, "msg_type": msg_type}
|
||||
|
||||
if tool_name == "jd_evaluate_cs":
|
||||
return await self._business.evaluate_cs(params.get("chat_id", ""), params.get("score", 5), token)
|
||||
|
||||
return {"success": False, "error": f"未知工具: {tool_name}"}
|
||||
|
||||
|
||||
ChannelPluginRegistry.register(JDPlugin())
|
||||
200
backend/package/yuxi/channel/extensions/jd/business.py
Normal file
200
backend/package/yuxi/channel/extensions/jd/business.py
Normal file
@ -0,0 +1,200 @@
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.jd.client import JOSClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JDBusiness:
|
||||
def __init__(self, client: JOSClient, shop_id: str):
|
||||
self._client = client
|
||||
self._shop_id = shop_id
|
||||
|
||||
async def query_order(
|
||||
self,
|
||||
order_id: str | None = None,
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
access_token: str = "",
|
||||
) -> dict:
|
||||
biz_params = {
|
||||
"optional_fields": "orderInfo",
|
||||
"page": str(page),
|
||||
"page_size": str(page_size),
|
||||
}
|
||||
if order_id:
|
||||
biz_params["order_id"] = order_id
|
||||
if start_time:
|
||||
biz_params["start_date"] = start_time
|
||||
if end_time:
|
||||
biz_params["end_date"] = end_time
|
||||
|
||||
try:
|
||||
data = await self._client.call("jingdong.pop.order.search", biz_params, access_token)
|
||||
return {"success": True, "data": self._flatten_order_result(data)}
|
||||
except Exception as e:
|
||||
logger.error("JD order query failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def get_order_detail(self, order_id: str, access_token: str = "") -> dict:
|
||||
biz_params = {
|
||||
"order_id": order_id,
|
||||
"optional_fields": "orderInfo,paymentInfo,consigneeInfo",
|
||||
}
|
||||
try:
|
||||
data = await self._client.call("jingdong.pop.order.get", biz_params, access_token)
|
||||
return {"success": True, "data": self._flatten_order_detail(data)}
|
||||
except Exception as e:
|
||||
logger.error("JD order detail failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def query_logistics(self, order_id: str, access_token: str = "") -> dict:
|
||||
biz_params = {"order_id": order_id}
|
||||
|
||||
try:
|
||||
data = await self._client.call("jingdong.etms.trace.get", biz_params, access_token)
|
||||
trace_result = data.get("jingdong_etms_trace_get_responce", {}).get("traceResult", {})
|
||||
traces = trace_result.get("traceList", []) if isinstance(trace_result, dict) else []
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"order_id": order_id,
|
||||
"waybill_code": trace_result.get("waybillCode", ""),
|
||||
"traces": traces,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("JD logistics query failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def query_product(self, sku_id: str, access_token: str = "") -> dict:
|
||||
biz_params = {
|
||||
"sku": sku_id,
|
||||
"queryExts": "warebase,wareimage",
|
||||
}
|
||||
|
||||
try:
|
||||
data = await self._client.call("jingdong.ware.product.get", biz_params, access_token)
|
||||
ware_result = data.get("jingdong_ware_product_get_responce", {}).get("product", {})
|
||||
if isinstance(ware_result, dict):
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"sku_id": ware_result.get("sku", ""),
|
||||
"name": ware_result.get("name", ""),
|
||||
"brand_name": ware_result.get("brandName", ""),
|
||||
"category": ware_result.get("category", ""),
|
||||
"introduction": ware_result.get("introduction", ""),
|
||||
"weight": ware_result.get("weight", ""),
|
||||
"sale_date": ware_result.get("saleDate", ""),
|
||||
"upc": ware_result.get("upc", ""),
|
||||
"image_url": ware_result.get("imageUrl", ""),
|
||||
},
|
||||
}
|
||||
return {"success": False, "error": "未找到商品信息"}
|
||||
except Exception as e:
|
||||
logger.error("JD product query failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def query_product_price(self, sku_ids: list[str], access_token: str = "") -> dict:
|
||||
biz_params = {"sku": ",".join(sku_ids)}
|
||||
|
||||
try:
|
||||
data = await self._client.call("jingdong.ware.price.get", biz_params, access_token)
|
||||
price_list = data.get("jingdong_ware_price_get_responce", {}).get("priceList", [])
|
||||
return {"success": True, "data": price_list}
|
||||
except Exception as e:
|
||||
logger.error("JD product price query failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def query_refund(self, order_id: str, access_token: str = "") -> dict:
|
||||
biz_params = {"order_id": order_id}
|
||||
try:
|
||||
data = await self._client.call("jingdong.pop.afs.soa.refundapply.get", biz_params, access_token)
|
||||
result = data.get("jingdong_pop_afs_soa_refundapply_get_responce", {}).get("queryResult", {})
|
||||
return {"success": True, "data": result}
|
||||
except Exception as e:
|
||||
logger.error("JD refund query failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def set_cs_status(self, status: str, access_token: str = "") -> dict:
|
||||
biz_params = {"status": status}
|
||||
try:
|
||||
await self._client.call("jingdong.im.setStatus", biz_params, access_token)
|
||||
return {"success": True, "status": status}
|
||||
except Exception as e:
|
||||
logger.error("JD set cs status failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def get_cs_status(self, access_token: str = "") -> dict:
|
||||
try:
|
||||
data = await self._client.call("jingdong.im.getStatus", {}, access_token)
|
||||
return {"success": True, "data": data}
|
||||
except Exception as e:
|
||||
logger.error("JD get cs status failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def get_user_info(self, user_id: str, access_token: str = "") -> dict:
|
||||
biz_params = {"user_id": user_id}
|
||||
|
||||
try:
|
||||
data = await self._client.call("jingdong.im.getUserInfo", biz_params, access_token)
|
||||
user_info = data.get("jingdong_im_getUserInfo_responce", {}).get("result", {})
|
||||
return {"success": True, "data": user_info if isinstance(user_info, dict) else {}}
|
||||
except Exception as e:
|
||||
logger.error("JD get user info failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def query_comments(self, sku_id: str, page: int = 1, page_size: int = 10, access_token: str = "") -> dict:
|
||||
biz_params = {"sku": sku_id, "page": str(page), "pageSize": str(page_size)}
|
||||
|
||||
try:
|
||||
data = await self._client.call("jingdong.comment.products.get", biz_params, access_token)
|
||||
result = data.get("jingdong_comment_products_get_responce", {}).get("result", {})
|
||||
return {"success": True, "data": result}
|
||||
except Exception as e:
|
||||
logger.error("JD comment query failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def evaluate_cs(self, chat_id: str, score: int, access_token: str = "") -> dict:
|
||||
biz_params = {"chat_id": chat_id, "score": str(score)}
|
||||
try:
|
||||
await self._client.call("jingdong.im.evaluate", biz_params, access_token)
|
||||
return {"success": True, "score": score}
|
||||
except Exception as e:
|
||||
logger.error("JD evaluate cs failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _flatten_order_result(self, data: dict) -> list[dict]:
|
||||
result = data.get("jingdong_pop_order_search_responce", {}).get("searchorderinforesult", {})
|
||||
order_list = result.get("orderInfoList", [])
|
||||
if isinstance(order_list, dict):
|
||||
order_list = [order_list]
|
||||
return [
|
||||
{
|
||||
"order_id": o.get("orderId", ""),
|
||||
"order_state": o.get("orderState", ""),
|
||||
"order_total_price": o.get("orderTotalPrice", ""),
|
||||
"order_create_time": o.get("orderCreateTime", ""),
|
||||
"order_payment_time": o.get("orderPaymentTime", ""),
|
||||
}
|
||||
for o in order_list
|
||||
]
|
||||
|
||||
def _flatten_order_detail(self, data: dict) -> dict:
|
||||
result = data.get("jingdong_pop_order_get_responce", {}).get("orderDetailInfo", {})
|
||||
order_info = result.get("orderInfo", {}) if isinstance(result, dict) else {}
|
||||
return {
|
||||
"order_id": order_info.get("orderId", ""),
|
||||
"order_state": order_info.get("orderState", ""),
|
||||
"order_total_price": order_info.get("orderTotalPrice", ""),
|
||||
"freight_price": order_info.get("freightPrice", ""),
|
||||
"seller_discount": order_info.get("sellerDiscount", ""),
|
||||
"order_create_time": order_info.get("orderCreateTime", ""),
|
||||
"order_payment_time": order_info.get("orderPaymentTime", ""),
|
||||
"consignee_name": order_info.get("consigneeInfo", {}).get("fullname", ""),
|
||||
"consignee_mobile": order_info.get("consigneeInfo", {}).get("mobile", ""),
|
||||
"consignee_address": order_info.get("consigneeInfo", {}).get("fullAddress", ""),
|
||||
}
|
||||
83
backend/package/yuxi/channel/extensions/jd/client.py
Normal file
83
backend/package/yuxi/channel/extensions/jd/client.py
Normal file
@ -0,0 +1,83 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from yuxi.channel.extensions.jd.signature import build_signed_params
|
||||
from yuxi.channel.extensions.jd.types import JOS_API_GATEWAY, JOS_MAX_RETRIES, JOS_RETRY_BASE_DELAY, OutboundResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JOSClient:
|
||||
def __init__(self, app_key: str, app_secret: str, shop_id: str):
|
||||
self._app_key = app_key
|
||||
self._app_secret = app_secret
|
||||
self._shop_id = shop_id
|
||||
self._http = httpx.AsyncClient(timeout=15.0)
|
||||
|
||||
async def close(self):
|
||||
await self._http.aclose()
|
||||
|
||||
async def call(self, method: str, biz_params: dict, access_token: str) -> dict:
|
||||
params = build_signed_params(
|
||||
method=method,
|
||||
biz_params=biz_params,
|
||||
app_key=self._app_key,
|
||||
app_secret=self._app_secret,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
for attempt in range(JOS_MAX_RETRIES):
|
||||
try:
|
||||
resp = await self._http.post(JOS_API_GATEWAY, data=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
error_response = data.get("error_response")
|
||||
if error_response:
|
||||
code = error_response.get("code")
|
||||
msg = error_response.get("zh_desc", "Unknown error")
|
||||
logger.error("JOS API error [%s]: %s", code, msg)
|
||||
raise ValueError(f"JOS API error {code}: {msg}")
|
||||
|
||||
return data
|
||||
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as e:
|
||||
logger.warning("JOS API retry %d/%d: %s", attempt + 1, JOS_MAX_RETRIES, e)
|
||||
if attempt == JOS_MAX_RETRIES - 1:
|
||||
raise
|
||||
await asyncio.sleep(JOS_RETRY_BASE_DELAY * (2**attempt))
|
||||
|
||||
raise RuntimeError("JOS API max retries exceeded")
|
||||
|
||||
async def send_msg(
|
||||
self,
|
||||
chat_id: str,
|
||||
to_id: str,
|
||||
msg_type: int,
|
||||
content: str,
|
||||
access_token: str,
|
||||
) -> OutboundResult:
|
||||
biz_params = {
|
||||
"chat_id": chat_id,
|
||||
"from_id": self._shop_id,
|
||||
"to_id": to_id,
|
||||
"msg_type": msg_type,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
try:
|
||||
data = await self.call("jingdong.im.sendMsg", biz_params, access_token)
|
||||
result = data.get("jingdong_im_sendMsg_responce", {}).get("result", {})
|
||||
return OutboundResult(
|
||||
message_id=result.get("msg_id", ""),
|
||||
success=True,
|
||||
)
|
||||
except Exception as e:
|
||||
return OutboundResult(
|
||||
message_id="",
|
||||
success=False,
|
||||
error_code="JOS_ERROR",
|
||||
error_msg=str(e),
|
||||
)
|
||||
102
backend/package/yuxi/channel/extensions/jd/config.py
Normal file
102
backend/package/yuxi/channel/extensions/jd/config.py
Normal file
@ -0,0 +1,102 @@
|
||||
import os
|
||||
|
||||
from yuxi.channel.extensions.jd.types import JDAccount
|
||||
|
||||
|
||||
class JDConfig:
|
||||
ENV_MAP = {
|
||||
"app_key": "JD_APP_KEY",
|
||||
"app_secret": "JD_APP_SECRET",
|
||||
"shop_id": "JD_SHOP_ID",
|
||||
"dm_policy": "JD_DM_POLICY",
|
||||
}
|
||||
|
||||
def list_account_ids(self, config: dict | None = None) -> list[str]:
|
||||
channels = (config or {}).get("channels", {})
|
||||
jd_config = channels.get("jd", {})
|
||||
accounts = jd_config.get("accounts", {})
|
||||
if accounts:
|
||||
return list(accounts.keys())
|
||||
if self._env_or_config("app_key", config):
|
||||
return ["default"]
|
||||
return []
|
||||
|
||||
def resolve_account(self, account_id: str = "default", config: dict | None = None) -> JDAccount:
|
||||
channels = (config or {}).get("channels", {})
|
||||
jd_config = channels.get("jd", {})
|
||||
accounts = jd_config.get("accounts", {})
|
||||
account_data = accounts.get(account_id, {})
|
||||
|
||||
return JDAccount(
|
||||
account_id=account_id,
|
||||
app_key=account_data.get("app_key") or self._env_or_config("app_key") or "",
|
||||
app_secret=account_data.get("app_secret") or self._env_or_config("app_secret") or "",
|
||||
shop_id=account_data.get("shop_id") or self._env_or_config("shop_id") or "",
|
||||
access_token=account_data.get("access_token"),
|
||||
refresh_token=account_data.get("refresh_token"),
|
||||
webhook_token=account_data.get("webhook_token"),
|
||||
dm_policy=account_data.get("dm_policy") or self._env_or_config("dm_policy") or "pairing",
|
||||
webhook_sign_algorithm=account_data.get("webhook_sign_algorithm", "hmac-sha256"),
|
||||
allow_from=account_data.get("allow_from", []),
|
||||
)
|
||||
|
||||
def is_configured(self, account: dict | None = None) -> bool:
|
||||
if account:
|
||||
return bool(account.get("app_key") and account.get("app_secret") and account.get("shop_id"))
|
||||
return bool(
|
||||
self._env_or_config("app_key") and self._env_or_config("app_secret") and self._env_or_config("shop_id")
|
||||
)
|
||||
|
||||
def _env_or_config(self, key: str, config: dict | None = None) -> str | None:
|
||||
env_key = self.ENV_MAP.get(key, "")
|
||||
if env_key:
|
||||
val = os.getenv(env_key)
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
def config_schema(self) -> dict:
|
||||
return {
|
||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "京东/咚咚渠道配置",
|
||||
"properties": {
|
||||
"appKey": {
|
||||
"type": "string",
|
||||
"title": "App Key",
|
||||
"description": "京东开放平台应用 App Key",
|
||||
},
|
||||
"appSecret": {
|
||||
"type": "string",
|
||||
"title": "App Secret",
|
||||
"x-ui-password": True,
|
||||
"description": "京东开放平台应用 App Secret",
|
||||
},
|
||||
"shopId": {
|
||||
"type": "string",
|
||||
"title": "店铺 ID",
|
||||
"description": "京东店铺 ID(用于标识发送方)",
|
||||
},
|
||||
"webhookToken": {
|
||||
"type": "string",
|
||||
"title": "Webhook Token",
|
||||
"x-ui-password": True,
|
||||
"description": "咚咚消息推送签名验证 Token(如配置)",
|
||||
},
|
||||
"dmPolicy": {
|
||||
"type": "string",
|
||||
"title": "DM 策略",
|
||||
"enum": ["pairing", "allowlist", "open", "disabled"],
|
||||
"default": "pairing",
|
||||
"description": "私聊安全策略:pairing=配对码, allowlist=白名单, open=完全开放",
|
||||
},
|
||||
"webhookSignAlgorithm": {
|
||||
"type": "string",
|
||||
"title": "Webhook 签名算法",
|
||||
"enum": ["hmac-sha256", "md5"],
|
||||
"default": "hmac-sha256",
|
||||
"description": "咚咚 Webhook 消息推送签名算法",
|
||||
},
|
||||
},
|
||||
"required": ["appKey", "appSecret", "shopId"],
|
||||
}
|
||||
28
backend/package/yuxi/channel/extensions/jd/crypto.py
Normal file
28
backend/package/yuxi/channel/extensions/jd/crypto.py
Normal file
@ -0,0 +1,28 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def decrypt_jd_message(encrypted_body: str, app_secret: str) -> dict | None:
|
||||
try:
|
||||
key = hashlib.md5(app_secret.encode("utf-8")).hexdigest()[:16].encode("utf-8")
|
||||
raw = base64.b64decode(encrypted_body)
|
||||
|
||||
iv = raw[:16]
|
||||
ciphertext = raw[16:]
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(ciphertext)
|
||||
|
||||
pad_len = decrypted[-1]
|
||||
decrypted = decrypted[:-pad_len]
|
||||
|
||||
return json.loads(decrypted.decode("utf-8"))
|
||||
except Exception:
|
||||
logger.warning("JD message AES decrypt failed")
|
||||
return None
|
||||
38
backend/package/yuxi/channel/extensions/jd/dedupe.py
Normal file
38
backend/package/yuxi/channel/extensions/jd/dedupe.py
Normal file
@ -0,0 +1,38 @@
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
class MessageDeduplicator:
|
||||
MAX_SIZE = 10000
|
||||
TTL_SECONDS = 300
|
||||
|
||||
def __init__(self, max_size: int = MAX_SIZE, ttl: int = TTL_SECONDS):
|
||||
self._cache: OrderedDict[str, float] = OrderedDict()
|
||||
self._max_size = max_size
|
||||
self._ttl = ttl
|
||||
|
||||
def is_duplicate(self, msg_id: str) -> bool:
|
||||
if not msg_id:
|
||||
return False
|
||||
|
||||
self._evict_expired()
|
||||
|
||||
if msg_id in self._cache:
|
||||
return True
|
||||
|
||||
self._cache[msg_id] = time.time()
|
||||
self._cache.move_to_end(msg_id)
|
||||
|
||||
while len(self._cache) > self._max_size:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
return False
|
||||
|
||||
def _evict_expired(self):
|
||||
now = time.time()
|
||||
expired = [k for k, ts in self._cache.items() if now - ts > self._ttl]
|
||||
for k in expired:
|
||||
del self._cache[k]
|
||||
|
||||
def reset(self):
|
||||
self._cache.clear()
|
||||
20
backend/package/yuxi/channel/extensions/jd/errors.py
Normal file
20
backend/package/yuxi/channel/extensions/jd/errors.py
Normal file
@ -0,0 +1,20 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class JDErrorCode(StrEnum):
|
||||
TOKEN_INVALID = "JD_TOKEN_INVALID"
|
||||
TOKEN_EXPIRED = "JD_TOKEN_EXPIRED"
|
||||
JOS_API_ERROR = "JD_JOS_API_ERROR"
|
||||
NETWORK_ERROR = "JD_NETWORK_ERROR"
|
||||
SIGNATURE_ERROR = "JD_SIGNATURE_ERROR"
|
||||
DECRYPT_ERROR = "JD_DECRYPT_ERROR"
|
||||
WEBHOOK_REJECTED = "JD_WEBHOOK_REJECTED"
|
||||
RATE_LIMITED = "JD_RATE_LIMITED"
|
||||
|
||||
|
||||
class JDError(Exception):
|
||||
def __init__(self, code: JDErrorCode, message: str, detail: dict | None = None):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.detail = detail or {}
|
||||
super().__init__(message)
|
||||
19
backend/package/yuxi/channel/extensions/jd/format.py
Normal file
19
backend/package/yuxi/channel/extensions/jd/format.py
Normal file
@ -0,0 +1,19 @@
|
||||
import re
|
||||
|
||||
from yuxi.channel.extensions.jd.types import JD_TEXT_MAX_LENGTH
|
||||
|
||||
|
||||
def remove_markdown(text: str) -> str:
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
text = re.sub(r"`(.+?)`", r"\1", text)
|
||||
text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text)
|
||||
text = re.sub(r"^\s*[-*+]\s+", "• ", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def truncate(text: str, max_chars: int = JD_TEXT_MAX_LENGTH) -> str:
|
||||
if len(text) > max_chars:
|
||||
return text[: max_chars - 3] + "..."
|
||||
return text
|
||||
239
backend/package/yuxi/channel/extensions/jd/gateway.py
Normal file
239
backend/package/yuxi/channel/extensions/jd/gateway.py
Normal file
@ -0,0 +1,239 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from yuxi.channel.extensions.jd.config import JDConfig
|
||||
from yuxi.channel.extensions.jd.types import JDAccount, JOSToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OAUTH_TOKEN_URL = "https://oauth.jd.com/oauth/token"
|
||||
TOKEN_REFRESH_MARGIN = 300
|
||||
REFRESH_INTERVAL = 1800
|
||||
RETRY_MAX = 3
|
||||
RETRY_BASE_DELAY = 2
|
||||
|
||||
_TOKEN_STORE_DIR = os.path.join(os.path.dirname(__file__), ".token_cache")
|
||||
|
||||
|
||||
class JDGateway:
|
||||
def __init__(self):
|
||||
self._account: JDAccount | None = None
|
||||
self._queue: asyncio.Queue | None = None
|
||||
self._cancel_event = asyncio.Event()
|
||||
self._access_token: str | None = None
|
||||
self._token_obj: JOSToken | None = None
|
||||
self._token_expires_at: float = 0
|
||||
self._refresh_task: asyncio.Task | None = None
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._running = False
|
||||
self._token_lock = asyncio.Lock()
|
||||
self._last_chat_id: dict[str, str] = {}
|
||||
|
||||
async def start(self, ctx) -> object:
|
||||
config = JDConfig()
|
||||
account = config.resolve_account(ctx.account_id if hasattr(ctx, "account_id") else "default")
|
||||
|
||||
if not account.is_configured():
|
||||
logger.warning("JD account not configured, skipping start")
|
||||
return {"running": False, "reason": "not-configured"}
|
||||
|
||||
self._account = account
|
||||
self._queue = asyncio.Queue(maxsize=1000)
|
||||
self._cancel_event.clear()
|
||||
self._http = httpx.AsyncClient(timeout=15.0)
|
||||
|
||||
try:
|
||||
await self._restore_or_fetch_token()
|
||||
self._refresh_task = asyncio.create_task(self._token_refresh_loop())
|
||||
self._running = True
|
||||
logger.info(
|
||||
"JD gateway started: account=%s shop=%s token_expires=%d",
|
||||
account.account_id,
|
||||
account.shop_id,
|
||||
int(self._token_expires_at - time.time()) if self._token_expires_at else 0,
|
||||
)
|
||||
return {
|
||||
"running": True,
|
||||
"account_id": account.account_id,
|
||||
"shop_id": account.shop_id,
|
||||
"queue": self._queue,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("JD gateway failed to start")
|
||||
await self.stop(ctx)
|
||||
raise
|
||||
|
||||
async def stop(self, ctx) -> None:
|
||||
self._running = False
|
||||
self._cancel_event.set()
|
||||
|
||||
if self._refresh_task and not self._refresh_task.done():
|
||||
self._refresh_task.cancel()
|
||||
try:
|
||||
await self._refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self._http:
|
||||
await self._http.aclose()
|
||||
self._http = None
|
||||
|
||||
self._queue = None
|
||||
self._last_chat_id.clear()
|
||||
logger.info("JD gateway stopped")
|
||||
|
||||
@property
|
||||
def account(self) -> JDAccount | None:
|
||||
return self._account
|
||||
|
||||
@property
|
||||
def queue(self) -> asyncio.Queue | None:
|
||||
return self._queue
|
||||
|
||||
def get_token(self, account_id: str = "default") -> str | None:
|
||||
if self._access_token and time.time() < self._token_expires_at:
|
||||
return self._access_token
|
||||
return None
|
||||
|
||||
def get_account(self, account_id: str = "default") -> JDAccount | None:
|
||||
return self._account
|
||||
|
||||
def cache_chat_id(self, user_id: str, chat_id: str):
|
||||
self._last_chat_id[user_id] = chat_id
|
||||
|
||||
def get_cached_chat_id(self, user_id: str) -> str | None:
|
||||
return self._last_chat_id.get(user_id)
|
||||
|
||||
async def _restore_or_fetch_token(self):
|
||||
account = self._account
|
||||
|
||||
loaded = await self._load_token_from_disk()
|
||||
if loaded and loaded.get("expires_at", 0) > time.time() + TOKEN_REFRESH_MARGIN:
|
||||
self._access_token = loaded["access_token"]
|
||||
self._token_expires_at = loaded["expires_at"]
|
||||
if loaded.get("refresh_token"):
|
||||
self._account.refresh_token = loaded["refresh_token"]
|
||||
logger.info("JD token restored from disk")
|
||||
return
|
||||
|
||||
if account.access_token and account.token_expires_at:
|
||||
if account.token_expires_at > time.time() + TOKEN_REFRESH_MARGIN:
|
||||
self._access_token = account.access_token
|
||||
self._token_expires_at = account.token_expires_at
|
||||
return
|
||||
|
||||
if account.refresh_token:
|
||||
try:
|
||||
await self._refresh_token()
|
||||
except Exception as e:
|
||||
logger.error("JD token refresh failed on start: %s", e)
|
||||
|
||||
if not self._access_token:
|
||||
logger.warning("JD account %s has no valid access token", account.account_id)
|
||||
|
||||
async def _token_refresh_loop(self):
|
||||
while not self._cancel_event.is_set():
|
||||
try:
|
||||
await asyncio.sleep(REFRESH_INTERVAL)
|
||||
if self._cancel_event.is_set():
|
||||
return
|
||||
|
||||
remaining = self._token_expires_at - time.time()
|
||||
if remaining < TOKEN_REFRESH_MARGIN:
|
||||
logger.info("JD token refresh triggered: remaining=%ds", int(remaining))
|
||||
await self._refresh_token()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("JD token refresh loop error: %s", e)
|
||||
|
||||
async def _refresh_token(self):
|
||||
async with self._token_lock:
|
||||
if not self._account.refresh_token:
|
||||
return
|
||||
|
||||
params = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self._account.app_key,
|
||||
"client_secret": self._account.app_secret,
|
||||
"refresh_token": self._account.refresh_token,
|
||||
}
|
||||
|
||||
for attempt in range(RETRY_MAX):
|
||||
try:
|
||||
resp = await self._http.post(OAUTH_TOKEN_URL, data=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
expires_in = data.get("expires_in", 7200)
|
||||
self._access_token = data["access_token"]
|
||||
self._token_expires_at = time.time() + expires_in - TOKEN_REFRESH_MARGIN
|
||||
|
||||
new_refresh = data.get("refresh_token", "")
|
||||
if new_refresh:
|
||||
self._account.refresh_token = new_refresh
|
||||
|
||||
self._token_obj = JOSToken(
|
||||
access_token=data["access_token"],
|
||||
refresh_token=data.get("refresh_token", self._account.refresh_token or ""),
|
||||
expires_at=time.time() + expires_in,
|
||||
uid=data.get("uid", ""),
|
||||
user_nick=data.get("user_nick", ""),
|
||||
)
|
||||
|
||||
await self._save_token_to_disk()
|
||||
|
||||
logger.info(
|
||||
"JD token refreshed: expires_in=%ds uid=%s",
|
||||
expires_in,
|
||||
data.get("uid", ""),
|
||||
)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("JD token refresh attempt %d failed: %s", attempt + 1, e)
|
||||
if attempt == RETRY_MAX - 1:
|
||||
raise
|
||||
delay = RETRY_BASE_DELAY * (2**attempt)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def _save_token_to_disk(self):
|
||||
if not self._token_obj:
|
||||
return
|
||||
try:
|
||||
os.makedirs(_TOKEN_STORE_DIR, exist_ok=True)
|
||||
token_file = os.path.join(_TOKEN_STORE_DIR, f"{self._account.shop_id}.json")
|
||||
with open(token_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"access_token": self._access_token,
|
||||
"refresh_token": self._account.refresh_token,
|
||||
"expires_at": self._token_expires_at,
|
||||
"uid": self._token_obj.uid,
|
||||
"user_nick": self._token_obj.user_nick,
|
||||
},
|
||||
f,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save JD token to disk: %s", e)
|
||||
|
||||
async def _load_token_from_disk(self) -> dict | None:
|
||||
if not self._account:
|
||||
return None
|
||||
token_file = os.path.join(_TOKEN_STORE_DIR, f"{self._account.shop_id}.json")
|
||||
if not os.path.exists(token_file):
|
||||
return None
|
||||
try:
|
||||
with open(token_file) as f:
|
||||
data = json.load(f)
|
||||
if data.get("expires_at", 0) > time.time():
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load JD token from disk: %s", e)
|
||||
return None
|
||||
63
backend/package/yuxi/channel/extensions/jd/monitor.py
Normal file
63
backend/package/yuxi/channel/extensions/jd/monitor.py
Normal file
@ -0,0 +1,63 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, UTC
|
||||
|
||||
from yuxi.channel.extensions.jd.types import InboundJDMessage
|
||||
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
|
||||
from yuxi.channel.routing.models import PeerKind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JDMonitor:
|
||||
def convert_to_unified(self, msg: InboundJDMessage) -> UnifiedMessage:
|
||||
msg_type = MessageType.TEXT
|
||||
if msg.msg_type == 2:
|
||||
msg_type = MessageType.IMAGE
|
||||
|
||||
content = self._extract_content(msg)
|
||||
|
||||
return UnifiedMessage(
|
||||
msg_id=msg.msg_id,
|
||||
channel_type="jd",
|
||||
account_id="default",
|
||||
content=content,
|
||||
message_type=msg_type,
|
||||
sender=PeerInfo(
|
||||
id=msg.from_id,
|
||||
kind=PeerKind.DIRECT,
|
||||
display_name=msg.from_id,
|
||||
),
|
||||
timestamp=self._parse_timestamp(msg.msg_time),
|
||||
raw_payload=msg.raw,
|
||||
body_for_agent=content,
|
||||
metadata={
|
||||
"chat_id": msg.chat_id,
|
||||
"to_id": msg.to_id,
|
||||
"from_type": msg.from_type,
|
||||
"extend": msg.extend,
|
||||
},
|
||||
)
|
||||
|
||||
def _extract_content(self, msg: InboundJDMessage) -> str:
|
||||
if msg.msg_type in (2, 3, 4):
|
||||
try:
|
||||
data = json.loads(msg.content)
|
||||
if msg.msg_type == 2:
|
||||
return data.get("url", msg.content)
|
||||
if msg.msg_type == 3:
|
||||
return f"{data.get('text', '')}\n{data.get('url', '')}"
|
||||
if msg.msg_type == 4:
|
||||
return f"[商品卡片] {data.get('pid', '')}\n{data.get('url', '')}"
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return msg.content
|
||||
return msg.content
|
||||
|
||||
def _parse_timestamp(self, msg_time: str) -> datetime | None:
|
||||
try:
|
||||
dt = datetime.strptime(msg_time, "%Y-%m-%d %H:%M:%S")
|
||||
return dt.replace(tzinfo=UTC)
|
||||
except (ValueError, TypeError):
|
||||
import time
|
||||
|
||||
return datetime.fromtimestamp(time.time(), tz=UTC)
|
||||
147
backend/package/yuxi/channel/extensions/jd/outbound.py
Normal file
147
backend/package/yuxi/channel/extensions/jd/outbound.py
Normal file
@ -0,0 +1,147 @@
|
||||
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
|
||||
57
backend/package/yuxi/channel/extensions/jd/pairing.py
Normal file
57
backend/package/yuxi/channel/extensions/jd/pairing.py
Normal file
@ -0,0 +1,57 @@
|
||||
import random
|
||||
import time
|
||||
|
||||
CODE_LENGTH = 6
|
||||
CODE_TTL_SECONDS = 600
|
||||
PENDING_MAX = 3
|
||||
RATE_LIMIT_SECONDS = 3600
|
||||
|
||||
|
||||
class JDPairing:
|
||||
def __init__(self):
|
||||
self._pending: dict[str, dict] = {}
|
||||
self._user_last_code_at: dict[str, float] = {}
|
||||
|
||||
def generate_code(self, user_id: str) -> str | None:
|
||||
now = time.time()
|
||||
|
||||
if user_id in self._user_last_code_at:
|
||||
elapsed = now - self._user_last_code_at[user_id]
|
||||
if elapsed < RATE_LIMIT_SECONDS:
|
||||
return None
|
||||
|
||||
if len(self._pending) >= PENDING_MAX:
|
||||
oldest = min(self._pending.values(), key=lambda p: p["created_at"])
|
||||
if now - oldest["created_at"] < CODE_TTL_SECONDS:
|
||||
return None
|
||||
|
||||
expired_user = next(k for k, v in self._pending.items() if v == oldest)
|
||||
del self._pending[expired_user]
|
||||
|
||||
code = _generate_code()
|
||||
self._pending[user_id] = {"code": code, "created_at": now}
|
||||
self._user_last_code_at[user_id] = now
|
||||
return code
|
||||
|
||||
def verify(self, user_id: str, code: str) -> bool:
|
||||
entry = self._pending.get(user_id)
|
||||
if not entry:
|
||||
return False
|
||||
|
||||
if time.time() - entry["created_at"] > CODE_TTL_SECONDS:
|
||||
del self._pending[user_id]
|
||||
return False
|
||||
|
||||
if entry["code"] != code.upper().strip():
|
||||
return False
|
||||
|
||||
del self._pending[user_id]
|
||||
return True
|
||||
|
||||
def get_pending_count(self) -> int:
|
||||
return len(self._pending)
|
||||
|
||||
|
||||
def _generate_code() -> str:
|
||||
no_ambiguous = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
return "".join(random.choices(no_ambiguous, k=CODE_LENGTH))
|
||||
27
backend/package/yuxi/channel/extensions/jd/plugin.json
Normal file
27
backend/package/yuxi/channel/extensions/jd/plugin.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "jd",
|
||||
"name": "京东/咚咚",
|
||||
"version": "0.3.0",
|
||||
"description": "京东电商平台咚咚 IM 客服渠道,通过咚咚消息推送 Webhook + 京东开放平台 JOS API 实现双向消息收发,支持订单/物流/商品查询",
|
||||
"author": "ForcePilot",
|
||||
"order": 90,
|
||||
"dependencies": ["httpx"],
|
||||
"capabilities": {
|
||||
"chat_types": ["direct"],
|
||||
"message_types": ["text", "image", "link", "product_card"],
|
||||
"reactions": false,
|
||||
"typing_indicator": false,
|
||||
"threads": false,
|
||||
"edit": false,
|
||||
"unsend": true,
|
||||
"reply": false,
|
||||
"media": true,
|
||||
"effects": false,
|
||||
"native_commands": false,
|
||||
"polls": false,
|
||||
"group_management": false,
|
||||
"streaming": false
|
||||
},
|
||||
"enabled": true,
|
||||
"python_requires": ">=3.12"
|
||||
}
|
||||
36
backend/package/yuxi/channel/extensions/jd/security.py
Normal file
36
backend/package/yuxi/channel/extensions/jd/security.py
Normal file
@ -0,0 +1,36 @@
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.jd.types import JDAccount
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JDSecurity:
|
||||
VALID_POLICIES = {"open", "pairing", "allowlist", "disabled"}
|
||||
|
||||
def __init__(self, account: JDAccount | None = None):
|
||||
self._account = account
|
||||
if account and account.dm_policy in self.VALID_POLICIES:
|
||||
self._policy = account.dm_policy
|
||||
else:
|
||||
self._policy = "open"
|
||||
self._allowlist: set[str] = set(account.allow_from) if account else set()
|
||||
|
||||
def resolve_dm_policy(self) -> str:
|
||||
return self._policy
|
||||
|
||||
def check_allowlist(self, user_id: str, channel_type: str = "jd") -> bool:
|
||||
if self._policy == "open":
|
||||
return True
|
||||
if self._policy == "disabled":
|
||||
return False
|
||||
if not self._allowlist:
|
||||
logger.warning("allowlist is empty with policy=%s", self._policy)
|
||||
return False
|
||||
return user_id in self._allowlist
|
||||
|
||||
def add_to_allowlist(self, user_id: str):
|
||||
self._allowlist.add(user_id)
|
||||
|
||||
def remove_from_allowlist(self, user_id: str):
|
||||
self._allowlist.discard(user_id)
|
||||
128
backend/package/yuxi/channel/extensions/jd/session.py
Normal file
128
backend/package/yuxi/channel/extensions/jd/session.py
Normal file
@ -0,0 +1,128 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, UTC
|
||||
|
||||
from yuxi.channel.extensions.jd.client import JOSClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionEntry:
|
||||
__slots__ = ("chat_id", "user_id", "messages", "turn_count", "created_at", "updated_at", "status")
|
||||
|
||||
def __init__(self, chat_id: str, user_id: str):
|
||||
self.chat_id = chat_id
|
||||
self.user_id = user_id
|
||||
self.messages: list[dict] = []
|
||||
self.turn_count = 0
|
||||
self.created_at = datetime.now(UTC)
|
||||
self.updated_at = datetime.now(UTC)
|
||||
self.status = "active"
|
||||
|
||||
|
||||
class JDSession:
|
||||
MAX_HISTORY = 50
|
||||
SESSION_TIMEOUT_SECONDS = 1800
|
||||
CLEANUP_INTERVAL = 600
|
||||
|
||||
def __init__(self, client: JOSClient | None = None):
|
||||
self._sessions: dict[str, SessionEntry] = {}
|
||||
self._client = client
|
||||
self._cleanup_task: asyncio.Task | None = None
|
||||
|
||||
async def start(self):
|
||||
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
||||
|
||||
async def stop(self):
|
||||
if self._cleanup_task:
|
||||
self._cleanup_task.cancel()
|
||||
try:
|
||||
await self._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._cleanup_task = None
|
||||
|
||||
def add_message(self, chat_id: str, user_id: str, msg: dict):
|
||||
session = self._sessions.get(chat_id)
|
||||
if session is None:
|
||||
session = SessionEntry(chat_id, user_id)
|
||||
self._sessions[chat_id] = session
|
||||
|
||||
session.messages.append({**msg, "_timestamp": datetime.now(UTC).isoformat()})
|
||||
if len(session.messages) > self.MAX_HISTORY:
|
||||
session.messages.pop(0)
|
||||
|
||||
if session.status != "active":
|
||||
session.status = "active"
|
||||
session.turn_count = 1
|
||||
else:
|
||||
session.turn_count += 1
|
||||
|
||||
session.updated_at = datetime.now(UTC)
|
||||
|
||||
def get_history(self, chat_id: str, limit: int = 20) -> list[dict]:
|
||||
session = self._sessions.get(chat_id)
|
||||
if session is None:
|
||||
return []
|
||||
return session.messages[-limit:]
|
||||
|
||||
def get_session_context(self, chat_id: str) -> dict | None:
|
||||
session = self._sessions.get(chat_id)
|
||||
if session is None:
|
||||
return None
|
||||
return {
|
||||
"chat_id": session.chat_id,
|
||||
"user_id": session.user_id,
|
||||
"turn_count": session.turn_count,
|
||||
"status": session.status,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"updated_at": session.updated_at.isoformat(),
|
||||
"message_count": len(session.messages),
|
||||
}
|
||||
|
||||
def close_session(self, chat_id: str):
|
||||
session = self._sessions.get(chat_id)
|
||||
if session:
|
||||
session.status = "closed"
|
||||
session.updated_at = datetime.now(UTC)
|
||||
|
||||
def clear_session(self, chat_id: str):
|
||||
self._sessions.pop(chat_id, None)
|
||||
|
||||
async def fetch_remote_history(
|
||||
self, chat_id: str, to_id: str, access_token: str, page: int = 1, page_size: int = 20
|
||||
) -> list[dict]:
|
||||
if self._client is None:
|
||||
return []
|
||||
|
||||
biz_params = {
|
||||
"chat_id": chat_id,
|
||||
"to_id": to_id,
|
||||
"page": str(page),
|
||||
"page_size": str(page_size),
|
||||
}
|
||||
|
||||
try:
|
||||
data = await self._client.call("jingdong.im.getChatLog", biz_params, access_token)
|
||||
messages = data.get("jingdong_im_getChatLog_responce", {}).get("result", {}).get("messages", [])
|
||||
return messages if isinstance(messages, list) else []
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch JD chat history for %s", chat_id)
|
||||
return []
|
||||
|
||||
async def _cleanup_loop(self):
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.CLEANUP_INTERVAL)
|
||||
self._expire_sessions()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("JD session cleanup loop error")
|
||||
|
||||
def _expire_sessions(self):
|
||||
cutoff = datetime.now(UTC) - timedelta(seconds=self.SESSION_TIMEOUT_SECONDS)
|
||||
expired = [chat_id for chat_id, session in self._sessions.items() if session.updated_at < cutoff]
|
||||
for chat_id in expired:
|
||||
session = self._sessions.pop(chat_id)
|
||||
logger.debug("JD session expired: chat_id=%s turns=%d", session.chat_id, session.turn_count)
|
||||
79
backend/package/yuxi/channel/extensions/jd/signature.py
Normal file
79
backend/package/yuxi/channel/extensions/jd/signature.py
Normal file
@ -0,0 +1,79 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def sign_jos_request(params: dict, app_secret: str) -> str:
|
||||
sorted_keys = sorted(params.keys())
|
||||
|
||||
sign_str = ""
|
||||
for key in sorted_keys:
|
||||
raw_value = str(params[key])
|
||||
encoded = quote(raw_value, safe="")
|
||||
sign_str += key + encoded
|
||||
|
||||
full_str = app_secret + sign_str + app_secret
|
||||
signature = hmac.new(app_secret.encode("utf-8"), full_str.encode("utf-8"), hashlib.sha256).hexdigest().upper()
|
||||
|
||||
return signature
|
||||
|
||||
|
||||
def build_signed_params(
|
||||
method: str,
|
||||
biz_params: dict,
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
access_token: str,
|
||||
) -> dict:
|
||||
params = {
|
||||
"method": method,
|
||||
"app_key": app_key,
|
||||
"access_token": access_token,
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"format": "json",
|
||||
"v": "2.0",
|
||||
"sign_method": "hmac",
|
||||
}
|
||||
|
||||
biz_json = json.dumps(biz_params, ensure_ascii=False, separators=(",", ":"))
|
||||
params["360buy_param"] = biz_json
|
||||
|
||||
sign = sign_jos_request(params, app_secret)
|
||||
params["sign"] = sign
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def verify_webhook_signature(
|
||||
raw_body: bytes,
|
||||
signature_header: str,
|
||||
timestamp_header: str,
|
||||
app_secret: str,
|
||||
tolerance_seconds: int = 300,
|
||||
algorithm: str = "hmac-sha256",
|
||||
) -> bool:
|
||||
try:
|
||||
request_time = int(timestamp_header)
|
||||
current_time = int(time.time())
|
||||
if abs(current_time - request_time) > tolerance_seconds:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
if algorithm == "md5":
|
||||
sign_str = app_secret + raw_body.decode("utf-8") + app_secret
|
||||
expected = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
return signature_header.upper() == expected
|
||||
|
||||
sign_str = raw_body + timestamp_header.encode("utf-8")
|
||||
computed = hmac.new(app_secret.encode("utf-8"), sign_str, hashlib.sha256).digest()
|
||||
|
||||
try:
|
||||
expected = base64.b64decode(signature_header)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return hmac.compare_digest(computed, expected)
|
||||
83
backend/package/yuxi/channel/extensions/jd/status.py
Normal file
83
backend/package/yuxi/channel/extensions/jd/status.py
Normal file
@ -0,0 +1,83 @@
|
||||
import logging
|
||||
|
||||
from yuxi.channel.protocols import ChannelAccountSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JDStatus:
|
||||
def __init__(self, gateway=None):
|
||||
self._gateway = gateway
|
||||
|
||||
async def probe(self, account: dict | None = None) -> bool:
|
||||
gateway = self._gateway
|
||||
if gateway is None:
|
||||
return False
|
||||
|
||||
token = gateway.get_token("default")
|
||||
if not token:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def build_summary(self, snapshot: object) -> dict:
|
||||
if not isinstance(snapshot, ChannelAccountSnapshot):
|
||||
return {}
|
||||
return {
|
||||
"account_id": snapshot.account_id,
|
||||
"running": snapshot.running,
|
||||
"connected": snapshot.connected,
|
||||
"health_state": snapshot.health_state,
|
||||
}
|
||||
|
||||
def build_account_snapshot(
|
||||
self,
|
||||
account: dict,
|
||||
config: dict,
|
||||
runtime: ChannelAccountSnapshot | None = None,
|
||||
probe_result: object | None = None,
|
||||
audit: object | None = None,
|
||||
) -> ChannelAccountSnapshot:
|
||||
gateway = self._gateway
|
||||
token = gateway.get_token("default") if gateway else None
|
||||
|
||||
shop_id = account.get("shop_id", "")
|
||||
display = f"jd-{shop_id}" if shop_id else "jd-default"
|
||||
|
||||
return ChannelAccountSnapshot(
|
||||
account_id=account.get("account_id", "default"),
|
||||
name=account.get("name", display),
|
||||
enabled=account.get("enabled", True),
|
||||
configured=bool(account.get("app_key") and account.get("app_secret") and account.get("shop_id")),
|
||||
status_state="linked" if token else "not-linked",
|
||||
running=getattr(runtime, "running", False) if runtime else False,
|
||||
connected=bool(token),
|
||||
last_message_at=getattr(runtime, "last_message_at", None) if runtime else None,
|
||||
health_state="ok" if probe_result else "unknown",
|
||||
dm_policy=account.get("dm_policy", "pairing"),
|
||||
)
|
||||
|
||||
def collect_status_issues(self, accounts: list[ChannelAccountSnapshot]) -> list:
|
||||
issues = []
|
||||
for acc in accounts:
|
||||
if not acc.configured:
|
||||
issues.append(
|
||||
{
|
||||
"channel": "jd",
|
||||
"account_id": acc.account_id,
|
||||
"kind": "not-configured",
|
||||
"message": "JD credentials not configured",
|
||||
"fix": "Set JD_APP_KEY, JD_APP_SECRET, JD_SHOP_ID environment variables",
|
||||
}
|
||||
)
|
||||
elif not acc.connected:
|
||||
issues.append(
|
||||
{
|
||||
"channel": "jd",
|
||||
"account_id": acc.account_id,
|
||||
"kind": "not-connected",
|
||||
"message": "JD gateway token not available",
|
||||
"fix": "Check JD OAuth token configuration and gateway status",
|
||||
}
|
||||
)
|
||||
return issues
|
||||
57
backend/package/yuxi/channel/extensions/jd/types.py
Normal file
57
backend/package/yuxi/channel/extensions/jd/types.py
Normal file
@ -0,0 +1,57 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
JD_TEXT_MAX_LENGTH = 4096
|
||||
JOS_API_GATEWAY = "https://api.jd.com/routerjson"
|
||||
JOS_MAX_RETRIES = 3
|
||||
JOS_RETRY_BASE_DELAY = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class JDAccount:
|
||||
account_id: str
|
||||
app_key: str
|
||||
app_secret: str
|
||||
shop_id: str
|
||||
access_token: str | None = None
|
||||
refresh_token: str | None = None
|
||||
token_expires_at: float | None = None
|
||||
webhook_token: str | None = None
|
||||
dm_policy: str = "pairing"
|
||||
webhook_sign_algorithm: str = "hmac-sha256"
|
||||
allow_from: list[str] = field(default_factory=list)
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.app_key and self.app_secret and self.shop_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InboundJDMessage:
|
||||
msg_id: str
|
||||
msg_type: Literal[1, 2, 3, 4] = 1
|
||||
content: str = ""
|
||||
from_id: str = ""
|
||||
to_id: str = ""
|
||||
chat_id: str = ""
|
||||
msg_time: str = ""
|
||||
from_type: str = "1"
|
||||
to_type: str = "2"
|
||||
extend: str = "{}"
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutboundResult:
|
||||
message_id: str
|
||||
success: bool
|
||||
error_code: str | None = None
|
||||
error_msg: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class JOSToken:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_at: float
|
||||
uid: str = ""
|
||||
user_nick: str = ""
|
||||
205
backend/package/yuxi/channel/extensions/jd/webhook.py
Normal file
205
backend/package/yuxi/channel/extensions/jd/webhook.py
Normal file
@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from yuxi.channel.extensions.jd.crypto import decrypt_jd_message
|
||||
from yuxi.channel.extensions.jd.format import remove_markdown
|
||||
from yuxi.channel.extensions.jd.outbound import JDOutbound
|
||||
from yuxi.channel.extensions.jd.security import JDSecurity
|
||||
from yuxi.channel.extensions.jd.signature import verify_webhook_signature
|
||||
from yuxi.channel.extensions.jd.types import InboundJDMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from yuxi.channel.extensions.jd import JDPlugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/webhook/jd", tags=["jd"])
|
||||
|
||||
_plugin: JDPlugin | None = None
|
||||
|
||||
|
||||
def set_plugin(plugin: JDPlugin) -> None:
|
||||
global _plugin
|
||||
_plugin = plugin
|
||||
|
||||
|
||||
def get_plugin() -> JDPlugin | None:
|
||||
return _plugin
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def jd_message_callback(request: Request):
|
||||
plugin = _plugin
|
||||
if plugin is None:
|
||||
logger.warning("JD plugin not initialized, rejecting webhook")
|
||||
return Response(content='{"code":-1}', media_type="application/json", status_code=503)
|
||||
|
||||
gw = plugin._gateway
|
||||
account = gw.account if gw else None
|
||||
|
||||
raw_body = await request.body()
|
||||
|
||||
if account and account.app_secret:
|
||||
signature = request.headers.get("X-JD-Sign", "")
|
||||
timestamp = request.headers.get("X-JD-Timestamp", "")
|
||||
sign_algorithm = account.webhook_sign_algorithm or "hmac-sha256"
|
||||
if not verify_webhook_signature(raw_body, signature, timestamp, account.app_secret, algorithm=sign_algorithm):
|
||||
logger.warning("JD webhook signature verification failed (algorithm=%s)", sign_algorithm)
|
||||
return Response(content='{"code":-1}', media_type="application/json", status_code=403)
|
||||
|
||||
try:
|
||||
data = json.loads(raw_body)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("JD webhook JSON parse error: %s", e)
|
||||
return Response(content='{"code":-1}', media_type="application/json", status_code=400)
|
||||
|
||||
if account and account.app_secret and data.get("encrypt"):
|
||||
decrypted = decrypt_jd_message(data.get("encrypt_jd_param_json", ""), account.app_secret)
|
||||
if decrypted:
|
||||
data = decrypted
|
||||
else:
|
||||
logger.warning("JD webhook AES decrypt failed")
|
||||
return Response(content='{"code":-1}', media_type="application/json", status_code=400)
|
||||
|
||||
messages = data.get("messages", [])
|
||||
if not messages:
|
||||
event_type = data.get("type")
|
||||
if event_type in ("system", "event"):
|
||||
logger.info("JD webhook event: type=%s", event_type)
|
||||
if event_type == "system":
|
||||
await _handle_system_event(data)
|
||||
return Response(content='{"code":0}', media_type="application/json", status_code=200)
|
||||
|
||||
deduplicator = plugin._deduplicator
|
||||
pairing = plugin._pairing
|
||||
monitor = plugin._monitor
|
||||
|
||||
for msg_data in messages:
|
||||
msg_id = msg_data.get("msg_id", "")
|
||||
if not msg_id or deduplicator.is_duplicate(msg_id):
|
||||
continue
|
||||
|
||||
from_type = msg_data.get("from_type", "1")
|
||||
if from_type == "2":
|
||||
continue
|
||||
|
||||
content = msg_data.get("content", "")
|
||||
from_id = msg_data.get("from_id", "")
|
||||
chat_id = msg_data.get("chat_id", "")
|
||||
|
||||
if not content or not account:
|
||||
continue
|
||||
|
||||
if gw and chat_id:
|
||||
gw.cache_chat_id(from_id, chat_id)
|
||||
|
||||
security = plugin._security
|
||||
if security is None:
|
||||
security = JDSecurity(account)
|
||||
|
||||
policy = security.resolve_dm_policy()
|
||||
|
||||
if policy == "disabled":
|
||||
continue
|
||||
|
||||
if policy == "pairing":
|
||||
if not security.check_allowlist(from_id):
|
||||
if content.strip().startswith("配对 "):
|
||||
code_input = content.strip()[3:].strip()
|
||||
if pairing.verify(from_id, code_input):
|
||||
security.add_to_allowlist(from_id)
|
||||
await _send_jd_text(plugin, from_id, "配对成功!现在可以开始对话了。", chat_id=chat_id)
|
||||
else:
|
||||
await _send_jd_text(
|
||||
plugin, from_id, "配对码无效或已过期,请重新发送消息获取配对码。", chat_id=chat_id
|
||||
)
|
||||
else:
|
||||
code = pairing.generate_code(from_id)
|
||||
if code:
|
||||
await _send_jd_text(
|
||||
plugin,
|
||||
from_id,
|
||||
f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 10 分钟)",
|
||||
chat_id=chat_id,
|
||||
)
|
||||
else:
|
||||
await _send_jd_text(plugin, from_id, "配对请求过于频繁,请稍后再试。", chat_id=chat_id)
|
||||
continue
|
||||
|
||||
if policy == "allowlist":
|
||||
if not security.check_allowlist(from_id):
|
||||
continue
|
||||
|
||||
content = remove_markdown(content)
|
||||
|
||||
msg = InboundJDMessage(
|
||||
msg_id=msg_id,
|
||||
msg_type=msg_data.get("msg_type", 1),
|
||||
content=content,
|
||||
from_id=from_id,
|
||||
to_id=msg_data.get("to_id", ""),
|
||||
chat_id=chat_id,
|
||||
msg_time=msg_data.get("msg_time", ""),
|
||||
from_type=msg_data.get("from_type", "1"),
|
||||
to_type=msg_data.get("to_type", "2"),
|
||||
extend=msg_data.get("extend", "{}"),
|
||||
raw=msg_data,
|
||||
)
|
||||
|
||||
unified = monitor.convert_to_unified(msg)
|
||||
|
||||
from yuxi.channel.runtime.manager import gateway
|
||||
|
||||
processor = getattr(gateway, "_processor", None) if gateway else None
|
||||
if processor is None:
|
||||
logger.warning("Message processor not available, cannot dispatch JD message")
|
||||
continue
|
||||
|
||||
asyncio.create_task(
|
||||
_dispatch_to_agent(processor, unified),
|
||||
name=f"jd-dispatch-{from_id}",
|
||||
)
|
||||
|
||||
return Response(content='{"code":0}', media_type="application/json", status_code=200)
|
||||
|
||||
|
||||
async def _handle_system_event(data: dict):
|
||||
event_subtype = data.get("subtype", "")
|
||||
chat_id = data.get("chat_id", "")
|
||||
if event_subtype == "session_close":
|
||||
logger.info("JD session closed: chat_id=%s", chat_id)
|
||||
elif event_subtype == "user_enter":
|
||||
logger.info("JD user entered: chat_id=%s", chat_id)
|
||||
else:
|
||||
logger.info("JD system event: subtype=%s chat_id=%s", event_subtype, chat_id)
|
||||
|
||||
|
||||
async def _dispatch_to_agent(processor, msg) -> None:
|
||||
try:
|
||||
await asyncio.wait_for(processor.process(msg), timeout=120.0)
|
||||
except TimeoutError:
|
||||
logger.error("Agent response timeout for JD user %s", msg.sender.id)
|
||||
except Exception:
|
||||
logger.exception("Failed to process JD message for user %s", msg.sender.id)
|
||||
|
||||
|
||||
async def _send_jd_text(plugin, to_user: str, content: str, *, chat_id: str | None = None) -> None:
|
||||
gw = plugin._gateway
|
||||
if gw is None:
|
||||
logger.warning("No JD gateway available for sending text")
|
||||
return
|
||||
|
||||
outbound = JDOutbound(gw)
|
||||
try:
|
||||
await outbound.send_text(to_user, content, chat_id=chat_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to send JD text to %s", to_user)
|
||||
finally:
|
||||
await outbound.close()
|
||||
Loading…
Reference in New Issue
Block a user