ForcePilot/backend/package/yuxi/channels/adapters/msteams/pairing.py
Kris bd60c15df0 feat(msteams): 新增完整的 Microsoft Teams 适配器模块
实现了 Teams 机器人所需的全功能组件,包括:
- 基础命令解析与帮助卡片生成
- 租户验证与访问控制
- 自定义 UA 与媒体工具
- 消息分块、批注处理与会话管理
- 防抖、缓存与配置路由能力
- 投票、配对、审计与运行时状态管理
- TTS 语音合成与卡片构建工具
- 群组管理与权限控制逻辑
2026-05-12 00:46:44 +08:00

159 lines
4.9 KiB
Python

"""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},
},
],
}