实现了 Teams 机器人所需的全功能组件,包括: - 基础命令解析与帮助卡片生成 - 租户验证与访问控制 - 自定义 UA 与媒体工具 - 消息分块、批注处理与会话管理 - 防抖、缓存与配置路由能力 - 投票、配对、审计与运行时状态管理 - TTS 语音合成与卡片构建工具 - 群组管理与权限控制逻辑
136 lines
4.5 KiB
Python
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)
|