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 (
''
f''
f''
f'{int(time.time())}'
''
f''
''
)
@staticmethod
def build_passive_image_reply(to_user: str, from_user: str, media_id: str) -> str:
return (
''
f''
f''
f'{int(time.time())}'
''
''
f''
''
''
)
@staticmethod
def build_passive_voice_reply(to_user: str, from_user: str, media_id: str) -> str:
return (
''
f''
f''
f'{int(time.time())}'
''
''
f''
''
''
)
async def close(self):
if self._http:
await self._http.aclose()
self._http = None