新增京东(JD)渠道扩展,支持在 Yuxi 平台中集成京东客服渠道。 包含以下功能模块: - client: 京东 API 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - pairing: 用户配对与绑定 - security: 安全校验 - signature: 请求签名验证 - crypto: 加解密处理 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - business: 业务逻辑处理 - types: 类型定义
206 lines
7.3 KiB
Python
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()
|