ForcePilot/backend/package/yuxi/channel/extensions/wecom/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

311 lines
9.4 KiB
Python

import asyncio
import logging
import httpx
logger = logging.getLogger(__name__)
MESSAGE_SEND_URL = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
MAX_UTF8_LEN_BYTES = 2048
def split_utf8(text: str, max_bytes: int = MAX_UTF8_LEN_BYTES) -> 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
class WeComOutbound:
def __init__(self, gateway=None):
self._gateway = gateway
self._http: httpx.AsyncClient | None = None
@property
def agent_id(self) -> int:
account = self._gateway.account if self._gateway else None
return getattr(account, "agent_id", 0)
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,
toparty: str | None = None,
totag: str | None = None,
safe: int = 0,
enable_duplicate_check: int = 0,
duplicate_check_interval: int = 1800,
) -> None:
if not content or not target_id:
return
texts = split_utf8(content)
for i, text in enumerate(texts):
payload: dict = {
"msgtype": "text",
"agentid": self.agent_id,
"text": {"content": text},
"safe": safe,
}
if toparty:
payload["toparty"] = toparty
else:
payload["touser"] = target_id
if totag:
payload["totag"] = totag
if enable_duplicate_check:
payload["enable_duplicate_check"] = enable_duplicate_check
payload["duplicate_check_interval"] = duplicate_check_interval
await self._send_message(payload)
if i < len(texts) - 1:
await asyncio.sleep(0.3)
async def send_markdown(
self,
target_id: str,
content: str,
*,
toparty: str | None = None,
totag: str | None = None,
safe: int = 0,
enable_duplicate_check: int = 0,
duplicate_check_interval: int = 1800,
) -> None:
if not content or not target_id:
return
md_chunks = split_utf8(content, max_bytes=4096)
for i, chunk in enumerate(md_chunks):
payload: dict = {
"msgtype": "markdown",
"agentid": self.agent_id,
"markdown": {"content": chunk},
"safe": safe,
}
if toparty:
payload["toparty"] = toparty
else:
payload["touser"] = target_id
if totag:
payload["totag"] = totag
if enable_duplicate_check:
payload["enable_duplicate_check"] = enable_duplicate_check
payload["duplicate_check_interval"] = duplicate_check_interval
await self._send_message(payload)
if i < len(md_chunks) - 1:
await asyncio.sleep(0.3)
async def send_media(
self,
target_id: str,
media_id: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
title: str | None = None,
description: str | None = None,
) -> None:
media_field_map = {
"image": "image",
"voice": "voice",
"video": "video",
"file": "file",
}
field = media_field_map.get(media_type, media_type)
payload = {
"touser": target_id,
"msgtype": media_type,
"agentid": self.agent_id,
field: {"media_id": media_id},
}
if media_type == "video":
payload[field]["title"] = title or ""
payload[field]["description"] = description or ""
await self._send_message(payload)
async def send_textcard(
self,
target_id: str,
title: str,
description: str,
url: str,
btn_text: str = "查看详情",
) -> None:
payload = {
"touser": target_id,
"msgtype": "textcard",
"agentid": self.agent_id,
"textcard": {
"title": title,
"description": description,
"url": url,
"btntxt": btn_text,
},
}
await self._send_message(payload)
async def _send_message(self, payload: dict) -> bool:
token = self._gateway.access_token if self._gateway else None
if not token:
logger.error("No valid access_token for wecom message send")
return False
url = f"{MESSAGE_SEND_URL}?access_token={token}"
if self._http is None:
self._http = httpx.AsyncClient(timeout=15.0)
for attempt in range(3):
try:
resp = await self._http.post(url, json=payload)
data = resp.json()
errcode = data.get("errcode", -1)
if errcode == 0:
return True
if errcode in (40001, 40014, 42001):
logger.warning("Token expired/invalid (%s), will retry", errcode)
else:
logger.warning(
"WeCom send failed: errcode=%s errmsg=%s payload_type=%s",
errcode,
data.get("errmsg"),
payload.get("msgtype"),
)
if errcode in (40003, 60011, 84061):
return False
except Exception as e:
logger.warning("WeCom send attempt %s failed: %s", attempt + 1, e)
await asyncio.sleep(min(attempt + 1, 3))
return False
async def send_template_card(self, target_id: str, template_card: dict) -> bool:
payload = {
"touser": target_id,
"msgtype": "template_card",
"agentid": self.agent_id,
"template_card": template_card,
}
return await self._send_message(payload)
async def send_news(self, target_id: str, articles: list[dict]) -> None:
if not articles or not target_id:
return
if len(articles) > 8:
articles = articles[:8]
payload = {
"touser": target_id,
"msgtype": "news",
"agentid": self.agent_id,
"news": {"articles": articles},
}
await self._send_message(payload)
async def send_mpnews(self, target_id: str, articles: list[dict]) -> None:
if not articles or not target_id:
return
if len(articles) > 8:
articles = articles[:8]
payload = {
"touser": target_id,
"msgtype": "mpnews",
"agentid": self.agent_id,
"mpnews": {"articles": articles},
}
await self._send_message(payload)
async def send_miniprogram_notice(
self,
target_id: str,
appid: str,
title: str,
description: str = "",
page: str = "",
emphasis_first_item: bool = False,
content_item: list[dict] | None = None,
) -> None:
notice: dict = {
"appid": appid,
"title": title,
"description": description,
"emphasis_first_item": emphasis_first_item,
}
if page:
notice["page"] = page
if content_item:
notice["content_item"] = content_item
payload = {
"touser": target_id,
"msgtype": "miniprogram_notice",
"agentid": self.agent_id,
"miniprogram_notice": notice,
}
await self._send_message(payload)
async def send_taskcard(
self,
target_id: str,
task_id: str,
title: str,
description: str = "",
url: str = "",
buttons: list[dict] | None = None,
) -> None:
card: dict = {
"title": title,
"description": description,
"url": url,
"task_id": task_id,
}
if buttons:
card["btn"] = buttons
payload = {
"touser": target_id,
"msgtype": "interactive_taskcard",
"agentid": self.agent_id,
"interactive_taskcard": card,
}
await self._send_message(payload)
async def recall_message(self, msg_id: str) -> bool:
token = self._gateway.access_token if self._gateway else None
if not token:
return False
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/recall?access_token={token}"
if self._http is None:
self._http = httpx.AsyncClient(timeout=15.0)
try:
resp = await self._http.post(url, json={"msgid": msg_id})
data = resp.json()
return data.get("errcode") == 0
except Exception:
logger.exception("WeCom recall_message failed")
return False
async def close(self):
if self._http:
await self._http.aclose()
self._http = None