这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
169 lines
5.5 KiB
Python
169 lines
5.5 KiB
Python
"""Microsoft Teams 投票持久化存储。
|
|
|
|
提供投票创建、投票、结果查询与过期清理,基于 JSON 文件存储,
|
|
支持去重和 30 天 TTL。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
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:
|
|
_SAVE_DEBOUNCE_S = 5.0
|
|
|
|
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._vote_lock = asyncio.Lock()
|
|
self._dirty = False
|
|
self._save_task: asyncio.Task | None = None
|
|
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 _schedule_save(self) -> None:
|
|
if not self._dirty:
|
|
return
|
|
if self._save_task and not self._save_task.done():
|
|
return
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
self._save_task = loop.create_task(self._debounced_save())
|
|
except RuntimeError:
|
|
self._save()
|
|
|
|
async def _debounced_save(self) -> None:
|
|
await asyncio.sleep(self._SAVE_DEBOUNCE_S)
|
|
self._dirty = False
|
|
self._save()
|
|
|
|
async def flush(self) -> None:
|
|
if self._dirty:
|
|
self._dirty = False
|
|
if self._save_task and not self._save_task.done():
|
|
self._save_task.cancel()
|
|
self._save()
|
|
|
|
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._dirty = True
|
|
self._schedule_save()
|
|
logger.info(f"MSTeams poll created: {poll_id} ({title})")
|
|
return self._polls[poll_id]
|
|
|
|
async def cast_vote(self, poll_id: str, user_id: str, option: str) -> bool:
|
|
async with self._vote_lock:
|
|
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._dirty = True
|
|
self._schedule_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._dirty = True
|
|
self._schedule_save()
|
|
return len(self._polls)
|
|
|
|
@property
|
|
def active_count(self) -> int:
|
|
return len(self._polls)
|