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

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

206 lines
7.3 KiB
Python

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