from __future__ import annotations
import time
from typing import Any
import httpx
from yuxi.channels.adapters.wechat.errors import is_token_expired, parse_mp_error
from yuxi.channels.adapters.wechat.format import truncate_text
from yuxi.channels.adapters.wechat.retry import retry_with_backoff
from yuxi.channels.models import DeliveryResult
from .client import MPClient
XML_HEADER = ''
async def send_mp_custom_message(
client: MPClient,
http_client: httpx.AsyncClient,
to_user: str,
content: str,
reply_to_msg_id: str | None = None,
reply_to_user: str | None = None,
) -> DeliveryResult:
token = await client.get_access_token()
display_content = content
if reply_to_msg_id and reply_to_user:
quoted_prefix = f"「回复 @{reply_to_user}」\n"
if len(quoted_prefix + content) <= 2048:
display_content = quoted_prefix + content
payload = {
"touser": to_user,
"msgtype": "text",
"text": {"content": truncate_text(display_content, 2048)},
}
if reply_to_msg_id:
payload["_reply_to_msg_id"] = reply_to_msg_id
try:
result = await _post_with_token_retry(client, http_client, token, payload)
return result
except httpx.HTTPError as e:
return DeliveryResult(success=False, error=str(e))
async def send_mp_template_message(
client: MPClient,
http_client: httpx.AsyncClient,
to_user: str,
template_id: str,
data: dict[str, Any],
url: str | None = None,
) -> DeliveryResult:
token = await client.get_access_token()
payload: dict[str, Any] = {
"touser": to_user,
"template_id": template_id,
"data": data,
}
if url:
payload["url"] = url
try:
result = await _post_with_token_retry(client, http_client, token, payload)
return result
except httpx.HTTPError as e:
return DeliveryResult(success=False, error=str(e))
_MP_CUSTOM_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/custom/send"
_MP_TEMPLATE_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/template/send"
async def _post_with_token_retry(
client: MPClient,
http_client: httpx.AsyncClient,
token: str,
payload: dict[str, Any],
) -> DeliveryResult:
api_url = f"{_MP_CUSTOM_SEND_URL}?access_token={token}"
if "template_id" in payload:
api_url = f"{_MP_TEMPLATE_SEND_URL}?access_token={token}"
async def _post():
return await http_client.post(api_url, json=payload)
resp = await retry_with_backoff(_post)
data = resp.json()
if data.get("errcode") == 0:
return DeliveryResult(success=True, message_id=str(data.get("msgid", "")))
if is_token_expired(data.get("errcode", 0)):
client.invalidate_token()
token = await client.get_access_token()
api_url = f"{_MP_CUSTOM_SEND_URL}?access_token={token}"
if "template_id" in payload:
api_url = f"{_MP_TEMPLATE_SEND_URL}?access_token={token}"
async def _retry_post():
return await http_client.post(api_url, json=payload)
resp = await retry_with_backoff(_retry_post)
data = resp.json()
if data.get("errcode") == 0:
return DeliveryResult(success=True, message_id=str(data.get("msgid", "")))
_, err_detail = parse_mp_error(data)
return DeliveryResult(success=False, error=err_detail)
async def send_mp_image(
client: MPClient,
http_client: httpx.AsyncClient,
to_user: str,
image_data: bytes,
filename: str = "image.png",
) -> DeliveryResult:
try:
media_id = await client.upload_temp_media(image_data, filename, "image")
except Exception as e:
return DeliveryResult(success=False, error=f"MP image upload failed: {e}")
token = await client.get_access_token()
payload = {
"touser": to_user,
"msgtype": "image",
"image": {"media_id": media_id},
}
try:
return await _post_with_token_retry(client, http_client, token, payload)
except httpx.HTTPError as e:
return DeliveryResult(success=False, error=str(e))
async def send_mp_voice(
client: MPClient,
http_client: httpx.AsyncClient,
to_user: str,
voice_data: bytes,
) -> DeliveryResult:
try:
media_id = await client.upload_temp_media(voice_data, "voice.amr", "voice")
except Exception as e:
return DeliveryResult(success=False, error=f"MP voice upload failed: {e}")
token = await client.get_access_token()
payload = {
"touser": to_user,
"msgtype": "voice",
"voice": {"media_id": media_id},
}
try:
return await _post_with_token_retry(client, http_client, token, payload)
except httpx.HTTPError as e:
return DeliveryResult(success=False, error=str(e))
async def send_mp_video(
client: MPClient,
http_client: httpx.AsyncClient,
to_user: str,
video_data: bytes,
title: str = "",
description: str = "",
) -> DeliveryResult:
try:
media_id = await client.upload_temp_media(video_data, "video.mp4", "video")
except Exception as e:
return DeliveryResult(success=False, error=f"MP video upload failed: {e}")
token = await client.get_access_token()
payload = {
"touser": to_user,
"msgtype": "video",
"video": {"media_id": media_id, "title": title, "description": description},
}
try:
return await _post_with_token_retry(client, http_client, token, payload)
except httpx.HTTPError as e:
return DeliveryResult(success=False, error=str(e))
def build_mp_news_payload(
to_user: str,
articles: list[dict[str, Any]],
) -> dict[str, Any]:
return {
"touser": to_user,
"msgtype": "news",
"news": {"articles": articles},
}
async def send_mp_news(
client: MPClient,
http_client: httpx.AsyncClient,
payload: dict[str, Any],
) -> DeliveryResult:
token = await client.get_access_token()
try:
return await _post_with_token_retry(client, http_client, token, payload)
except httpx.HTTPError as e:
return DeliveryResult(success=False, error=str(e))
def _escape_cdata(text: str) -> str:
return f"', ']]]]>')}]]>"
def build_text_passive_reply(
to_user: str,
from_user: str,
content: str,
) -> str:
timestamp = int(time.time())
return (
f'{XML_HEADER}\n'
f'\n'
f' {_escape_cdata(to_user)}\n'
f' {_escape_cdata(from_user)}\n'
f' {timestamp}\n'
f' {_escape_cdata("text")}\n'
f' {_escape_cdata(content[:2048])}\n'
f''
)
def build_image_passive_reply(
to_user: str,
from_user: str,
media_id: str,
) -> str:
timestamp = int(time.time())
return (
f'{XML_HEADER}\n'
f'\n'
f' {_escape_cdata(to_user)}\n'
f' {_escape_cdata(from_user)}\n'
f' {timestamp}\n'
f' {_escape_cdata("image")}\n'
f' {_escape_cdata(media_id)}\n'
f''
)
def build_voice_passive_reply(
to_user: str,
from_user: str,
media_id: str,
) -> str:
timestamp = int(time.time())
return (
f'{XML_HEADER}\n'
f'\n'
f' {_escape_cdata(to_user)}\n'
f' {_escape_cdata(from_user)}\n'
f' {timestamp}\n'
f' {_escape_cdata("voice")}\n'
f' {_escape_cdata(media_id)}\n'
f''
)
def build_video_passive_reply(
to_user: str,
from_user: str,
media_id: str,
title: str = "",
description: str = "",
) -> str:
timestamp = int(time.time())
return (
f'{XML_HEADER}\n'
f'\n'
f' {_escape_cdata(to_user)}\n'
f' {_escape_cdata(from_user)}\n'
f' {timestamp}\n'
f' {_escape_cdata("video")}\n'
f' \n'
f''
)
def build_transfer_customer_service_reply(
to_user: str,
from_user: str,
) -> str:
timestamp = int(time.time())
return (
f'{XML_HEADER}\n'
f'\n'
f' {_escape_cdata(to_user)}\n'
f' {_escape_cdata(from_user)}\n'
f' {timestamp}\n'
f' {_escape_cdata("transfer_customer_service")}\n'
f''
)
def build_empty_passive_reply() -> str:
return ""