ForcePilot/backend/package/yuxi/channels/adapters/imessage/polls.py
Kris 068cf70fe9 feat(imessage): 实现完整的 iMessage 适配器基础模块
新增 iMessage 通道适配器完整实现,包含:
1. 核心适配器与工具工厂导出
2. 运行时存储、反射防护、会话路由等基础组件
3. 消息信封、线程管理、回复上下文格式化
4. Tapback 表情反应处理、自定义异常体系
5. 审批按钮、联系人解析、速率限制功能
6. 文本净化、目标解析、缓存管理模块
7. 配置 schema、多账户支持、安装向导等配置模块
8. 审计日志、媒体AI处理等扩展功能
2026-05-12 00:44:44 +08:00

145 lines
4.3 KiB
Python

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)