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