import asyncio
import logging
import time
import httpx
from yuxi.channel.extensions.wechat_mp.format import (
MAX_UTF8_LEN,
remove_markdown,
split_utf8,
split_utf8_safe,
)
logger = logging.getLogger(__name__)
CUSTOM_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/custom/send"
class WeChatMPOutbound:
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,
extra: dict | None = None,
) -> None:
payload = {"touser": target_id, "msgtype": media_type}
field = (extra or {}).copy()
if media_type in ("image", "voice"):
field.setdefault("media_id", media_url)
elif media_type == "video":
field.setdefault("media_id", media_url)
field.setdefault("thumb_media_id", field.get("thumb_media_id", ""))
field.setdefault("title", field.get("title", ""))
field.setdefault("description", field.get("description", ""))
elif media_type == "music":
field.setdefault("thumb_media_id", media_url)
else:
field.setdefault("media_id", media_url)
payload[media_type] = field
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_video(
self,
to_user: str,
media_id: str,
thumb_media_id: str = "",
title: str = "",
description: str = "",
) -> bool:
return await self._send_media(
to_user,
"video",
media_id,
extra={
"thumb_media_id": thumb_media_id,
"title": title,
"description": description,
},
)
async def send_music(
self,
to_user: str,
title: str,
description: str,
music_url: str,
hq_music_url: str,
thumb_media_id: str,
) -> bool:
payload = {
"touser": to_user,
"msgtype": "music",
"music": {
"title": title,
"description": description,
"musicurl": music_url,
"hqmusicurl": hq_music_url,
"thumb_media_id": thumb_media_id,
},
}
return await self._send_custom(payload)
async def send_news(self, to_user: str, articles: list[dict]) -> bool:
payload = {
"touser": to_user,
"msgtype": "news",
"news": {"articles": articles[:8]},
}
return await self._send_custom(payload)
async def send_menu(self, to_user: str, buttons: list[dict]) -> bool:
payload = {
"touser": to_user,
"msgtype": "msgmenu",
"msgmenu": {"head_content": "", "list": buttons[:10], "tail_content": ""},
}
return await self._send_custom(payload)
async def send_miniprogram_page(
self,
to_user: str,
title: str,
appid: str,
pagepath: str,
thumb_media_id: str,
) -> bool:
payload = {
"touser": to_user,
"msgtype": "miniprogrampage",
"miniprogrampage": {
"title": title,
"appid": appid,
"pagepath": pagepath,
"thumb_media_id": thumb_media_id,
},
}
return await self._send_custom(payload)
async def _send_media(self, to_user: str, media_type: str, media_id: str, extra: dict | None = None) -> bool:
payload = {
"touser": to_user,
"msgtype": media_type,
media_type: {"media_id": media_id},
}
if extra:
payload[media_type].update(extra)
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''
''
''
)
@staticmethod
def build_passive_video_reply(
to_user: str, from_user: str, media_id: str, title: str = "", description: str = ""
) -> str:
return (
''
f''
f''
f'{int(time.time())}'
''
''
''
)
@staticmethod
def build_passive_music_reply(
to_user: str,
from_user: str,
title: str,
description: str,
music_url: str,
hq_music_url: str,
thumb_media_id: str,
) -> str:
return (
''
f''
f''
f'{int(time.time())}'
''
''
f''
f''
f''
f''
f''
''
''
)
@staticmethod
def build_passive_news_reply(to_user: str, from_user: str, articles: list[dict]) -> str:
articles_xml = ""
for article in articles[:8]:
articles_xml += (
"- "
f''
f''
f''
f''
"
"
)
return (
''
f''
f''
f'{int(time.time())}'
''
f'{len(articles[:8])}'
f'{articles_xml}'
''
)
async def close(self):
if self._http:
await self._http.aclose()
self._http = None