该提交实现了支持企业微信、微信公众号、个人微信桥接三种模式的完整微信渠道适配器,包含以下核心模块: 1. 基础认证与配置相关:auth_adapter、config_reload、setup_contract等 2. 消息处理与格式转换:format、attachment_adapter、outbound_adapter等 3. 多模式客户端支持:wecom/mp子模块,包含加解密、消息收发能力 4. 辅助能力:限速器、防抖、会话绑定、事件映射、模板渲染等 5. 扩展能力:二维码登录、消息读取、特权用户、心跳监控等 实现了完整的微信生态对接能力,支持消息收发、事件处理、API调用限流、配置热重载等功能。
187 lines
6.5 KiB
Python
187 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
import time
|
|
from collections.abc import Callable, Awaitable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
PAIRING_CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
|
PAIRING_CODE_LENGTH = 8
|
|
PAIRING_VALIDITY_S = 3600
|
|
PAIRING_RATE_LIMIT_S = 3600
|
|
PAIRING_MAX_PENDING = 3
|
|
|
|
|
|
@dataclass
|
|
class PairingRequest:
|
|
code: str
|
|
sender_id: str
|
|
channel_user_id: str
|
|
requested_at: float
|
|
expires_at: float
|
|
metadata: dict[str, Any] | None = None
|
|
|
|
|
|
class WeChatPairingAdapter:
|
|
id_label: str = "WeChat 用户 ID"
|
|
|
|
def __init__(self, storage_dir: str | None = None):
|
|
self._pending: dict[str, PairingRequest] = {}
|
|
self._rate_limit: dict[str, float] = {}
|
|
self._approved: set[str] = set()
|
|
self._storage_dir = storage_dir or str(Path.home() / ".yuxi" / "wechat")
|
|
|
|
@staticmethod
|
|
def normalize_allow_entry(raw: str) -> str:
|
|
raw = raw.strip()
|
|
if not raw.startswith("wx:"):
|
|
return f"wx:{raw}"
|
|
return raw
|
|
|
|
def generate_code(self) -> str:
|
|
return "".join(secrets.choice(PAIRING_CODE_CHARS) for _ in range(PAIRING_CODE_LENGTH))
|
|
|
|
def request_pairing(
|
|
self,
|
|
sender_id: str,
|
|
channel_user_id: str,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> str | None:
|
|
if self.is_approved(channel_user_id):
|
|
return None
|
|
|
|
now = time.monotonic()
|
|
last_request = self._rate_limit.get(channel_user_id, 0)
|
|
if now - last_request < PAIRING_RATE_LIMIT_S:
|
|
logger.info(f"[WeChat/Pairing] Rate limited for {channel_user_id}")
|
|
return None
|
|
|
|
if len(self._pending) >= PAIRING_MAX_PENDING:
|
|
self._cleanup_expired()
|
|
if len(self._pending) >= PAIRING_MAX_PENDING:
|
|
logger.warning("[WeChat/Pairing] Max pending requests reached")
|
|
return None
|
|
|
|
code = self.generate_code()
|
|
self._pending[code] = PairingRequest(
|
|
code=code,
|
|
sender_id=sender_id,
|
|
channel_user_id=channel_user_id,
|
|
requested_at=time.time(),
|
|
expires_at=time.time() + PAIRING_VALIDITY_S,
|
|
metadata=metadata,
|
|
)
|
|
self._rate_limit[channel_user_id] = now
|
|
logger.info(f"[WeChat/Pairing] New request: code={code}, sender={sender_id}")
|
|
return code
|
|
|
|
def approve(self, code: str) -> bool:
|
|
self._cleanup_expired()
|
|
req = self._pending.pop(code, None)
|
|
if req is None:
|
|
return False
|
|
|
|
self._approved.add(req.channel_user_id)
|
|
logger.info(f"[WeChat/Pairing] Approved: {req.channel_user_id}")
|
|
return True
|
|
|
|
def reject(self, code: str) -> bool:
|
|
req = self._pending.pop(code, None)
|
|
if req is None:
|
|
return False
|
|
logger.info(f"[WeChat/Pairing] Rejected: {req.channel_user_id}")
|
|
return True
|
|
|
|
def list_pending(self) -> list[dict[str, Any]]:
|
|
self._cleanup_expired()
|
|
return [
|
|
{
|
|
"code": r.code,
|
|
"sender_id": r.sender_id,
|
|
"channel_user_id": r.channel_user_id,
|
|
"requested_at": r.requested_at,
|
|
"expires_at": r.expires_at,
|
|
}
|
|
for r in self._pending.values()
|
|
]
|
|
|
|
def is_approved(self, channel_user_id: str) -> bool:
|
|
return channel_user_id in self._approved
|
|
|
|
async def notify_approval(
|
|
self,
|
|
send_fn: Callable[..., Awaitable[Any]],
|
|
channel_user_id: str,
|
|
) -> None:
|
|
try:
|
|
await send_fn(channel_user_id, "您的访问请求已获批准,现在可以与我对话了!")
|
|
except Exception as e:
|
|
logger.error(f"[WeChat/Pairing] notify_approval failed: {e}")
|
|
|
|
def _cleanup_expired(self) -> None:
|
|
now = time.time()
|
|
expired = [code for code, req in self._pending.items() if req.expires_at < now]
|
|
for code in expired:
|
|
self._pending.pop(code, None)
|
|
|
|
async def save_allowlist(self, account_id: str = "default") -> None:
|
|
dir_path = Path(self._storage_dir) / account_id
|
|
dir_path.mkdir(parents=True, exist_ok=True)
|
|
file_path = dir_path / "allowFrom.json"
|
|
file_path.write_text(
|
|
json.dumps(sorted(self._approved), ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
async def load_allowlist(self, account_id: str = "default") -> list[str]:
|
|
file_path = Path(self._storage_dir) / account_id / "allowFrom.json"
|
|
if not file_path.exists():
|
|
return []
|
|
try:
|
|
data = json.loads(file_path.read_text(encoding="utf-8"))
|
|
if isinstance(data, list):
|
|
self._approved.update(data)
|
|
return data
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning(f"[WeChat/Pairing] Failed to load allowlist: {e}")
|
|
return []
|
|
|
|
async def save_pending(self, account_id: str = "default") -> None:
|
|
dir_path = Path(self._storage_dir) / account_id
|
|
dir_path.mkdir(parents=True, exist_ok=True)
|
|
pending_data = {
|
|
code: {
|
|
"sender_id": r.sender_id,
|
|
"channel_user_id": r.channel_user_id,
|
|
"requested_at": r.requested_at,
|
|
"expires_at": r.expires_at,
|
|
}
|
|
for code, r in self._pending.items()
|
|
}
|
|
file_path = dir_path / "pairing-pending.json"
|
|
file_path.write_text(json.dumps(pending_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
async def load_pending(self, account_id: str = "default") -> None:
|
|
file_path = Path(self._storage_dir) / account_id / "pairing-pending.json"
|
|
if not file_path.exists():
|
|
return
|
|
try:
|
|
data = json.loads(file_path.read_text(encoding="utf-8"))
|
|
for code, info in data.items():
|
|
if info.get("expires_at", 0) < time.time():
|
|
continue
|
|
self._pending[code] = PairingRequest(
|
|
code=code,
|
|
sender_id=info.get("sender_id", ""),
|
|
channel_user_id=info.get("channel_user_id", ""),
|
|
requested_at=info.get("requested_at", time.time()),
|
|
expires_at=info.get("expires_at", time.time() + PAIRING_VALIDITY_S),
|
|
)
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning(f"[WeChat/Pairing] Failed to load pending: {e}")
|