该提交实现了完整的抖音开放平台IM渠道插件,包含: 1. 基础配置与账号管理能力 2. 消息去重、安全策略校验 3. 流式回复、多媒体消息发送 4. Webhook回调处理与事件解析 5. 配对认证与流量限流机制
282 lines
9.6 KiB
Python
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)
|