from __future__ import annotations import asyncio import time from dataclasses import dataclass, field from typing import Any from yuxi.utils.logging_config import logger @dataclass class PollOption: index: int label: str vote_count: int = 0 @dataclass class Poll: poll_id: str chat_guid: str question: str options: list[PollOption] created_at: float = field(default_factory=time.time) closed: bool = False expires_at: float = 0.0 voter_map: dict[str, int] = field(default_factory=dict) def vote(self, user_id: str, option_index: int) -> bool: if self.closed: return False if option_index < 0 or option_index >= len(self.options): return False prev_vote = self.voter_map.get(user_id) if prev_vote is not None and 0 <= prev_vote < len(self.options): self.options[prev_vote].vote_count -= 1 self.options[option_index].vote_count += 1 self.voter_map[user_id] = option_index return True def results_text(self) -> str: lines = [f"Poll: {self.question}"] total = sum(o.vote_count for o in self.options) for opt in self.options: bar = _bar(opt.vote_count, total) if total > 0 else "" lines.append(f" {opt.index + 1}. {opt.label} — {opt.vote_count} vote(s) {bar}") lines.append(f"Total: {total} vote(s)") if self.closed: lines.append("(Poll closed)") return "\n".join(lines) def _bar(count: int, total: int, width: int = 10) -> str: if total == 0: return "" filled = round(count / total * width) return "[" + "■" * filled + "□" * (width - filled) + "]" class IMessagePollManager: def __init__(self, adapter: Any): self._adapter = adapter self._polls: dict[str, Poll] = {} self._counter = 0 async def create_poll( self, chat_guid: str, question: str, option_labels: list[str], expires_in_s: float = 300.0, ) -> Poll: self._counter += 1 poll_id = f"poll_{self._counter}_{int(time.time())}" options = [PollOption(index=i, label=label) for i, label in enumerate(option_labels)] poll = Poll( poll_id=poll_id, chat_guid=chat_guid, question=question, options=options, expires_at=time.time() + expires_in_s, ) self._polls[poll_id] = poll poll_text = _format_poll_text(poll) result = await self._adapter._get_client().send_text_message( chat_guid=chat_guid, content=poll_text, ) if result.success and result.message_id: logger.info(f"[iMessage/Poll] Created poll {poll_id} in {chat_guid}") else: logger.error(f"[iMessage/Poll] Failed to send poll {poll_id}: {result.error}") if expires_in_s > 0: asyncio.create_task(self._auto_close(poll_id, expires_in_s)) return poll async def vote(self, poll_id: str, user_id: str, option_index: int) -> dict[str, Any] | None: poll = self._polls.get(poll_id) if poll is None: return None if not poll.vote(user_id, option_index): return {"error": "Invalid vote", "poll_closed": poll.closed} return { "success": True, "results": poll.results_text(), } async def close_poll(self, poll_id: str) -> dict[str, Any] | None: poll = self._polls.get(poll_id) if poll is None: return None poll.closed = True results = poll.results_text() await self._adapter._get_client().send_text_message( chat_guid=poll.chat_guid, content=results, ) return {"closed": True, "results": results} def get_poll(self, poll_id: str) -> Poll | None: return self._polls.get(poll_id) async def _auto_close(self, poll_id: str, delay: float) -> None: await asyncio.sleep(delay) await self.close_poll(poll_id) def _format_poll_text(poll: Poll) -> str: lines = [f"Poll: {poll.question}", ""] for opt in poll.options: lines.append(f" {opt.index + 1}. {opt.label}") lines.append("") lines.append("Reply with the number to vote (e.g. '1')") return "\n".join(lines)