ForcePilot/backend/package/yuxi/channels/adapters/wechat/adapter.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

1506 lines
56 KiB
Python

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