from __future__ import annotations import asyncio import os from typing import Any, Literal import httpx from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelException, ) from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.meta import ChannelMeta from yuxi.channels.models import ( Attachment, ChannelAccountSnapshot, ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, ChatType, DeliveryResult, EventType, HealthStatus, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger from .accounts import WeChatAccountManager from .agent_prompt import WeChatAgentPromptAdapter from .approval import WeChatApprovalAdapter from .attachment_adapter import WeChatAttachmentAdapter from .auth_adapter import WeChatAuthAdapter from .base64_adapter import WeChatBase64Adapter from .bindings import WeChatConfiguredBindingAdapter from .bridge import BridgeClient from .capabilities import WECHAT_META, get_capabilities, get_message_capabilities, get_meta from .channel_capacity import WeChatCapacityProvider from .chunker import WeChatChunker from .config_reload import WeChatConfigReloader from .config_schema import get_schema, validate_wechat_config from .conversation_bindings import WeChatConversationBindingAdapter from .debounce import WeChatQueueDebounce from .diagnose import diagnose_channel, repair_channel from .directory import ( list_groups_bridge, list_groups_mp, list_groups_wecom, list_peers_bridge, list_peers_mp, list_peers_wecom, ) from .discovery_adapter import WeChatDiscoveryAdapter from .elevated import WeChatElevatedAdapter from .event_subscribe_adapter import WeChatEventSubscribeAdapter from .format import ( map_bridge_msg_type, map_mp_msg_type, map_wecom_msg_type, truncate_text, ) from .gateway_hooks import WeChatGatewayAdapter from .group_admin import WeChatGroupAdmin from .group_policy import WeChatGroupAdapter from .heartbeat_adapter import WeChatHeartbeatAdapter from .integration_status import WeChatIntegrationStatus from .interactive import WeChatInteractiveAdapter from .lifecycle import WeChatLifecycleAdapter from .link_format import WeChatLinkFormatter from .mcp_server import list_mcp_resources, list_mcp_tools from .mention_adapter import WeChatMentionAdapter from .message_actions import WeChatMessageActionAdapter from .message_read import read_message_bridge, read_message_mp, read_message_wecom from .messaging_router import WeChatMessagingRouter from .monitoring_adapter import WeChatMonitoringAdapter from .mp import MPClient from .mp import verify_signature as mp_verify_sig from .music import WeChatMusicHandler from .notification_adapter import WeChatNotificationAdapter from .oauth import build_mp_oauth_url, build_wecom_oauth_url from .outbound_adapter import WeChatOutboundAdapter from .pairing import WeChatPairingAdapter from .parallel_monitor import ParallelMonitor from .polling_lease import PollingLease from .probe import probe_bridge, probe_mp, probe_wecom from .qr_login import QRLoginManager from .rate_limiter import TokenBucketRateLimiter from .reply_mode_adapter import WeChatReplyModeAdapter from .resolver import WeChatResolverAdapter from .rpc_methods import list_rpc_methods from .secrets import WeChatSecretsAdapter from .security import ( WeChatSecurityAdapter, check_dm_policy, check_group_policy, is_bot_mentioned, ) from .selector import WeChatSelectorAdapter from .send_cache import WeChatSendCache from .sent_message_store import SentMessageStore from .template_adapter import WeChatTemplateAdapter from .threading_adapter import WeChatThreadingAdapter from .voice import send_voice_bridge, send_voice_mp, send_voice_wecom from .wecom import ( WeComClient, WeComMonitor, build_wecom_text_payload, send_wecom_message, ) from .wecom.crypto import verify_signature as wecom_verify_sig def _media_extension(media_type: str) -> str: _map = {"image": "png", "voice": "mp3", "video": "mp4", "file": "bin"} return _map.get(media_type, "bin") @register_builtin_adapter class WeChatAdapter(BaseChannelAdapter): channel_id = "wechat" channel_type = ChannelType.WECHAT meta = WECHAT_META text_chunk_limit = 2048 supports_markdown = False supports_streaming = False max_media_size_mb = 20 def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._mode: Literal["wecom", "mp", "personal"] = "personal" self._http_client: httpx.AsyncClient | None = None self._circuit_breaker = CircuitBreaker(failure_threshold=5) self._bridge_url: str = "" self._qr_url: str | None = None self._polling_task: asyncio.Task | None = None self._wecom_client: WeComClient | None = None self._mp_client: MPClient | None = None self._bridge_client: BridgeClient | None = None self._qr_login_mgr: QRLoginManager | None = None self._monitor: WeComMonitor | None = None self._rate_limiter = TokenBucketRateLimiter(rate=20, per=60.0) self._dedup: dict[tuple, float] = {} self._dedup_ttl = 1.0 self._banned = False self._banned_reason: str | None = None self._ban_attempts = 0 self._ban_cooldown_until: float | None = None self._ban_backoff_intervals = self.config.get("ban_backoff_intervals", [60, 300, 900]) self._ban_permanent = False self._account_mgr = WeChatAccountManager() self._pairing = WeChatPairingAdapter() self._gateway = WeChatGatewayAdapter() self._debounce = WeChatQueueDebounce() self._group_adapter = WeChatGroupAdapter() self._security_adapter = WeChatSecurityAdapter() self._config_reloader = WeChatConfigReloader(self) self._secrets = WeChatSecretsAdapter() self._elevated = WeChatElevatedAdapter() self._approval = WeChatApprovalAdapter() self._lifecycle = WeChatLifecycleAdapter() self._chunker = WeChatChunker() self._outbound = WeChatOutboundAdapter() self._threading = WeChatThreadingAdapter() self._mention = WeChatMentionAdapter() self._router = WeChatMessagingRouter() self._heartbeat = WeChatHeartbeatAdapter(self.config) self._message_actions = WeChatMessageActionAdapter() self._group_admin = WeChatGroupAdmin() self._attachment = WeChatAttachmentAdapter() self._auth = WeChatAuthAdapter() self._base64 = WeChatBase64Adapter() self._cap_provider = WeChatCapacityProvider() self._discovery = WeChatDiscoveryAdapter() self._event_subscribe = WeChatEventSubscribeAdapter() self._link_fmt = WeChatLinkFormatter() self._monitoring = WeChatMonitoringAdapter() self._music_handler = WeChatMusicHandler() self._notification = WeChatNotificationAdapter() self._reply_mode = WeChatReplyModeAdapter() self._template = WeChatTemplateAdapter() self._integration_status = WeChatIntegrationStatus() self._selector = WeChatSelectorAdapter() self._send_cache = WeChatSendCache() self._agent_prompt = WeChatAgentPromptAdapter() self._bindings = WeChatConfiguredBindingAdapter() self._conversation_bindings = WeChatConversationBindingAdapter() self._resolver = WeChatResolverAdapter() self._interactive = WeChatInteractiveAdapter() self._sent_store = SentMessageStore() self._polling_lease = PollingLease( channel_id=f"wechat-{self.config.get('name', 'default')}", lease_ttl=self.config.get("polling_lease_ttl", 15.0), renew_interval=min(self.config.get("poll_interval", 1.0) / 2, 2.0), redis_url=os.environ.get("REDIS_URL", "redis://redis:6379/0"), ) self._parallel_monitor = ParallelMonitor( poll_interval=self.config.get("poll_interval", 1.0), ) # ---- Connection Lifecycle ---- async def connect(self) -> None: if self._status == ChannelStatus.CONNECTED: return self._status = ChannelStatus.CONNECTING logger.info(f"[WeChat] Starting channel '{self.config.get('name', self.channel_id)}'") try: proxy = self.config.get("proxy") self._http_client = httpx.AsyncClient( proxy=proxy, timeout=httpx.Timeout(15.0), ) await self._select_mode() logger.info(f"[WeChat] Mode selected: {self._mode}") await self._pairing.load_allowlist() await self._pairing.load_pending() if not await self._polling_lease.try_acquire(): raise ChannelException("Failed to acquire polling lease - another instance may be active") await self._polling_lease.start_renew() if self._mode == "wecom": await self._start_wecom() elif self._mode == "mp": await self._start_mp() elif self._mode == "personal": await self._start_personal() else: raise ChannelException(f"Unknown mode: {self._mode}") self._status = ChannelStatus.CONNECTED logger.info(f"[WeChat] Channel started ({self._mode} mode)") except ChannelAuthenticationError: self._status = ChannelStatus.ERROR raise except Exception as e: self._status = ChannelStatus.ERROR logger.error(f"[WeChat] Failed to start: {e}") raise async def disconnect(self) -> None: if self._status == ChannelStatus.DISCONNECTED: return logger.info(f"[WeChat] Stopping channel '{self.config.get('name', self.channel_id)}'") try: await self._polling_lease.release() await self._polling_lease.close() await self._parallel_monitor.stop() await self._pairing.save_allowlist() await self._pairing.save_pending() if self._monitor: await self._monitor.stop() self._monitor = None if self._polling_task and not self._polling_task.done(): self._polling_task.cancel() try: await self._polling_task except asyncio.CancelledError: pass self._polling_task = None self._wecom_client = None self._mp_client = None self._bridge_client = None self._qr_login_mgr = None self._qr_url = None if self._http_client: await self._http_client.aclose() self._http_client = None self._status = ChannelStatus.DISCONNECTED logger.info("[WeChat] Channel stopped") except Exception as e: logger.error(f"[WeChat] Error stopping: {e}") self._status = ChannelStatus.ERROR async def logout_account(self) -> dict[str, Any]: cleared = [] if self._wecom_client: self._wecom_client.invalidate_token() self._wecom_client = None cleared.append("wecom_token") if self._mp_client: self._mp_client.invalidate_token() self._mp_client = None cleared.append("mp_token") if self._bridge_client: self._bridge_client = None cleared.append("bridge_client") return await self._gateway.logout_account( account_id=self.config.get("account_id", "default"), cfg=self.config, ) # ---- Send ---- async def send(self, response: ChannelResponse, disable_notification: bool = False) -> DeliveryResult: self._check_ban_cooldown() if self._ban_permanent: return DeliveryResult( success=False, error=f"Channel permanently banned: {self._banned_reason or 'API unauthorized (48001)'}", ) if self._banned: import time remaining = "" if self._ban_cooldown_until: left = max(0, self._ban_cooldown_until - time.time()) remaining = f", cooldown: {left:.0f}s" return DeliveryResult( success=False, error=f"Channel temporarily banned: {self._banned_reason or 'API unauthorized (48001)'}{remaining}", ) if self._mode == "wecom" and self._is_wecom_duplicate(response): return DeliveryResult(success=False, error="Duplicate message within dedup window") if not await self._rate_limiter.acquire(): return DeliveryResult(success=False, error="Rate limit exceeded, try again later") async def _do_send(): return await self._do_send(response, disable_notification=disable_notification) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker is open, WeChat channel unavailable") def _is_wecom_duplicate(self, response: ChannelResponse) -> bool: import time user_id = response.identity.channel_user_id key = (user_id, response.content) now = time.monotonic() if key in self._dedup: if now - self._dedup[key] < self._dedup_ttl: return True self._dedup[key] = now stale_keys = [k for k, ts in self._dedup.items() if now - ts > self._dedup_ttl] for k in stale_keys: del self._dedup[k] return False async def _do_send(self, response: ChannelResponse, disable_notification: bool = False) -> DeliveryResult: if self._mode == "wecom": result = await self._send_wecom(response, disable_notification=disable_notification) self._check_banned_response(result) return result elif self._mode == "mp": result = await self._send_mp(response) self._check_banned_response(result) return result elif self._mode == "personal": return await self._send_bridge(response, disable_notification=disable_notification) return DeliveryResult(success=False, error=f"Unknown mode: {self._mode}") def _check_banned_response(self, result: DeliveryResult) -> None: if not result.success and result.error and "48001" in str(result.error): import time self._banned_reason = result.error self._ban_attempts += 1 if self._ban_attempts <= len(self._ban_backoff_intervals): cooldown_seconds = self._ban_backoff_intervals[self._ban_attempts - 1] self._ban_cooldown_until = time.time() + cooldown_seconds self._banned = True self._status = ChannelStatus.ERROR logger.warning( f"[WeChat] Channel temporarily banned (48001), " f"attempt {self._ban_attempts}/{len(self._ban_backoff_intervals)}, " f"cooldown {cooldown_seconds}s. Reason: {self._banned_reason}" ) else: self._banned = True self._ban_permanent = True self._status = ChannelStatus.DISABLED logger.error( f"[WeChat] Channel permanently banned (48001) after " f"{self._ban_attempts} attempts. Reason: {self._banned_reason}. " f"Adapter disabled." ) def unban_channel(self) -> bool: if self._ban_permanent: logger.warning("[WeChat] Cannot unban a permanently banned channel") return False self._banned = False self._banned_reason = None self._ban_cooldown_until = None logger.info("[WeChat] Channel manually unbanned") return True @property def ban_status(self) -> dict[str, Any]: import time cooldown_remaining = None if self._ban_cooldown_until: remaining = self._ban_cooldown_until - time.time() cooldown_remaining = max(0.0, remaining) return { "banned": self._banned, "permanent": self._ban_permanent, "attempts": self._ban_attempts, "reason": self._banned_reason, "cooldown_remaining_seconds": cooldown_remaining, } def _check_ban_cooldown(self) -> None: import time if not self._banned or self._ban_permanent: return if self._ban_cooldown_until and time.time() >= self._ban_cooldown_until: self._banned = False self._ban_cooldown_until = None logger.info("[WeChat] Ban cooldown expired, channel can be reconnected") # ---- Normalize / Format ---- def normalize_inbound(self, raw: dict | bytes | str) -> ChannelMessage: payload = raw if isinstance(raw, (bytes, str)): payload = self._parse_webhook_body(raw) if self._mode == "wecom": return self._normalize_wecom_message(payload) elif self._mode == "mp": return self._normalize_mp_message(payload) elif self._mode == "personal": return self._normalize_bridge_message(payload) raise ValueError(f"Unknown mode: {self._mode}") def _parse_webhook_body(self, raw: bytes | str) -> dict: if isinstance(raw, bytes): raw = raw.decode("utf-8") if len(raw) > 1_048_576: raise ValueError("Webhook body exceeds 1MB limit") try: import xml.etree.ElementTree as ET parser = ET.XMLParser(target=ET.TreeBuilder()) root = ET.fromstring(raw, parser=parser) return {child.tag: child.text or "" for child in root} except ET.ParseError: import json return json.loads(raw) def format_outbound(self, response: ChannelResponse) -> dict[str, Any]: if self._mode == "wecom": return self._format_wecom_text(response) elif self._mode == "mp": return self._format_mp_text(response) elif self._mode == "personal": return self._format_bridge_text(response) raise ValueError(f"Unknown mode: {self._mode}") # ---- Health Check ---- async def health_check(self) -> HealthStatus: if self._ban_permanent: return HealthStatus( status="unhealthy", last_error=f"Channel permanently banned: {self._banned_reason or 'API unauthorized (48001)'}", ) if self._banned: import time remaining = "" if self._ban_cooldown_until: left = max(0, self._ban_cooldown_until - time.time()) remaining = f", cooldown remaining: {left:.0f}s" return HealthStatus( status="degraded", last_error=( f"Channel temporarily banned: {self._banned_reason or 'API unauthorized (48001)'}{remaining}" ), ) if not self._http_client: return HealthStatus(status="unhealthy", last_error="HTTP client not initialized") if self._mode == "wecom": return await probe_wecom(self._http_client, self.config) elif self._mode == "mp": return await probe_mp(self._http_client, self.config) elif self._mode == "personal": bridge_url = self.config.get("bridge_url", self._bridge_url) return await probe_bridge(self._http_client, bridge_url) return HealthStatus( status="healthy", metadata={"mode": self._mode, "adapter_status": self._status.value}, last_connected_at=utc_now_naive(), ) # ---- Pre-connect (QR Login) ---- async def pre_connect(self) -> dict: if self._mode == "wecom": return {"status": "ready", "message": "WeCom uses token auth, no QR needed"} if self._mode == "mp": return { "status": "pending_scan", "message": "请扫描公众号二维码完成认证", "qr_url": None, } if self._mode == "personal": return await self._pre_connect_personal() return {"status": "error", "message": "Unknown mode"} async def login_with_qr_wait(self, timeout: int | None = None) -> dict[str, Any]: if self._mode == "wecom": return {"status": "ready", "message": "WeCom uses token auth, no QR login needed"} if self._mode == "mp": return {"status": "ready", "message": "MP uses OAuth, no QR login needed"} if self._mode == "personal": if not self._qr_login_mgr: await self._pre_connect_personal() qr_result = await self._qr_login_mgr.request_qr() if qr_result.get("status") == "error": return qr_result wait_result = await self._qr_login_mgr.wait_login(timeout=timeout) return { **qr_result, "login_result": wait_result.get("status"), "login_message": wait_result.get("message"), } return {"status": "error", "message": "Unknown mode"} # ---- Mode Selection ---- def _resolve_config(self, key: str, env_var: str | None = None) -> str | None: value = self.config.get(key) if value: return value if env_var: import os return os.environ.get(env_var) return None async def _select_mode(self) -> None: if all( [ self._resolve_config("corp_id", "WECHAT_CORP_ID"), self._resolve_config("corp_secret", "WECHAT_CORP_SECRET"), self._resolve_config("agent_id", "WECHAT_AGENT_ID"), ] ): self._mode = "wecom" return if all( [ self._resolve_config("app_id", "WECHAT_APP_ID"), self._resolve_config("app_secret", "WECHAT_APP_SECRET"), ] ): self._mode = "mp" return bridge_url = self._resolve_config("bridge_url", "WECHAT_BRIDGE_URL") if bridge_url: self._mode = "personal" self._bridge_url = bridge_url.rstrip("/") return raise ChannelException( "No valid WeChat configuration found. " "Provide either [wecom] (corp_id + corp_secret + agent_id), " "[mp] (app_id + app_secret), or [personal] (bridge_url)." ) # ---- WeCom Mode ---- async def _start_wecom(self) -> None: corp_id = self.config.get("corp_id") corp_secret = self.config.get("corp_secret") agent_id = self.config.get("agent_id") if not all([corp_id, corp_secret, agent_id]): raise ChannelException("WeCom mode requires corp_id, corp_secret, and agent_id") self._wecom_client = WeComClient(self._http_client, self.config) await self._wecom_client.get_access_token() logger.info("[WeChat/WeCom] Access token obtained") self._monitor = WeComMonitor(self._http_client, self.config, self._handle_message) await self._monitor.start(lambda: self._wecom_client.get_access_token()) webhook_url = self.config.get("webhook_url") if webhook_url: logger.info(f"[WeChat/WeCom] Webhook mode ready: {webhook_url}") else: logger.warning("[WeChat/WeCom] No webhook_url configured") await self._auto_join_groups() async def _send_wecom(self, response: ChannelResponse, disable_notification: bool = False) -> DeliveryResult: reply_to_user = None if response.reply_to_message_id: reply_to_user = response.metadata.get( "reply_to_channel_user_id", response.metadata.get("sender_wxid"), ) payload = build_wecom_text_payload( agent_id=self.config["agent_id"], to_user=response.identity.channel_user_id, content=response.content, chat_type=response.metadata.get("chat_type", "direct"), reply_to_msg_id=response.reply_to_message_id, reply_to_user=reply_to_user, safe=1 if disable_notification else 0, ) return await send_wecom_message(self._wecom_client, self._http_client, payload) def _normalize_wecom_message(self, payload: dict) -> ChannelMessage: from_user = payload.get("FromUserName", "") msg_id = str(payload.get("MsgId", "")) msg_type_str = payload.get("MsgType", "text") content = payload.get("Content", "") message_type = map_wecom_msg_type(msg_type_str) reply_to_msg_id = payload.get("ReplyToMsgId") attachments: list[Attachment] = [] if msg_type_str == "image": media_id = payload.get("MediaId", "") if media_id: attachments.append(Attachment(type="image", file_id=media_id)) identity = ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=from_user, channel_chat_id=from_user, channel_message_id=msg_id, ) return ChannelMessage( identity=identity, event_type=EventType.MESSAGE_RECEIVED, message_type=message_type, chat_type=ChatType.DIRECT, content=content, attachments=attachments, reply_to_message_id=reply_to_msg_id, metadata={ "wechat_mode": "wecom", "msg_type": msg_type_str, }, ) def _format_wecom_text(self, response: ChannelResponse) -> dict[str, Any]: return build_wecom_text_payload( agent_id=self.config["agent_id"], to_user=response.identity.channel_user_id, content=response.content, chat_type=response.metadata.get("chat_type", "direct"), ) # ---- MP Mode ---- async def _start_mp(self) -> None: app_id = self.config.get("app_id") app_secret = self.config.get("app_secret") if not all([app_id, app_secret]): raise ChannelException("MP mode requires app_id and app_secret") self._mp_client = MPClient(self._http_client, self.config) await self._mp_client.get_access_token() logger.info("[WeChat/MP] Access token obtained") webhook_url = self.config.get("webhook_url") if webhook_url: logger.info(f"[WeChat/MP] Webhook mode ready: {webhook_url}") else: logger.warning("[WeChat/MP] No webhook_url configured") async def _send_mp(self, response: ChannelResponse) -> DeliveryResult: from .mp import send_mp_custom_message reply_to_user = None if response.reply_to_message_id: reply_to_user = response.metadata.get( "reply_to_channel_user_id", response.metadata.get("sender_wxid"), ) return await send_mp_custom_message( self._mp_client, self._http_client, response.identity.channel_user_id, response.content, reply_to_msg_id=response.reply_to_message_id, reply_to_user=reply_to_user, ) def _normalize_mp_message(self, payload: dict) -> ChannelMessage: from_user = payload.get("FromUserName", "") msg_id = str(payload.get("MsgId", "")) msg_type_str = payload.get("MsgType", "text") content = payload.get("Content", "") message_type = map_mp_msg_type(msg_type_str) identity = ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=from_user, channel_chat_id=from_user, channel_message_id=msg_id, ) return ChannelMessage( identity=identity, event_type=EventType.MESSAGE_RECEIVED, message_type=message_type, chat_type=ChatType.DIRECT, content=content, attachments=[], metadata={ "wechat_mode": "mp", "msg_type": msg_type_str, "chat_type": "direct", }, ) def _format_mp_text(self, response: ChannelResponse) -> dict[str, Any]: return { "touser": response.identity.channel_user_id, "msgtype": "text", "text": {"content": truncate_text(response.content, 2048)}, } # ---- Template Message (MP) ---- async def send_template_message( self, to_user: str, template_id: str, data: dict[str, Any], url: str | None = None, ) -> DeliveryResult: if self._mode != "mp" or not self._mp_client: return DeliveryResult(success=False, error="Template messages only available in MP mode") from .mp import send_mp_template_message return await send_mp_template_message( self._mp_client, self._http_client, to_user, template_id, data, url=url, ) # ---- Personal Bridge Mode ---- async def _start_personal(self) -> None: bridge_url = self.config.get("bridge_url") if not bridge_url: raise ChannelException("Personal mode requires bridge_url") self._bridge_url = bridge_url.rstrip("/") self._bridge_client = BridgeClient(self._http_client, self._bridge_url) self._qr_login_mgr = QRLoginManager(self._http_client, self._bridge_url) healthy = await self._bridge_client.health_check() if not healthy: if self.config.get("auto_login", False): await self._bridge_client.get_qr_code() self._status = ChannelStatus.CONNECTING return login_status = await self._bridge_client.get_login_status() if not login_status.get("logged_in"): self._status = ChannelStatus.CONNECTING return poll_interval = self.config.get("poll_interval", 1.0) self._polling_task = asyncio.create_task(self._bridge_event_loop(poll_interval)) logger.info(f"[WeChat/Personal] Bridge connected: {self._bridge_url}") async def _send_bridge(self, response: ChannelResponse, disable_notification: bool = False) -> DeliveryResult: payload = self._format_bridge_text(response, disable_notification=disable_notification) return await self._bridge_client.send_message(payload) def _normalize_bridge_message(self, payload: dict) -> ChannelMessage: sender_id = str(payload.get("sender_id", "")) chat_id = str(payload.get("chat_id", "")) msg_id = str(payload.get("msg_id", "")) content = payload.get("content", "") is_group = payload.get("is_group", False) msg_type_int = payload.get("msg_type", 1) reply_to_msg_id = payload.get("reply_to_msg_id") message_type = map_bridge_msg_type(msg_type_int) chat_type = ChatType.GROUP if is_group else ChatType.DIRECT channel_chat_id = chat_id if is_group else sender_id identity = ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=sender_id, channel_chat_id=channel_chat_id, channel_message_id=msg_id, ) metadata: dict[str, Any] = { "wechat_mode": "personal", "msg_type": str(msg_type_int), "chat_type": chat_type.value, "is_group": is_group, } if is_group: metadata["room_id"] = chat_id metadata["sender_wxid"] = sender_id if self._should_auto_derive(): metadata["auto_derive_session"] = True metadata["auto_derive_key"] = self._get_auto_derive_key(chat_id, sender_id) return ChannelMessage( identity=identity, event_type=EventType.MESSAGE_RECEIVED, message_type=message_type, chat_type=chat_type, content=content, attachments=[], reply_to_message_id=reply_to_msg_id, metadata=metadata, ) def _format_bridge_text(self, response: ChannelResponse, disable_notification: bool = False) -> dict[str, Any]: chat_type = response.metadata.get("chat_type", "direct") is_group = chat_type == "group" payload: dict[str, Any] = { "content": truncate_text(response.content, 4096), "msg_type": 1, } if disable_notification: payload["silent"] = True if is_group: payload["chat_id"] = response.identity.channel_chat_id payload["is_group"] = True reply_mode = self.config.get("reply_to_mode", "off") if reply_mode == "first": reply_msg_id = response.metadata.get("reply_to_message_id") if reply_msg_id: sender_wxid = response.metadata.get("sender_wxid") if sender_wxid: payload["at_list"] = [sender_wxid] payload["content"] = f"@{sender_wxid}\n{payload['content']}" else: payload["chat_id"] = response.identity.channel_user_id payload["is_group"] = False return payload async def _bridge_event_loop(self, poll_interval: float) -> None: iteration = 0 health_check_interval = max(int(30.0 / poll_interval), 1) while self._status == ChannelStatus.CONNECTED: try: if not self._polling_lease.is_active: logger.warning("[WeChat/Personal] Polling lease lost, stopping event loop") break if iteration % health_check_interval == 0: healthy = await self._bridge_client.health_check() if not healthy: logger.warning("[WeChat/Personal] Bridge health check failed, attempting reconnect...") self._status = ChannelStatus.RECONNECTING events = await self._bridge_client.fetch_events() for event in events: if event.get("type") == "message": data = event.get("data", {}) channel_msg = self.normalize_inbound(data) await self._handle_message(channel_msg) except asyncio.CancelledError: break except Exception as e: logger.error(f"[WeChat/Personal] Event loop error: {e}") await asyncio.sleep(poll_interval) iteration += 1 async def _pre_connect_personal(self) -> dict: if not self._qr_login_mgr: self._bridge_url = self.config.get("bridge_url", "").rstrip("/") if not self._bridge_url: return {"status": "error", "message": "Bridge URL not configured"} if not self._http_client: proxy = self.config.get("proxy") self._http_client = httpx.AsyncClient( proxy=proxy, timeout=httpx.Timeout(15.0), ) self._qr_login_mgr = QRLoginManager(self._http_client, self._bridge_url) result = await self._qr_login_mgr.request_qr() if result.get("qr_url"): self._qr_url = result["qr_url"] return result # ---- Missing Base Methods (from BaseChannelAdapter) ---- async def send_media( self, chat_id: str, media_type: str, data: Any, disable_notification: bool = False ) -> DeliveryResult: if self._mode == "wecom" and self._wecom_client: return await self._send_wecom_media(media_type, data, chat_id) if self._mode == "mp" and self._mp_client: return await self._send_mp_media(media_type, data, chat_id) if self._mode == "personal" and self._bridge_client: return await self._send_bridge_media(media_type, data, chat_id, disable_notification=disable_notification) return DeliveryResult(success=False, error=f"send_media not supported for mode={self._mode}") async def _send_wecom_media(self, media_type: str, data: Any, chat_id: str) -> DeliveryResult: from .wecom.send import build_wecom_file_payload, build_wecom_image_payload, send_wecom_message if not isinstance(data, bytes): return DeliveryResult(success=False, error="send_media expects raw bytes for WeCom mode") try: media_id = await self._wecom_client.upload_media( data, filename=f"media.{_media_extension(media_type)}", media_type=media_type ) except Exception as e: return DeliveryResult(success=False, error=f"WeCom media upload failed: {e}") agent_id = self.config["agent_id"] if media_type == "image": payload = build_wecom_image_payload(agent_id, chat_id, media_id) elif media_type == "file": payload = build_wecom_file_payload(agent_id, chat_id, media_id) else: return DeliveryResult(success=False, error=f"Unsupported media_type for WeCom: {media_type}") return await send_wecom_message(self._wecom_client, self._http_client, payload) async def _send_mp_media(self, media_type: str, data: Any, chat_id: str) -> DeliveryResult: from .mp.send import send_mp_image if media_type != "image": return DeliveryResult(success=False, error=f"MP only supports image media, got: {media_type}") if not isinstance(data, bytes): return DeliveryResult(success=False, error="send_media expects raw bytes for MP mode") return await send_mp_image(self._mp_client, self._http_client, chat_id, data) async def _send_bridge_media( self, media_type: str, data: Any, chat_id: str, disable_notification: bool = False ) -> DeliveryResult: from .bridge import build_bridge_file_payload, build_bridge_image_payload if not isinstance(data, bytes): return DeliveryResult(success=False, error="send_media expects raw bytes for Bridge mode") is_group = False if media_type == "image": payload = build_bridge_image_payload(chat_id, data, is_group) elif media_type == "file": filename = "file.bin" payload = build_bridge_file_payload(chat_id, data, filename, is_group) else: return DeliveryResult(success=False, error=f"Unsupported media_type for Bridge: {media_type}") if disable_notification: payload["silent"] = True if not self._rate_limiter.available_tokens >= 1.0: return DeliveryResult(success=False, error="Rate limit exceeded, try again later") await self._rate_limiter.acquire() return await self._bridge_client.send_media_message(payload) async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: return DeliveryResult(success=False, error="WeChat does not support message editing") async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: return DeliveryResult(success=False, error="WeChat does not support message deletion via API") async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: return DeliveryResult(success=False, error="WeChat does not support reactions") async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: self._check_ban_cooldown() if self._banned: return {} if self._mode == "mp" and self._mp_client: try: token = await self._mp_client.get_access_token() url = "https://api.weixin.qq.com/cgi-bin/user/info" params = {"access_token": token, "openid": channel_user_id, "lang": "zh_CN"} resp = await self._http_client.get(url, params=params) data = resp.json() if "openid" in data: return { "openid": data.get("openid", ""), "nickname": data.get("nickname", ""), "headimgurl": data.get("headimgurl", ""), } except Exception as e: logger.warning(f"[WeChat/MP] get_user_info failed for {channel_user_id}: {e}") if self._mode == "wecom" and self._wecom_client: try: token = await self._wecom_client.get_access_token() url = "https://qyapi.weixin.qq.com/cgi-bin/user/get" params = {"access_token": token, "userid": channel_user_id} resp = await self._http_client.get(url, params=params) data = resp.json() if data.get("errcode") == 0: return { "userid": data.get("userid", ""), "name": data.get("name", ""), "avatar": data.get("avatar", ""), } except Exception as e: logger.warning(f"[WeChat/WeCom] get_user_info failed for {channel_user_id}: {e}") return {} async def download_media(self, file_id: str) -> bytes: self._check_ban_cooldown() if self._ban_permanent: raise RuntimeError(f"Channel permanently banned: {self._banned_reason or 'API unauthorized (48001)'}") if self._banned: import time remaining = "" if self._ban_cooldown_until: left = max(0, self._ban_cooldown_until - time.time()) remaining = f", cooldown: {left:.0f}s" raise RuntimeError( f"Channel temporarily banned: {self._banned_reason or 'API unauthorized (48001)'}{remaining}" ) if not self._http_client: raise RuntimeError("HTTP client not initialized") if self._mode == "wecom" and self._wecom_client: token = await self._wecom_client.get_access_token() url = "https://qyapi.weixin.qq.com/cgi-bin/media/get" params = {"access_token": token, "media_id": file_id} resp = await self._http_client.get(url, params=params) if resp.status_code == 200: return resp.content raise RuntimeError(f"WeCom media download failed: HTTP {resp.status_code}") if self._mode == "mp" and self._mp_client: token = await self._mp_client.get_access_token() url = "https://api.weixin.qq.com/cgi-bin/media/get" params = {"access_token": token, "media_id": file_id} resp = await self._http_client.get(url, params=params) if resp.status_code == 200: return resp.content raise RuntimeError(f"MP media download failed: HTTP {resp.status_code}") raise RuntimeError(f"download_media not available for mode={self._mode}") # ---- Auto Join / Auto Derive / Banned ---- async def _auto_join_groups(self) -> None: auto_join = self.config.get("auto_join_groups", []) if not auto_join: return logger.info(f"[WeChat] Auto-joining {len(auto_join)} groups") if self._mode == "wecom" and self._wecom_client and self._http_client: for chat_id in auto_join: try: token = await self._wecom_client.get_access_token() url = "https://qyapi.weixin.qq.com/cgi-bin/appchat/get" params = {"access_token": token, "chatid": chat_id} resp = await self._http_client.get(url, params=params) data = resp.json() if data.get("errcode") == 0: logger.info(f"[WeChat/AutoJoin] Already in group: {chat_id}") continue logger.info(f"[WeChat/AutoJoin] Group {chat_id} not found or accessible, skipped") except Exception as e: logger.warning(f"[WeChat/AutoJoin] Failed for {chat_id}: {e}") elif self._mode == "mp" and self._mp_client: for chat_id in auto_join: logger.info(f"[WeChat/AutoJoin] MP auto-join not supported for {chat_id}, skipped") elif self._mode == "personal" and self._bridge_client: for chat_id in auto_join: try: payload = {"chat_id": chat_id, "action": "join"} await self._bridge_client.send_message(payload) logger.info(f"[WeChat/AutoJoin] Bridge join sent for: {chat_id}") except Exception as e: logger.warning(f"[WeChat/AutoJoin] Bridge join failed for {chat_id}: {e}") def _should_auto_derive(self) -> bool: return bool(self.config.get("auto_derive_sessions", False)) def _get_auto_derive_key(self, group_chat_id: str, sender_id: str) -> str: return f"auto_derive:wechat:{self._mode}:{group_chat_id}:{sender_id}" @property def is_banned(self) -> bool: return self._banned @property def banned_reason(self) -> str | None: return self._banned_reason # ---- Security (delegated + pairing integration) ---- def _check_dm_policy(self, open_id: str) -> bool: dm_policy = self.config.get("dm_policy", "pairing") if dm_policy == "pairing": return self._pairing.is_approved(open_id) or check_dm_policy(self.config, open_id) return check_dm_policy(self.config, open_id) def _check_group_policy(self, chat_id: str, open_id: str) -> bool: return check_group_policy(self.config, chat_id, open_id) def _is_at_bot(self, payload: dict) -> bool: return is_bot_mentioned(self.config, payload, self._mode) # ---- P0 Module Accessors ---- @property def pairing(self) -> WeChatPairingAdapter: return self._pairing @property def account_manager(self) -> WeChatAccountManager: return self._account_mgr @property def gateway(self) -> WeChatGatewayAdapter: return self._gateway @property def debounce(self) -> WeChatQueueDebounce: return self._debounce @property def group_adapter(self) -> WeChatGroupAdapter: return self._group_adapter @property def security_adapter(self) -> WeChatSecurityAdapter: return self._security_adapter @property def config_reloader(self) -> WeChatConfigReloader: return self._config_reloader @property def secrets(self) -> WeChatSecretsAdapter: return self._secrets @property def elevated(self) -> WeChatElevatedAdapter: return self._elevated @property def approval(self) -> WeChatApprovalAdapter: return self._approval @property def lifecycle(self) -> WeChatLifecycleAdapter: return self._lifecycle def get_meta_info(self) -> ChannelMeta: return get_meta() def get_channel_capabilities(self) -> dict: return get_capabilities() def get_message_capabilities_info(self) -> dict: return get_message_capabilities() def get_config_schema(self) -> dict: return get_schema() def validate_config(self) -> list[str]: return validate_wechat_config(self.config) def list_gateway_rpc_methods(self) -> list[str]: return list_rpc_methods() def get_safety_strategies(self) -> dict: from .security import WECHAT_SAFETY_STRATEGIES return dict(WECHAT_SAFETY_STRATEGIES) # ---- P1 Module Accessors ---- @property def chunker(self) -> WeChatChunker: return self._chunker @property def outbound(self) -> WeChatOutboundAdapter: return self._outbound @property def threading(self) -> WeChatThreadingAdapter: return self._threading @property def mention(self) -> WeChatMentionAdapter: return self._mention @property def router(self) -> WeChatMessagingRouter: return self._router @property def heartbeat(self) -> WeChatHeartbeatAdapter: return self._heartbeat @property def message_actions(self) -> WeChatMessageActionAdapter: return self._message_actions @property def group_admin(self) -> WeChatGroupAdmin: return self._group_admin async def send_voice(self, chat_id: str, voice_data: bytes, is_group: bool = False) -> DeliveryResult: if self._mode == "wecom" and self._wecom_client and self._http_client: return await send_voice_wecom( self._wecom_client, self._http_client, chat_id, voice_data, self.config["agent_id"] ) if self._mode == "mp" and self._mp_client and self._http_client: return await send_voice_mp(self._mp_client, self._http_client, chat_id, voice_data) if self._mode == "personal" and self._bridge_client: return await send_voice_bridge(self._bridge_client, chat_id, voice_data, is_group) return DeliveryResult(success=False, error=f"send_voice not supported for mode={self._mode}") async def read_message(self, msg_id: str) -> dict: if self._mode == "wecom" and self._wecom_client and self._http_client: return await read_message_wecom(self._wecom_client, self._http_client, msg_id) if self._mode == "mp" and self._mp_client and self._http_client: return await read_message_mp(self._mp_client, self._http_client, msg_id) if self._mode == "personal" and self._bridge_client: return await read_message_bridge(self._bridge_client, msg_id) return {"msg_id": msg_id, "available": False} # ---- P2 Module Accessors ---- @property def attachment(self) -> WeChatAttachmentAdapter: return self._attachment @property def auth(self) -> WeChatAuthAdapter: return self._auth @property def base64(self) -> WeChatBase64Adapter: return self._base64 @property def capacity_provider(self) -> WeChatCapacityProvider: return self._cap_provider @property def discovery(self) -> WeChatDiscoveryAdapter: return self._discovery @property def event_subscribe(self) -> WeChatEventSubscribeAdapter: return self._event_subscribe @property def link_formatter(self) -> WeChatLinkFormatter: return self._link_fmt @property def monitoring(self) -> WeChatMonitoringAdapter: return self._monitoring @property def music_handler(self) -> WeChatMusicHandler: return self._music_handler @property def notification(self) -> WeChatNotificationAdapter: return self._notification @property def reply_mode(self) -> WeChatReplyModeAdapter: return self._reply_mode @property def template(self) -> WeChatTemplateAdapter: return self._template @property def integration_status(self) -> WeChatIntegrationStatus: return self._integration_status @property def state_snapshot(self) -> ChannelAccountSnapshot: import time self._check_ban_cooldown() now = time.time() effective_status = self._status if self._banned and not self._ban_permanent: effective_status = ChannelStatus.ERROR return ChannelAccountSnapshot( account_id=self.config.get("account_id", "default"), name=self.config.get("name", self.channel_id), configured=bool(self.config), enabled=not self._ban_permanent, linked=self._status == ChannelStatus.CONNECTED, running=self._status == ChannelStatus.CONNECTED, connected=self._status == ChannelStatus.CONNECTED, status_state=effective_status.value, health_state="healthy" if self._status == ChannelStatus.CONNECTED else effective_status.value, last_start_at=now if self._status == ChannelStatus.CONNECTED else None, last_error=self._banned_reason, reconnect_attempts=self._ban_attempts if self._banned else 0, dm_policy=self.config.get("dm_policy", "pairing"), group_policy=str(self.config.get("group_policy", "allowlist")), allow_from_count=0, webhook_url=self.config.get("webhook_url", ""), probe={ "mode": self._mode, "lease_active": self._polling_lease.is_active, "parallel_monitor_count": self._parallel_monitor.get_monitor_count(), "banned": self._banned, "ban_permanent": self._ban_permanent, "ban_attempts": self._ban_attempts, }, ) @property def selector(self) -> WeChatSelectorAdapter: return self._selector @property def send_cache(self) -> WeChatSendCache: return self._send_cache @property def agent_prompt(self) -> WeChatAgentPromptAdapter: return self._agent_prompt @property def bindings(self) -> WeChatConfiguredBindingAdapter: return self._bindings @property def conversation_bindings(self) -> WeChatConversationBindingAdapter: return self._conversation_bindings @property def resolver(self) -> WeChatResolverAdapter: return self._resolver @property def interactive(self) -> WeChatInteractiveAdapter: return self._interactive @property def sent_store(self) -> SentMessageStore: return self._sent_store @property def polling_lease(self) -> PollingLease: return self._polling_lease @property def parallel_monitor(self) -> ParallelMonitor: return self._parallel_monitor async def list_directory_peers(self) -> list[dict]: if self._mode == "wecom" and self._wecom_client: return await list_peers_wecom(self._wecom_client, self._http_client) if self._mode == "mp" and self._mp_client: return await list_peers_mp(self._mp_client, self._http_client) if self._mode == "personal" and self._bridge_client: return await list_peers_bridge(self._bridge_client, self.config) return [] async def list_directory_groups(self) -> list[dict]: if self._mode == "wecom" and self._wecom_client: return await list_groups_wecom(self._wecom_client, self._http_client) if self._mode == "mp" and self._mp_client: return await list_groups_mp(self._mp_client, self._http_client) if self._mode == "personal" and self._bridge_client: return await list_groups_bridge(self._bridge_client, self.config) return [] async def run_diagnose(self) -> dict: return await diagnose_channel(self) async def run_repair(self) -> dict: return await repair_channel(self) def get_mcp_tools(self) -> list[dict]: return list_mcp_tools() def get_mcp_resources(self) -> list[dict]: return list_mcp_resources() def get_oauth_url(self, redirect_uri: str, state: str = "") -> str | None: if self._mode == "wecom": return build_wecom_oauth_url(self.config.get("corp_id", ""), redirect_uri, state) if self._mode == "mp": return build_mp_oauth_url(self.config.get("app_id", ""), redirect_uri, state) return None # ---- Verify Webhook Signature ---- async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool: if self._mode == "wecom": token = self.config.get("token", "") if not token: return True signature = headers.get("x-wx-signature", headers.get("signature", "")) timestamp = headers.get("x-wx-timestamp", headers.get("timestamp", "")) nonce = headers.get("x-wx-nonce", headers.get("nonce", "")) if not all([signature, timestamp, nonce]): return True return wecom_verify_sig(token, timestamp, nonce, signature) if self._mode == "mp": token = self.config.get("token", "") if not token: return True signature = headers.get("x-wx-signature", headers.get("signature", "")) timestamp = headers.get("x-wx-timestamp", headers.get("timestamp", "")) nonce = headers.get("x-wx-nonce", headers.get("nonce", "")) if not all([signature, timestamp, nonce]): return True return mp_verify_sig(token, timestamp, nonce, signature) return True