"""WeCom callback-mode adapter for self-built enterprise applications. WeCom POSTs encrypted XML to an HTTP endpoint; we decrypt, queue for the agent and ack immediately. Replies go out later via the proactive ``message/send`` API with an access-token. Multiple apps per gateway are scoped by ``corp_id:user_id`` to avoid cross-corp collisions. """ from __future__ import annotations import asyncio import logging import socket as _socket import time from typing import Any, Dict, List, Optional # Untrusted pre-auth bodies are parsed with defusedxml (billion-laughs / XXE). try: import defusedxml.ElementTree as ET DEFUSEDXML_AVAILABLE = True except ImportError: ET = None # type: ignore[assignment] DEFUSEDXML_AVAILABLE = False try: from aiohttp import web AIOHTTP_AVAILABLE = True except ImportError: web = None # type: ignore[assignment] AIOHTTP_AVAILABLE = False try: import httpx HTTPX_AVAILABLE = True except ImportError: httpx = None # type: ignore[assignment] HTTPX_AVAILABLE = False from gateway.config import Platform, PlatformConfig from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult from plugins.platforms.wecom.wecom_crypto import WXBizMsgCrypt, WeComCryptoError logger = logging.getLogger(__name__) # None → aiohttp binds one socket per address family (IPv4 + IPv6); "0.0.0.0" # was unreachable on IPv6-only networks. Pin via WECOM_CALLBACK_HOST / extra.host. DEFAULT_HOST = None DEFAULT_PORT = 8645 DEFAULT_PATH = "/wecom/callback" # Pre-auth body cap: callbacks are small encrypted XML envelopes (media is # out-of-band via MediaId), so 64 KB bounds unauthenticated work. _MAX_BODY = 65_536 ACCESS_TOKEN_TTL_SECONDS = 7200 MESSAGE_DEDUP_TTL_SECONDS = 300 def check_wecom_callback_requirements() -> bool: """PASSIVE probe (registry ``check_fn``) — must never install anything.""" return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE and DEFUSEDXML_AVAILABLE def ensure_wecom_callback_requirements() -> bool: """ACTIVE lazy-installer (``ensure_deps_fn``): installs ``defusedxml`` — the only non-core dep — when the passive probe fails, and rebinds module globals.""" if check_wecom_callback_requirements(): return True def _import() -> dict: import defusedxml.ElementTree as _ET return {"ET": _ET, "DEFUSEDXML_AVAILABLE": True} try: from tools.lazy_deps import ensure_and_bind except Exception: # pragma: no cover — defensive return False if not ensure_and_bind("platform.wecom_callback", _import, globals(), prompt=False): return False return check_wecom_callback_requirements() class WecomCallbackAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.WECOM_CALLBACK) extra = config.extra or {} _raw_host = extra.get("host") or DEFAULT_HOST self._host = str(_raw_host) if _raw_host else None self._port = int(extra.get("port") or DEFAULT_PORT) self._path = str(extra.get("path") or DEFAULT_PATH) self._apps: List[Dict[str, Any]] = self._normalize_apps(extra) self._runner: Optional[web.AppRunner] = None self._site: Optional[web.TCPSite] = None self._app: Optional[web.Application] = None self._http_client: Optional[httpx.AsyncClient] = None self._message_queue: asyncio.Queue[MessageEvent] = asyncio.Queue() self._poll_task: Optional[asyncio.Task] = None self._seen_messages: Dict[str, float] = {} self._user_app_map: Dict[str, str] = {} self._access_tokens: Dict[str, Dict[str, Any]] = {} @staticmethod def _user_app_key(corp_id: str, user_id: str) -> str: return f"{corp_id}:{user_id}" if corp_id else user_id @staticmethod def _normalize_apps(extra: Dict[str, Any]) -> List[Dict[str, Any]]: apps = extra.get("apps") if isinstance(apps, list) and apps: return [dict(app) for app in apps if isinstance(app, dict)] if extra.get("corp_id"): return [{ "name": extra.get("name") or "default", "corp_id": extra.get("corp_id", ""), "corp_secret": extra.get("corp_secret", ""), "agent_id": str(extra.get("agent_id", "")), "token": extra.get("token", ""), "encoding_aes_key": extra.get("encoding_aes_key", ""), }] return [] async def connect(self, *, is_reconnect: bool = False) -> bool: del is_reconnect # kwarg MUST exist (GatewayRunner passes it) even though unused if not self._apps: logger.warning("[WecomCallback] No callback apps configured") return False if not check_wecom_callback_requirements(): logger.warning("[WecomCallback] aiohttp/httpx not installed") return False try: # quick port-in-use check with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as sock: sock.settimeout(1) sock.connect(("127.0.0.1", self._port)) logger.error("[WecomCallback] Port %d already in use", self._port) return False except (ConnectionRefusedError, OSError): pass try: from gateway.platforms._http_client_limits import platform_httpx_limits self._http_client = httpx.AsyncClient(timeout=20.0, limits=platform_httpx_limits()) # client_max_size → 413 before our handler / any signature work runs. self._app = web.Application(client_max_size=_MAX_BODY) self._app.router.add_get("/health", self._handle_health) self._app.router.add_get(self._path, self._handle_verify) self._app.router.add_post(self._path, self._handle_callback) self._runner = web.AppRunner(self._app) await self._runner.setup() self._site = web.TCPSite(self._runner, self._host, self._port) await self._site.start() self._poll_task = asyncio.create_task(self._poll_loop()) self._mark_connected() logger.info("[WecomCallback] HTTP server listening on %s:%s%s", self._host, self._port, self._path) for app in self._apps: try: await self._refresh_access_token(app) except Exception as exc: logger.warning( "[WecomCallback] Initial token refresh failed for app '%s': %s", app.get("name", "default"), exc, ) return True except Exception: await self._cleanup() logger.exception("[WecomCallback] Failed to start") return False async def disconnect(self) -> None: self._running = False if self._poll_task: self._poll_task.cancel() try: await self._poll_task except asyncio.CancelledError: pass self._poll_task = None await self._cleanup() self._mark_disconnected() logger.info("[WecomCallback] Disconnected") async def _cleanup(self) -> None: self._site = None if self._runner: await self._runner.cleanup() self._runner = None self._app = None if self._http_client: await self._http_client.aclose() self._http_client = None async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: app = self._resolve_app_for_chat(chat_id) touser = chat_id.split(":", 1)[1] if ":" in chat_id else chat_id try: payload = { "touser": touser, "msgtype": "text", "agentid": int(str(app.get("agent_id") or 0)), "text": {"content": content[:2048]}, "safe": 0, } for _attempt in range(2): token = await self._get_access_token(app) resp = await self._http_client.post( f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}", json=payload, ) data = resp.json() errcode = data.get("errcode") if errcode in {40001, 42001} and _attempt == 0: # Token rejected — evict so the next call fetches a fresh one. logger.warning( "[WecomCallback] Token rejected for app '%s' (errcode=%s), refreshing", app.get("name", "default"), errcode, ) self._access_tokens.pop(app["name"], None) continue if errcode != 0: return SendResult(success=False, error=str(data)) return SendResult(success=True, message_id=str(data.get("msgid", "")), raw_response=data) return SendResult(success=False, error="send failed after token refresh") except Exception as exc: return SendResult(success=False, error=str(exc)) def _resolve_app_for_chat(self, chat_id: str) -> Dict[str, Any]: """Pick the app associated with *chat_id*, falling back sensibly.""" app_name = self._user_app_map.get(chat_id) if not app_name and ":" not in chat_id: # legacy bare user_id — unique match only matching = [k for k in self._user_app_map if k.endswith(f":{chat_id}")] if len(matching) == 1: app_name = self._user_app_map.get(matching[0]) app = self._get_app_by_name(app_name) if app_name else None return app or self._apps[0] async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: return {"name": chat_id, "type": "dm"} async def _handle_health(self, request: web.Request) -> web.Response: return web.json_response({"status": "ok", "platform": "wecom_callback"}) async def _handle_verify(self, request: web.Request) -> web.Response: """GET endpoint — WeCom URL verification handshake.""" msg_signature, timestamp, nonce = self._signature_params(request) echostr = request.query.get("echostr", "") for app in self._apps: try: plain = self._crypt_for_app(app).verify_url(msg_signature, timestamp, nonce, echostr) return web.Response(text=plain, content_type="text/plain") except Exception: continue return web.Response(status=403, text="signature verification failed") async def _handle_callback(self, request: web.Request) -> web.Response: """POST endpoint — receive an encrypted message callback.""" msg_signature, timestamp, nonce = self._signature_params(request) # Explicit guard in addition to client_max_size (DoS / zip bombs). body_bytes = await request.read() if len(body_bytes) > _MAX_BODY: logger.warning("[WecomCallback] Payload too large (%d bytes) — rejected", len(body_bytes)) return web.Response(status=413, text="payload too large") body = body_bytes.decode("utf-8", errors="replace") for app in self._apps: try: decrypted = self._decrypt_request(app, body, msg_signature, timestamp, nonce) event = self._build_event(app, decrypted) if event is not None: # WeCom retries callbacks on timeout → duplicate inbound messages. if event.message_id and self._is_duplicate(event.message_id): logger.debug("[WecomCallback] Duplicate MsgId %s, skipping", event.message_id) return web.Response(text="success", content_type="text/plain") if event.source and event.source.user_id: map_key = self._user_app_key(str(app.get("corp_id") or ""), event.source.user_id) self._user_app_map[map_key] = app["name"] await self._message_queue.put(event) # Ack immediately — the reply arrives later via proactive message/send. return web.Response(text="success", content_type="text/plain") except WeComCryptoError: continue except Exception: logger.exception("[WecomCallback] Error handling message") break return web.Response(status=400, text="invalid callback payload") @staticmethod def _signature_params(request: web.Request): q = request.query return q.get("msg_signature", ""), q.get("timestamp", ""), q.get("nonce", "") def _is_duplicate(self, message_id: str) -> bool: now = time.time() if message_id in self._seen_messages: if now - self._seen_messages[message_id] < MESSAGE_DEDUP_TTL_SECONDS: return True del self._seen_messages[message_id] self._seen_messages[message_id] = now if len(self._seen_messages) > 2000: # prune expired entries cutoff = now - MESSAGE_DEDUP_TTL_SECONDS self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff} return False async def _poll_loop(self) -> None: """Drain the message queue and dispatch to the gateway runner.""" while True: event = await self._message_queue.get() try: task = asyncio.create_task(self.handle_message(event)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) except Exception: logger.exception("[WecomCallback] Failed to enqueue event") def _decrypt_request(self, app: Dict[str, Any], body: str, msg_signature: str, timestamp: str, nonce: str) -> str: encrypt = ET.fromstring(body).findtext("Encrypt", default="") return self._crypt_for_app(app).decrypt(msg_signature, timestamp, nonce, encrypt).decode("utf-8") def _build_event(self, app: Dict[str, Any], xml_text: str) -> Optional[MessageEvent]: root = ET.fromstring(xml_text) msg_type = (root.findtext("MsgType") or "").lower() # Lifecycle events are silently acknowledged. if msg_type == "event" and (root.findtext("Event") or "").lower() in {"enter_agent", "subscribe"}: return None if msg_type not in {"text", "event"}: return None user_id = root.findtext("FromUserName", default="") corp_id = root.findtext("ToUserName", default=app.get("corp_id", "")) scoped_chat_id = self._user_app_key(corp_id, user_id) content = root.findtext("Content", default="").strip() if not content and msg_type == "event": content = "/start" msg_id = root.findtext("MsgId") or f"{user_id}:{root.findtext('CreateTime', default='0')}" source = self.build_source(chat_id=scoped_chat_id, chat_name=user_id, chat_type="dm", user_id=user_id, user_name=user_id) return MessageEvent(text=content, message_type=MessageType.TEXT, source=source, raw_message=xml_text, message_id=msg_id) def _crypt_for_app(self, app: Dict[str, Any]) -> WXBizMsgCrypt: return WXBizMsgCrypt( token=str(app.get("token") or ""), encoding_aes_key=str(app.get("encoding_aes_key") or ""), receive_id=str(app.get("corp_id") or ""), ) def _get_app_by_name(self, name: Optional[str]) -> Optional[Dict[str, Any]]: return next((app for app in self._apps if app.get("name") == name), None) if name else None async def _get_access_token(self, app: Dict[str, Any]) -> str: cached = self._access_tokens.get(app["name"]) now = time.time() if cached and cached.get("expires_at", 0) > now + 60: return cached["token"] return await self._refresh_access_token(app) async def _refresh_access_token(self, app: Dict[str, Any]) -> str: resp = await self._http_client.get( "https://qyapi.weixin.qq.com/cgi-bin/gettoken", params={"corpid": app.get("corp_id"), "corpsecret": app.get("corp_secret")}, ) data = resp.json() if data.get("errcode") != 0: raise RuntimeError(f"WeCom token refresh failed: {data}") token = data["access_token"] expires_in = int(data.get("expires_in", ACCESS_TOKEN_TTL_SECONDS)) self._access_tokens[app["name"]] = {"token": token, "expires_at": time.time() + expires_in} logger.info( "[WecomCallback] Token refreshed for app '%s' (corp=%s), expires in %ss", app.get("name", "default"), app.get("corp_id", ""), expires_in, ) return token