"""Microsoft Teams Pairing Request 流程。 当未在白名单用户向 Bot 发送 DM 时,创建配对请求卡片。 管理员可通过命令审批配对请求,审批后用户被添加到已配对集合。 """ from __future__ import annotations import json import time from pathlib import Path from typing import Any from yuxi.utils.logging_config import logger PAIRING_TTL_SECONDS = 24 * 3600 PAIRING_STORE_FILENAME = "msteams-pairing.json" class PairingManager: def __init__(self, storage_dir: str | None = None): self._storage_dir = Path(storage_dir or str(Path.home() / ".yuxi" / "msteams")) self._storage_dir.mkdir(parents=True, exist_ok=True) self._pending: dict[str, dict[str, Any]] = {} self._approved: set[str] = set() self._load() @property def file_path(self) -> Path: return self._storage_dir / PAIRING_STORE_FILENAME def _load(self) -> None: if not self.file_path.exists(): return try: data = json.loads(self.file_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return self._pending = data.get("pending", {}) self._approved = set(data.get("approved", [])) def _save(self) -> None: try: self.file_path.write_text( json.dumps( {"pending": self._pending, "approved": list(self._approved)}, ensure_ascii=False, indent=2, ), encoding="utf-8", ) except OSError as e: logger.error(f"MSTeams pairing: failed to save: {e}") def request_pairing(self, user_id: str, user_name: str = "", chat_id: str = "") -> str: self._cleanup_expired() now = time.time() pair_id = _make_pair_id(user_id, now) self._pending[pair_id] = { "pair_id": pair_id, "user_id": user_id, "user_name": user_name, "chat_id": chat_id, "requested_at": now, } self._save() logger.info(f"MSTeams pairing request: {user_name} ({user_id}) -> {pair_id}") return pair_id def approve(self, pair_id: str) -> bool: if pair_id not in self._pending: return False user_id = self._pending[pair_id]["user_id"] self._approved.add(user_id) self._pending.pop(pair_id, None) self._save() logger.info(f"MSTeams pairing approved: {user_id} ({pair_id})") return True def deny(self, pair_id: str) -> bool: if pair_id not in self._pending: return False user_id = self._pending[pair_id]["user_id"] self._pending.pop(pair_id, None) self._save() logger.info(f"MSTeams pairing denied: {user_id} ({pair_id})") return True def is_approved(self, user_id: str) -> bool: return user_id in self._approved def is_pending(self, user_id: str) -> bool: return any(p.get("user_id") == user_id for p in self._pending.values()) def get_pending_requests(self) -> list[dict[str, Any]]: self._cleanup_expired() return list(self._pending.values()) def _cleanup_expired(self) -> None: now = time.time() expired = [pid for pid, req in self._pending.items() if now - req.get("requested_at", 0) > PAIRING_TTL_SECONDS] for pid in expired: self._pending.pop(pid, None) def cleanup(self) -> int: self._cleanup_expired() self._save() return len(self._pending) def _make_pair_id(user_id: str, timestamp: float) -> str: return f"req_{user_id[:12]}_{int(timestamp)}" def build_pairing_request_card( user_name: str, user_id: str, pair_id: str, bot_name: str = "ForcePilot", ) -> dict[str, Any]: return { "type": "AdaptiveCard", "version": "1.5", "body": [ { "type": "TextBlock", "size": "Large", "weight": "Bolder", "text": "配对请求", }, { "type": "TextBlock", "text": f"用户 **{user_name}** ({user_id}) 请求与 {bot_name} 配对。", "wrap": True, }, { "type": "FactSet", "facts": [ {"title": "用户 ID", "value": user_id}, {"title": "配对 ID", "value": pair_id}, ], }, ], "actions": [ { "type": "Action.Submit", "title": "批准", "style": "positive", "data": {"action": "pairing_approve", "pair_id": pair_id}, }, { "type": "Action.Submit", "title": "拒绝", "style": "destructive", "data": {"action": "pairing_deny", "pair_id": pair_id}, }, ], }