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 .bridge_client import BlueBubblesClient from .commands import is_authorized_for_commands, parse_command from .contact_resolver import resolve_contact from .echo_cache import EchoCache from .envelope import format_inbound_envelope from .formatter import convert_markdown_tables, sanitize_imessage_text from .media_security import validate_attachment_path from .normalize import parse_forwarded from .monitor import IMessageMonitor from .pairing import IMessagePairingManager 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=False, 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")) 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._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._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] = {} 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: 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(): monitor.on_message(self._handle_ws_event) 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() 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: chunks = _chunk_text(content, self.text_chunk_limit) last_result = DeliveryResult(success=True) for i, chunk in enumerate(chunks): chunk_reply_to = reply_to if i == 0 else None 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 not chunk_result.success: if i == 0: return chunk_result last_result = chunk_result else: last_result = chunk_result result = last_result 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_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: 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) reply_context = self._parse_reply_context(data) sender_name = await resolve_contact(self._get_client(), sender) envelope = format_inbound_envelope( 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, ), sender_name=sender_name or "", chat_name=chat_guid, reply_context=reply_context, ) 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: 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) 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: 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) 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) 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 == "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 — Approve a pairing\n" "/reject — Reject a pairing\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) 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, ) 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")