ForcePilot/backend/package/yuxi/channel/extensions/douyin/webhook.py
Kris 4552bde837 feat(douyin): 新增抖音渠道插件,支持私信收发与相关能力
该提交实现了完整的抖音开放平台IM渠道插件,包含:
1. 基础配置与账号管理能力
2. 消息去重、安全策略校验
3. 流式回复、多媒体消息发送
4. Webhook回调处理与事件解析
5. 配对认证与流量限流机制
2026-05-21 10:45:37 +08:00

282 lines
9.6 KiB
Python

import asyncio
import hashlib
import hmac
import json
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, PlainTextResponse
from yuxi.channel.extensions.douyin.outbound import DouyinOutbound, clean_for_douyin
from yuxi.channel.extensions.douyin.security import DouyinSecurity
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
from yuxi.channel.runtime.manager import gateway
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook/douyin", tags=["douyin"])
_plugin = None
def set_plugin(plugin) -> None:
global _plugin
_plugin = plugin
def clear_plugin() -> None:
global _plugin
_plugin = None
def verify_signature(client_secret: str, body_bytes: bytes, signature: str) -> bool:
data = client_secret.encode() + body_bytes
computed = hashlib.sha1(data).hexdigest()
return hmac.compare_digest(computed, signature)
@router.post("/callback")
async def douyin_webhook_receive(request: Request):
body_bytes = await request.body()
body_str = body_bytes.decode("utf-8")
signature = request.headers.get("X-Douyin-Signature", "")
plugin = _plugin
if not plugin:
logger.error("Douyin plugin not initialized, rejecting webhook")
return JSONResponse({"status": "plugin_not_ready"}, status_code=503)
config_adapter = plugin._config_adapter
account = config_adapter.resolve_account()
if not verify_signature(account.client_secret, body_bytes, signature):
logger.warning("Douyin webhook: invalid X-Douyin-Signature")
return JSONResponse({"status": "signature_invalid"}, status_code=403)
try:
event = json.loads(body_str)
except json.JSONDecodeError:
logger.warning("Douyin webhook: invalid JSON body")
return PlainTextResponse("", status_code=400)
if event.get("event") == "verify_webhook":
challenge = event.get("content", {}).get("challenge")
if challenge:
return JSONResponse({"echostr": challenge})
return PlainTextResponse("", status_code=400)
event_type = event.get("event", "")
if event_type == "im_send_message_failed":
logger.error("Douyin message send failed: %s", json.dumps(event, ensure_ascii=False))
return PlainTextResponse("success")
if event_type == "im_send_msg":
logger.info(
"Douyin message sent callback: msg_id=%s, to_user=%s",
event.get("content", {}).get("server_message_id", ""),
event.get("to_user_id", ""),
)
return PlainTextResponse("success")
if event_type == "im_recall_msg":
logger.info(
"Douyin message recalled: msg_id=%s, from_user=%s",
event.get("content", {}).get("server_message_id", ""),
event.get("from_user_id", ""),
)
return PlainTextResponse("success")
if event_type == "im_msg_read":
logger.info(
"Douyin message read: msg_id=%s, from_user=%s",
event.get("content", {}).get("server_message_id", ""),
event.get("from_user_id", ""),
)
return PlainTextResponse("success")
if event_type not in ("im_receive_msg", "im_enter_direct_msg"):
return PlainTextResponse("success")
msg_id = request.headers.get("Msg-Id", event.get("log_id", ""))
if msg_id and plugin._deduplicator.is_duplicate(msg_id):
return PlainTextResponse("success")
content_data = event.get("content", {})
msg_type = content_data.get("message_type", "")
if msg_type == "text":
content = content_data.get("text", "")
elif msg_type in ("image", "user_local_image"):
content = "[图片]"
elif msg_type in ("video", "user_local_video"):
content = "[视频]"
elif msg_type == "emoji":
emoji_info = content_data.get("emoji", {})
emoji_text = emoji_info.get("text", "") or emoji_info.get("resource_url", "") or "[表情]"
content = f"[表情: {emoji_text}]"
elif msg_type == "retain_consult_card":
content = "[留资卡片]"
else:
content = f"[不支持的消息类型: {msg_type}]"
if not content:
return PlainTextResponse("success")
from_user_id = event.get("from_user_id", "")
user_infos = event.get("user_infos", [])
nick_name = from_user_id
avatar = ""
if user_infos:
user_info = user_infos[0]
nick_name = user_info.get("nick_name", from_user_id)
avatar = user_info.get("avatar", "")
outbound = plugin._outbound
if outbound:
outbound.window_tracker.record(from_user_id)
if event_type == "im_enter_direct_msg":
await _handle_enter_direct_msg(event, account, plugin)
return PlainTextResponse("success")
conversation_short_id = content_data.get("conversation_short_id", "")
server_message_id = content_data.get("server_message_id", "")
if (conversation_short_id or server_message_id) and outbound:
outbound.set_send_context(from_user_id, conversation_short_id, server_message_id)
security = plugin._security
if security is None:
security = DouyinSecurity(account)
if plugin._remove_markdown:
agent_content = clean_for_douyin(content)
else:
agent_content = content
policy = security.resolve_dm_policy()
if policy == "disabled":
return PlainTextResponse("success")
if policy == "pairing":
if not security.check_allowlist(from_user_id):
handled = await _handle_pairing(from_user_id, agent_content, plugin)
if handled:
return PlainTextResponse("success")
return PlainTextResponse("success")
if policy == "allowlist":
if not security.check_allowlist(from_user_id):
return PlainTextResponse("success")
unified = UnifiedMessage(
msg_id=msg_id,
channel_type="douyin",
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=agent_content,
metadata={
"conversation_short_id": conversation_short_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"douyin-dispatch-{from_user_id}",
)
return PlainTextResponse("success")
async def _dispatch_to_agent(msg: UnifiedMessage) -> None:
processor = gateway._processor
if processor is None:
logger.warning("Message processor not available, cannot dispatch Douyin message")
return
try:
await asyncio.wait_for(processor.process(msg), timeout=120.0)
except TimeoutError:
logger.error("Agent response timeout for douyin user %s", msg.sender.id)
except Exception:
logger.exception("Failed to process Douyin message for user %s", msg.sender.id)
async def _handle_enter_direct_msg(event: dict, account, plugin) -> None:
from_user_id = event.get("from_user_id", "")
logger.info("Douyin user %s entered direct message session", from_user_id)
security = plugin._security
if security is None:
security = DouyinSecurity(account)
policy = security.resolve_dm_policy()
if policy == "disabled":
return
outbound = plugin._outbound
if outbound and not outbound.window_tracker.can_enter_dm_reply(from_user_id):
logger.info("Douyin enter-dm rate limited for user %s", from_user_id)
return
welcome_text = account.welcome_text or "你好!有什么可以帮助你的?"
await _send_douyin_text(from_user_id, welcome_text, plugin)
if outbound:
outbound.window_tracker.record_enter_dm_reply(from_user_id)
async def _handle_pairing(from_user_id: str, content: str, plugin) -> bool:
if content.strip().startswith("配对 "):
code_input = content.strip()[3:].strip()
if plugin._pairing.verify(from_user_id, code_input):
security = plugin._security
if security:
security.add_to_allowlist(from_user_id)
await _send_douyin_text(from_user_id, "配对成功!现在可以开始对话了。", plugin)
return True
else:
await _send_douyin_text(from_user_id, "配对码无效或已过期,请重新发送消息获取配对码。", plugin)
return True
else:
code = plugin._pairing.generate_code(from_user_id)
if code:
await _send_douyin_text(
from_user_id,
f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 10 分钟)",
plugin,
)
else:
await _send_douyin_text(from_user_id, "配对请求过于频繁,请稍后再试。", plugin)
return True
return False
async def _send_douyin_text(to_user_id: str, content: str, plugin) -> None:
ob = plugin._outbound
if ob is None:
gw = plugin._gateway
if gw is None:
logger.warning("No Douyin gateway available for sending text")
return
ob = DouyinOutbound(gw)
try:
await ob.send_text(to_user_id, content)
except Exception:
logger.exception("Failed to send Douyin text to %s", to_user_id)