for merge

This commit is contained in:
MuXinCG
2026-02-15 02:11:38 +08:00
parent d591fda338
commit 61cf79e525
83 changed files with 15100 additions and 1101 deletions
+777
View File
@@ -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)