ForcePilot/backend/package/yuxi/channels/policy/approval.py
Kris 7a972055b7 feat: 新增渠道服务基础框架与核心工具类
新增大量渠道适配器相关的协议、策略、工具类与基础设施代码,包括:
1.  多协议定义:认证、消息、配置、网关等核心接口
2.  策略模块:上下文、群聊、去重、防抖等业务策略
3.  工具集:重试、去重、文本分块、消息格式化等SDK工具
4.  基础设施:外部进程管理、事件广播、熔断机制等
5.  账户与管道系统:账户管理、消息处理管道实现
6.  运行时服务:状态收集、维护任务、日志等后台服务
2026-05-12 00:53:57 +08:00

166 lines
4.8 KiB
Python

from __future__ import annotations
import asyncio
import time
import uuid as uuid_lib
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
class ApprovalAction(StrEnum):
"""审批动作"""
APPROVE = "approve"
REJECT = "reject"
ALLOW_ONCE = "allow-once"
ALLOW_ALWAYS = "allow-always"
DENY = "deny"
class ApprovalState(StrEnum):
"""审批状态"""
PENDING = "pending"
APPROVED = "approved"
DENIED = "denied"
TIMED_OUT = "timed_out"
CANCELLED = "cancelled"
@dataclass
class ApprovalRequest:
"""审批请求"""
approval_id: str
agent_id: str
command: str
requester_id: str
approver_ids: list[str]
created_at: float
timeout_at: float
state: ApprovalState = ApprovalState.PENDING
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class ApprovalConfig:
"""审批配置(渠道通用字段)"""
enabled: bool | str = False
approvers: list[str] = field(default_factory=list)
agent_filter: list[str] = field(default_factory=list)
session_filter: list[str] = field(default_factory=list)
timeout_s: float = 300.0
target: str = "dm"
auto_approve_owner: bool = True
class BaseApprovalCapability:
"""审批能力基类
封装:
- 审批请求创建/撤销
- 审批结果解析(多 action 映射)
- 超时处理
- Agent 回复抑制(审批未完成时不回复结果)
- 可选:自动批准拥有者
各适配器子类可扩展:
- 审批通知发送(向审批者发送审批请求消息)
- 审批按钮数据解析(各渠道按钮格式不同)
"""
def __init__(self, config: ApprovalConfig):
self._config = config
self._pending: dict[str, ApprovalRequest] = {}
self._lock = asyncio.Lock()
def is_enabled(self) -> bool:
return self._config.enabled is True or self._config.enabled == "auto"
def requires_approval(self, agent_id: str, session_id: str = "") -> bool:
if not self.is_enabled():
return False
if self._config.agent_filter and agent_id not in self._config.agent_filter:
return False
if self._config.session_filter and session_id not in self._config.session_filter:
return False
return True
async def create_approval(
self,
agent_id: str,
command: str,
requester_id: str,
) -> str:
approval_id = str(uuid_lib.uuid4())[:8]
now = time.time()
async with self._lock:
self._pending[approval_id] = ApprovalRequest(
approval_id=approval_id,
agent_id=agent_id,
command=command,
requester_id=requester_id,
approver_ids=list(self._config.approvers),
created_at=now,
timeout_at=now + self._config.timeout_s,
)
return approval_id
async def resolve(
self,
approval_id: str,
action: ApprovalAction,
approver_id: str = "",
) -> bool:
async with self._lock:
req = self._pending.get(approval_id)
if req is None or req.state != ApprovalState.PENDING:
return False
if approver_id and req.approver_ids and approver_id not in req.approver_ids:
return False
if action in (
ApprovalAction.APPROVE,
ApprovalAction.ALLOW_ONCE,
ApprovalAction.ALLOW_ALWAYS,
):
req.state = ApprovalState.APPROVED
else:
req.state = ApprovalState.DENIED
return True
async def cancel(self, approval_id: str) -> bool:
async with self._lock:
removed = self._pending.pop(approval_id, None)
return removed is not None
def get_state(self, approval_id: str) -> ApprovalState | None:
req = self._pending.get(approval_id)
return req.state if req else None
def should_suppress_reply(self, approval_id: str) -> bool:
req = self._pending.get(approval_id)
if req is None:
return False
return req.state == ApprovalState.PENDING
def check_timeouts(self) -> list[str]:
now = time.time()
expired: list[str] = []
for approval_id, req in self._pending.items():
if req.state == ApprovalState.PENDING and now >= req.timeout_at:
req.state = ApprovalState.TIMED_OUT
expired.append(approval_id)
return expired
def cleanup_completed(self) -> int:
to_remove = [aid for aid, req in self._pending.items() if req.state != ApprovalState.PENDING]
for aid in to_remove:
del self._pending[aid]
return len(to_remove)