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

136 lines
4.5 KiB
Python

"""Microsoft Teams 投票持久化存储。
提供投票创建、投票、结果查询与过期清理,基于 JSON 文件存储,
支持去重和 30 天 TTL。
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any
from yuxi.utils.logging_config import logger
POLL_STORE_FILENAME = "msteams-polls.json"
POLL_TTL_SECONDS = 30 * 24 * 3600
class PollStore:
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._polls: dict[str, dict[str, Any]] = {}
self._votes: dict[str, dict[str, str]] = {}
self._load()
@property
def file_path(self) -> Path:
return self._storage_dir / POLL_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) as e:
logger.warning(f"MSTeams polls: failed to load {self.file_path}: {e}")
return
self._polls = data.get("polls", {})
self._votes = data.get("votes", {})
def _save(self) -> None:
try:
self.file_path.write_text(
json.dumps({"polls": self._polls, "votes": self._votes}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError as e:
logger.error(f"MSTeams polls: failed to save {self.file_path}: {e}")
def create_poll(
self,
poll_id: str,
title: str,
options: list[str],
creator_id: str = "",
multi_select: bool = False,
max_selections: int = 1,
) -> dict[str, Any]:
now = time.time()
self._polls[poll_id] = {
"poll_id": poll_id,
"title": title,
"options": options,
"multi_select": multi_select,
"max_selections": max_selections,
"creator_id": creator_id,
"created_at": now,
}
self._votes[poll_id] = {}
self._cleanup_expired()
self._save()
logger.info(f"MSTeams poll created: {poll_id} ({title})")
return self._polls[poll_id]
def cast_vote(self, poll_id: str, user_id: str, option: str) -> bool:
poll = self._polls.get(poll_id)
if not poll:
logger.warning(f"MSTeams cast_vote: poll not found: {poll_id}")
return False
if option not in poll["options"]:
logger.warning(f"MSTeams cast_vote: invalid option '{option}' for poll {poll_id}")
return False
if poll_id not in self._votes:
self._votes[poll_id] = {}
if user_id in self._votes[poll_id]:
logger.debug(f"MSTeams cast_vote: duplicate vote from {user_id} on {poll_id}")
return False
self._votes[poll_id][user_id] = option
self._save()
logger.info(f"MSTeams vote cast: {user_id} -> {option} on {poll_id}")
return True
def get_results(self, poll_id: str) -> dict[str, Any] | None:
poll = self._polls.get(poll_id)
if not poll:
return None
votes = self._votes.get(poll_id, {})
tally: dict[str, int] = {opt: 0 for opt in poll["options"]}
for opt in votes.values():
if opt in tally:
tally[opt] += 1
return {
"poll_id": poll_id,
"title": poll["title"],
"options": poll["options"],
"total_votes": len(votes),
"tally": tally,
"voters": list(votes.keys()),
"created_at": poll["created_at"],
}
def get_poll(self, poll_id: str) -> dict[str, Any] | None:
return self._polls.get(poll_id)
def has_voted(self, poll_id: str, user_id: str) -> bool:
return user_id in self._votes.get(poll_id, {})
def _cleanup_expired(self) -> None:
now = time.time()
expired = [pid for pid, poll in self._polls.items() if now - poll.get("created_at", 0) > POLL_TTL_SECONDS]
for pid in expired:
self._polls.pop(pid, None)
self._votes.pop(pid, None)
logger.debug(f"MSTeams poll expired and removed: {pid}")
def cleanup(self) -> int:
self._cleanup_expired()
self._save()
return len(self._polls)
@property
def active_count(self) -> int:
return len(self._polls)