for merge
This commit is contained in:
@@ -0,0 +1,777 @@
|
||||
"""WeChat channel implementation.
|
||||
|
||||
Supports two backends via a unified Channel interface:
|
||||
|
||||
1. **wecom** (企业微信应用): Corporate WeChat official API
|
||||
- Receives messages via HTTP callback (XML + optional AES encryption)
|
||||
- Sends replies via REST API (POST /cgi-bin/message/send)
|
||||
- Supports text, image, file, markdown messages
|
||||
- Token auto-refresh with 2-hour TTL
|
||||
|
||||
2. **wechatmp** (微信公众号): WeChat Official Account API
|
||||
- Receives messages via HTTP callback (XML + optional AES encryption)
|
||||
- Sends replies via REST API (POST /cgi-bin/message/custom/send)
|
||||
- Supports text, image, news messages
|
||||
|
||||
Both backends use httpx (already a core dependency) and aiohttp for
|
||||
webhook server — matching the Feishu channel pattern.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..mixins import WebhookMixin, TokenMixin
|
||||
from ..base import Channel, RawIncoming, ChannelError
|
||||
from ..capabilities import WECHAT as WECHAT_CAPS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Markdown → plain text (fallback for WeChat text messages) ────
|
||||
|
||||
def _strip_markdown(text: str) -> str:
|
||||
"""Strip Markdown formatting for plain-text WeChat messages."""
|
||||
# Remove code blocks
|
||||
text = re.sub(r"```[\s\S]*?```", lambda m: m.group(0).strip("`").strip(), text)
|
||||
# Remove inline code
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
# Remove bold
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
# Remove italic
|
||||
text = re.sub(r"(?<!\w)_([^_]+?)_(?!\w)", r"\1", text)
|
||||
# Remove strikethrough
|
||||
text = re.sub(r"~~(.+?)~~", r"\1", text)
|
||||
# Convert links
|
||||
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1(\2)", text)
|
||||
# Remove heading markers
|
||||
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
|
||||
# Convert list items
|
||||
text = re.sub(r"^[\-\*]\s+", "• ", text, flags=re.MULTILINE)
|
||||
return text
|
||||
|
||||
|
||||
# ── Config dataclasses ───────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class WeComConfig:
|
||||
"""Configuration for WeCom (企业微信) backend."""
|
||||
corp_id: str = ""
|
||||
agent_id: str = ""
|
||||
secret: str = ""
|
||||
token: str = ""
|
||||
encoding_aes_key: str = ""
|
||||
webhook_port: int = 9001
|
||||
allowed_senders: set[str] | None = None
|
||||
allowed_channels: set[str] | None = None
|
||||
text_chunk_limit: int = 4096 # WeCom text limit
|
||||
proxy: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeChatMPConfig:
|
||||
"""Configuration for WeChat Official Account (公众号) backend."""
|
||||
app_id: str = ""
|
||||
app_secret: str = ""
|
||||
token: str = ""
|
||||
encoding_aes_key: str = ""
|
||||
webhook_port: int = 9001
|
||||
allowed_senders: set[str] | None = None
|
||||
allowed_channels: set[str] | None = None
|
||||
text_chunk_limit: int = 4096
|
||||
proxy: str | None = None
|
||||
|
||||
|
||||
# ── Unified WeChat Channel ───────────────────────────────────────
|
||||
|
||||
class WeChatChannel(Channel, WebhookMixin, TokenMixin):
|
||||
capabilities = WECHAT_CAPS
|
||||
"""Unified WeChat channel supporting WeCom and Official Account backends.
|
||||
|
||||
Architecture follows the same pattern as FeishuChannel:
|
||||
- HTTP webhook server (aiohttp) for inbound messages
|
||||
- REST API calls (httpx) for outbound messages
|
||||
- Token auto-refresh
|
||||
"""
|
||||
|
||||
name = "wechat"
|
||||
_typing_interval: float = 5.0 # WeChat has no typing API, but keep for interface
|
||||
_ready_attrs = ("_http_client", "_access_token")
|
||||
_rate_limit_patterns = ("45009", "frequency", "freq")
|
||||
_rate_limit_delay = 2.0
|
||||
_mention_pattern = r"@\S+\s*"
|
||||
_mention_strip_count = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: WeComConfig | WeChatMPConfig,
|
||||
backend: str = "wecom",
|
||||
):
|
||||
super().__init__(config)
|
||||
self._backend = backend
|
||||
self._access_token: str | None = None
|
||||
self._token_expires: float = 0
|
||||
self._runner = None
|
||||
self._site = None
|
||||
self._http_client = None
|
||||
self._crypto = None # WeChatCrypto instance (optional)
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
def _webhook_routes(self) -> list[tuple[str, str, Any]]:
|
||||
"""Return HTTP routes for the shared webhook server."""
|
||||
return [
|
||||
("GET", "/wechat/callback", self._handle_verify),
|
||||
("POST", "/wechat/callback", self._handle_message),
|
||||
]
|
||||
|
||||
async def start(self) -> None:
|
||||
try:
|
||||
from aiohttp import web
|
||||
import httpx # noqa: F401
|
||||
except ImportError:
|
||||
raise ChannelError(
|
||||
"aiohttp or httpx not installed. "
|
||||
"Install with: pip install aiohttp httpx"
|
||||
)
|
||||
|
||||
self._validate_config()
|
||||
|
||||
import httpx
|
||||
self._http_client = httpx.AsyncClient(
|
||||
timeout=15,
|
||||
proxy=self._get_proxy(),
|
||||
)
|
||||
|
||||
# Set up message encryption if configured
|
||||
if self.config.encoding_aes_key and self.config.token:
|
||||
from .crypto import WeChatCrypto
|
||||
app_id = self._get_app_id()
|
||||
self._crypto = WeChatCrypto(
|
||||
token=self.config.token,
|
||||
encoding_aes_key=self.config.encoding_aes_key,
|
||||
app_id=app_id,
|
||||
)
|
||||
|
||||
# Verify credentials by fetching initial token
|
||||
await self._refresh_token()
|
||||
|
||||
if not getattr(self, "_shared_webhook_server", None):
|
||||
app = web.Application()
|
||||
app.router.add_get("/wechat/callback", self._handle_verify)
|
||||
app.router.add_post("/wechat/callback", self._handle_message)
|
||||
|
||||
self._runner = web.AppRunner(app)
|
||||
await self._runner.setup()
|
||||
self._site = web.TCPSite(
|
||||
self._runner, "0.0.0.0", self.config.webhook_port,
|
||||
)
|
||||
await self._site.start()
|
||||
|
||||
self._running = True
|
||||
logger.info(
|
||||
f"WeChat channel started "
|
||||
f"(backend={self._backend}, "
|
||||
f"webhook on port {self.config.webhook_port})"
|
||||
)
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
if self._site:
|
||||
await self._site.stop()
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
self._access_token = None
|
||||
logger.info("WeChat channel stopped")
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate required config fields based on backend."""
|
||||
if self._backend == "wecom":
|
||||
cfg = self.config
|
||||
if not cfg.corp_id:
|
||||
raise ChannelError("WeCom corp_id is required")
|
||||
if not cfg.secret:
|
||||
raise ChannelError("WeCom secret is required")
|
||||
if not cfg.agent_id:
|
||||
raise ChannelError("WeCom agent_id is required")
|
||||
elif self._backend == "wechatmp":
|
||||
cfg = self.config
|
||||
if not cfg.app_id:
|
||||
raise ChannelError("WeChat MP app_id is required")
|
||||
if not cfg.app_secret:
|
||||
raise ChannelError("WeChat MP app_secret is required")
|
||||
|
||||
def _get_app_id(self) -> str:
|
||||
"""Return the app identifier for crypto operations."""
|
||||
if self._backend == "wecom":
|
||||
return self.config.corp_id
|
||||
return self.config.app_id
|
||||
|
||||
# ── Token management ──────────────────────────────────────────
|
||||
|
||||
async def _refresh_token(self) -> None:
|
||||
"""Fetch or refresh the access_token."""
|
||||
if self._backend == "wecom":
|
||||
url = (
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
f"?corpid={self.config.corp_id}"
|
||||
f"&corpsecret={self.config.secret}"
|
||||
)
|
||||
else:
|
||||
url = (
|
||||
f"https://api.weixin.qq.com/cgi-bin/token"
|
||||
f"?grant_type=client_credential"
|
||||
f"&appid={self.config.app_id}"
|
||||
f"&secret={self.config.app_secret}"
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await self._http_client.get(url)
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
raise ChannelError(f"Failed to get WeChat access token: {e}")
|
||||
|
||||
if data.get("errcode", 0) != 0:
|
||||
raise ChannelError(
|
||||
f"WeChat auth error ({data.get('errcode')}): "
|
||||
f"{data.get('errmsg', 'unknown')}"
|
||||
)
|
||||
|
||||
self._access_token = data["access_token"]
|
||||
expire = data.get("expires_in", 7200)
|
||||
# Refresh 5 minutes before expiry
|
||||
self._token_expires = time.monotonic() + expire - 300
|
||||
logger.debug(f"WeChat token refreshed, expires in {expire}s")
|
||||
|
||||
async def _ensure_token(self) -> str:
|
||||
"""Return a valid access token, refreshing if needed."""
|
||||
if not self._access_token or time.monotonic() >= self._token_expires:
|
||||
await self._refresh_token()
|
||||
return self._access_token
|
||||
|
||||
# ── Signature verification (GET callback) ─────────────────────
|
||||
|
||||
async def _handle_verify(self, request) -> "web.Response":
|
||||
"""Handle GET /wechat/callback for URL verification.
|
||||
|
||||
WeChat/WeCom sends: msg_signature, timestamp, nonce, echostr
|
||||
We decrypt echostr (encrypted mode) or verify signature (plain mode)
|
||||
and return the plain echostr.
|
||||
"""
|
||||
from aiohttp import web
|
||||
|
||||
signature = request.query.get("msg_signature") or request.query.get("signature", "")
|
||||
timestamp = request.query.get("timestamp", "")
|
||||
nonce = request.query.get("nonce", "")
|
||||
echostr = request.query.get("echostr", "")
|
||||
|
||||
logger.info(f"Verify request received: timestamp={timestamp}")
|
||||
|
||||
if not echostr:
|
||||
return web.Response(status=400, text="missing echostr")
|
||||
|
||||
# Encrypted mode: WeCom sends msg_signature and encrypted echostr
|
||||
if self._crypto and request.query.get("msg_signature"):
|
||||
# Verify signature first
|
||||
sig_ok = self._crypto.verify_signature(signature, timestamp, nonce, echostr)
|
||||
if not sig_ok:
|
||||
logger.warning("WeChat verify: signature mismatch")
|
||||
# Try to decrypt regardless — the decrypted echostr must be returned
|
||||
try:
|
||||
plain_echostr, _ = self._crypto.decrypt(echostr)
|
||||
logger.info("WeChat verify: echostr decrypted successfully")
|
||||
return web.Response(text=plain_echostr)
|
||||
except Exception as e:
|
||||
logger.error(f"WeChat verify: echostr decrypt failed: {e}")
|
||||
return web.Response(status=500)
|
||||
else:
|
||||
# Plain mode verification
|
||||
token = self.config.token
|
||||
if token:
|
||||
parts = sorted([token, timestamp, nonce])
|
||||
expected = hashlib.sha1("".join(parts).encode()).hexdigest()
|
||||
if expected != signature:
|
||||
logger.warning("WeChat verify: signature mismatch (plain)")
|
||||
return web.Response(status=403)
|
||||
return web.Response(text=echostr)
|
||||
|
||||
# ── Inbound message handling (POST callback) ──────────────────
|
||||
|
||||
async def _handle_message(self, request) -> "web.Response":
|
||||
"""Handle POST /wechat/callback for incoming messages."""
|
||||
from aiohttp import web
|
||||
from .crypto import parse_xml
|
||||
|
||||
try:
|
||||
body = await request.text()
|
||||
except Exception:
|
||||
return web.Response(status=400)
|
||||
|
||||
xml_data = parse_xml(body)
|
||||
|
||||
# If encrypted, decrypt first
|
||||
encrypt = xml_data.get("Encrypt", "")
|
||||
if encrypt and self._crypto:
|
||||
signature = request.query.get("msg_signature", "")
|
||||
timestamp = request.query.get("timestamp", "")
|
||||
nonce = request.query.get("nonce", "")
|
||||
|
||||
if not self._crypto.verify_signature(signature, timestamp, nonce, encrypt):
|
||||
logger.warning("WeChat message signature mismatch")
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
decrypted_xml, from_id = self._crypto.decrypt(encrypt)
|
||||
xml_data = parse_xml(decrypted_xml)
|
||||
except Exception as e:
|
||||
logger.error(f"WeChat decrypt failed: {e}")
|
||||
return web.Response(status=500)
|
||||
|
||||
# Route to backend-specific handler
|
||||
await self._process_message(xml_data)
|
||||
|
||||
# Return "success" to acknowledge receipt (async reply via API)
|
||||
return web.Response(text="success")
|
||||
|
||||
async def _process_message(self, xml_data: dict[str, str]) -> None:
|
||||
"""Process a parsed XML message from WeChat/WeCom callback."""
|
||||
msg_type = xml_data.get("MsgType", "")
|
||||
from_user = xml_data.get("FromUserName", "")
|
||||
to_user = xml_data.get("ToUserName", "")
|
||||
content = xml_data.get("Content", "")
|
||||
msg_id = xml_data.get("MsgId", "")
|
||||
create_time = xml_data.get("CreateTime", "")
|
||||
|
||||
if not from_user:
|
||||
return
|
||||
|
||||
# Determine chat_id
|
||||
# For WeCom: FromUserName is the user's UserID
|
||||
# For MP: FromUserName is the user's OpenID
|
||||
chat_id = from_user
|
||||
|
||||
# Group chat detection
|
||||
is_group = False
|
||||
was_mentioned = True # Default: treat as mentioned (DMs)
|
||||
|
||||
# WeCom group detection: ChatId field indicates a group message
|
||||
if self._backend == "wecom":
|
||||
group_chat_id = xml_data.get("ChatId", "")
|
||||
if group_chat_id:
|
||||
is_group = True
|
||||
chat_id = group_chat_id
|
||||
# WeCom sets MsgType=event with Event=sys when bot is @mentioned,
|
||||
# but for text messages we check the XML AtUserList field
|
||||
at_user_list = xml_data.get("AtUserList", "")
|
||||
was_mentioned = bool(at_user_list)
|
||||
|
||||
# Handle different message types
|
||||
text = ""
|
||||
annotations: list[str] = []
|
||||
media_paths: list[str] = []
|
||||
|
||||
if msg_type == "text":
|
||||
text = content
|
||||
elif msg_type == "image":
|
||||
pic_url = xml_data.get("PicUrl", "")
|
||||
media_id = xml_data.get("MediaId", "")
|
||||
if pic_url:
|
||||
local, ann = await self._download_attachment(
|
||||
pic_url, f"wechat_{msg_id}.jpg",
|
||||
)
|
||||
if local:
|
||||
media_paths.append(local)
|
||||
if ann:
|
||||
annotations.append(ann)
|
||||
elif media_id:
|
||||
local, ann = await self._download_wechat_media(
|
||||
media_id, f"wechat_image_{msg_id}",
|
||||
)
|
||||
if local:
|
||||
media_paths.append(local)
|
||||
if ann:
|
||||
annotations.append(ann)
|
||||
else:
|
||||
annotations.append(f"[image: no download source]")
|
||||
elif msg_type == "voice":
|
||||
recognition = xml_data.get("Recognition", "")
|
||||
media_id = xml_data.get("MediaId", "")
|
||||
if media_id:
|
||||
local, ann = await self._download_wechat_media(media_id, f"wechat_voice_{msg_id}")
|
||||
if local:
|
||||
media_paths.append(local)
|
||||
if ann:
|
||||
ann = ann.replace("[attachment:", "[voice:")
|
||||
annotations.append(ann)
|
||||
if recognition:
|
||||
text = f"[语音识别] {recognition}"
|
||||
elif not media_paths:
|
||||
annotations.append("[voice message]")
|
||||
elif msg_type in ("video", "shortvideo"):
|
||||
media_id = xml_data.get("MediaId", "")
|
||||
if media_id:
|
||||
local, ann = await self._download_wechat_media(media_id, f"wechat_{msg_type}_{msg_id}")
|
||||
if local:
|
||||
media_paths.append(local)
|
||||
if ann:
|
||||
annotations.append(ann)
|
||||
if not media_paths:
|
||||
annotations.append(f"[{msg_type} message]")
|
||||
elif msg_type == "location":
|
||||
label = xml_data.get("Label", "")
|
||||
lat = xml_data.get("Location_X", "")
|
||||
lon = xml_data.get("Location_Y", "")
|
||||
text = f"[位置] {label} ({lat}, {lon})"
|
||||
elif msg_type == "link":
|
||||
title = xml_data.get("Title", "")
|
||||
description = xml_data.get("Description", "")
|
||||
url = xml_data.get("Url", "")
|
||||
text = f"[链接] {title}\n{description}\n{url}"
|
||||
elif msg_type == "event":
|
||||
event_type = xml_data.get("Event", "")
|
||||
if event_type == "subscribe":
|
||||
text = "[用户关注]"
|
||||
elif event_type == "unsubscribe":
|
||||
logger.info(f"User {from_user} unsubscribed")
|
||||
return # Don't process
|
||||
elif event_type == "CLICK":
|
||||
event_key = xml_data.get("EventKey", "")
|
||||
text = f"[菜单点击] {event_key}"
|
||||
elif event_type in ("LOCATION", "VIEW"):
|
||||
# Periodic location reports and menu-link clicks — ignore
|
||||
return
|
||||
else:
|
||||
logger.debug(f"Ignoring WeChat event: {event_type}")
|
||||
return
|
||||
else:
|
||||
text = f"[{msg_type} message]"
|
||||
|
||||
if not text and not media_paths and not annotations:
|
||||
return
|
||||
|
||||
# Parse timestamp
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(
|
||||
int(create_time)
|
||||
) if create_time else datetime.now()
|
||||
except (ValueError, TypeError, OSError):
|
||||
timestamp = datetime.now()
|
||||
|
||||
await self._enqueue_raw(RawIncoming(
|
||||
sender_id=from_user,
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
media_files=media_paths,
|
||||
content_annotations=annotations,
|
||||
timestamp=timestamp,
|
||||
message_id=msg_id,
|
||||
is_group=is_group,
|
||||
was_mentioned=was_mentioned,
|
||||
metadata={
|
||||
"chat_id": chat_id,
|
||||
"to_user": to_user,
|
||||
"backend": self._backend,
|
||||
},
|
||||
))
|
||||
|
||||
# ── Send (template method overrides) ──────────────────────────
|
||||
|
||||
def _format_chunk(self, text: str) -> str:
|
||||
"""WeCom uses markdown formatter; MP uses plain text."""
|
||||
if self._backend == "wecom":
|
||||
return self._formatter.format(text) # markdown profile
|
||||
return _strip_markdown(text)
|
||||
|
||||
async def _send_chunk(
|
||||
self, chat_id, formatted_text, raw_text, reply_to, metadata,
|
||||
):
|
||||
token = await self._ensure_token()
|
||||
|
||||
if self._backend == "wecom":
|
||||
# Group chat: use appchat/send endpoint
|
||||
if chat_id.startswith("wr"):
|
||||
try:
|
||||
await self._wecom_send_group_markdown(token, chat_id, raw_text)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await self._wecom_send_group_text(token, chat_id, raw_text)
|
||||
else:
|
||||
# DM: Try markdown first, fall back to plain text
|
||||
try:
|
||||
await self._wecom_send_markdown(token, chat_id, raw_text)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await self._wecom_send_text(token, chat_id, raw_text)
|
||||
else:
|
||||
await self._mp_send_text(token, chat_id, raw_text)
|
||||
|
||||
# ── WeCom send ────────────────────────────────────────────────
|
||||
|
||||
async def _wecom_send_text(
|
||||
self, token: str, user_id: str, text: str,
|
||||
) -> None:
|
||||
"""Send a text message via WeCom API."""
|
||||
url = (
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/message/send"
|
||||
f"?access_token={token}"
|
||||
)
|
||||
body = {
|
||||
"touser": user_id,
|
||||
"msgtype": "text",
|
||||
"agentid": int(self.config.agent_id),
|
||||
"text": {"content": _strip_markdown(text)},
|
||||
}
|
||||
await self._post_api(url, body)
|
||||
|
||||
async def _wecom_send_markdown(
|
||||
self, token: str, user_id: str, text: str,
|
||||
) -> None:
|
||||
"""Send a markdown message via WeCom API.
|
||||
|
||||
Note: WeCom markdown only supports a subset of Markdown
|
||||
(no code blocks, no images). Falls back to text if the
|
||||
message is too complex.
|
||||
"""
|
||||
url = (
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/message/send"
|
||||
f"?access_token={token}"
|
||||
)
|
||||
body = {
|
||||
"touser": user_id,
|
||||
"msgtype": "markdown",
|
||||
"agentid": int(self.config.agent_id),
|
||||
"markdown": {"content": text},
|
||||
}
|
||||
await self._post_api(url, body)
|
||||
|
||||
# ── WeCom group send ────────────────────────────────────────────
|
||||
|
||||
async def _wecom_send_group_text(
|
||||
self, token: str, chatid: str, text: str,
|
||||
) -> None:
|
||||
"""Send a text message to a WeCom group chat."""
|
||||
url = (
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send"
|
||||
f"?access_token={token}"
|
||||
)
|
||||
body = {
|
||||
"chatid": chatid,
|
||||
"msgtype": "text",
|
||||
"text": {"content": _strip_markdown(text)},
|
||||
}
|
||||
await self._post_api(url, body)
|
||||
|
||||
async def _wecom_send_group_markdown(
|
||||
self, token: str, chatid: str, text: str,
|
||||
) -> None:
|
||||
"""Send a markdown message to a WeCom group chat."""
|
||||
url = (
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send"
|
||||
f"?access_token={token}"
|
||||
)
|
||||
body = {
|
||||
"chatid": chatid,
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": text},
|
||||
}
|
||||
await self._post_api(url, body)
|
||||
|
||||
# ── MP send ───────────────────────────────────────────────────
|
||||
|
||||
async def _mp_send_text(
|
||||
self, token: str, openid: str, text: str,
|
||||
) -> None:
|
||||
"""Send a text message via WeChat MP customer service API."""
|
||||
url = (
|
||||
f"https://api.weixin.qq.com/cgi-bin/message/custom/send"
|
||||
f"?access_token={token}"
|
||||
)
|
||||
body = {
|
||||
"touser": openid,
|
||||
"msgtype": "text",
|
||||
"text": {"content": _strip_markdown(text)},
|
||||
}
|
||||
await self._post_api(url, body)
|
||||
|
||||
# ── Media send ────────────────────────────────────────────────
|
||||
|
||||
async def _send_media_impl(
|
||||
self,
|
||||
recipient: str,
|
||||
file_path: str,
|
||||
caption: str = "",
|
||||
metadata: dict | None = None,
|
||||
) -> bool:
|
||||
"""Send a media file via WeChat/WeCom."""
|
||||
token = await self._ensure_token()
|
||||
chat_id = self._resolve_media_chat_id(recipient, metadata)
|
||||
|
||||
# Upload media to get media_id
|
||||
media_id = await self._upload_media(token, file_path)
|
||||
if not media_id:
|
||||
return False
|
||||
|
||||
path = Path(file_path)
|
||||
ext = path.suffix.lower()
|
||||
is_image = ext in {".jpg", ".jpeg", ".png", ".gif", ".bmp"}
|
||||
|
||||
if self._backend == "wecom":
|
||||
msg_type = "image" if is_image else "file"
|
||||
# Group chat: use appchat/send endpoint
|
||||
if chat_id.startswith("wr"):
|
||||
url = (
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send"
|
||||
f"?access_token={token}"
|
||||
)
|
||||
body = {
|
||||
"chatid": chat_id,
|
||||
"msgtype": msg_type,
|
||||
msg_type: {"media_id": media_id},
|
||||
}
|
||||
else:
|
||||
url = (
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/message/send"
|
||||
f"?access_token={token}"
|
||||
)
|
||||
body = {
|
||||
"touser": chat_id,
|
||||
"msgtype": msg_type,
|
||||
"agentid": int(self.config.agent_id),
|
||||
msg_type: {"media_id": media_id},
|
||||
}
|
||||
else:
|
||||
url = (
|
||||
f"https://api.weixin.qq.com/cgi-bin/message/custom/send"
|
||||
f"?access_token={token}"
|
||||
)
|
||||
msg_type = "image" if is_image else "file" # MP only supports image
|
||||
if not is_image:
|
||||
# MP doesn't support file via customer service API;
|
||||
# send caption as text instead
|
||||
if caption:
|
||||
await self._mp_send_text(token, chat_id, f"[文件] {path.name}\n{caption}")
|
||||
return True
|
||||
body = {
|
||||
"touser": chat_id,
|
||||
"msgtype": "image",
|
||||
"image": {"media_id": media_id},
|
||||
}
|
||||
|
||||
await self._post_api(url, body)
|
||||
|
||||
# Send caption separately if provided
|
||||
if caption:
|
||||
if self._backend == "wecom":
|
||||
if chat_id.startswith("wr"):
|
||||
await self._wecom_send_group_text(token, chat_id, caption)
|
||||
else:
|
||||
await self._wecom_send_text(token, chat_id, caption)
|
||||
else:
|
||||
await self._mp_send_text(token, chat_id, caption)
|
||||
|
||||
return True
|
||||
|
||||
async def _upload_media(
|
||||
self, token: str, file_path: str,
|
||||
) -> str | None:
|
||||
"""Upload a media file and return the media_id."""
|
||||
path = Path(file_path)
|
||||
ext = path.suffix.lower()
|
||||
is_image = ext in {".jpg", ".jpeg", ".png", ".gif", ".bmp"}
|
||||
media_type = "image" if is_image else "file"
|
||||
|
||||
if self._backend == "wecom":
|
||||
url = (
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/media/upload"
|
||||
f"?access_token={token}&type={media_type}"
|
||||
)
|
||||
else:
|
||||
url = (
|
||||
f"https://api.weixin.qq.com/cgi-bin/media/upload"
|
||||
f"?access_token={token}&type={media_type}"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
resp = await self._http_client.post(
|
||||
url,
|
||||
files={"media": (path.name, f)},
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errcode", 0) != 0 and "media_id" not in data:
|
||||
logger.error(
|
||||
f"WeChat media upload failed: {data.get('errmsg')}"
|
||||
)
|
||||
return None
|
||||
return data.get("media_id")
|
||||
except Exception as e:
|
||||
logger.error(f"WeChat media upload error: {e}")
|
||||
return None
|
||||
|
||||
# ── Media download helper ────────────────────────────────────
|
||||
|
||||
async def _download_wechat_media(
|
||||
self, media_id: str, filename: str,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Download media by media_id via WeChat/WeCom media API."""
|
||||
token = await self._ensure_token()
|
||||
if self._backend == "wecom":
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token={token}&media_id={media_id}"
|
||||
else:
|
||||
url = f"https://api.weixin.qq.com/cgi-bin/media/get?access_token={token}&media_id={media_id}"
|
||||
return await self._download_attachment(url, filename)
|
||||
|
||||
# ── Shared API helper ─────────────────────────────────────────
|
||||
|
||||
async def _post_api(self, url: str, body: dict) -> dict:
|
||||
"""POST to WeChat/WeCom API, check errcode, return response."""
|
||||
try:
|
||||
resp = await self._http_client.post(url, json=body)
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"WeChat API error: {e}")
|
||||
|
||||
errcode = data.get("errcode", 0)
|
||||
if errcode != 0:
|
||||
errmsg = data.get("errmsg", "unknown")
|
||||
# Token expired — refresh and retry once
|
||||
if errcode in (40014, 42001):
|
||||
logger.warning("WeChat token expired, refreshing...")
|
||||
await self._refresh_token()
|
||||
token = self._access_token
|
||||
# Replace token in URL
|
||||
if "access_token=" in url:
|
||||
url = re.sub(
|
||||
r"access_token=[^&]+",
|
||||
f"access_token={token}",
|
||||
url,
|
||||
)
|
||||
resp = await self._http_client.post(url, json=body)
|
||||
data = resp.json()
|
||||
if data.get("errcode", 0) != 0:
|
||||
raise RuntimeError(
|
||||
f"WeChat API error after retry: "
|
||||
f"{data.get('errmsg')}"
|
||||
)
|
||||
return data
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"WeChat API error ({errcode}): {errmsg}"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
# _send_typing_action: inherited no-op (WeChat has no typing API)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user