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:
Kris 2026-05-21 11:05:40 +08:00
parent 2ddcf1cc55
commit bb8ade9e2b
19 changed files with 2014 additions and 0 deletions

View 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())

View 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", ""),
}

View 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),
)

View 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"],
}

View 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

View 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()

View 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)

View 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

View 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

View 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)

View 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

View 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))

View 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"
}

View 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)

View 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)

View 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)

View 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

View 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 = ""

View 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()