ForcePilot/backend/package/yuxi/channels/adapters/imessage/adapter.py
Kris 8f352d91bb feat(imessage): 新增投票管理、审计日志功能并优化多项细节
1. 新增poll命令支持创建、关闭、列出投票
2. 新增审计日志记录功能
3. 优化远程附件URL安全校验逻辑
4. 修复表格匹配正则,支持包含竖线的表格分隔行
5. 新增媒体AI处理能力,支持图片描述和音频转录
6. 完善配置校验和错误处理
7. 重构文本发送逻辑,增加重试机制
8. 新增投票投票处理逻辑,支持数字快捷投票
2026-05-13 16:10:03 +08:00

1290 lines
53 KiB
Python

from __future__ import annotations
import asyncio
import datetime as dt
import os
import tempfile
import time
from collections.abc import AsyncIterator
from typing import Any
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.exceptions import ChannelAuthenticationError
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
DeliveryResult,
EventType,
HealthStatus,
MentionsInfo,
MessageType,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from .accounts import list_enabled_accounts
from .audit import AuditLogger
from .bridge_client import BlueBubblesClient
from .commands import is_authorized_for_commands, parse_command
from .config_schema import validate_config
from .contact_resolver import resolve_contact
from .echo_cache import EchoCache
from .formatter import convert_markdown_tables, sanitize_imessage_text
from .media_ai import describe_image, transcribe_audio
from .media_security import validate_attachment_path
from .monitor import IMessageMonitor
from .normalize import parse_forwarded
from .pairing import IMessagePairingManager
from .polls import IMessagePollManager
from .probe import run_doctor_diagnosis
from .rate_limiter import LoopRateLimiter
from .reflection_guard import ReflectionGuard
from .sanitize import media_placeholder
from .security import IMessageSecurityPolicy
from .session import resolve_chat_type, resolve_thread_key
from .sticker_cache import StickerCache
from .tapback import describe_tapback, emoji_to_tapback, resolve_tapback_value
from .threading import format_reply_context
DEBOUNCE_WINDOW_S = 1.0
@register_builtin_adapter
class IMessageAdapter(BaseChannelAdapter):
channel_id = "imessage"
channel_type = ChannelType.IMESSAGE
text_chunk_limit = 4096
supports_markdown = True
supports_streaming = True
streaming_modes = ["off", "partial", "block"]
max_media_size_mb = 100
capabilities = ChannelCapabilities(
chat_types=["direct", "group"],
supports_markdown=True,
supports_streaming=True,
streaming_modes=["off", "partial", "block"],
text_chunk_limit=4096,
max_media_size_mb=100,
reply=True,
reactions=True,
edit=True,
unsend=True,
media=True,
polls=True,
threads=False,
pin=False,
unpin=False,
list_pins=False,
group_management=False,
native_commands=False,
effects=False,
)
meta = ChannelMeta(
id="imessage",
label="iMessage",
aliases=["imsg"],
selection_label="iMessage (via BlueBubbles)",
blurb="Connect to iMessage via BlueBubbles bridge server",
markdown_capable=True,
)
def __init__(self, config: dict[str, Any] | None = None):
self.config = config or {}
self.config.setdefault("server_url", os.getenv("IMESSAGE_SERVER_URL", "http://localhost:1234"))
self.config.setdefault("password", os.getenv("IMESSAGE_PASSWORD", ""))
self._account_id = self.config.get("accountId", self.config.get("account_id", "default"))
try:
validate_config(self.config)
except Exception as e:
raise ValueError(f"iMessage configuration validation failed: {e}") from e
super().__init__(self.config)
self._block_streaming = self.config.get("blockStreaming", self.config.get("block_streaming", False))
if self._block_streaming:
self.supports_streaming = False
self.streaming_modes = ["off"]
self.capabilities = self.capabilities.model_copy(
update={
"supports_streaming": False,
"streaming_modes": ["off"],
"block_streaming": True,
}
)
self._status = ChannelStatus.DISCONNECTED
self._accounts_info = list_enabled_accounts(self.config)
self._clients: dict[str, BlueBubblesClient] = {}
self._monitors: dict[str, IMessageMonitor] = {}
account_configs = self.config.get("accounts", {})
if account_configs:
for acct in self._accounts_info:
acct_config = self._build_account_config(acct)
self._clients[acct.account_id] = BlueBubblesClient(acct_config)
self._monitors[acct.account_id] = IMessageMonitor(acct_config, self._clients[acct.account_id])
primary_id = self._accounts_info[0].account_id if self._accounts_info else "default"
else:
self._clients["default"] = BlueBubblesClient(self.config)
self._monitors["default"] = IMessageMonitor(self.config, self._clients["default"])
primary_id = "default"
self._primary_bridge = self._clients[primary_id]
self._chat_to_account: dict[str, str] = {}
self._self_handle: str | None = None
self._self_handles: dict[str, str] = {}
self._pending_messages: dict[str, str] = {}
self._stream_lock = asyncio.Lock()
self._include_attachments = self.config.get("includeAttachments", self.config.get("include_attachments", False))
self._attachment_roots = self.config.get("attachmentRoots", self.config.get("attachment_roots", []))
self._security = IMessageSecurityPolicy(
self.config,
config_writes=self.config.get("configWrites", self.config.get("config_writes", True)),
)
self._pairing = IMessagePairingManager(
config_writes=self.config.get("configWrites", self.config.get("config_writes", True))
)
self._audit_logger = AuditLogger(log_dir=self.config.get("auditDir", self.config.get("audit_dir", None)))
self._poll_manager = IMessagePollManager(adapter=self)
self._echo_cache = EchoCache()
self._sticker_cache = StickerCache()
self._last_inbound: dict[str, tuple[float, str]] = {}
self._reflection_guard = ReflectionGuard()
self._loop_limiter = LoopRateLimiter(
max_loops=self.config.get("loopRateLimit", self.config.get("loop_rate_limit", 5)),
window_s=self.config.get("loopRateWindowS", self.config.get("loop_rate_window_s", 30.0)),
cooldown_s=self.config.get("loopCooldownS", self.config.get("loop_cooldown_s", 60.0)),
)
self._merge_window_s = self.config.get("mergeWindowMs", 1000) / 1000.0
self._merge_buffers: dict[str, list[ChannelMessage]] = {}
self._merge_tasks: dict[str, asyncio.Task] = {}
media_ai_cfg = self.config.get("mediaAi", self.config.get("media_ai", {}))
self._media_ai_enabled = media_ai_cfg.get("enabled", False)
self._media_ai_config = {
"vision_api_url": media_ai_cfg.get("visionApiUrl", media_ai_cfg.get("vision_api_url", "")),
"vision_api_key": media_ai_cfg.get("visionApiKey", media_ai_cfg.get("vision_api_key", "")),
"transcription_api_url": media_ai_cfg.get(
"transcriptionApiUrl", media_ai_cfg.get("transcription_api_url", "")
),
"transcription_api_key": media_ai_cfg.get(
"transcriptionApiKey", media_ai_cfg.get("transcription_api_key", "")
),
}
def _build_account_config(self, acct) -> dict[str, Any]:
return {
"server_url": acct.server_url,
"password": acct.password,
"http_timeout_s": self.config.get("http_timeout_s", self.config.get("httpTimeoutS", 30.0)),
"max_retries": self.config.get("max_retries", 3),
"probeTimeoutMs": self.config.get("probeTimeoutMs", self.config.get("probe_timeout_ms", 10000)),
"ws_reconnect_initial_delay": self.config.get("ws_reconnect_initial_delay", 5.0),
"ws_reconnect_max_delay": self.config.get("ws_reconnect_max_delay", 60.0),
}
def _get_client(self, chat_guid: str = "") -> BlueBubblesClient:
if len(self._clients) <= 1:
return self._primary_bridge
if chat_guid and chat_guid in self._chat_to_account:
account_id = self._chat_to_account[chat_guid]
return self._clients.get(account_id, self._primary_bridge)
return self._primary_bridge
def _get_monitor(self, account_id: str = "") -> IMessageMonitor:
if account_id and account_id in self._monitors:
return self._monitors[account_id]
if len(self._monitors) == 1:
return next(iter(self._monitors.values()))
return next(iter(self._monitors.values()))
async def connect(self) -> None:
if self._status == ChannelStatus.CONNECTED:
return
self._status = ChannelStatus.CONNECTING
logger.info(f"[iMessage] Starting channel '{self.config.get('name', self.channel_id)}'")
try:
server_info = await self._primary_bridge.probe_server()
logger.info(f"[iMessage] BlueBubbles server v{server_info.get('version')}")
except Exception as e:
raise ChannelAuthenticationError() from e
handle_info = await self._primary_bridge.get_handle()
if not handle_info.get("connected"):
raise ChannelAuthenticationError()
self._self_handle = handle_info.get("handle", "")
logger.info(f"[iMessage] Connected as {self._self_handle}")
await self._pairing.load_allowlist()
await self._pairing.load_pending()
await self._security.load_allowlist()
logger.info(f"[iMessage/Pairing] Loaded {len(self._pairing.get_paired_handles())} paired handles")
logger.info(
f"[iMessage/Security] Loaded {len(self._security.allow_list)} allowlist entries, "
f"{len(self._security.group_allow_list)} group allowlist entries"
)
for account_id, monitor in self._monitors.items():
wrapped_handler = self._build_account_handler(account_id)
monitor.on_message(wrapped_handler)
await monitor.start()
logger.info(f"[iMessage] Monitor started for account '{account_id}'")
self._status = ChannelStatus.CONNECTED
logger.info("[iMessage] Channel started")
async def pre_connect(self) -> dict:
try:
server_info = await self._primary_bridge.probe_server()
handle_info = await self._primary_bridge.get_handle()
return {
"status": "ok",
"imessage_connected": handle_info.get("connected", False),
"handle": handle_info.get("handle", ""),
"server_version": server_info.get("version", "unknown"),
"bridge_os_version": server_info.get("os_version", "unknown"),
"probe_server": server_info,
"probe_handle": handle_info,
"configured": bool(self.config.get("server_url") and self.config.get("password")),
"enabled": True,
}
except Exception as e:
return {"status": "error", "message": str(e), "configured": False, "enabled": False}
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info(f"[iMessage] Stopping channel '{self.config.get('name', self.channel_id)}'")
for monitor in self._monitors.values():
await monitor.stop()
for client in self._clients.values():
await client.close()
await self._audit_logger.flush()
self._status = ChannelStatus.DISCONNECTED
self._pending_messages.clear()
async def send(self, response: ChannelResponse) -> DeliveryResult:
chat_guid = response.identity.channel_chat_id
reply_to = response.reply_to_message_id
mentions = response.metadata.get("mentions")
disable_notification = response.metadata.get("disable_notification", False)
content = sanitize_imessage_text(convert_markdown_tables(response.content))
content_len = len(content)
if content_len <= self.text_chunk_limit:
result = await self._get_client().send_text_message(
chat_guid=chat_guid,
content=content,
reply_to=reply_to,
is_html=self.supports_markdown,
mentions=mentions,
disable_notification=disable_notification,
)
else:
result = await self._send_chunked(
chat_guid=chat_guid,
content=content,
reply_to=reply_to,
)
if result.success and result.message_id:
self._pending_messages[result.message_id] = content
self._echo_cache.record_sent(result.message_id, content, chat_guid)
return result
async def _send_chunked(
self,
chat_guid: str,
content: str,
reply_to: str | None = None,
max_retries: int = 2,
) -> DeliveryResult:
chunks = _chunk_text(content, self.text_chunk_limit)
results: list[DeliveryResult] = []
for i, chunk in enumerate(chunks):
chunk_reply_to = reply_to if i == 0 else None
chunk_result = None
for attempt in range(max_retries + 1):
chunk_result = await self._get_client().send_text_message(
chat_guid=chat_guid,
content=chunk,
reply_to=chunk_reply_to,
is_html=self.supports_markdown,
)
if chunk_result.success:
break
if attempt < max_retries:
logger.warning(
f"[iMessage] Chunk {i + 1}/{len(chunks)} send failed "
f"(attempt {attempt + 1}/{max_retries + 1}): {chunk_result.error}"
)
await asyncio.sleep(1 * (attempt + 1))
results.append(chunk_result)
if not chunk_result.success:
if i == 0:
return chunk_result
logger.error(
f"[iMessage] Chunk {i + 1}/{len(chunks)} send failed after "
f"{max_retries + 1} attempts: {chunk_result.error}"
)
all_success = all(r.success for r in results)
if all_success:
return results[-1]
failed_indices = [i + 1 for i, r in enumerate(results) if not r.success]
return DeliveryResult(
success=False,
error=f"Chunks {failed_indices} failed to send",
metadata={"failed_chunks": failed_indices, "total_chunks": len(chunks)},
)
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
suffix_map = {"image": ".jpg", "video": ".mp4", "audio": ".caf", "file": ""}
suffix = suffix_map.get(media_type, "")
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
f.write(data if isinstance(data, bytes) else data.encode() if isinstance(data, str) else b"")
tmp_path = f.name
try:
result = await self._get_client().send_attachment(
chat_guid=chat_id,
file_path=tmp_path,
)
return result
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
result = await self._get_client().edit_message(msg_id, content)
if result.success:
self._pending_messages[msg_id] = content
return result
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
return await self._get_client().delete_message(chat_id, msg_id)
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
tapback_name = emoji_to_tapback(emoji) or emoji
tapback = resolve_tapback_value(tapback_name)
if tapback is None:
return DeliveryResult(success=False, error=f"Unsupported tapback emoji: {emoji}")
return await self._get_client().send_reaction(
chat_guid=chat_id,
message_guid=msg_id,
tapback=tapback,
)
async def remove_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
tapback_name = emoji_to_tapback(emoji) or emoji
tapback = resolve_tapback_value(tapback_name)
if tapback is None:
return DeliveryResult(success=False, error=f"Unsupported tapback emoji: {emoji}")
return await self._get_client().send_reaction(
chat_guid=chat_id,
message_guid=msg_id,
tapback=-tapback if tapback else 0,
)
async def send_sticker(self, chat_guid: str, sticker_id: str) -> DeliveryResult:
# 注意: BlueBubbles 当前不提供原生 sticker API。
# Sticker 以 URL 文本消息形式发送,而非使用 iMessage 原生贴纸协议。
# 待 BlueBubbles 支持原生 sticker API 后可升级此实现。
sticker_entry = self._sticker_cache.get(sticker_id)
if not sticker_entry:
return DeliveryResult(success=False, error=f"Sticker not found: {sticker_id}")
sticker_url = sticker_entry.get("url", "")
if not sticker_url:
return DeliveryResult(success=False, error=f"Sticker URL missing: {sticker_id}")
return await self._get_client().send_text_message(
chat_guid=chat_guid,
content=sticker_url,
)
async def send_typing_indicator(self, chat_id: str, display: bool = True) -> DeliveryResult:
return await self._get_client().send_typing_indicator(chat_id, display)
async def send_read_receipt(self, chat_id: str) -> DeliveryResult:
return await self._get_client().send_read_receipt(chat_id)
async def unsend_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
return await self._get_client().unsend_message(msg_id)
async def list_chats(self) -> list[dict[str, Any]]:
return await self._get_client().get_chats()
async def get_chat_history(self, chat_guid: str, limit: int = 50) -> list[dict[str, Any]]:
return await self._get_client().get_chat_messages(chat_guid, limit)
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
async with self._stream_lock:
if finished:
self._pending_messages.pop(msg_id, None)
return await self._get_client().edit_message(msg_id, chunk)
current = self._pending_messages.get(msg_id, "")
accumulated = current + chunk
result = await self._get_client().edit_message(msg_id, accumulated)
if result.success:
self._pending_messages[msg_id] = accumulated
return result
async def receive(self) -> AsyncIterator[ChannelMessage]:
return
yield # type: ignore[misc]
async def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage:
data = raw.get("data", raw)
event_type = raw.get("type", "new-message")
chat_guid = data.get("chatGuid", "unknown")
chat_type = resolve_chat_type(chat_guid)
if event_type == "typing-indicator":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=data.get("senderGuid", "unknown"),
channel_chat_id=chat_guid,
),
event_type=EventType.TYPING,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "typing", "display": data.get("display", False)},
)
if event_type == "chat-read-receipt":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=data.get("senderGuid", "unknown"),
channel_chat_id=chat_guid,
),
event_type=EventType.READ_RECEIPT,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "read_receipt"},
)
if event_type == "chat-name-change":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="system",
channel_chat_id=chat_guid,
),
event_type=EventType.MESSAGE_UPDATED,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "chat_name_change", "new_name": data.get("newName", "")},
)
if event_type == "message-deleted":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="system",
channel_chat_id=chat_guid,
),
event_type=EventType.MESSAGE_DELETED,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "message_deleted", "deleted_guid": data.get("guid", "")},
)
if event_type == "participant-added":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=data.get("participant", "unknown"),
channel_chat_id=chat_guid,
),
event_type=EventType.MEMBER_JOINED,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "member_joined", "participant": data.get("participant", "")},
)
if event_type == "participant-removed":
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=data.get("participant", "unknown"),
channel_chat_id=chat_guid,
),
event_type=EventType.MEMBER_LEFT,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={"event": "member_left", "participant": data.get("participant", "")},
)
msg_guid = data.get("guid")
sender = data.get("sender", data.get("handle", {}).get("id", "unknown"))
is_from_me = data.get("isFromMe", False)
if is_from_me and not data.get("isTapback"):
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
if self._echo_cache.is_self_chat_echo(sender, self._self_handle or "", chat_guid):
logger.debug(f"[iMessage] Self-chat echo filtered: {sender} in {chat_guid}")
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
if data.get("isTapback"):
tapback_value = data.get("tapback", -1)
content = describe_tapback(tapback_value)
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=self._clean_handle(sender),
channel_chat_id=chat_guid,
channel_message_id=msg_guid,
),
event_type=EventType.MESSAGE_RECEIVED,
message_type=MessageType.TEXT,
chat_type=chat_type,
content=content,
metadata={
"event": "tapback",
"tapback_value": tapback_value,
"sender_handle": sender,
},
)
content, msg_type, attachments = self._extract_content(data, include_attachments=self._include_attachments)
mentions = self._parse_mentions(data)
if self._media_ai_enabled and attachments:
ai_descriptions = await self._run_media_ai(attachments)
if ai_descriptions:
content = content + "\n" + "\n".join(ai_descriptions)
reply_context = self._parse_reply_context(data)
if reply_context:
reply_tag = format_reply_context(
reply_to_message_id=reply_context.get("reply_to_message_id"),
reply_to_text=reply_context.get("reply_to_text"),
)
if reply_tag:
content = reply_tag + content
if content and content.startswith("/"):
cmd = parse_command(content)
if cmd:
authorized = is_authorized_for_commands(sender, self._security.allow_list)
if authorized:
result = await self._handle_control_command(cmd, chat_guid, sender)
if result:
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
else:
logger.info(f"[iMessage] Unauthorized command attempt from {sender}: {content[:50]}")
if self._reflection_guard.is_reflection(content):
logger.debug(f"[iMessage] Reflection detected for {sender} in {chat_guid}")
self._reflection_guard.mark_blocked()
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
loop_key = f"{chat_guid}:{sender}"
if self._loop_limiter.check(loop_key, content):
logger.warning(f"[iMessage] Loop rate limit triggered for {sender} in {chat_guid}")
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
debounce_key = f"{chat_guid}:{sender}:{content[:200]}"
if self._check_debounce(debounce_key):
logger.debug(f"[iMessage] Debounce triggered for {sender} in {chat_guid}")
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
if msg_type == MessageType.TEXT and self._echo_cache.is_echo(
message_id=msg_guid, text=content, chat_guid=chat_guid
):
logger.debug(f"[iMessage] Echo detected for msg_id={msg_guid} in {chat_guid}")
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
if chat_type == ChatType.DIRECT:
access = self._security.check_dm_access(sender)
if not access.allowed:
self._audit_logger.record_dm_access(
sender=sender,
allowed=False,
reason=access.reject_reason or "unknown",
)
logger.info(f"[iMessage] DM blocked: sender={sender}, reason={access.reject_reason}")
if access.reply:
asyncio.ensure_future(
self._get_client().send_text_message(
chat_guid=chat_guid,
content=access.reply,
)
)
if self._security.dm_policy == "pairing" and access.reject_reason == "not_in_dm_allowlist":
pairing_code = self._pairing.request_pairing(
sender_handle=sender,
channel_user_id=self._clean_handle(sender),
chat_guid=chat_guid,
)
if pairing_code:
reply_msg = (
f"Welcome! To chat with this bot, please provide this pairing code "
f"to the bot administrator: {pairing_code}"
)
asyncio.ensure_future(
self._get_client().send_text_message(
chat_guid=chat_guid,
content=reply_msg,
)
)
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
self._audit_logger.record_dm_access(sender=sender, allowed=True)
if not self._pairing.is_approved(self._clean_handle(sender)):
if self._security.dm_policy == "pairing":
pairing_code = self._pairing.request_pairing(
sender_handle=sender,
channel_user_id=self._clean_handle(sender),
chat_guid=chat_guid,
)
if pairing_code:
reply_msg = (
f"Welcome! To chat with this bot, please provide this pairing code "
f"to the bot administrator: {pairing_code}"
)
asyncio.ensure_future(
self._get_client().send_text_message(
chat_guid=chat_guid,
content=reply_msg,
)
)
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
elif chat_type == ChatType.GROUP:
access = self._security.check_group_access(chat_guid)
if not access.allowed:
self._audit_logger.record_group_access(
chat_guid=chat_guid,
allowed=False,
reason=access.reject_reason or "unknown",
)
logger.info(f"[iMessage] Group blocked: chat={chat_guid}, reason={access.reject_reason}")
if access.reply:
asyncio.ensure_future(
self._get_client().send_text_message(
chat_guid=chat_guid,
content=access.reply,
)
)
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
self._audit_logger.record_group_access(chat_guid=chat_guid, allowed=True)
mention_check = self._security.check_mention_required(chat_guid, mentions, self._self_handle or "")
if not mention_check.allowed:
logger.debug(f"[iMessage] Mention required but bot not mentioned in {chat_guid}")
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
if content and content.strip().isdigit():
await self._handle_poll_vote(chat_guid, sender, content.strip())
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=self._clean_handle(sender),
channel_chat_id=chat_guid,
channel_message_id=msg_guid,
),
event_type=self._map_event_type(event_type),
message_type=msg_type,
chat_type=chat_type,
content=content,
attachments=attachments,
mentions=mentions,
timestamp=self._parse_timestamp(data.get("dateCreated")),
metadata={
"sender_handle": sender,
"item_type": data.get("itemType"),
"subject": data.get("subject", ""),
"country": data.get("country", ""),
"is_from_me": is_from_me,
"chat_type": chat_type.value if isinstance(chat_type, ChatType) else chat_type,
**({"reply_context": reply_context} if reply_context else {}),
**({"forwarded": parse_forwarded(data)} if parse_forwarded(data) else {}),
},
)
def _extract_content(
self, data: dict, include_attachments: bool = False
) -> tuple[str, MessageType, list[Attachment]]:
text = data.get("text", "")
attachments_raw = data.get("attachments", []) if include_attachments else []
attachments = []
for att in attachments_raw:
att_path = att.get("url") or att.get("path") or ""
if self._attachment_roots and att_path and not validate_attachment_path(att_path, self._attachment_roots):
logger.debug(f"[iMessage] Attachment rejected: {att_path} not in allowed roots")
continue
att_type = self._map_attachment_type(att.get("mimeType", ""))
attachments.append(
Attachment(
type=att_type.value if isinstance(att_type, MessageType) else att_type,
url=att.get("url"),
filename=att.get("transferName"),
mime_type=att.get("mimeType"),
size_bytes=att.get("totalBytes", 0),
)
)
if attachments:
msg_type = attachments[0].type if attachments else MessageType.FILE
msg_type_enum = MessageType(msg_type) if isinstance(msg_type, str) else msg_type
media_type = _msg_type_to_media_str(msg_type_enum)
content = text or media_placeholder(media_type)
return content, msg_type_enum, attachments
return text, MessageType.TEXT, attachments
@staticmethod
def _map_attachment_type(mime_type: str) -> MessageType:
if mime_type.startswith("image/"):
return MessageType.IMAGE
if mime_type.startswith("video/"):
return MessageType.VIDEO
if mime_type.startswith("audio/"):
return MessageType.AUDIO
return MessageType.FILE
@staticmethod
def _parse_mentions(data: dict) -> MentionsInfo | None:
raw_mentions = data.get("mentions")
if not raw_mentions:
return None
user_ids: list[str] = []
for m in raw_mentions:
if isinstance(m, dict):
uid = m.get("mention") or m.get("id") or ""
if uid:
user_ids.append(uid)
elif isinstance(m, str):
user_ids.append(m)
return MentionsInfo(mentioned_user_ids=user_ids) if user_ids else None
@staticmethod
def _parse_reply_context(data: dict) -> dict[str, Any] | None:
reply_to_guid = data.get("replyToGuid")
thread_originator_guid = data.get("threadOriginatorGuid")
thread_originator_text = data.get("threadOriginatorText")
if not reply_to_guid and not thread_originator_guid:
return None
context: dict[str, Any] = {}
if reply_to_guid:
context["reply_to_message_id"] = reply_to_guid
if thread_originator_guid:
context["thread_originator_id"] = thread_originator_guid
if thread_originator_text:
context["reply_to_text"] = thread_originator_text[:500]
return context if context else None
@staticmethod
def _clean_handle(handle: str) -> str:
return handle.strip().replace(" ", "").lstrip("+")
@staticmethod
def _map_event_type(raw_type: str) -> EventType:
mapping = {
"new-message": EventType.MESSAGE_RECEIVED,
"message-updated": EventType.MESSAGE_UPDATED,
"message-deleted": EventType.MESSAGE_DELETED,
"typing-indicator": EventType.TYPING,
"read-receipt": EventType.READ_RECEIPT,
}
return mapping.get(raw_type, EventType.MESSAGE_RECEIVED)
@staticmethod
def _parse_timestamp(timestamp_ms: int | None) -> dt.datetime:
if timestamp_ms:
return dt.datetime.fromtimestamp(timestamp_ms / 1000, tz=dt.UTC)
return utc_now_naive()
def _make_passthrough_event(
self, chat_guid: str, msg_guid: str | None, sender: str, event_type: str
) -> ChannelMessage:
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=self._clean_handle(sender),
channel_chat_id=chat_guid,
channel_message_id=msg_guid,
),
event_type=self._map_event_type(event_type),
message_type=MessageType.TEXT,
chat_type=resolve_chat_type(chat_guid),
content="(self-sent message)",
metadata={"sender_handle": sender, "is_from_me": True},
)
def format_outbound(self, response: ChannelResponse) -> dict[str, Any]:
result: dict[str, Any] = {
"chatGuid": response.identity.channel_chat_id,
"content": response.content,
}
if response.reply_to_message_id:
result["replyTo"] = response.reply_to_message_id
return result
async def health_check(self) -> HealthStatus:
try:
server_info = await self._get_client().probe_server()
handle_info = await self._get_client().get_handle()
return HealthStatus(
status="healthy" if handle_info.get("connected") else "degraded",
metadata={
"handle": handle_info.get("handle"),
"connected": handle_info.get("connected"),
"bridge_version": server_info.get("version"),
"adapter_status": self._status.value,
},
last_connected_at=utc_now_naive() if self._status == ChannelStatus.CONNECTED else None,
)
except Exception as e:
return HealthStatus(status="unhealthy", last_error=str(e))
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
return await resolve_contact(self._get_client(), channel_user_id)
async def download_media(self, file_id: str) -> bytes:
return await self._get_client().download_attachment(file_id)
def thread_key_for(self, identity: ChannelIdentity) -> str:
return resolve_thread_key(identity)
def _check_debounce(self, key: str) -> bool:
now = time.monotonic()
last = self._last_inbound.get(key)
if last is not None and now - last[0] < DEBOUNCE_WINDOW_S:
return True
self._last_inbound[key] = (now, key)
self._cleanup_debounce()
return False
def _cleanup_debounce(self) -> None:
now = time.monotonic()
expired = [k for k, (ts, _) in self._last_inbound.items() if now - ts > DEBOUNCE_WINDOW_S * 3]
for k in expired:
del self._last_inbound[k]
async def _handle_control_command(self, cmd: dict[str, Any], chat_guid: str, sender: str) -> bool:
action = cmd.get("action", "")
args = cmd.get("args", [])
if action == "status":
snapshot = self.get_channel_snapshot()
await self._get_client().send_text_message(
chat_guid=chat_guid,
content=f"Status: {snapshot['status']}\nHandle: {snapshot.get('self_handle', 'N/A')}",
)
return True
if action == "health":
async with asyncio.timeout(10):
health = await self.health_check()
await self._get_client().send_text_message(
chat_guid=chat_guid,
content=f"Health: {health.status}\n{health.metadata}",
)
return True
if action == "pairing":
pending = self.list_pending_pairings()
if not pending:
await self._get_client().send_text_message(chat_guid=chat_guid, content="No pending pairing requests.")
else:
lines = ["Pending pairing requests:"]
for p in pending:
lines.append(f" Code: {p['code']}{p['sender_handle']}")
await self._get_client().send_text_message(
chat_guid=chat_guid,
content="\n".join(lines),
)
return True
if action == "approve" and args:
ok = self.approve_pairing(args[0])
await self._get_client().send_text_message(
chat_guid=chat_guid,
content=f"Pairing {'approved' if ok else 'failed — invalid or expired code'}.",
)
return True
if action == "reject" and args:
ok = self.reject_pairing(args[0])
await self._get_client().send_text_message(
chat_guid=chat_guid,
content=f"Pairing {'rejected' if ok else 'failed — invalid or expired code'}.",
)
return True
if action == "poll":
sub_action = args[0].lower() if args else ""
if sub_action == "create":
remaining = " ".join(args[1:]) if len(args) > 1 else ""
parts = _parse_quoted_args(remaining)
if len(parts) >= 3:
question = parts[0]
option_labels = parts[1:]
poll = await self._poll_manager.create_poll(
chat_guid=chat_guid,
question=question,
option_labels=option_labels,
expires_in_s=300.0,
)
await self._get_client().send_text_message(
chat_guid=chat_guid,
content=f"Poll created! ID: {poll.poll_id}",
)
else:
await self._get_client().send_text_message(
chat_guid=chat_guid,
content='Usage: /poll create "Question" "Option1" "Option2" ...',
)
elif sub_action == "close":
poll_id = args[1] if len(args) > 1 else ""
if poll_id:
result = await self._poll_manager.close_poll(poll_id)
if result:
await self._get_client().send_text_message(
chat_guid=chat_guid,
content="Poll closed.",
)
else:
await self._get_client().send_text_message(
chat_guid=chat_guid,
content="Usage: /poll close <poll_id>",
)
elif sub_action == "list":
active = [p for p in self._poll_manager._polls.values() if p.chat_guid == chat_guid and not p.closed]
if active:
lines = ["Active polls:"]
for p in active:
lines.append(f" {p.poll_id}: {p.question}")
await self._get_client().send_text_message(
chat_guid=chat_guid,
content="\n".join(lines),
)
else:
await self._get_client().send_text_message(
chat_guid=chat_guid,
content="No active polls in this chat.",
)
else:
await self._get_client().send_text_message(
chat_guid=chat_guid,
content="/poll create|close|list",
)
return True
if action == "help":
await self._get_client().send_text_message(
chat_guid=chat_guid,
content="Available commands:\n"
"/status — Show adapter status\n"
"/health — Health check\n"
"/pairing — List pending pairing requests\n"
"/approve <code> — Approve a pairing\n"
"/reject <code> — Reject a pairing\n"
'/poll create "Q" "A" "B" — Create a poll\n'
"/poll close <id> — Close a poll\n"
"/poll list — List active polls\n"
"/help — Show this help",
)
return True
return False
def approve_pairing(self, code: str) -> bool:
return self._pairing.approve(code)
def reject_pairing(self, code: str) -> bool:
return self._pairing.reject(code)
def list_pending_pairings(self) -> list[dict[str, Any]]:
return self._pairing.list_pending()
def is_paired(self, channel_user_id: str) -> bool:
return self._pairing.is_paired(channel_user_id)
async def create_poll(
self,
chat_id: str,
question: str,
options: list[str],
duration_minutes: int = 0,
) -> dict[str, Any]:
poll = await self._poll_manager.create_poll(
chat_guid=chat_id,
question=question,
option_labels=options,
expires_in_s=duration_minutes * 60 if duration_minutes > 0 else 300.0,
)
return {
"poll_id": poll.poll_id,
"chat_guid": poll.chat_guid,
"question": poll.question,
"options": [{"index": o.index + 1, "label": o.label} for o in poll.options],
}
async def close_poll(self, chat_id: str, poll_id: str) -> dict[str, Any]:
result = await self._poll_manager.close_poll(poll_id)
if result is None:
return {"error": f"Poll not found: {poll_id}"}
return result
async def vote_poll(self, poll_id: str, user_id: str, option_index: int) -> dict[str, Any]:
result = await self._poll_manager.vote(poll_id, user_id, option_index)
if result is None:
return {"error": f"Poll not found: {poll_id}"}
return result
def get_poll(self, poll_id: str) -> dict[str, Any] | None:
poll = self._poll_manager.get_poll(poll_id)
if poll is None:
return None
return {
"poll_id": poll.poll_id,
"chat_guid": poll.chat_guid,
"question": poll.question,
"closed": poll.closed,
"options": [{"index": o.index + 1, "label": o.label, "vote_count": o.vote_count} for o in poll.options],
}
async def _handle_poll_vote(self, chat_guid: str, sender: str, digit_str: str) -> None:
active_polls = [p for p in self._poll_manager._polls.values() if p.chat_guid == chat_guid and not p.closed]
if not active_polls:
return
option_index = int(digit_str) - 1
poll = active_polls[-1]
result = await self._poll_manager.vote(poll.poll_id, sender, option_index)
if result and result.get("success"):
logger.info(f"[iMessage/Poll] Vote recorded: user={sender}, poll={poll.poll_id}, option={option_index + 1}")
elif result and result.get("poll_closed"):
await self._get_client().send_text_message(
chat_guid=chat_guid,
content="This poll is already closed.",
)
async def _run_media_ai(self, attachments: list[Attachment]) -> list[str]:
descriptions: list[str] = []
cfg = self._media_ai_config
for att in attachments:
try:
async with asyncio.timeout(10):
if att.type in (MessageType.IMAGE.value, "image"):
if att.url and cfg["vision_api_url"]:
image_bytes = await self._get_client().download_attachment(att.url)
desc = await describe_image(
image_bytes,
vision_api_url=cfg["vision_api_url"],
vision_api_key=cfg["vision_api_key"],
)
if desc:
descriptions.append(f"[图片描述: {desc}]")
elif att.type in (MessageType.AUDIO.value, "audio"):
if att.url and cfg["transcription_api_url"]:
audio_bytes = await self._get_client().download_attachment(att.url)
transcript = await transcribe_audio(
audio_bytes,
transcription_api_url=cfg["transcription_api_url"],
transcription_api_key=cfg["transcription_api_key"],
)
if transcript:
descriptions.append(f"[音频转录: {transcript}]")
except TimeoutError:
logger.warning(f"[iMessage/AI] Media AI timed out for {att.url}")
except Exception as e:
logger.warning(f"[iMessage/AI] Media AI failed for {att.url}: {e}")
return descriptions
def get_security_warnings(self) -> list[str]:
return self._security.collect_warnings()
async def run_doctor(self) -> dict[str, Any]:
pairing_info = {
"pending": len(self.list_pending_pairings()),
"paired": len(self._pairing.get_paired_handles()),
}
return await run_doctor_diagnosis(
bridge_client=self._primary_bridge,
security_warnings=self.get_security_warnings(),
adapter_status=self._status.value,
self_handle=self._self_handle,
pairing_info=pairing_info,
)
def _build_account_handler(self, account_id: str):
async def handler(raw_payload: dict[str, Any]) -> None:
data = raw_payload.get("data", raw_payload)
chat_guid = data.get("chatGuid", "")
if chat_guid:
self._chat_to_account[chat_guid] = account_id
await self._handle_ws_event(raw_payload)
return handler
async def _handle_ws_event(self, raw_payload: dict[str, Any]) -> None:
if self._message_handler is None:
return
channel_msg = await self.normalize_inbound(raw_payload)
if channel_msg.content == "(self-sent message)":
return
await self._maybe_merge_and_dispatch(channel_msg)
def get_channel_snapshot(self) -> dict[str, Any]:
"""返回渠道状态快照"""
return {
"channel_id": self.channel_id,
"account_id": self._account_id,
"status": self._status.value,
"configured": bool(self.config.get("server_url") and self.config.get("password")),
"enabled": True,
"self_handle": self._self_handle,
"streaming_modes": self.streaming_modes,
"blocked_reflections": self._reflection_guard.blocked_count,
"active_stream_id": (list(self._pending_messages.keys())[0] if self._pending_messages else None),
}
async def _maybe_merge_and_dispatch(self, msg: ChannelMessage) -> None:
merge_key = f"{msg.identity.channel_chat_id}:{msg.identity.channel_user_id}"
if merge_key not in self._merge_buffers:
self._merge_buffers[merge_key] = []
self._merge_buffers[merge_key].append(msg)
if merge_key in self._merge_tasks and not self._merge_tasks[merge_key].done():
return
self._merge_tasks[merge_key] = asyncio.create_task(self._flush_merged(merge_key, self._merge_window_s))
async def _flush_merged(self, merge_key: str, delay: float) -> None:
await asyncio.sleep(delay)
msgs = self._merge_buffers.pop(merge_key, [])
self._merge_tasks.pop(merge_key, None)
if not msgs:
return
if len(msgs) == 1:
await self._message_handler(msgs[0])
return
merged_text = "\n".join(msg.content for msg in msgs if msg.content)
merged_msg = ChannelMessage(
identity=msgs[0].identity,
event_type=msgs[0].event_type,
message_type=msgs[0].message_type,
chat_type=msgs[0].chat_type,
content=merged_text,
attachments=[att for msg in msgs for att in msg.attachments],
mentions=msgs[-1].mentions,
timestamp=msgs[-1].timestamp,
metadata={"merged_messages": len(msgs), **msgs[0].metadata},
)
await self._message_handler(merged_msg)
def _chunk_text(text: str, limit: int) -> list[str]:
if len(text) <= limit:
return [text]
chunks: list[str] = []
pos = 0
while pos < len(text):
end = min(pos + limit, len(text))
if end < len(text):
split_at = text.rfind("\n", pos, end)
if split_at == -1 or split_at <= pos:
split_at = text.rfind(". ", pos, end)
if split_at == -1 or split_at <= pos:
split_at = text.rfind(" ", pos, end)
if split_at == -1 or split_at <= pos:
split_at = end
else:
split_at += 1
else:
split_at = end
chunks.append(text[pos:split_at].strip())
pos = split_at
return chunks
def _msg_type_to_media_str(msg_type) -> str:
mapping = {
MessageType.IMAGE: "image",
MessageType.VIDEO: "video",
MessageType.AUDIO: "audio",
MessageType.FILE: "file",
}
return mapping.get(msg_type, "file")
def _parse_quoted_args(text: str) -> list[str]:
import shlex
try:
return shlex.split(text)
except ValueError:
return text.split()