新增 iMessage 通道适配器完整实现,包含: 1. 核心适配器与工具工厂导出 2. 运行时存储、反射防护、会话路由等基础组件 3. 消息信封、线程管理、回复上下文格式化 4. Tapback 表情反应处理、自定义异常体系 5. 审批按钮、联系人解析、速率限制功能 6. 文本净化、目标解析、缓存管理模块 7. 配置 schema、多账户支持、安装向导等配置模块 8. 审计日志、媒体AI处理等扩展功能
145 lines
4.3 KiB
Python
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)
|