本次提交包含多项优化与新增功能: 1. 清理多个文件中多余的空行与导入顺序 2. 修复voice.py中的多行字符串格式化问题 3. 新增微信公众号被动回复构建函数与配置项 4. 新增企业微信markdown消息发送支持 5. 新增消息去重TTL与最大条目配置 6. 新增markdown文本截断工具函数 7. 新增微信授权与OAuth相关工具方法 8. 重构消息去重逻辑,使用DedupPolicy替代本地字典实现 9. 新增子账号多租户支持功能 10. 新增消息动作处理适配器,支持send/reply等操作 11. 修复token持久化逻辑,新增状态存储支持
226 lines
6.9 KiB
Python
226 lines
6.9 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from yuxi.channels.adapters.wechat.errors import is_token_expired, parse_wecom_error
|
|
from yuxi.channels.adapters.wechat.format import truncate_markdown, truncate_text
|
|
from yuxi.channels.adapters.wechat.retry import retry_with_backoff
|
|
from yuxi.channels.exceptions import ChannelRateLimitError
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
from .client import WeComClient
|
|
|
|
|
|
async def send_wecom_message(
|
|
client: WeComClient,
|
|
http_client: httpx.AsyncClient,
|
|
payload: dict[str, Any],
|
|
) -> DeliveryResult:
|
|
token = await client.get_access_token()
|
|
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
|
|
|
|
async def _post():
|
|
return await http_client.post(api_url, json=payload)
|
|
|
|
try:
|
|
resp = await retry_with_backoff(_post)
|
|
data = resp.json()
|
|
|
|
if data.get("errcode") == 0:
|
|
return DeliveryResult(success=True, message_id=data.get("msgid"))
|
|
elif is_token_expired(data.get("errcode", 0)):
|
|
client.invalidate_token()
|
|
token = await client.get_access_token()
|
|
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?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=data.get("msgid"))
|
|
_, err_detail = parse_wecom_error(data)
|
|
return DeliveryResult(success=False, error=err_detail)
|
|
elif data.get("errcode") in (45009, 45011):
|
|
raise ChannelRateLimitError(retry_after_ms=60000)
|
|
else:
|
|
_, err_detail = parse_wecom_error(data)
|
|
return DeliveryResult(success=False, error=err_detail)
|
|
except httpx.HTTPError as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
|
|
def build_wecom_text_payload(
|
|
agent_id: str,
|
|
to_user: str,
|
|
content: str,
|
|
chat_type: str = "direct",
|
|
reply_to_msg_id: str | None = None,
|
|
reply_to_user: str | None = None,
|
|
safe: int = 0,
|
|
) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"msgtype": "text",
|
|
"agentid": agent_id,
|
|
"text": {"content": truncate_text(content, 2048)},
|
|
"safe": safe,
|
|
}
|
|
payload["touser"] = to_user
|
|
if reply_to_msg_id:
|
|
payload["_reply_to_msg_id"] = reply_to_msg_id
|
|
if reply_to_user:
|
|
payload["_reply_to_user"] = reply_to_user
|
|
quoted_prefix = f"「回复 @{reply_to_user}」\n"
|
|
if len(quoted_prefix + content) <= 2048:
|
|
payload["text"]["content"] = quoted_prefix + content
|
|
return payload
|
|
|
|
|
|
def build_wecom_markdown_payload(
|
|
agent_id: str,
|
|
to_user: str,
|
|
content: str,
|
|
chat_type: str = "direct",
|
|
reply_to_msg_id: str | None = None,
|
|
reply_to_user: str | None = None,
|
|
safe: int = 0,
|
|
) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"msgtype": "markdown",
|
|
"agentid": agent_id,
|
|
"markdown": {"content": truncate_markdown(content, 2048)},
|
|
"safe": safe,
|
|
}
|
|
payload["touser"] = to_user
|
|
if reply_to_msg_id and reply_to_user:
|
|
payload["_reply_to_msg_id"] = reply_to_msg_id
|
|
payload["_reply_to_user"] = reply_to_user
|
|
quoted_prefix = f"> 回复 @{reply_to_user}\n\n"
|
|
if len(quoted_prefix + content) <= 2048:
|
|
payload["markdown"]["content"] = quoted_prefix + content
|
|
return payload
|
|
|
|
|
|
def build_wecom_image_payload(agent_id: str, to_user: str, media_id: str) -> dict[str, Any]:
|
|
return {
|
|
"touser": to_user,
|
|
"msgtype": "image",
|
|
"agentid": agent_id,
|
|
"image": {"media_id": media_id},
|
|
}
|
|
|
|
|
|
def build_wecom_file_payload(agent_id: str, to_user: str, media_id: str) -> dict[str, Any]:
|
|
return {
|
|
"touser": to_user,
|
|
"msgtype": "file",
|
|
"agentid": agent_id,
|
|
"file": {"media_id": media_id},
|
|
}
|
|
|
|
|
|
def build_wecom_voice_payload(agent_id: str, to_user: str, media_id: str) -> dict[str, Any]:
|
|
return {
|
|
"touser": to_user,
|
|
"msgtype": "voice",
|
|
"agentid": agent_id,
|
|
"voice": {"media_id": media_id},
|
|
}
|
|
|
|
|
|
def build_wecom_video_payload(
|
|
agent_id: str, to_user: str, media_id: str, title: str = "", description: str = ""
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"touser": to_user,
|
|
"msgtype": "video",
|
|
"agentid": agent_id,
|
|
"video": {"media_id": media_id, "title": title, "description": description},
|
|
}
|
|
|
|
|
|
def build_wecom_news_payload(agent_id: str, to_user: str, articles: list[dict[str, Any]]) -> dict[str, Any]:
|
|
return {
|
|
"touser": to_user,
|
|
"msgtype": "news",
|
|
"agentid": agent_id,
|
|
"news": {"articles": articles},
|
|
}
|
|
|
|
|
|
def build_wecom_miniprogram_payload(
|
|
agent_id: str,
|
|
to_user: str,
|
|
title: str,
|
|
appid: str,
|
|
pagepath: str,
|
|
thumb_media_id: str,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"touser": to_user,
|
|
"msgtype": "miniprogram_notice",
|
|
"agentid": agent_id,
|
|
"miniprogram_notice": {
|
|
"appid": appid,
|
|
"title": title,
|
|
"page": pagepath,
|
|
"emphasis_first_item": False,
|
|
"content_item": [{"key": "详情", "value": title}],
|
|
},
|
|
}
|
|
|
|
|
|
async def send_wecom_voice(
|
|
client: WeComClient,
|
|
http_client: httpx.AsyncClient,
|
|
agent_id: str,
|
|
to_user: str,
|
|
voice_data: bytes,
|
|
) -> DeliveryResult:
|
|
try:
|
|
media_id = await client.upload_media(voice_data, "voice.amr", "voice")
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=f"WeCom voice upload failed: {e}")
|
|
|
|
payload = build_wecom_voice_payload(agent_id, to_user, media_id)
|
|
return await send_wecom_message(client, http_client, payload)
|
|
|
|
|
|
async def send_wecom_video(
|
|
client: WeComClient,
|
|
http_client: httpx.AsyncClient,
|
|
agent_id: str,
|
|
to_user: str,
|
|
video_data: bytes,
|
|
title: str = "",
|
|
description: str = "",
|
|
) -> DeliveryResult:
|
|
try:
|
|
media_id = await client.upload_media(video_data, "video.mp4", "video")
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=f"WeCom video upload failed: {e}")
|
|
|
|
payload = build_wecom_video_payload(agent_id, to_user, media_id, title, description)
|
|
return await send_wecom_message(client, http_client, payload)
|
|
|
|
|
|
def build_wecom_template_card_payload(
|
|
agent_id: str,
|
|
to_user: str,
|
|
card: "TemplateCard",
|
|
enable_id_trans: bool = False,
|
|
enable_duplicate_check: bool = False,
|
|
) -> dict[str, Any]:
|
|
from yuxi.channels.adapters.wechat.template_card_render import render_template_card_message
|
|
|
|
return render_template_card_message(
|
|
to_user=to_user,
|
|
agent_id=agent_id,
|
|
card=card,
|
|
enable_id_trans=enable_id_trans,
|
|
enable_duplicate_check=enable_duplicate_check,
|
|
)
|