import asyncio import json import logging import re import httpx logger = logging.getLogger(__name__) SEND_URL = "https://m.api.weibo.com/2/messages/send.json" MAX_UTF8_LEN = 900 def remove_markdown(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) return text.strip() def split_utf8(text: str, max_bytes: int = MAX_UTF8_LEN) -> list[str]: encoded = text.encode("utf-8") if len(encoded) <= max_bytes: return [text] result: list[str] = [] remaining = encoded while remaining: chunk = remaining[:max_bytes] for cut in range(len(chunk), 0, -1): try: result.append(chunk[:cut].decode("utf-8")) break except UnicodeDecodeError: continue remaining = remaining[cut:] return result def split_utf8_safe(text: str, max_bytes: int = MAX_UTF8_LEN) -> tuple[str, str | None]: encoded = text.encode("utf-8") if len(encoded) <= max_bytes: return text, None hint = "\n【未完待续,回复任意文字以继续】" hint_bytes = len(hint.encode("utf-8")) limit = max_bytes - hint_bytes try: cut_point = len(encoded[:limit].decode("utf-8", errors="ignore")) except Exception: cut_point = limit first_part = text[:cut_point] + hint remaining = text[cut_point:] return first_part, remaining class WeiboOutbound: def __init__(self, gateway=None): self._gateway = gateway self._http: httpx.AsyncClient | None = None async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: if not content: return if self._gateway and not self._gateway.can_reply(target_id): logger.warning("Reply window expired or quota exceeded for user %s", target_id) return token = self._gateway.access_token if self._gateway else None if not token: logger.error("No access_token for weibo send_text") return texts = split_utf8(content) if self._http is None: self._http = httpx.AsyncClient(timeout=15.0) for i, text in enumerate(texts): payload = { "access_token": token, "type": "text", "receiver_id": int(target_id), "save_sender_box": 1, "data": json.dumps({"text": text}), } success = await self._send_api(payload) if success and self._gateway: self._gateway.record_outbound(target_id) if not success: logger.error("Weibo send_text failed for %s, text len=%d", target_id, len(text)) return if i < len(texts) - 1: await asyncio.sleep(0.5) async def send_articles( self, target_id: str, articles: list[dict], ) -> bool: token = self._gateway.access_token if self._gateway else None if not token: return False if self._http is None: self._http = httpx.AsyncClient(timeout=15.0) payload = { "access_token": token, "type": "articles", "receiver_id": int(target_id), "save_sender_box": 1, "data": json.dumps({"articles": articles}), } return await self._send_api(payload) async def send_media( self, target_id: str, media_url: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, ) -> None: token = self._gateway.access_token if self._gateway else None if not token: return if self._http is None: self._http = httpx.AsyncClient(timeout=15.0) payload = { "access_token": token, "type": media_type, "receiver_id": int(target_id), "save_sender_box": 1, "data": json.dumps({"media_id": media_url}), } await self._send_api(payload) async def send_position( self, target_id: str, longitude: float, latitude: float, ) -> None: token = self._gateway.access_token if self._gateway else None if not token: return if self._http is None: self._http = httpx.AsyncClient(timeout=15.0) payload = { "access_token": token, "type": "position", "receiver_id": int(target_id), "save_sender_box": 1, "data": json.dumps({"longitude": str(longitude), "latitude": str(latitude)}), } await self._send_api(payload) @staticmethod def build_passive_text_reply(target_id: str, content: str) -> dict: return { "result": True, "receiver_id": int(target_id), "type": "text", "data": json.dumps({"text": content}), } @staticmethod def build_passive_articles_reply(target_id: str, articles: list[dict]) -> dict: return { "result": True, "receiver_id": int(target_id), "type": "articles", "data": json.dumps({"articles": articles}), } async def _send_api(self, payload: dict) -> bool: for attempt in range(3): try: resp = await self._http.post(SEND_URL, data=payload) data = resp.json() if "error_code" not in data: return True error_code = data.get("error_code", "") error_msg = data.get("error", "") if error_code in ("26401", "26402"): logger.error("Weibo token expired: %s", error_msg) return False if error_code == "10022": await asyncio.sleep(attempt * 10 + 10) continue logger.warning("Weibo send failed: error_code=%s, error=%s", error_code, error_msg) if attempt < 2: await asyncio.sleep(attempt + 1) except Exception as e: logger.warning("Weibo send attempt %s failed: %s", attempt + 1, e) if attempt < 2: await asyncio.sleep(attempt + 1) return False async def close(self) -> None: if self._http: await self._http.aclose() self._http = None