"""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)