新增 Webex、微信 iLink、微信小程序三个渠道扩展。 Webex 渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - media: 媒体资源处理 微信 iLink 渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - context_store: 上下文存储 - aes_ecb: AES-ECB 加解密 - media: 媒体资源处理 - typing: 输入状态 微信小程序渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - crypto: 加解密处理 - dedupe: 消息去重 - message: 消息处理 - passive_reply: 被动回复 - media: 媒体资源处理 - status: 会话状态管理
256 lines
7.9 KiB
Python
256 lines
7.9 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.wechat_mp.outbound import remove_markdown, split_utf8, split_utf8_safe # noqa: F401
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CUSTOM_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/custom/send"
|
|
TYPING_URL = "https://api.weixin.qq.com/cgi-bin/message/custom/business/typing"
|
|
MAX_UTF8_LEN = 2048
|
|
|
|
|
|
class MiniProgramOutbound:
|
|
|
|
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
|
|
|
|
texts = split_utf8(content)
|
|
for i, text in enumerate(texts):
|
|
payload = {
|
|
"touser": target_id,
|
|
"msgtype": "text",
|
|
"text": {"content": text},
|
|
}
|
|
await self._send_custom(payload)
|
|
if i < len(texts) - 1:
|
|
await asyncio.sleep(0.5)
|
|
|
|
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:
|
|
payload = {
|
|
"touser": target_id,
|
|
"msgtype": media_type,
|
|
media_type: {"media_id": media_url},
|
|
}
|
|
await self._send_custom(payload)
|
|
|
|
async def send_image(self, to_user: str, media_id: str) -> bool:
|
|
return await self._send_media(to_user, "image", media_id)
|
|
|
|
async def send_voice(self, to_user: str, media_id: str) -> bool:
|
|
return await self._send_media(to_user, "voice", media_id)
|
|
|
|
async def send_miniprogram_page(
|
|
self,
|
|
target_id: str,
|
|
title: str,
|
|
pagepath: str,
|
|
thumb_media_id: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
) -> bool:
|
|
payload = {
|
|
"touser": target_id,
|
|
"msgtype": "miniprogrampage",
|
|
"miniprogrampage": {
|
|
"title": title,
|
|
"pagepath": pagepath,
|
|
"thumb_media_id": thumb_media_id,
|
|
},
|
|
}
|
|
return await self._send_custom(payload)
|
|
|
|
async def set_typing(self, target_id: str, command: str = "Typing") -> bool:
|
|
payload = {"touser": target_id, "command": command}
|
|
token = self._gateway.access_token if self._gateway else None
|
|
if not token:
|
|
logger.error("No valid access_token for typing")
|
|
return False
|
|
|
|
url = f"{TYPING_URL}?access_token={token}"
|
|
|
|
if self._http is None:
|
|
self._http = httpx.AsyncClient(timeout=15.0)
|
|
|
|
try:
|
|
resp = await self._http.post(url, json=payload)
|
|
data = resp.json()
|
|
return data.get("errcode") == 0
|
|
except Exception:
|
|
logger.exception("MiniProgram set_typing failed")
|
|
return False
|
|
|
|
async def cancel_typing(self, target_id: str) -> bool:
|
|
return await self.set_typing(target_id, "CancelTyping")
|
|
|
|
async def send_link(
|
|
self,
|
|
target_id: str,
|
|
title: str,
|
|
description: str,
|
|
url: str,
|
|
thumb_url: str = "",
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
) -> bool:
|
|
payload = {
|
|
"touser": target_id,
|
|
"msgtype": "link",
|
|
"link": {
|
|
"title": title,
|
|
"description": description,
|
|
"url": url,
|
|
"thumb_url": thumb_url,
|
|
},
|
|
}
|
|
return await self._send_custom(payload)
|
|
|
|
async def send_video(
|
|
self,
|
|
target_id: str,
|
|
media_id: str,
|
|
thumb_media_id: str = "",
|
|
title: str = "",
|
|
description: str = "",
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
) -> bool:
|
|
payload: dict = {
|
|
"touser": target_id,
|
|
"msgtype": "video",
|
|
"video": {
|
|
"media_id": media_id,
|
|
},
|
|
}
|
|
if thumb_media_id:
|
|
payload["video"]["thumb_media_id"] = thumb_media_id
|
|
if title:
|
|
payload["video"]["title"] = title
|
|
if description:
|
|
payload["video"]["description"] = description
|
|
return await self._send_custom(payload)
|
|
|
|
async def _send_media(self, to_user: str, media_type: str, media_id: str) -> bool:
|
|
payload = {
|
|
"touser": to_user,
|
|
"msgtype": media_type,
|
|
media_type: {"media_id": media_id},
|
|
}
|
|
return await self._send_custom(payload)
|
|
|
|
async def _send_custom(self, payload: dict) -> bool:
|
|
token = self._gateway.access_token if self._gateway else None
|
|
if not token:
|
|
logger.error("No valid access_token for custom send")
|
|
return False
|
|
|
|
url = f"{CUSTOM_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 == 45015:
|
|
logger.warning("48h window expired for %s", payload.get("touser"))
|
|
return False
|
|
if errcode == 40001:
|
|
logger.warning("Token expired, will retry")
|
|
else:
|
|
logger.warning("Custom send failed: errcode=%s errmsg=%s", errcode, data.get("errmsg"))
|
|
|
|
except Exception as e:
|
|
logger.warning("Custom send attempt %s failed: %s", attempt + 1, e)
|
|
|
|
await asyncio.sleep(attempt + 1)
|
|
|
|
return False
|
|
|
|
async def send_stream(self, target_id: str, text_generator, chunk_size: int = 200):
|
|
buffer = ""
|
|
async for chunk in text_generator:
|
|
buffer += chunk
|
|
if len(buffer) >= chunk_size:
|
|
await self.send_text(target_id, buffer)
|
|
buffer = ""
|
|
await asyncio.sleep(0.5)
|
|
if buffer:
|
|
await self.send_text(target_id, buffer)
|
|
|
|
@staticmethod
|
|
def build_passive_text_reply(to_user: str, from_user: str, content: str) -> str:
|
|
return (
|
|
'<xml>'
|
|
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
|
|
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
|
|
f'<CreateTime>{int(time.time())}</CreateTime>'
|
|
'<MsgType><![CDATA[text]]></MsgType>'
|
|
f'<Content><![CDATA[{content}]]></Content>'
|
|
'</xml>'
|
|
)
|
|
|
|
@staticmethod
|
|
def build_passive_image_reply(to_user: str, from_user: str, media_id: str) -> str:
|
|
return (
|
|
'<xml>'
|
|
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
|
|
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
|
|
f'<CreateTime>{int(time.time())}</CreateTime>'
|
|
'<MsgType><![CDATA[image]]></MsgType>'
|
|
'<Image>'
|
|
f'<MediaId><![CDATA[{media_id}]]></MediaId>'
|
|
'</Image>'
|
|
'</xml>'
|
|
)
|
|
|
|
@staticmethod
|
|
def build_passive_voice_reply(to_user: str, from_user: str, media_id: str) -> str:
|
|
return (
|
|
'<xml>'
|
|
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
|
|
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
|
|
f'<CreateTime>{int(time.time())}</CreateTime>'
|
|
'<MsgType><![CDATA[voice]]></MsgType>'
|
|
'<Voice>'
|
|
f'<MediaId><![CDATA[{media_id}]]></MediaId>'
|
|
'</Voice>'
|
|
'</xml>'
|
|
)
|
|
|
|
async def close(self):
|
|
if self._http:
|
|
await self._http.aclose()
|
|
self._http = None
|