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