新增小红书、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
238 lines
8.5 KiB
Python
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)
|