这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
1506 lines
56 KiB
Python
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
|