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