ForcePilot/backend/package/yuxi/channel/extensions/xiaohongshu/webhook.py
Kris 5946478772 feat(channel): 添加小红书、XMPP、元宝和 Zalo 渠道扩展
新增小红书、XMPP、元宝、Zalo 四个渠道扩展。

小红书渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, media, status, window

XMPP 渠道扩展主要模块:plugin, config, gateway, outbound, streaming, pairing, security, dedupe, accounts, commands, muc, rate_limiter, stanza_utils, status, monitor

元宝渠道扩展主要模块:plugin, client, config_schema, gateway, outbound(chunk/queue/transport), inbound(dispatcher), streaming, pairing, security, accounts, actions, commands, codec(biz/conn), session, shared, utils

Zalo 渠道扩展主要模块:api, config, gateway, webhook, outbound, pairing, security, session, polling, monitor, status
2026-05-21 12:04:05 +08:00

238 lines
8.5 KiB
Python

import asyncio
import hashlib
import hmac
import json
import logging
from datetime import datetime, UTC
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from yuxi.channel.extensions.xiaohongshu.config import XiaohongshuConfig
from yuxi.channel.extensions.xiaohongshu.dedupe import MessageDeduplicator
from yuxi.channel.extensions.xiaohongshu.security import XiaohongshuSecurity
from yuxi.channel.extensions.xiaohongshu.pairing import XiaohongshuPairing
from yuxi.channel.extensions.xiaohongshu.outbound import XiaohongshuOutbound, set_send_context, get_window_tracker
from yuxi.channel.runtime.manager import gateway
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook/xiaohongshu", tags=["xiaohongshu"])
_config = XiaohongshuConfig()
_deduplicator = MessageDeduplicator()
_pairing = XiaohongshuPairing()
_window_tracker = get_window_tracker()
_outbound: XiaohongshuOutbound | None = None
def set_outbound(outbound: XiaohongshuOutbound | None) -> None:
global _outbound
_outbound = outbound
def verify_signature(app_secret: str, request_path: str, all_params: dict[str, str], sign: str) -> bool:
all_params = {k: v for k, v in all_params.items() if k != "sign"}
sorted_keys = sorted(all_params.keys())
sorted_parts = [f"{k}={all_params[k]}" for k in sorted_keys]
base_string = request_path + "&".join(sorted_parts) + app_secret
computed = hashlib.md5(base_string.encode()).hexdigest()
return hmac.compare_digest(computed, sign)
@router.post("/callback")
async def xiaohongshu_webhook_receive(request: Request):
body_bytes = await request.body()
body_str = body_bytes.decode("utf-8")
timestamp = request.headers.get("timestamp", "")
app_key = request.headers.get("app-key", "")
sign = request.headers.get("sign", "")
all_params = {"timestamp": timestamp, "app-key": app_key}
for k, v in request.query_params.items():
all_params[k] = v
account = _config.resolve_account()
if not verify_signature(account.app_secret, request.url.path, all_params, sign):
logger.warning("Xiaohongshu webhook: invalid signature")
return JSONResponse({"code": 401, "msg": "signature_invalid"}, status_code=401)
try:
event = json.loads(body_str)
except json.JSONDecodeError:
logger.warning("Xiaohongshu webhook: invalid JSON body")
return JSONResponse({"code": 400, "msg": "invalid_json"}, status_code=400)
if event.get("test") is True:
return JSONResponse({"code": 0, "msg": "ok"}, status_code=200)
msg_tag = event.get("msgTag", "")
if msg_tag == "im_enter_chat":
await _handle_enter_chat(event, account)
return JSONResponse({"code": 0, "msg": "ok"}, status_code=200)
if msg_tag != "im_message_receive":
return JSONResponse({"code": 0, "msg": "ok"}, status_code=200)
msg_id = event.get("msgId", "") or event.get("log_id", "")
if msg_id and _deduplicator.is_duplicate(msg_id):
return JSONResponse({"code": 0, "msg": "ok"}, status_code=200)
content_data = event.get("content", {})
msg_type = content_data.get("msg_type", "")
if msg_type == "text":
content = content_data.get("text", "")
elif msg_type == "image":
content = "[图片]"
elif msg_type == "video":
content = "[视频]"
elif msg_type == "voice":
content = "[语音]"
else:
content = f"[不支持的消息类型: {msg_type}]"
if not content:
return JSONResponse({"code": 0, "msg": "ok"}, status_code=200)
from_user_id = event.get("from_user_id", "")
user_info = event.get("user_info", {}) or {}
nick_name = user_info.get("nick_name", from_user_id)
avatar = user_info.get("avatar", "")
_window_tracker.record(from_user_id)
security = XiaohongshuSecurity(account)
conversation_id = content_data.get("conversation_id", "")
server_message_id = content_data.get("server_message_id", "")
if conversation_id or server_message_id:
set_send_context(from_user_id, conversation_id, server_message_id)
policy = security.resolve_dm_policy()
if policy == "disabled":
return JSONResponse({"code": 0, "msg": "ok"}, status_code=200)
if policy == "pairing":
if not security.check_allowlist(from_user_id):
handled = await _handle_pairing(from_user_id, content)
if handled:
return JSONResponse({"code": 0, "msg": "ok"}, status_code=200)
return JSONResponse({"code": 0, "msg": "ok"}, status_code=200)
if policy == "allowlist":
if not security.check_allowlist(from_user_id):
return JSONResponse({"code": 0, "msg": "ok"}, status_code=200)
unified = UnifiedMessage(
msg_id=msg_id,
channel_type="xiaohongshu",
account_id="default",
content=content,
message_type=MessageType.TEXT if msg_type == "text" else MessageType.IMAGE,
sender=PeerInfo(
id=from_user_id,
kind=PeerKind.DIRECT,
display_name=nick_name,
),
timestamp=datetime.fromtimestamp(
content_data.get("create_time", 0) / 1000, tz=UTC
),
raw_payload=event,
body_for_agent=content,
metadata={
"conversation_id": conversation_id,
"message_type": msg_type,
"open_id": from_user_id,
"server_message_id": server_message_id,
"avatar": avatar,
"nick_name": nick_name,
},
)
asyncio.create_task(
_dispatch_to_agent(unified),
name=f"xiaohongshu-dispatch-{from_user_id}",
)
return JSONResponse({"code": 0, "msg": "ok"}, status_code=200)
async def _dispatch_to_agent(msg: UnifiedMessage) -> None:
processor = gateway._processor
if processor is None:
logger.warning("Message processor not available, cannot dispatch Xiaohongshu message")
return
try:
await asyncio.wait_for(processor.process(msg), timeout=120.0)
except TimeoutError:
logger.error("Agent response timeout for xiaohongshu user %s", msg.sender.id)
except Exception:
logger.exception("Failed to process Xiaohongshu message for user %s", msg.sender.id)
async def _handle_enter_chat(event: dict, account) -> None:
from_user_id = event.get("from_user_id", "")
logger.info("Xiaohongshu user %s entered chat session", from_user_id)
security = XiaohongshuSecurity(account)
policy = security.resolve_dm_policy()
if policy == "disabled":
return
if not _window_tracker.can_enter_dm_reply(from_user_id):
logger.info("Xiaohongshu enter-chat rate limited for user %s", from_user_id)
return
welcome_text = account.welcome_text or "你好!有什么可以帮助你的?"
await _send_xiaohongshu_text(from_user_id, welcome_text)
_window_tracker.record_enter_dm_reply(from_user_id)
async def _handle_pairing(from_user_id: str, content: str) -> bool:
if content.strip().startswith("配对 "):
code_input = content.strip()[3:].strip()
if _pairing.verify(from_user_id, code_input):
security = XiaohongshuSecurity(_config.resolve_account())
security.add_to_allowlist(from_user_id)
await _send_xiaohongshu_text(from_user_id, "配对成功!现在可以开始对话了。")
return True
else:
await _send_xiaohongshu_text(from_user_id, "配对码无效或已过期,请重新发送消息获取配对码。")
return True
else:
code = _pairing.generate_code(from_user_id)
if code:
await _send_xiaohongshu_text(
from_user_id,
f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 10 分钟)",
)
else:
await _send_xiaohongshu_text(from_user_id, "配对请求过于频繁,请稍后再试。")
return True
return False
async def _send_xiaohongshu_text(to_user_id: str, content: str) -> None:
ob = _outbound
if ob is None:
from yuxi.channel.extensions.xiaohongshu.gateway import _get_gateway
gw = _get_gateway()
if gw is None:
logger.warning("No Xiaohongshu gateway available for sending text")
return
ob = XiaohongshuOutbound(gw)
try:
await ob.send_text(to_user_id, content)
except Exception:
logger.exception("Failed to send Xiaohongshu text to %s", to_user_id)