ForcePilot/backend/package/yuxi/channels/adapters/msteams/polls.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

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)