ForcePilot/backend/package/yuxi/channel/extensions/weibo/outbound.py
Kris 7215418610 feat(channel): 添加企业微信、微博、WhatsApp 和 Workplace 渠道扩展
新增企业微信、微博、WhatsApp、Workplace 四个渠道扩展。

企业微信渠道扩展主要模块:config, gateway, webhook, webhook_bot, outbound, streaming, pairing, security, crypto, dedupe, persistent_dedupe, card, directory, events, externalcontact, media, mentions, menu, message, oauth, status

微博渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, passive_reply, broadcast, message, menu, media, subscription, template, status

WhatsApp 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, actions, monitor, status

Workplace 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, actions, challenge, groups, media, mentions, menu, monitor, persona, quick_reply, signature, subscriptions, template, threading, users, status
2026-05-21 12:01:56 +08:00

228 lines
6.8 KiB
Python

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