ForcePilot/backend/package/yuxi/channels/adapters/wechat/approval.py
Kris 05abecc02b feat(wechat): 新增完整微信渠道适配器实现
该提交实现了支持企业微信、微信公众号、个人微信桥接三种模式的完整微信渠道适配器,包含以下核心模块:
1. 基础认证与配置相关:auth_adapter、config_reload、setup_contract等
2. 消息处理与格式转换:format、attachment_adapter、outbound_adapter等
3. 多模式客户端支持:wecom/mp子模块,包含加解密、消息收发能力
4. 辅助能力:限速器、防抖、会话绑定、事件映射、模板渲染等
5. 扩展能力:二维码登录、消息读取、特权用户、心跳监控等

实现了完整的微信生态对接能力,支持消息收发、事件处理、API调用限流、配置热重载等功能。
2026-05-12 00:51:04 +08:00

210 lines
7.4 KiB
Python

from __future__ import annotations
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Literal
from yuxi.utils.logging_config import logger
DEFAULT_APPROVAL_TIMEOUT_MINUTES = 5
DEFAULT_MAX_PENDING_APPROVALS = 50
ApprovalCallback = Callable[[str, str, dict[str, Any]], None]
@dataclass
class ApprovalRequest:
request_id: str
action: str
channel_user_id: str
chat_id: str
payload: dict[str, Any]
created_at: float
expires_at: float
status: str = "pending"
chat_type: Literal["direct", "group"] = "direct"
class WeChatApprovalAdapter:
def __init__(self):
self._pending: dict[str, ApprovalRequest] = {}
self._history: list[dict[str, Any]] = []
self._max_history = 200
self._enabled: bool = False
self._timeout_minutes: int = DEFAULT_APPROVAL_TIMEOUT_MINUTES
self._dm_enabled: bool = True
self._group_enabled: bool = True
self._callbacks: dict[str, list[ApprovalCallback]] = {
"approved": [],
"denied": [],
"timeout": [],
}
def configure(self, config: dict[str, Any]) -> None:
approval_cfg = config.get("approval", {})
self._enabled = approval_cfg.get("enabled", False)
self._timeout_minutes = approval_cfg.get("timeout_minutes", DEFAULT_APPROVAL_TIMEOUT_MINUTES)
self._dm_enabled = approval_cfg.get("dm_enabled", True)
self._group_enabled = approval_cfg.get("group_enabled", True)
logger.info(
f"[WeChat/Approval] Configured: enabled={self._enabled}, "
f"timeout={self._timeout_minutes}m, dm={self._dm_enabled}, group={self._group_enabled}"
)
def on(self, event: Literal["approved", "denied", "timeout"], callback: ApprovalCallback) -> None:
self._callbacks[event].append(callback)
def _trigger_callbacks(self, event: str, request_id: str, outcome: str, payload: dict[str, Any]) -> None:
for cb in self._callbacks.get(event, []):
try:
cb(request_id, outcome, payload)
except Exception as e:
logger.error(f"[WeChat/Approval] Callback error for {event}: {e}")
def is_chat_type_enabled(self, chat_type: Literal["direct", "group"]) -> bool:
if not self._enabled:
return False
if chat_type == "direct":
return self._dm_enabled
return self._group_enabled
def create_request(
self,
request_id: str,
action: str,
channel_user_id: str,
chat_id: str,
payload: dict[str, Any] | None = None,
chat_type: Literal["direct", "group"] = "direct",
) -> ApprovalRequest | None:
if not self._enabled:
return None
if not self.is_chat_type_enabled(chat_type):
return None
self._cleanup_expired()
if len(self._pending) >= DEFAULT_MAX_PENDING_APPROVALS:
logger.warning("[WeChat/Approval] Max pending approvals reached")
return None
now = time.time()
req = ApprovalRequest(
request_id=request_id,
action=action,
channel_user_id=channel_user_id,
chat_id=chat_id,
payload=payload or {},
created_at=now,
expires_at=now + self._timeout_minutes * 60,
chat_type=chat_type,
)
self._pending[request_id] = req
logger.info(f"[WeChat/Approval] Created: {request_id} for '{action}' by {channel_user_id} [{chat_type}]")
return req
def approve(self, request_id: str, approver_id: str = "admin") -> bool:
req = self._pending.get(request_id)
if req is None:
return False
if req.status != "pending":
return False
req.status = "approved"
self._record_history(req, approver_id, "approved")
self._pending.pop(request_id, None)
logger.info(f"[WeChat/Approval] Approved: {request_id} by {approver_id}")
self._trigger_callbacks("approved", request_id, "approved", req.payload)
return True
def deny(self, request_id: str, reason: str = "", approver_id: str = "admin") -> bool:
req = self._pending.get(request_id)
if req is None:
return False
if req.status != "pending":
return False
req.status = "denied"
self._record_history(req, approver_id, "denied", reason)
self._pending.pop(request_id, None)
logger.info(f"[WeChat/Approval] Denied: {request_id} reason={reason}")
self._trigger_callbacks("denied", request_id, "denied", {**req.payload, "reason": reason})
return True
def is_approved(self, request_id: str) -> bool:
req = self._pending.get(request_id)
return req is not None and req.status == "approved"
def is_pending(self, request_id: str) -> bool:
req = self._pending.get(request_id)
return req is not None and req.status == "pending"
def list_pending(self, chat_type: Literal["direct", "group"] | None = None) -> list[dict[str, Any]]:
self._cleanup_expired()
result = []
for r in self._pending.values():
if chat_type and r.chat_type != chat_type:
continue
result.append(
{
"request_id": r.request_id,
"action": r.action,
"channel_user_id": r.channel_user_id,
"chat_id": r.chat_id,
"chat_type": r.chat_type,
"created_at": r.created_at,
"expires_at": r.expires_at,
"status": r.status,
}
)
return result
def resolve_timeout_requests(self) -> list[str]:
expired: list[str] = []
now = time.time()
for req_id, req in list(self._pending.items()):
if req.expires_at < now:
req.status = "timeout"
self._record_history(req, "system", "timeout")
self._pending.pop(req_id, None)
expired.append(req_id)
self._trigger_callbacks("timeout", req_id, "timeout", req.payload)
if expired:
logger.info(f"[WeChat/Approval] Timed out: {len(expired)} request(s)")
return expired
def get_pending_count(self, chat_type: Literal["direct", "group"] | None = None) -> int:
if chat_type:
return sum(1 for r in self._pending.values() if r.chat_type == chat_type)
return len(self._pending)
def _cleanup_expired(self) -> None:
self.resolve_timeout_requests()
def _record_history(
self,
req: ApprovalRequest,
approver_id: str,
outcome: str,
reason: str = "",
) -> None:
entry = {
"request_id": req.request_id,
"action": req.action,
"channel_user_id": req.channel_user_id,
"chat_id": req.chat_id,
"chat_type": req.chat_type,
"created_at": req.created_at,
"resolved_at": time.time(),
"outcome": outcome,
"approver_id": approver_id,
"reason": reason,
}
self._history.append(entry)
if len(self._history) > self._max_history:
self._history = self._history[-self._max_history :]
def get_history(self, limit: int = 50) -> list[dict[str, Any]]:
return list(reversed(self._history[-limit:]))