from __future__ import annotations import json import logging import os import secrets from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from .adaptive_card import build_attachment, build_poll_card from .sdk import BotFrameworkAdapter from .types import StoredConversationReference logger = logging.getLogger(__name__) MAX_POLLS = 1000 POLL_TTL_DAYS = 30 @dataclass class Poll: poll_id: str question: str options: list[str] is_multi_select: bool = False conversation_id: str = "" message_id: str = "" service_url: str = "" tenant_id: str | None = None created_at: str = "" votes: dict[str, list[str]] = field(default_factory=dict) closed: bool = False @property def results(self) -> dict: counts = {opt: 0 for opt in self.options} for voter, selected in self.votes.items(): for opt in selected: if opt in counts: counts[opt] += 1 return { "question": self.question, "options": self.options, "is_multi_select": self.is_multi_select, "total_voters": len(self.votes), "votes": counts, "closed": self.closed, } def to_dict(self) -> dict: return { "poll_id": self.poll_id, "question": self.question, "options": self.options, "is_multi_select": self.is_multi_select, "conversation_id": self.conversation_id, "message_id": self.message_id, "service_url": self.service_url, "tenant_id": self.tenant_id, "created_at": self.created_at, "votes": self.votes, "closed": self.closed, } @classmethod def from_dict(cls, data: dict) -> Poll: return cls( poll_id=data.get("poll_id", ""), question=data.get("question", ""), options=data.get("options", []), is_multi_select=data.get("is_multi_select", False), conversation_id=data.get("conversation_id", ""), message_id=data.get("message_id", ""), service_url=data.get("service_url", ""), tenant_id=data.get("tenant_id"), created_at=data.get("created_at", ""), votes=data.get("votes", {}), closed=data.get("closed", False), ) class PollStore: def __init__(self, file_path: str): self._file_path = file_path self._polls: dict[str, Poll] = {} def load(self) -> None: try: if not os.path.exists(self._file_path): return with open(self._file_path, encoding="utf-8") as f: data = json.load(f) if not isinstance(data, dict): return cutoff = (datetime.now(UTC) - timedelta(days=POLL_TTL_DAYS)).isoformat() loaded = 0 for poll_id, entry in data.items(): created = entry.get("created_at", "") if created and created < cutoff: continue self._polls[poll_id] = Poll.from_dict(entry) loaded += 1 logger.info("Loaded %d polls from %s", loaded, self._file_path) except Exception: logger.exception("Failed to load poll store from %s", self._file_path) def save(self) -> None: try: os.makedirs(os.path.dirname(self._file_path) or ".", exist_ok=True) data = {} for poll_id, poll in self._polls.items(): data[poll_id] = poll.to_dict() with open(self._file_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) except Exception: logger.exception("Failed to save poll store to %s", self._file_path) def create( self, question: str, options: list[str], *, is_multi_select: bool = False, conversation_id: str = "", message_id: str = "", service_url: str = "", tenant_id: str | None = None, ) -> Poll: poll_id = secrets.token_hex(12) now = datetime.now(UTC).isoformat() poll = Poll( poll_id=poll_id, question=question, options=options, is_multi_select=is_multi_select, conversation_id=conversation_id, message_id=message_id, service_url=service_url, tenant_id=tenant_id, created_at=now, ) self._polls[poll_id] = poll self._evict_if_needed() self.save() return poll def get(self, poll_id: str) -> Poll | None: poll = self._polls.get(poll_id) if poll and poll.closed: return None if poll: created = datetime.fromisoformat(poll.created_at) if datetime.now(UTC) - created > timedelta(days=POLL_TTL_DAYS): self._polls.pop(poll_id, None) self.save() return None return poll def vote( self, poll_id: str, voter_id: str, selected: list[str], ) -> Poll | None: poll = self.get(poll_id) if not poll: return None valid = [opt for opt in selected if opt in poll.options] if not valid: return None if poll.is_multi_select: poll.votes[voter_id] = valid else: poll.votes[voter_id] = valid[:1] self.save() return poll def close(self, poll_id: str) -> bool: poll = self._polls.get(poll_id) if not poll: return False poll.closed = True self.save() return True def remove(self, poll_id: str) -> bool: if poll_id in self._polls: self._polls.pop(poll_id, None) self.save() return True return False def _evict_if_needed(self) -> None: if len(self._polls) <= MAX_POLLS: return sorted_polls = sorted( self._polls.items(), key=lambda x: x[1].created_at or "", ) to_remove = len(self._polls) - MAX_POLLS for poll_id, _ in sorted_polls[:to_remove]: self._polls.pop(poll_id, None) logger.info("Evicted %d oldest polls (limit=%d)", to_remove, MAX_POLLS) def list_all(self) -> list[Poll]: return list(self._polls.values()) def __len__(self) -> int: return len(self._polls) async def send_poll( adapter: BotFrameworkAdapter, ref: StoredConversationReference, question: str, options: list[str], poll_store: PollStore, *, is_multi_select: bool = False, reply_to_id: str | None = None, ) -> Poll: card = build_poll_card(question, options, is_multi_select=is_multi_select) attachment = build_attachment(card) activity: dict = { "type": "message", "attachments": [attachment], } if reply_to_id: activity["replyToId"] = reply_to_id if ref.tenant_id: activity.setdefault("channelData", {}) activity["channelData"]["tenant"] = {"id": ref.tenant_id} result = await adapter.send_to_conversation(ref.service_url, ref.conversation_id, activity) message_id = result.get("id", "") poll = poll_store.create( question=question, options=options, is_multi_select=is_multi_select, conversation_id=ref.conversation_id, message_id=message_id, service_url=ref.service_url, tenant_id=ref.tenant_id, ) return poll async def handle_poll_vote( adapter: BotFrameworkAdapter, poll_store: PollStore, vote_data: dict, *, sender_id: str = "", ) -> dict: poll_id = vote_data.get("poll_id", "") selected = vote_data.get("selected", []) if isinstance(selected, str): selected = [selected] if not poll_id or not selected: return {"success": False, "error": "Missing poll_id or selected options"} poll = poll_store.vote(poll_id, sender_id, selected) if not poll: return {"success": False, "error": "Poll not found or expired"} results = poll.results return { "success": True, "result": { "poll_id": poll_id, "question": poll.question, "results": results, }, } def build_poll_results_text(poll: Poll) -> str: results = poll.results total = results["total_voters"] lines = [f"**📊 {poll.question}**", f"总票数: {total}", ""] for opt in poll.options: count = results["votes"].get(opt, 0) bar_len = max(1, int(count / max(total, 1) * 10)) bar = "█" * bar_len + "░" * (10 - bar_len) lines.append(f"{bar} {opt} ({count})") return "\n".join(lines)