新增企业微信、微博、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
228 lines
6.8 KiB
Python
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
|