该提交实现了支持企业微信、微信公众号、个人微信桥接三种模式的完整微信渠道适配器,包含以下核心模块: 1. 基础认证与配置相关:auth_adapter、config_reload、setup_contract等 2. 消息处理与格式转换:format、attachment_adapter、outbound_adapter等 3. 多模式客户端支持:wecom/mp子模块,包含加解密、消息收发能力 4. 辅助能力:限速器、防抖、会话绑定、事件映射、模板渲染等 5. 扩展能力:二维码登录、消息读取、特权用户、心跳监控等 实现了完整的微信生态对接能力,支持消息收发、事件处理、API调用限流、配置热重载等功能。
210 lines
7.4 KiB
Python
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:]))
|