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

212 lines
7.5 KiB
Python

from __future__ import annotations
import asyncio
import json
import secrets
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from yuxi.utils.logging_config import logger
PAIRING_CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
@dataclass
class PairingRequest:
code: str
sender_id: str
channel_user_id: str
display_name: str
requested_at: float
expires_at: float
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class PairingConfig:
code_length: int = 8
code_ttl_s: float = 3600.0
rate_limit_s: float = 3600.0
max_pending: int = 3
storage_path: str = ""
store_pending_file: str = "pairing_pending.json"
store_allowlist_file: str = "pairing_allowlist.json"
class BasePairingManager:
def __init__(self, config: PairingConfig):
self._config = config
self._pending: dict[str, PairingRequest] = {}
self._sender_cooldown: dict[str, float] = {}
self._approved: set[str] = set()
self._lock = asyncio.Lock()
storage_dir = Path(config.storage_path)
storage_dir.mkdir(parents=True, exist_ok=True)
def generate_code(self) -> str:
return "".join(secrets.choice(PAIRING_CODE_CHARS) for _ in range(self._config.code_length))
async def request_pairing(
self,
sender_id: str,
channel_user_id: str,
display_name: str = "",
metadata: dict[str, Any] | None = None,
) -> str | None:
now = time.time()
async with self._lock:
last_time = self._sender_cooldown.get(sender_id, 0)
if now - last_time < self._config.rate_limit_s:
logger.debug(f"[Pairing] Rate limited for sender {sender_id}")
return None
if channel_user_id in self._approved:
return None
self._cleanup_expired(now)
if len(self._pending) >= self._config.max_pending:
logger.warning(f"[Pairing] Max pending ({self._config.max_pending}) reached")
return None
code = self.generate_code()
self._pending[code] = PairingRequest(
code=code,
sender_id=sender_id,
channel_user_id=channel_user_id,
display_name=display_name,
requested_at=now,
expires_at=now + self._config.code_ttl_s,
metadata=metadata or {},
)
self._sender_cooldown[sender_id] = now
await self._save_pending()
logger.info(f"[Pairing] New request: {display_name} ({channel_user_id}) — code {code}")
return code
async def approve(self, code: str) -> bool:
async with self._lock:
request = self._pending.pop(code, None)
if request is None:
return False
self._approved.add(request.channel_user_id)
await self._save_pending()
await self._save_allowlist()
await self._notify_approval(request.sender_id, request.channel_user_id)
logger.info(f"[Pairing] Approved: {request.display_name} ({request.channel_user_id})")
return True
async def reject(self, code: str) -> bool:
async with self._lock:
removed = self._pending.pop(code, None)
if removed is not None:
await self._save_pending()
return removed is not None
def is_approved(self, channel_user_id: str) -> bool:
return channel_user_id in self._approved
def list_pending(self) -> list[dict[str, Any]]:
now = time.time()
return [
{
"code": req.code,
"display_name": req.display_name,
"channel_user_id": req.channel_user_id,
"requested_at": req.requested_at,
"expires_in_s": max(0, int(req.expires_at - now)),
}
for req in self._pending.values()
if req.expires_at > now
]
def get_allowlist(self) -> list[str]:
return sorted(self._approved)
async def add_to_allowlist(self, channel_user_id: str) -> None:
async with self._lock:
self._approved.add(channel_user_id)
await self._save_allowlist()
async def remove_from_allowlist(self, channel_user_id: str) -> bool:
async with self._lock:
if channel_user_id in self._approved:
self._approved.discard(channel_user_id)
await self._save_allowlist()
return True
return False
async def load_state(self) -> None:
async with self._lock:
await self._load_pending()
await self._load_allowlist()
logger.info(f"[Pairing] Loaded state: {len(self._pending)} pending, {len(self._approved)} approved")
async def _save_pending(self) -> None:
data = {}
now = time.time()
for code, req in self._pending.items():
if req.expires_at > now:
data[code] = {
"sender_id": req.sender_id,
"channel_user_id": req.channel_user_id,
"display_name": req.display_name,
"requested_at": req.requested_at,
"expires_at": req.expires_at,
"metadata": req.metadata,
}
path = Path(self._config.storage_path) / self._config.store_pending_file
path.write_text(json.dumps(data, ensure_ascii=False, indent=2))
async def _load_pending(self) -> None:
path = Path(self._config.storage_path) / self._config.store_pending_file
if not path.exists():
return
try:
data = json.loads(path.read_text())
now = time.time()
loaded = 0
for code, item in data.items():
if item["expires_at"] > now:
self._pending[code] = PairingRequest(
code=code,
sender_id=item["sender_id"],
channel_user_id=item["channel_user_id"],
display_name=item.get("display_name", ""),
requested_at=item["requested_at"],
expires_at=item["expires_at"],
metadata=item.get("metadata", {}),
)
loaded += 1
logger.debug(f"[Pairing] Loaded {loaded} pending requests (expired filtered)")
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"[Pairing] Failed to load pending: {e}")
async def _save_allowlist(self) -> None:
path = Path(self._config.storage_path) / self._config.store_allowlist_file
path.write_text(json.dumps(sorted(self._approved), ensure_ascii=False, indent=2))
async def _load_allowlist(self) -> None:
path = Path(self._config.storage_path) / self._config.store_allowlist_file
if not path.exists():
return
try:
loaded = set(json.loads(path.read_text()))
self._approved = loaded
logger.debug(f"[Pairing] Loaded {len(loaded)} approved users")
except json.JSONDecodeError as e:
logger.warning(f"[Pairing] Failed to load allowlist: {e}")
def _cleanup_expired(self, now: float) -> None:
expired = [code for code, req in self._pending.items() if req.expires_at < now]
for code in expired:
del self._pending[code]
async def _notify_approval(self, sender_id: str, channel_user_id: str) -> None:
pass