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

332 lines
9.4 KiB
Python

import asyncio
import logging
import re
import httpx
from yuxi.channel.extensions.douyin.window import DouyinWindowTracker
logger = logging.getLogger(__name__)
SEND_MSG_PATH = "/im/send/msg/"
UPLOAD_IMAGE_PATH = "/api/apps/v1/developer_toolbox/image_material/upload/"
RECALL_MSG_PATH = "/im/recall/msg/"
MAX_TEXT_LEN = 1000
def clean_for_douyin(text: str) -> str:
text = re.sub(r"```\w*\n?", "", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"\*([^*]+)\*", r"\1", text)
text = re.sub(r"__([^_]+)__", r"\1", text)
text = re.sub(r"_([^_]+)_", r"\1", text)
text = re.sub(r"(?m)^#{1,6}\s+", "", text)
text = re.sub(r"(?m)^[-*_]{3,}\s*$", "", text)
text = re.sub(r"https?://\S+", "[链接]", text)
return text.strip()
class DouyinOutbound:
def __init__(self, gateway=None):
self._gateway = gateway
self._send_context: dict[str, dict] = {}
self._window_tracker = DouyinWindowTracker()
@property
def window_tracker(self) -> DouyinWindowTracker:
return self._window_tracker
def set_send_context(self, open_id: str, conversation_id: str, server_message_id: str) -> None:
self._send_context[open_id] = {
"conversation_id": conversation_id,
"server_message_id": server_message_id,
}
def _http(self) -> httpx.AsyncClient | None:
if self._gateway and self._gateway.http:
return self._gateway.http
return None
async def send_text(
self,
to_user_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> bool:
if not content:
return False
if not self._window_tracker.can_reply(to_user_id):
return False
content = clean_for_douyin(content)
if len(content) > MAX_TEXT_LEN:
content = content[:MAX_TEXT_LEN]
result = await self._send_msg(
to_user_id,
{
"msg_type": 1,
"text": {"text": content},
},
)
if result:
self._window_tracker.record_send(to_user_id)
return result
async def send_image(
self,
to_user_id: str,
media_id: str,
) -> bool:
if not media_id:
return False
if not self._window_tracker.can_reply(to_user_id):
return False
result = await self._send_msg(
to_user_id,
{
"msg_type": 2,
"image": {"media_id": media_id},
},
)
if result:
self._window_tracker.record_send(to_user_id)
return result
async def send_video(
self,
to_user_id: str,
item_id: str,
) -> bool:
if not item_id:
return False
if not self._window_tracker.can_reply(to_user_id):
return False
result = await self._send_msg(
to_user_id,
{
"msg_type": 3,
"video": {"item_id": item_id},
},
)
if result:
self._window_tracker.record_send(to_user_id)
return result
async def send_card(
self,
to_user_id: str,
card_template_id: str,
card_data: dict | None = None,
) -> bool:
if not card_template_id:
return False
if not self._window_tracker.can_reply(to_user_id):
return False
payload: dict = {
"msg_type": 10,
"applet_card": {
"card_template_id": card_template_id,
"card_data": card_data or {},
},
}
result = await self._send_msg(to_user_id, payload)
if result:
self._window_tracker.record_send(to_user_id)
return result
async def send_guide_card(
self,
to_user_id: str,
questions: list[dict],
card_type: int = 204,
) -> bool:
if not questions or card_type not in (204, 205):
return False
if not self._window_tracker.can_reply(to_user_id):
return False
result = await self._send_msg(
to_user_id,
{
"msg_type": card_type,
"guide_card": {"questions": questions},
},
)
if result:
self._window_tracker.record_send(to_user_id)
return result
async def recall_msg(
self,
open_id: str,
server_message_id: str,
) -> bool:
if not open_id or not server_message_id:
return False
token = self._gateway.business_token if self._gateway else None
if not token:
logger.error("No valid business_token for douyin recall")
return False
url = f"{RECALL_MSG_PATH}?open_id={open_id}"
headers = {
"access-token": token,
"Content-Type": "application/json",
}
client = self._http()
if client is None:
logger.error("No HTTP client available for douyin recall")
return False
for attempt in range(3):
try:
resp = await client.post(
url,
json={
"msg_id": server_message_id,
},
headers=headers,
)
data = resp.json()
err_code = data.get("data", {}).get("error_code", data.get("error_code", -1))
if err_code == 0:
logger.info(
"Douyin message recalled: open_id=%s, msg_id=%s",
open_id,
server_message_id,
)
return True
logger.warning(
"Douyin recall failed (attempt %d): error_code=%s, error_msg=%s",
attempt + 1,
err_code,
data.get("data", {}).get("description", data.get("message", "")),
)
except Exception as e:
logger.warning("Douyin recall attempt %d failed: %s", attempt + 1, e)
await asyncio.sleep(attempt + 1)
return False
async def _send_msg(self, to_user_id: str, content: dict) -> bool:
token = self._gateway.business_token if self._gateway else None
if not token:
logger.error("No valid business_token for douyin send")
return False
ctx = self._send_context.pop(to_user_id, {})
conversation_id = ctx.get("conversation_id", "")
server_message_id = ctx.get("server_message_id", "")
payload = {
"to_user_id": to_user_id,
"scene": "im_reply_msg",
**content,
}
if conversation_id:
payload["conversation_id"] = conversation_id
if server_message_id:
payload["msg_id"] = server_message_id
url = f"{SEND_MSG_PATH}?open_id={to_user_id}"
headers = {
"access-token": token,
"Content-Type": "application/json",
}
client = self._http()
if client is None:
logger.error("No HTTP client available for douyin send")
return False
for attempt in range(3):
try:
resp = await client.post(url, json=payload, headers=headers)
data = resp.json()
err_code = data.get("data", {}).get("error_code", data.get("error_code", -1))
if err_code == 0:
return True
logger.warning(
"Douyin send failed (attempt %d): error_code=%s, error_msg=%s",
attempt + 1,
err_code,
data.get("data", {}).get("description", data.get("message", "")),
)
if err_code == 2190001:
logger.error("Douyin access_token expired")
elif err_code == 2190008:
logger.warning("Douyin rate limit hit")
except Exception as e:
logger.warning("Douyin send attempt %d failed: %s", attempt + 1, e)
await asyncio.sleep(attempt + 1)
return False
async def upload_image(self, image_url: str) -> str | None:
token = self._gateway.client_token if self._gateway else None
if not token:
return None
client = self._http()
if client is None:
logger.error("No HTTP client available for douyin upload")
return None
headers = {
"access-token": token,
}
for attempt in range(3):
try:
resp = await client.post(
UPLOAD_IMAGE_PATH,
json={"image_material_url": image_url},
headers=headers,
)
data = resp.json()
inner = data.get("data", {})
if inner.get("error_code", -1) == 0:
return inner.get("image_id") or inner.get("media_id")
logger.warning("Douyin image upload failed (attempt %d): %s", attempt + 1, data)
except Exception:
logger.exception("Douyin image upload exception (attempt %d)", attempt + 1)
await asyncio.sleep(attempt + 1)
return None