From 068cf70fe972dae825b0553e47fe50f83ea94504 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Tue, 12 May 2026 00:44:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(imessage):=20=E5=AE=9E=E7=8E=B0=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=9A=84=20iMessage=20=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 iMessage 通道适配器完整实现,包含: 1. 核心适配器与工具工厂导出 2. 运行时存储、反射防护、会话路由等基础组件 3. 消息信封、线程管理、回复上下文格式化 4. Tapback 表情反应处理、自定义异常体系 5. 审批按钮、联系人解析、速率限制功能 6. 文本净化、目标解析、缓存管理模块 7. 配置 schema、多账户支持、安装向导等配置模块 8. 审计日志、媒体AI处理等扩展功能 --- .../channels/adapters/imessage/__init__.py | 4 + .../channels/adapters/imessage/accounts.py | 102 ++ .../channels/adapters/imessage/adapter.py | 1031 +++++++++++++++++ .../channels/adapters/imessage/agent_tools.py | 151 +++ .../channels/adapters/imessage/allowlist.py | 83 ++ .../channels/adapters/imessage/approval.py | 100 ++ .../adapters/imessage/approval_buttons.py | 56 + .../yuxi/channels/adapters/imessage/audit.py | 90 ++ .../adapters/imessage/bridge_client.py | 327 ++++++ .../channels/adapters/imessage/commands.py | 54 + .../adapters/imessage/config_schema.py | 116 ++ .../adapters/imessage/contact_resolver.py | 46 + .../imessage/conversation_bindings.py | 88 ++ .../adapters/imessage/conversation_route.py | 56 + .../channels/adapters/imessage/directory.py | 65 ++ .../channels/adapters/imessage/echo_cache.py | 146 +++ .../channels/adapters/imessage/envelope.py | 39 + .../channels/adapters/imessage/exceptions.py | 32 + .../channels/adapters/imessage/formatter.py | 124 ++ .../channels/adapters/imessage/media_ai.py | 84 ++ .../adapters/imessage/media_security.py | 84 ++ .../channels/adapters/imessage/monitor.py | 106 ++ .../channels/adapters/imessage/normalize.py | 75 ++ .../channels/adapters/imessage/pairing.py | 203 ++++ .../yuxi/channels/adapters/imessage/polls.py | 144 +++ .../yuxi/channels/adapters/imessage/probe.py | 91 ++ .../adapters/imessage/rate_limiter.py | 53 + .../adapters/imessage/reflection_guard.py | 33 + .../adapters/imessage/runtime_store.py | 25 + .../channels/adapters/imessage/sanitize.py | 67 ++ .../channels/adapters/imessage/security.py | 371 ++++++ .../channels/adapters/imessage/sent_cache.py | 59 + .../channels/adapters/imessage/session.py | 29 + .../adapters/imessage/setup_wizard.py | 133 +++ .../adapters/imessage/sticker_cache.py | 51 + .../channels/adapters/imessage/tapback.py | 58 + .../imessage/target_parsing_helpers.py | 87 ++ .../channels/adapters/imessage/targets.py | 85 ++ .../channels/adapters/imessage/threading.py | 43 + .../channels/adapters/imessage/transport.py | 31 + 40 files changed, 4622 insertions(+) create mode 100644 backend/package/yuxi/channels/adapters/imessage/__init__.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/accounts.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/adapter.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/agent_tools.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/allowlist.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/approval.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/approval_buttons.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/audit.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/bridge_client.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/commands.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/config_schema.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/contact_resolver.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/conversation_bindings.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/conversation_route.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/directory.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/echo_cache.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/envelope.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/exceptions.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/formatter.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/media_ai.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/media_security.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/monitor.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/normalize.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/pairing.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/polls.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/probe.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/rate_limiter.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/reflection_guard.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/runtime_store.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/sanitize.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/security.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/sent_cache.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/session.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/setup_wizard.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/sticker_cache.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/tapback.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/target_parsing_helpers.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/targets.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/threading.py create mode 100644 backend/package/yuxi/channels/adapters/imessage/transport.py diff --git a/backend/package/yuxi/channels/adapters/imessage/__init__.py b/backend/package/yuxi/channels/adapters/imessage/__init__.py new file mode 100644 index 00000000..a438744c --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/__init__.py @@ -0,0 +1,4 @@ +from yuxi.channels.adapters.imessage.adapter import IMessageAdapter +from yuxi.channels.adapters.imessage.agent_tools import IMessageAgentToolFactory + +__all__ = ["IMessageAdapter", "IMessageAgentToolFactory"] diff --git a/backend/package/yuxi/channels/adapters/imessage/accounts.py b/backend/package/yuxi/channels/adapters/imessage/accounts.py new file mode 100644 index 00000000..34f07317 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/accounts.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class IMessageAccount: + account_id: str + name: str = "" + server_url: str = "http://localhost:1234" + password: str = "" + dm_policy: str = "pairing" + group_policy: str = "allowlist" + allow_from: list[str] = field(default_factory=list) + group_allow_from: list[str] = field(default_factory=list) + require_mention: bool = False + default_to: str = "" + enabled: bool = True + + +def resolve_accounts(config: dict[str, Any]) -> list[IMessageAccount]: + """解析多账户配置。 + + 支持两种配置模式: + 1. 顶层单账户:config 直接包含 server_url / password + 2. accounts 块:config["accounts"] 为 {accountId: {...}, ...} + + 返回扁平化的 IMessageAccount 列表。 + """ + accounts_raw = config.get("accounts") + + if not accounts_raw: + account = _account_from_top_level(config) + return [account] if account.server_url else [] + + result: list[IMessageAccount] = [] + default_account_id = config.get("defaultAccount", config.get("default_account", "")) + + for account_id, acct_config in accounts_raw.items(): + if not isinstance(acct_config, dict): + continue + merged = _merge_account_config(config, account_id, acct_config) + result.append(merged) + + if default_account_id: + result.sort(key=lambda a: 0 if a.account_id == default_account_id else 1) + + return result + + +def list_enabled_accounts(config: dict[str, Any]) -> list[IMessageAccount]: + accounts = resolve_accounts(config) + return [a for a in accounts if a.enabled] + + +def resolve_default_account(config: dict[str, Any]) -> IMessageAccount | None: + accounts = list_enabled_accounts(config) + if not accounts: + return None + default_id = config.get("defaultAccount", config.get("default_account", "")) + for acct in accounts: + if acct.account_id == default_id: + return acct + return accounts[0] + + +def _account_from_top_level(config: dict[str, Any]) -> IMessageAccount: + return IMessageAccount( + account_id="default", + name=config.get("name", "iMessage"), + server_url=config.get("server_url", "http://localhost:1234"), + password=config.get("password", ""), + dm_policy=config.get("dmPolicy", config.get("dm_policy", "pairing")), + group_policy=config.get("groupPolicy", config.get("group_policy", "allowlist")), + allow_from=config.get("allowFrom", config.get("allow_from", [])), + group_allow_from=config.get("groupAllowFrom", config.get("group_allow_from", [])), + require_mention=config.get("requireMention", config.get("require_mention", False)), + default_to=config.get("defaultTo", config.get("default_to", "")), + ) + + +def _merge_account_config(top: dict[str, Any], account_id: str, acct: dict[str, Any]) -> IMessageAccount: + return IMessageAccount( + account_id=account_id, + name=acct.get("name", account_id), + server_url=acct.get("server_url", top.get("server_url", "http://localhost:1234")), + password=acct.get("password", top.get("password", "")), + dm_policy=acct.get("dmPolicy", acct.get("dm_policy", top.get("dmPolicy", top.get("dm_policy", "pairing")))), + group_policy=acct.get( + "groupPolicy", acct.get("group_policy", top.get("groupPolicy", top.get("group_policy", "allowlist"))) + ), + allow_from=acct.get("allowFrom", acct.get("allow_from", top.get("allowFrom", top.get("allow_from", [])))), + group_allow_from=acct.get( + "groupAllowFrom", acct.get("group_allow_from", top.get("groupAllowFrom", top.get("group_allow_from", []))) + ), + require_mention=acct.get( + "requireMention", acct.get("require_mention", top.get("requireMention", top.get("require_mention", False))) + ), + default_to=acct.get("defaultTo", acct.get("default_to", top.get("defaultTo", top.get("default_to", "")))), + enabled=acct.get("enabled", True), + ) diff --git a/backend/package/yuxi/channels/adapters/imessage/adapter.py b/backend/package/yuxi/channels/adapters/imessage/adapter.py new file mode 100644 index 00000000..fb87bc4e --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/adapter.py @@ -0,0 +1,1031 @@ +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") diff --git a/backend/package/yuxi/channels/adapters/imessage/agent_tools.py b/backend/package/yuxi/channels/adapters/imessage/agent_tools.py new file mode 100644 index 00000000..625ca47e --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/agent_tools.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from typing import Any + + +def describe_imessage_pairing_tool() -> dict: + return { + "type": "function", + "function": { + "name": "imessage_manage_pairing", + "description": "管理 iMessage 配对请求:列出待审批的用户、批准或拒绝配对", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "approve", "reject"], + "description": "操作类型:list=列出待审批, approve=批准配对, reject=拒绝配对", + }, + "code": { + "type": "string", + "description": "配对码(approve/reject 操作需要)", + }, + }, + "required": ["action"], + }, + }, + } + + +def describe_imessage_allowlist_tool() -> dict: + return { + "type": "function", + "function": { + "name": "imessage_manage_allowlist", + "description": "管理 iMessage 白名单:添加或移除联系人/群组白名单", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add_dm", "remove_dm", "add_group", "remove_group", "list"], + "description": "操作类型", + }, + "handle": { + "type": "string", + "description": "电话号码或 chat_guid", + }, + }, + "required": ["action"], + }, + }, + } + + +def describe_imessage_security_status_tool() -> dict: + return { + "type": "function", + "function": { + "name": "imessage_security_status", + "description": "查看 iMessage 渠道的安全状态和警告信息", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + } + + +class IMessageAgentToolFactory: + def __init__(self, adapter: Any): + self._adapter = adapter + + def list_tools(self) -> list[dict]: + return [ + describe_imessage_pairing_tool(), + describe_imessage_allowlist_tool(), + describe_imessage_security_status_tool(), + ] + + async def execute_tool(self, tool_name: str, params: dict) -> Any: + if tool_name == "imessage_manage_pairing": + return await self._manage_pairing(params) + if tool_name == "imessage_manage_allowlist": + return await self._manage_allowlist(params) + if tool_name == "imessage_security_status": + return self._security_status() + raise ValueError(f"Unknown tool: {tool_name}") + + async def _manage_pairing(self, params: dict) -> dict: + action = params.get("action", "list") + + if action == "list": + pending = self._adapter.list_pending_pairings() + paired = self._adapter._pairing.get_paired_handles() + return {"pending": pending, "paired": paired} + + code = params.get("code", "") + if action == "approve": + ok = self._adapter.approve_pairing(code) + if ok: + await self._adapter._pairing.save_allowlist() + return {"success": ok, "message": "Pairing approved" if ok else "Invalid or expired code"} + + if action == "reject": + ok = self._adapter.reject_pairing(code) + return {"success": ok, "message": "Pairing rejected" if ok else "Invalid or expired code"} + + return {"success": False, "error": f"Unknown action: {action}"} + + async def _manage_allowlist(self, params: dict) -> dict: + action = params.get("action", "list") + + if action == "list": + return { + "dm_allowlist": self._adapter._security.allow_list, + "group_allowlist": self._adapter._security.group_allow_list, + } + + handle = params.get("handle", "") + if not handle: + return {"success": False, "error": "handle is required"} + + if action == "add_dm": + self._adapter._security.add_to_allow_list(handle) + return {"success": True, "message": f"Added {handle} to DM allowlist"} + + if action == "remove_dm": + ok = self._adapter._security.remove_from_allow_list(handle) + return {"success": ok, "message": f"{'Removed' if ok else 'Not found'}: {handle}"} + + if action == "add_group": + self._adapter._security.add_to_group_allow_list(handle) + return {"success": True, "message": f"Added {handle} to group allowlist"} + + if action == "remove_group": + ok = self._adapter._security.remove_from_group_allow_list(handle) + return {"success": ok, "message": f"{'Removed' if ok else 'Not found'}: {handle}"} + + return {"success": False, "error": f"Unknown action: {action}"} + + def _security_status(self) -> dict: + warnings = self._adapter.get_security_warnings() + return { + "dm_policy": self._adapter._security.dm_policy.value, + "group_policy": self._adapter._security.group_policy.value, + "require_mention": self._adapter._security.require_mention, + "warnings": warnings, + "paired_count": len(self._adapter._pairing.get_paired_handles()), + } diff --git a/backend/package/yuxi/channels/adapters/imessage/allowlist.py b/backend/package/yuxi/channels/adapters/imessage/allowlist.py new file mode 100644 index 00000000..c4a37fa2 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/allowlist.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any + + +class IMessageAllowlist: + def __init__(self, config: dict[str, Any]): + self._dm_allowlist: list[str] = _normalize_entries(config.get("allowFrom", config.get("allow_from", []))) + self._group_allowlist: list[str] = _normalize_chat_entries( + config.get("groupAllowFrom", config.get("group_allow_from", [])) + ) + + def check_dm(self, sender_handle: str) -> bool: + return _match_handle(sender_handle, self._dm_allowlist) + + def check_group(self, chat_guid: str) -> bool: + return _match_chat(chat_guid, self._group_allowlist) + + def add_dm(self, handle: str) -> None: + handle_clean = _normalize_handle(handle) + if handle_clean not in self._dm_allowlist: + self._dm_allowlist.append(handle_clean) + + def remove_dm(self, handle: str) -> bool: + handle_clean = _normalize_handle(handle) + if handle_clean in self._dm_allowlist: + self._dm_allowlist.remove(handle_clean) + return True + return False + + def add_group(self, chat_guid: str) -> None: + if chat_guid not in self._group_allowlist: + self._group_allowlist.append(chat_guid) + + def remove_group(self, chat_guid: str) -> bool: + if chat_guid in self._group_allowlist: + self._group_allowlist.remove(chat_guid) + return True + return False + + @property + def dm_entries(self) -> list[str]: + return list(self._dm_allowlist) + + @property + def group_entries(self) -> list[str]: + return list(self._group_allowlist) + + @property + def dm_count(self) -> int: + return len(self._dm_allowlist) + + @property + def group_count(self) -> int: + return len(self._group_allowlist) + + +def _normalize_handle(handle: str) -> str: + return handle.strip().replace(" ", "").lstrip("+") + + +def _normalize_entries(entries: list[str]) -> list[str]: + return [_normalize_handle(e) for e in entries if e] + + +def _normalize_chat_entries(entries: list[str]) -> list[str]: + return [e.strip() for e in entries if e] + + +def _match_handle(target: str, allowlist: list[str]) -> bool: + if not allowlist or "*" in allowlist: + return True + target_clean = _normalize_handle(target) + for entry in allowlist: + if _normalize_handle(entry) == target_clean: + return True + return False + + +def _match_chat(target: str, allowlist: list[str]) -> bool: + if not allowlist or "*" in allowlist: + return True + return target in allowlist diff --git a/backend/package/yuxi/channels/adapters/imessage/approval.py b/backend/package/yuxi/channels/adapters/imessage/approval.py new file mode 100644 index 00000000..aa19b196 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/approval.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + +from yuxi.utils.logging_config import logger + + +class ApprovalStatus(StrEnum): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + EXPIRED = "expired" + + +@dataclass +class ApprovalRequest: + request_id: str + channel_chat_id: str + channel_user_id: str + action: str + action_payload: dict[str, Any] = field(default_factory=dict) + status: ApprovalStatus = ApprovalStatus.PENDING + created_at: float = field(default_factory=time.time) + expires_at: float = field(default_factory=lambda: time.time() + 300) + approved_by: str = "" + result: Any = None + + +class ExecApprovalManager: + def __init__(self, approval_ttl_s: float = 300.0, max_pending: int = 10): + self._pending: dict[str, ApprovalRequest] = {} + self._history: list[ApprovalRequest] = [] + self._approval_ttl = approval_ttl_s + self._max_pending = max_pending + + def request_approval( + self, + request_id: str, + channel_chat_id: str, + channel_user_id: str, + action: str, + action_payload: dict[str, Any] | None = None, + ) -> ApprovalRequest: + self._cleanup() + + if len(self._pending) >= self._max_pending: + oldest = min(self._pending.values(), key=lambda r: r.created_at) + self._pending.pop(oldest.request_id, None) + + req = ApprovalRequest( + request_id=request_id, + channel_chat_id=channel_chat_id, + channel_user_id=channel_user_id, + action=action, + action_payload=action_payload or {}, + ) + self._pending[request_id] = req + logger.info(f"[iMessage/Approval] New request: {request_id} action={action}") + return req + + def approve(self, request_id: str, approved_by: str = "") -> ApprovalRequest | None: + self._cleanup() + req = self._pending.get(request_id) + if req is None: + return None + req.status = ApprovalStatus.APPROVED + req.approved_by = approved_by + self._pending.pop(request_id, None) + self._history.append(req) + logger.info(f"[iMessage/Approval] Approved: {request_id}") + return req + + def reject(self, request_id: str, reason: str = "") -> ApprovalRequest | None: + self._cleanup() + req = self._pending.get(request_id) + if req is None: + return None + req.status = ApprovalStatus.REJECTED + self._pending.pop(request_id, None) + self._history.append(req) + logger.info(f"[iMessage/Approval] Rejected: {request_id} ({reason})") + return req + + def get(self, request_id: str) -> ApprovalRequest | None: + return self._pending.get(request_id) + + def list_pending(self) -> list[ApprovalRequest]: + self._cleanup() + return list(self._pending.values()) + + def _cleanup(self) -> None: + now = time.time() + expired = [rid for rid, req in self._pending.items() if req.expires_at < now] + for rid in expired: + req = self._pending.pop(rid) + req.status = ApprovalStatus.EXPIRED + self._history.append(req) diff --git a/backend/package/yuxi/channels/adapters/imessage/approval_buttons.py b/backend/package/yuxi/channels/adapters/imessage/approval_buttons.py new file mode 100644 index 00000000..8e323f31 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/approval_buttons.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import Any + + +def format_approval_message(request_id: str, action: str, payload: dict[str, Any] | None = None) -> str: + """生成审批请求消息文本。 + + iMessage 不支持内联按钮,使用文本命令模拟审批。 + """ + lines = [ + "Pending Approval Required", + f"Action: {action}", + f"Request ID: {request_id}", + "", + ] + + if payload: + for key, val in payload.items(): + val_str = str(val)[:100] + lines.append(f" {key}: {val_str}") + + lines.extend( + [ + "", + "Reply with:", + f" approve {request_id} — to approve", + f" reject {request_id} — to reject", + ] + ) + + return "\n".join(lines) + + +def format_approval_result(request_id: str, approved: bool, message: str = "") -> str: + status = "APPROVED" if approved else "REJECTED" + lines = [f"Approval {status}: {request_id}"] + if message: + lines.append(f" {message}") + return "\n".join(lines) + + +def parse_approval_command(text: str) -> tuple[str | None, str | None]: + """解析审批文本命令。 + + 返回 (action, request_id),其中 action 为 "approve" 或 "reject"。 + """ + text_lower = text.strip().lower() + parts = text_lower.split(maxsplit=1) + if len(parts) < 2: + return None, None + action = parts[0] + request_id = parts[1].strip() + if action in ("approve", "reject"): + return action, request_id + return None, None diff --git a/backend/package/yuxi/channels/adapters/imessage/audit.py b/backend/package/yuxi/channels/adapters/imessage/audit.py new file mode 100644 index 00000000..17b735c3 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/audit.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +from yuxi.utils.logging_config import logger + + +class AuditLogger: + def __init__(self, log_dir: str | None = None): + self._log_dir = Path(log_dir or str(Path.home() / ".yuxi" / "imessage" / "audit")) + self._log_dir.mkdir(parents=True, exist_ok=True) + self._buffer: list[dict[str, Any]] = [] + + def record( + self, + event: str, + channel_chat_id: str = "", + channel_user_id: str = "", + decision: str = "", + reason: str = "", + metadata: dict[str, Any] | None = None, + ) -> None: + entry = { + "timestamp": time.time(), + "event": event, + "channel_chat_id": channel_chat_id, + "channel_user_id": channel_user_id, + "decision": decision, + "reason": reason, + "metadata": metadata or {}, + } + self._buffer.append(entry) + logger.info(f"[iMessage/Audit] {event}: {decision} — {reason}") + + def record_dm_access(self, sender: str, allowed: bool, reason: str = "") -> None: + self.record( + event="dm_access", + channel_user_id=sender, + decision="allowed" if allowed else "blocked", + reason=reason, + ) + + def record_group_access(self, chat_guid: str, allowed: bool, reason: str = "") -> None: + self.record( + event="group_access", + channel_chat_id=chat_guid, + decision="allowed" if allowed else "blocked", + reason=reason, + ) + + def record_pairing(self, code: str, action: str, sender: str) -> None: + self.record( + event="pairing", + channel_user_id=sender, + decision=action, + reason=f"code={code}", + ) + + async def flush(self) -> None: + if not self._buffer: + return + date_str = time.strftime("%Y-%m-%d") + file_path = self._log_dir / f"audit-{date_str}.jsonl" + with file_path.open("a", encoding="utf-8") as f: + for entry in self._buffer: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + count = len(self._buffer) + self._buffer.clear() + logger.debug(f"[iMessage/Audit] Flushed {count} audit entries") + + def get_recent(self, limit: int = 100) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + files = sorted(self._log_dir.glob("audit-*.jsonl"), reverse=True) + for fp in files: + if len(result) >= limit: + break + try: + lines = fp.read_text(encoding="utf-8").strip().split("\n") + for line in reversed(lines): + if not line.strip(): + continue + result.append(json.loads(line)) + if len(result) >= limit: + break + except (json.JSONDecodeError, OSError): + continue + return result[:limit] diff --git a/backend/package/yuxi/channels/adapters/imessage/bridge_client.py b/backend/package/yuxi/channels/adapters/imessage/bridge_client.py new file mode 100644 index 00000000..968558dc --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/bridge_client.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import asyncio +import os +import time +from typing import Any +from urllib.parse import urlparse + +import aiohttp + +from yuxi.channels.adapters.imessage.exceptions import BlueBubblesHTTPError +from yuxi.channels.infra.circuit_breaker import CircuitBreaker +from yuxi.channels.models import DeliveryResult +from yuxi.utils.logging_config import logger + + +def is_test_env() -> bool: + return os.environ.get("NODE_ENV") == "test" or os.environ.get("PYTEST_VERSION", "") != "" + + +class BlueBubblesClient: + def __init__(self, config: dict[str, Any]): + self._server_url = config.get("server_url", "http://localhost:1234").rstrip("/") + self._password = config.get("password", "") + self._http_timeout_s = config.get("http_timeout_s", config.get("httpTimeoutS", 30.0)) + self._session: aiohttp.ClientSession | None = None + self._max_retries = config.get("max_retries", 3) + self._breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0) + self._probe_timeout_ms = config.get("probeTimeoutMs", config.get("probe_timeout_ms", 10000)) + self._remote_attachment_roots: list[str] = config.get( + "remoteAttachmentRoots", config.get("remote_attachment_roots", []) + ) + self._auth_backoff_s = 1.0 + self._last_auth_failure: float | None = None + + async def __aenter__(self) -> BlueBubblesClient: + self._session = aiohttp.ClientSession( + headers={"X-BlueBubbles-Password": self._password}, + timeout=aiohttp.ClientTimeout(total=self._http_timeout_s), + ) + return self + + async def __aexit__(self, *args) -> None: + await self.close() + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + async def _ensure_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + headers={"X-BlueBubbles-Password": self._password}, + timeout=aiohttp.ClientTimeout(total=self._http_timeout_s), + ) + return self._session + + async def probe_server(self) -> dict[str, Any]: + session = await self._ensure_session() + try: + timeout = aiohttp.ClientTimeout(total=self._probe_timeout_ms / 1000.0) + async with session.get(f"{self._server_url}/api/v1/server/info", timeout=timeout) as resp: + return await resp.json() + except Exception as e: + logger.error(f"[iMessage] probe_server failed: {e}") + raise + + async def get_handle(self) -> dict[str, Any]: + session = await self._ensure_session() + try: + async with session.get(f"{self._server_url}/api/v1/handle") as resp: + return await resp.json() + except Exception as e: + logger.error(f"[iMessage] get_handle failed: {e}") + raise + + async def send_text_message( + self, + chat_guid: str, + content: str, + reply_to: str | None = None, + is_html: bool = False, + mentions: list[str] | None = None, + disable_notification: bool = False, + ) -> DeliveryResult: + payload: dict[str, Any] = {"chatGuid": chat_guid, "message": content} + if is_html: + payload["isHTML"] = True + if reply_to: + payload["replyTo"] = reply_to + if mentions: + payload["mentions"] = [{"type": "mention", "mention": m} for m in mentions] + if disable_notification: + payload["disableNotification"] = True + + return await self._send_with_retry( + method="POST", + path="/api/v1/message/text", + json_payload=payload, + ) + + async def send_typing_indicator(self, chat_guid: str, display: bool = True) -> DeliveryResult: + return await self._send_with_retry( + method="POST", + path=f"/api/v1/chat/{chat_guid}/typing", + json_payload={"display": display}, + ) + + async def send_read_receipt(self, chat_guid: str) -> DeliveryResult: + return await self._send_with_retry( + method="POST", + path=f"/api/v1/chat/{chat_guid}/readreceipt", + json_payload={}, + ) + + async def download_attachment(self, attachment_path: str) -> bytes: + session = await self._ensure_session() + + if attachment_path.startswith(("http://", "https://")): + parsed = urlparse(attachment_path) + if parsed.netloc not in urlparse(self._server_url).netloc: + raise BlueBubblesHTTPError(403, "Attachment URL origin mismatch") + url = attachment_path + else: + sanitized = attachment_path.lstrip("/") + url = f"{self._server_url}/{sanitized}" + + try: + async with session.get(url) as resp: + if resp.status != 200: + raise BlueBubblesHTTPError(resp.status, f"Failed to download attachment: {resp.status}") + return await resp.read() + except BlueBubblesHTTPError: + raise + except Exception as e: + logger.error(f"[iMessage] download_attachment failed: {e}") + raise + + async def send_attachment( + self, + chat_guid: str, + file_path: str, + caption: str = "", + reply_to: str | None = None, + ) -> DeliveryResult: + import mimetypes + + mime_type, _ = mimetypes.guess_type(file_path) + + try: + loop = asyncio.get_running_loop() + + def _read_file(): + with open(file_path, "rb") as f: + return f.read() + + file_data = await loop.run_in_executor(None, _read_file) + + form = aiohttp.FormData() + form.add_field( + "file", + file_data, + filename=os.path.basename(file_path), + content_type=mime_type or "application/octet-stream", + ) + form.add_field("chatGuid", chat_guid) + if caption: + form.add_field("caption", caption) + if reply_to: + form.add_field("replyTo", reply_to) + + return await self._send_with_retry( + method="POST", + path="/api/v1/message/attachment", + data_payload=form, + ) + except Exception as e: + return DeliveryResult(success=False, error=str(e)) + + async def send_reaction( + self, + chat_guid: str, + message_guid: str, + tapback: int, + ) -> DeliveryResult: + payload = {"messageGuid": message_guid, "tapback": tapback} + + return await self._send_with_retry( + method="POST", + path=f"/api/v1/message/{chat_guid}/tapback", + json_payload=payload, + ) + + async def edit_message(self, message_guid: str, new_content: str) -> DeliveryResult: + payload = {"message": new_content} + return await self._send_with_retry( + method="PUT", + path=f"/api/v1/message/{message_guid}", + json_payload=payload, + ) + + async def delete_message(self, chat_guid: str, message_guid: str) -> DeliveryResult: + payload = {"chatGuid": chat_guid, "messageGuid": message_guid} + return await self._send_with_retry( + method="POST", + path="/api/v1/message/delete", + json_payload=payload, + ) + + async def get_chats(self) -> list[dict[str, Any]]: + try: + session = await self._ensure_session() + async with session.get(f"{self._server_url}/api/v1/chat") as resp: + data = await resp.json() + return data.get("data", []) + except Exception: + logger.warning("[iMessage] Failed to fetch chats") + return [] + + async def get_chat_messages(self, chat_guid: str, limit: int = 50) -> list[dict[str, Any]]: + try: + session = await self._ensure_session() + params = {"limit": limit} + async with session.get(f"{self._server_url}/api/v1/chat/{chat_guid}/message", params=params) as resp: + data = await resp.json() + return data.get("data", []) + except Exception: + logger.warning(f"[iMessage] Failed to fetch messages for chat {chat_guid}") + return [] + + async def query_contacts(self, addresses: list[str]) -> list[dict[str, Any]]: + session = await self._ensure_session() + url = f"{self._server_url}/api/v1/contact/query" + async with session.post(url, json={"addresses": addresses}) as resp: + data = await resp.json() + return data.get("data", []) + + async def unsend_message(self, message_guid: str) -> DeliveryResult: + return await self._send_with_retry( + method="POST", + path=f"/api/v1/message/{message_guid}/unsend", + json_payload={}, + ) + + async def _send_with_retry( + self, + method: str, + path: str, + json_payload: dict[str, Any] | None = None, + data_payload: aiohttp.FormData | None = None, + ) -> DeliveryResult: + async def _attempt() -> DeliveryResult: + if self._last_auth_failure is not None: + elapsed = time.time() - self._last_auth_failure + wait_s = min(self._auth_backoff_s, 120.0) + if elapsed < wait_s: + remaining = wait_s - elapsed + logger.debug(f"[iMessage] Auth backoff: waiting {remaining:.1f}s") + await asyncio.sleep(remaining) + else: + self._last_auth_failure = None + self._auth_backoff_s = 1.0 + + session = await self._ensure_session() + url = f"{self._server_url}{path}" + kwargs = {} + if json_payload is not None: + kwargs["json"] = json_payload + if data_payload is not None: + kwargs["data"] = data_payload + + try: + async with session.request(method, url, **kwargs) as resp: + data = await resp.json() + if resp.status == 200: + return DeliveryResult( + success=True, + message_id=data.get("data", {}).get("guid") or data.get("guid"), + ) + if resp.status == 401: + self._last_auth_failure = time.time() + self._auth_backoff_s = min(self._auth_backoff_s * 2, 120.0) + raise _RetryableError(f"Authentication failed (401): {data.get('message', '')}") + if resp.status == 429: + raise _RetryableError(f"Rate limited: {data.get('message', '')}") + if resp.status >= 500: + raise _RetryableError(f"Server error {resp.status}: {data.get('message', '')}") + return DeliveryResult(success=False, error=data.get("message", "Unknown error")) + except _RetryableError: + raise + except Exception as e: + raise _RetryableError(str(e)) + + async def _breaker_attempt() -> DeliveryResult: + return await self._breaker.call(lambda: _attempt()) + + base_delay = 1.0 + for attempt in range(self._max_retries): + try: + return await _breaker_attempt() + except _RetryableError as e: + if attempt < self._max_retries - 1: + delay = base_delay * (2**attempt) + logger.warning( + f"[iMessage] {method} {path} failed (attempt {attempt + 1}/{self._max_retries}), " + f"retrying in {delay:.1f}s: {e}" + ) + await asyncio.sleep(delay) + continue + logger.error(f"[iMessage] {method} {path} failed after {self._max_retries} attempts: {e}") + return DeliveryResult(success=False, error=str(e)) + + return DeliveryResult(success=False, error=f"Failed after {self._max_retries} retries") + + @property + def ws_url(self) -> str: + url = self._server_url.replace("http://", "ws://").replace("https://", "wss://") + return f"{url}/ws" + + @property + def password(self) -> str: + return self._password + + +class _RetryableError(Exception): + pass diff --git a/backend/package/yuxi/channels/adapters/imessage/commands.py b/backend/package/yuxi/channels/adapters/imessage/commands.py new file mode 100644 index 00000000..bade5561 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/commands.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Any + + +def parse_command(text: str) -> dict[str, Any] | None: + """解析控制命令。 + + 支持的格式:/cmd [args...] + + 返回 {"action": str, "args": list[str]} 或 None。 + """ + if not text.startswith("/"): + return None + + parts = text[1:].strip().split() + if not parts: + return None + + action = parts[0].lower() + args = parts[1:] if len(parts) > 1 else [] + return {"action": action, "args": args} + + +def is_authorized_for_commands(sender_handle: str, allow_list: list[str]) -> bool: + """检查发送者是否有权限执行控制命令。""" + if "*" in allow_list: + return True + sender_clean = sender_handle.strip().replace(" ", "").lstrip("+") + for entry in allow_list: + entry_clean = entry.strip().replace(" ", "").lstrip("+") + if entry_clean == sender_clean: + return True + return False + + +AVAILABLE_COMMANDS = { + "status": {"description": "Show adapter status", "requires_owner": False}, + "health": {"description": "Show health check result", "requires_owner": False}, + "pairing": {"description": "Show pairing status", "requires_owner": False}, + "approve": {"description": "Approve a pending approval request", "requires_owner": True}, + "reject": {"description": "Reject a pending approval request", "requires_owner": True}, + "allowlist": {"description": "Manage allowlist entries", "requires_owner": True}, + "help": {"description": "Show this help message", "requires_owner": False}, +} + + +def get_command_help(include_owner: bool = False) -> str: + lines = ["Available commands:"] + for name, info in AVAILABLE_COMMANDS.items(): + if info["requires_owner"] and not include_owner: + continue + lines.append(f" /{name} — {info['description']}") + return "\n".join(lines) diff --git a/backend/package/yuxi/channels/adapters/imessage/config_schema.py b/backend/package/yuxi/channels/adapters/imessage/config_schema.py new file mode 100644 index 00000000..b4ee4644 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/config_schema.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, Field, field_validator + + +class DmPolicyEnum(StrEnum): + PAIRING = "pairing" + ALLOWLIST = "allowlist" + OPEN = "open" + DISABLED = "disabled" + + +class GroupPolicyEnum(StrEnum): + OPEN = "open" + ALLOWLIST = "allowlist" + DISABLED = "disabled" + + +class IMessageConnectionConfig(BaseModel): + server_url: str = "http://localhost:1234" + password: str = "" + probe_timeout_ms: int = Field(default=10000, ge=1000, le=60000) + max_retries: int = Field(default=3, ge=1, le=10) + http_timeout_s: float = Field(default=30.0, ge=1.0, le=300.0) + + +class IMessageSecurityConfigSchema(BaseModel): + dm_policy: DmPolicyEnum = DmPolicyEnum.PAIRING + group_policy: GroupPolicyEnum = GroupPolicyEnum.ALLOWLIST + allow_from: list[str] = Field(default_factory=list) + group_allow_from: list[str] = Field(default_factory=list) + require_mention: bool = False + + @field_validator("dm_policy", mode="before") + @classmethod + def coerce_dm_policy(cls, v): + if isinstance(v, str): + try: + return DmPolicyEnum(v) + except ValueError: + return DmPolicyEnum.PAIRING + return v + + @field_validator("group_policy", mode="before") + @classmethod + def coerce_group_policy(cls, v): + if isinstance(v, str): + try: + return GroupPolicyEnum(v) + except ValueError: + return GroupPolicyEnum.ALLOWLIST + return v + + +class GroupOverrideConfig(BaseModel): + enabled: bool | None = None + require_mention: bool | None = None + tools: list[str] | None = None + + +class IMessageAccountConfig(BaseModel): + account_id: str = "default" + name: str = "" + connection: IMessageConnectionConfig = Field(default_factory=IMessageConnectionConfig) + security: IMessageSecurityConfigSchema = Field(default_factory=IMessageSecurityConfigSchema) + enabled: bool = True + default_to: str = "" + + +class IMessageFullConfig(BaseModel): + name: str = "iMessage" + connection: IMessageConnectionConfig = Field(default_factory=IMessageConnectionConfig) + security: IMessageSecurityConfigSchema = Field(default_factory=IMessageSecurityConfigSchema) + + config_writes: bool = True + block_streaming: bool = False + text_chunk_limit: int = Field(default=4096, ge=100, le=16000) + media_max_mb: int = Field(default=100, ge=1, le=1000) + history_limit: int = Field(default=20, ge=1, le=200) + + default_account: str = "" + default_to: str = "" + accounts: dict[str, IMessageAccountConfig] = Field(default_factory=dict) + + include_attachments: bool = False + attachment_roots: list[str] = Field(default_factory=list) + remote_attachment_roots: list[str] = Field(default_factory=list) + + groups: dict[str, GroupOverrideConfig] = Field(default_factory=dict) + + ws_reconnect_initial_delay: float = Field(default=5.0, ge=0.5, le=120.0) + ws_reconnect_max_delay: float = Field(default=60.0, ge=1.0, le=600.0) + + loop_rate_limit: int = Field(default=5, ge=0, le=50) + loop_rate_window_s: float = Field(default=60.0, ge=1.0, le=600.0) + loop_cooldown_s: float = Field(default=120.0, ge=1.0, le=3600.0) + + @field_validator("accounts", mode="before") + @classmethod + def coerce_accounts(cls, v): + if v is None: + return {} + if isinstance(v, list): + return {a.account_id: a for a in v} + return v + + +def validate_config(config: dict) -> IMessageFullConfig: + """校验并标准化 iMessage 配置。 + + Raises: + ValidationError: 配置不符合 Schema 时抛出。 + """ + return IMessageFullConfig(**config) diff --git a/backend/package/yuxi/channels/adapters/imessage/contact_resolver.py b/backend/package/yuxi/channels/adapters/imessage/contact_resolver.py new file mode 100644 index 00000000..7a6b591e --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/contact_resolver.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any + +from yuxi.utils.logging_config import logger + + +async def resolve_contact(bridge, address: str) -> dict[str, Any]: + try: + contacts = await bridge.query_contacts([address]) + if contacts: + contact = contacts[0] + return { + "address": contact.get("address", address), + "display_name": contact.get("displayName", ""), + "first_name": contact.get("firstName", ""), + "last_name": contact.get("lastName", ""), + } + return {"address": address, "display_name": ""} + except Exception as e: + logger.warning(f"[iMessage] Contact resolution failed for {address}: {e}") + return {"address": address, "display_name": ""} + + +async def resolve_contacts_batch(bridge, addresses: list[str]) -> dict[str, dict[str, Any]]: + if not addresses: + return {} + + try: + contacts = await bridge.query_contacts(addresses) + resolved: dict[str, dict[str, Any]] = {} + for contact in contacts: + addr = contact.get("address", "") + resolved[addr] = { + "address": addr, + "display_name": contact.get("displayName", ""), + "first_name": contact.get("firstName", ""), + "last_name": contact.get("lastName", ""), + } + for addr in addresses: + if addr not in resolved: + resolved[addr] = {"address": addr, "display_name": ""} + return resolved + except Exception as e: + logger.warning(f"[iMessage] Batch contact resolution failed: {e}") + return {addr: {"address": addr, "display_name": ""} for addr in addresses} diff --git a/backend/package/yuxi/channels/adapters/imessage/conversation_bindings.py b/backend/package/yuxi/channels/adapters/imessage/conversation_bindings.py new file mode 100644 index 00000000..55a96e5c --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/conversation_bindings.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from yuxi.utils.logging_config import logger + + +@dataclass +class ConversationBinding: + channel_chat_id: str + agent_id: str = "main" + label: str = "" + created_at: float = field(default_factory=time.time) + metadata: dict[str, Any] = field(default_factory=dict) + + +class ConversationBindingManager: + def __init__(self, storage_dir: str | None = None, account_id: str = "default"): + self._bindings: dict[str, ConversationBinding] = {} + self._storage_dir = storage_dir or str(Path.home() / ".yuxi" / "imessage") + self._account_id = account_id + + def create( + self, channel_chat_id: str, agent_id: str = "main", label: str = "", metadata: dict[str, Any] | None = None + ) -> ConversationBinding: + binding = ConversationBinding( + channel_chat_id=channel_chat_id, + agent_id=agent_id, + label=label, + metadata=metadata or {}, + ) + self._bindings[channel_chat_id] = binding + logger.info(f"[iMessage/Bindings] Created binding: {channel_chat_id} -> agent:{agent_id}") + return binding + + def get(self, channel_chat_id: str) -> ConversationBinding | None: + return self._bindings.get(channel_chat_id) + + def list_all(self) -> list[ConversationBinding]: + return list(self._bindings.values()) + + def delete(self, channel_chat_id: str) -> bool: + if channel_chat_id in self._bindings: + del self._bindings[channel_chat_id] + logger.info(f"[iMessage/Bindings] Deleted binding: {channel_chat_id}") + return True + return False + + def get_agent_for_chat(self, channel_chat_id: str, default: str = "main") -> str: + binding = self._bindings.get(channel_chat_id) + return binding.agent_id if binding else default + + async def save_bindings(self) -> None: + dir_path = Path(self._storage_dir) / self._account_id + dir_path.mkdir(parents=True, exist_ok=True) + data = { + cid: { + "agent_id": b.agent_id, + "label": b.label, + "created_at": b.created_at, + "metadata": b.metadata, + } + for cid, b in self._bindings.items() + } + file_path = dir_path / "conversation-bindings.json" + file_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + async def load_bindings(self) -> None: + file_path = Path(self._storage_dir) / self._account_id / "conversation-bindings.json" + if not file_path.exists(): + return + try: + data = json.loads(file_path.read_text(encoding="utf-8")) + for cid, info in data.items(): + if isinstance(info, dict): + self._bindings[cid] = ConversationBinding( + channel_chat_id=cid, + agent_id=info.get("agent_id", "main"), + label=info.get("label", ""), + created_at=info.get("created_at", time.time()), + metadata=info.get("metadata", {}), + ) + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"[iMessage/Bindings] Failed to load bindings: {e}") diff --git a/backend/package/yuxi/channels/adapters/imessage/conversation_route.py b/backend/package/yuxi/channels/adapters/imessage/conversation_route.py new file mode 100644 index 00000000..42308fe1 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/conversation_route.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass + + + +@dataclass +class ConversationRoute: + agent_id: str + channel_chat_id: str + source: str + + +def resolve_agent_route( + channel_chat_id: str, + configured_bindings: dict[str, str] | None = None, + runtime_bindings: dict[str, str] | None = None, + default_agent: str = "main", +) -> ConversationRoute: + if runtime_bindings and channel_chat_id in runtime_bindings: + return ConversationRoute( + agent_id=runtime_bindings[channel_chat_id], + channel_chat_id=channel_chat_id, + source="runtime_binding", + ) + + if configured_bindings and channel_chat_id in configured_bindings: + return ConversationRoute( + agent_id=configured_bindings[channel_chat_id], + channel_chat_id=channel_chat_id, + source="configured_binding", + ) + + return ConversationRoute( + agent_id=default_agent, + channel_chat_id=channel_chat_id, + source="default", + ) + + +def resolve_configured_binding( + channel_chat_id: str, + bindings: dict[str, str] | None = None, +) -> str | None: + if bindings and channel_chat_id in bindings: + return bindings[channel_chat_id] + return None + + +def resolve_runtime_binding( + channel_chat_id: str, + bindings: dict[str, str] | None = None, +) -> str | None: + if bindings and channel_chat_id in bindings: + return bindings[channel_chat_id] + return None diff --git a/backend/package/yuxi/channels/adapters/imessage/directory.py b/backend/package/yuxi/channels/adapters/imessage/directory.py new file mode 100644 index 00000000..6b284282 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/directory.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any + +from yuxi.channels.adapters.imessage.bridge_client import BlueBubblesClient +from yuxi.utils.logging_config import logger + + +async def search_contacts(bridge: BlueBubblesClient, query: str, limit: int = 20) -> list[dict[str, Any]]: + """搜索联系人。""" + try: + contacts = await bridge.query_contacts([query]) + return contacts[:limit] + except Exception as e: + logger.warning(f"[iMessage/Directory] Contact search failed: {e}") + return [] + + +async def list_recent_chats(bridge: BlueBubblesClient, limit: int = 30) -> list[dict[str, Any]]: + """获取最近聊天列表。""" + try: + chats = await bridge.get_chats() + chats.sort(key=lambda c: c.get("lastMessageDate", 0), reverse=True) + return chats[:limit] + except Exception as e: + logger.warning(f"[iMessage/Directory] Failed to list recent chats: {e}") + return [] + + +async def search_groups(bridge: BlueBubblesClient, name_query: str, limit: int = 20) -> list[dict[str, Any]]: + """搜索群组。""" + try: + chats = await bridge.get_chats() + groups = [c for c in chats if ";-;" in c.get("guid", "") or "@chat" in c.get("guid", "")] + if name_query: + name_lower = name_query.lower() + groups = [ + g + for g in groups + if name_lower in g.get("displayName", "").lower() or name_lower in g.get("guid", "").lower() + ] + groups.sort(key=lambda c: c.get("lastMessageDate", 0), reverse=True) + return groups[:limit] + except Exception as e: + logger.warning(f"[iMessage/Directory] Group search failed: {e}") + return [] + + +async def get_chat_info(bridge: BlueBubblesClient, chat_guid: str) -> dict[str, Any]: + """获取聊天详细信息。""" + try: + messages = await bridge.get_chat_messages(chat_guid, limit=1) + chats = await bridge.get_chats() + for chat in chats: + if chat.get("guid") == chat_guid: + return { + "guid": chat_guid, + "display_name": chat.get("displayName", ""), + "participants": chat.get("participants", []), + "last_message": messages[0] if messages else None, + } + return {"guid": chat_guid, "display_name": "", "participants": []} + except Exception as e: + logger.warning(f"[iMessage/Directory] Failed to get chat info for {chat_guid}: {e}") + return {"guid": chat_guid, "display_name": ""} diff --git a/backend/package/yuxi/channels/adapters/imessage/echo_cache.py b/backend/package/yuxi/channels/adapters/imessage/echo_cache.py new file mode 100644 index 00000000..68bacde8 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/echo_cache.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import hashlib +import time +from dataclasses import dataclass + +TEXT_TTL_S = 4.0 +TEXT_BACKED_BY_ID_TTL_S = 4.0 +MESSAGE_ID_TTL_S = 60.0 +SELF_CHAT_TTL_S = 10.0 +MAX_SELF_CHAT_ENTRIES = 512 + + +@dataclass +class _SentEntry: + message_id: str + text: str + chat_guid: str + sent_at: float + + +@dataclass +class _SelfChatEntry: + text_hash: str + chat_guid: str + recorded_at: float + + +class EchoCache: + def __init__( + self, + text_ttl_s: float = TEXT_TTL_S, + text_backed_by_id_ttl_s: float = TEXT_BACKED_BY_ID_TTL_S, + message_id_ttl_s: float = MESSAGE_ID_TTL_S, + self_chat_ttl_s: float = SELF_CHAT_TTL_S, + max_self_chat_entries: int = MAX_SELF_CHAT_ENTRIES, + ): + self._text_ttl = text_ttl_s + self._text_backed_by_id_ttl = text_backed_by_id_ttl_s + self._msg_id_ttl = message_id_ttl_s + self._self_chat_ttl = self_chat_ttl_s + self._max_self_chat_entries = max_self_chat_entries + + self._by_message_id: dict[str, _SentEntry] = {} + self._by_text: dict[str, list[_SentEntry]] = {} + self._by_text_backed_by_id: dict[str, _SentEntry] = {} + + self._self_chat_cache: list[_SelfChatEntry] = [] + self._last_self_chat_cleanup = time.monotonic() + + def record_sent(self, message_id: str, text: str, chat_guid: str) -> None: + now = time.monotonic() + entry = _SentEntry(message_id=message_id, text=text, chat_guid=chat_guid, sent_at=now) + self._by_message_id[message_id] = entry + + text_key = _text_key(text, chat_guid) + if text_key not in self._by_text: + self._by_text[text_key] = [] + self._by_text[text_key].append(entry) + + if message_id: + self._by_text_backed_by_id[message_id] = entry + + self._cleanup() + + def is_echo(self, message_id: str | None = None, text: str | None = None, chat_guid: str = "") -> bool: + self._cleanup() + + if message_id and message_id in self._by_message_id: + return True + + if message_id and message_id in self._by_text_backed_by_id: + return True + + if text: + text_key = _text_key(text, chat_guid) + entries = self._by_text.get(text_key, []) + if entries: + return True + + return False + + def is_self_chat_echo( + self, + sender_handle: str, + self_handle: str, + chat_guid: str, + ) -> bool: + if not self_handle: + return False + sender_clean = sender_handle.strip().replace(" ", "").lstrip("+") + self_clean = self_handle.strip().replace(" ", "").lstrip("+") + return sender_clean == self_clean + + def record_self_chat_text(self, text: str, chat_guid: str) -> None: + now = time.monotonic() + text_hash = hashlib.sha256(f"{chat_guid}:{text.strip()[:200]}".encode()).hexdigest() + entry = _SelfChatEntry(text_hash=text_hash, chat_guid=chat_guid, recorded_at=now) + self._self_chat_cache.append(entry) + self._cleanup_self_chat() + if len(self._self_chat_cache) > self._max_self_chat_entries: + self._self_chat_cache = self._self_chat_cache[-self._max_self_chat_entries :] + + def is_in_self_chat_cache(self, text: str, chat_guid: str) -> bool: + self._cleanup_self_chat() + text_hash = hashlib.sha256(f"{chat_guid}:{text.strip()[:200]}".encode()).hexdigest() + for entry in self._self_chat_cache: + if entry.text_hash == text_hash and entry.chat_guid == chat_guid: + return True + return False + + def _cleanup_self_chat(self) -> None: + now = time.monotonic() + self._self_chat_cache = [ + entry for entry in self._self_chat_cache if now - entry.recorded_at <= self._self_chat_ttl + ] + + def _cleanup(self) -> None: + now = time.monotonic() + + expired_msg_ids = [mid for mid, entry in self._by_message_id.items() if now - entry.sent_at > self._msg_id_ttl] + for mid in expired_msg_ids: + del self._by_message_id[mid] + + expired_text_backed = [ + mid + for mid, entry in self._by_text_backed_by_id.items() + if now - entry.sent_at > self._text_backed_by_id_ttl + ] + for mid in expired_text_backed: + del self._by_text_backed_by_id[mid] + + for text_key in list(self._by_text): + self._by_text[text_key] = [ + entry for entry in self._by_text[text_key] if now - entry.sent_at <= self._text_ttl + ] + if not self._by_text[text_key]: + del self._by_text[text_key] + + if now - self._last_self_chat_cleanup > self._self_chat_ttl: + self._cleanup_self_chat() + self._last_self_chat_cleanup = now + + +def _text_key(text: str, chat_guid: str) -> str: + return f"{chat_guid}:{text.strip()[:200]}" diff --git a/backend/package/yuxi/channels/adapters/imessage/envelope.py b/backend/package/yuxi/channels/adapters/imessage/envelope.py new file mode 100644 index 00000000..f2f1593b --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/envelope.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from yuxi.channels.models import ChannelIdentity + + +def format_inbound_envelope( + identity: ChannelIdentity, + sender_name: str = "", + chat_name: str = "", + reply_context: dict | None = None, +) -> str: + """生成标准入站信封格式。 + + 格式示例: + [From: Alice (alice@icloud.com)] @Family Chat + """ + parts: list[str] = [] + + sender_display = sender_name or identity.channel_user_id + parts.append(f"[From: {sender_display}") + + if identity.channel_user_id != sender_display: + parts.append(f" ({identity.channel_user_id})") + + parts.append("]") + + if chat_name: + parts.append(f" @{chat_name}") + + if reply_context: + reply_to_id = reply_context.get("reply_to_message_id") + reply_to_text = reply_context.get("reply_to_text") + if reply_to_text: + preview = reply_to_text[:80].replace("\n", " ") + parts.append(f"\n > Replying to: {preview}") + elif reply_to_id: + parts.append(f"\n > Replying to message id:{reply_to_id}") + + return "".join(parts) diff --git a/backend/package/yuxi/channels/adapters/imessage/exceptions.py b/backend/package/yuxi/channels/adapters/imessage/exceptions.py new file mode 100644 index 00000000..ded3de6c --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/exceptions.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from yuxi.channels.exceptions import ChannelException + + +class IMessageError(ChannelException): + def __init__(self, message: str, retryable: bool = False, retry_after_ms: int = 0): + super().__init__(message, retryable=retryable, retry_after_ms=retry_after_ms) + + +class BlueBubblesHTTPError(IMessageError): + def __init__(self, status_code: int, message: str = "", headers: dict | None = None): + self.status_code = status_code + self.headers = headers or {} + retryable = status_code >= 500 or status_code == 429 + detail = f"HTTP {status_code}: {message}" if message else f"HTTP {status_code}" + super().__init__(detail, retryable=retryable) + + +class BlueBubblesConnectionError(IMessageError): + def __init__(self, message: str = ""): + super().__init__(message or "BlueBubbles connection failed", retryable=True, retry_after_ms=5000) + + +class BlueBubblesAuthenticationError(IMessageError): + def __init__(self, message: str = ""): + super().__init__(message or "BlueBubbles authentication failed", retryable=False) + + +class TapbackError(IMessageError): + def __init__(self, message: str = ""): + super().__init__(message or "Tapback operation failed", retryable=False) diff --git a/backend/package/yuxi/channels/adapters/imessage/formatter.py b/backend/package/yuxi/channels/adapters/imessage/formatter.py new file mode 100644 index 00000000..cef6e8cf --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/formatter.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import re + +TABLE_ROW_RE = re.compile(r"^\|(.+)\|$") +TABLE_SEP_RE = re.compile(r"^\|[\s\-:]+\|$") +DIRECTIVE_RE = re.compile(r"<\s*/?directive[^>]*>", re.IGNORECASE) + + +def convert_markdown_tables(text: str) -> str: + """将 Markdown 表格转换为可读的 bullets 格式。 + + iMessage 不支持 Markdown 表格渲染,此函数将表格行转为可读的列表。 + """ + lines = text.split("\n") + result: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + if i + 2 < len(lines) and TABLE_ROW_RE.match(line) and TABLE_SEP_RE.match(lines[i + 1]): + header = line + sep = lines[i + 1] + body_rows: list[str] = [] + j = i + 2 + while j < len(lines) and TABLE_ROW_RE.match(lines[j]): + body_rows.append(lines[j]) + j += 1 + + converted = _table_to_bullets(header, sep, body_rows) + result.extend(converted) + i = j + continue + + result.append(line) + i += 1 + + return "\n".join(result) + + +def _table_to_bullets(header: str, sep: str, body_rows: list[str]) -> list[str]: + header_cells = _parse_cells(header) + sep_cells = _parse_cells(sep) + + if not header_cells: + return [header, sep] + body_rows + + alignments = _detect_alignments(sep_cells, len(header_cells)) + body_cells = [_parse_cells(row) for row in body_rows] + + output: list[str] = [] + for row_idx, cells in enumerate([header_cells] + body_cells): + if row_idx == 0: + output.append(f"• {', '.join(cells)}") + elif len(cells) <= 2: + output.append(" " + ": ".join(cells)) + else: + parts: list[str] = [] + for ci, (cell, hdr, align) in enumerate(zip(cells, header_cells, alignments)): + prefix = f" {hdr}: " if ci == 0 or align != "skip" else f" {hdr}: " + parts.append(f"{prefix}{cell}") + output.append("") + output.extend(parts) + + return output + + +def _parse_cells(row: str) -> list[str]: + return [cell.strip() for cell in row.strip("|").split("|")] + + +def _detect_alignments(sep_cells: list[str], col_count: int) -> list[str]: + aligns: list[str] = [] + for i in range(col_count): + if i < len(sep_cells): + cell = sep_cells[i] + left = cell.startswith(":") + right = cell.endswith(":") + if left and right: + aligns.append("center") + elif right: + aligns.append("right") + else: + aligns.append("left") + else: + aligns.append("left") + return aligns + + +def sanitize_imessage_text(text: str) -> str: + """净化出站文本,移除 iMessage 中不支持的格式。 + + - 去除 \\r 字符(换行标准化) + - 去除 内联指令标签 + - 去除长度前缀损坏(如 \"123\\nThis is message\") + - 过滤控制字符 + - 合并多余空白行 + """ + text = text.replace("\r", "") + + text = DIRECTIVE_RE.sub("", text) + + text = _strip_length_prefix(text) + + text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", text) + + text = re.sub(r"\n{3,}", "\n\n", text) + text = text.strip() + + return text + + +def _strip_length_prefix(text: str) -> str: + """去除 iMessage 长度前缀损坏格式。 + + BlueBubbles 可能在某些消息前附加长度前缀如 \"42\\nHello world\", + 去除数字换行前缀。 + """ + match = re.match(r"^(\d+)\n", text) + if match: + prefix_len = int(match.group(1)) + remaining = text[match.end() :] + if abs(len(remaining) - prefix_len) <= 5: + return remaining + return text diff --git a/backend/package/yuxi/channels/adapters/imessage/media_ai.py b/backend/package/yuxi/channels/adapters/imessage/media_ai.py new file mode 100644 index 00000000..3db89bac --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/media_ai.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Any + +from yuxi.utils.logging_config import logger + + +async def describe_image(image_data: bytes, vision_api_url: str = "", vision_api_key: str = "") -> str: + """通过外部 Vision API 描述图片内容。 + + 如果未配置 API URL,返回基本元数据描述。 + """ + if not vision_api_url: + return f"Image attachment ({len(image_data)} bytes)" + + try: + import base64 + + import aiohttp + + encoded = base64.b64encode(image_data).decode("utf-8") + payload = { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image briefly in one sentence."}, + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{encoded}"}, + }, + ], + } + ], + "max_tokens": 100, + } + headers = {"Authorization": f"Bearer {vision_api_key}"} + async with aiohttp.ClientSession() as session: + async with session.post( + vision_api_url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=15) + ) as resp: + if resp.status == 200: + data = await resp.json() + return data["choices"][0]["message"]["content"] + return f"Image attachment ({len(image_data)} bytes)" + except Exception as e: + logger.warning(f"[iMessage/AI] Vision API failed: {e}") + return f"Image attachment ({len(image_data)} bytes)" + + +async def transcribe_audio(audio_data: bytes, transcription_api_url: str = "", transcription_api_key: str = "") -> str: + """通过外部 API 转录音频内容。""" + if not transcription_api_url: + return f"Audio attachment ({len(audio_data)} bytes)" + + try: + import aiohttp + from aiohttp import FormData + + form = FormData() + form.add_field("file", audio_data, filename="audio.caf", content_type="audio/x-caf") + form.add_field("model", "whisper-1") + + headers = {"Authorization": f"Bearer {transcription_api_key}"} + async with aiohttp.ClientSession() as session: + async with session.post( + transcription_api_url, data=form, headers=headers, timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("text", "") + return f"Audio attachment ({len(audio_data)} bytes)" + except Exception as e: + logger.warning(f"[iMessage/AI] Transcription API failed: {e}") + return f"Audio attachment ({len(audio_data)} bytes)" + + +def is_vision_configured(config: dict[str, Any]) -> bool: + return bool(config.get("vision_api_url") and config.get("vision_api_key")) + + +def is_transcription_configured(config: dict[str, Any]) -> bool: + return bool(config.get("transcription_api_url") and config.get("transcription_api_key")) diff --git a/backend/package/yuxi/channels/adapters/imessage/media_security.py b/backend/package/yuxi/channels/adapters/imessage/media_security.py new file mode 100644 index 00000000..dd99fe94 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/media_security.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from yuxi.utils.logging_config import logger + +DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = [ + "/Users/*/Library/Messages/Attachments", +] + + +def resolve_attachment_roots( + configured_roots: list[str] | None = None, +) -> list[str]: + if configured_roots: + return configured_roots + expanded: list[str] = [] + for pattern in DEFAULT_IMESSAGE_ATTACHMENT_ROOTS: + import glob as _glob_module + + matches = _glob_module.glob(pattern) + if matches: + expanded.extend(matches) + return expanded or DEFAULT_IMESSAGE_ATTACHMENT_ROOTS + + +def validate_attachment_path( + file_path: str, + allowed_roots: list[str] | None = None, +) -> bool: + """校验附件路径是否在允许的根路径内。 + + 阻止路径遍历攻击(如 ../../etc/passwd)。 + """ + if not file_path: + return False + + real_path = os.path.realpath(file_path) if os.path.exists(file_path) else os.path.abspath(file_path) + + roots = allowed_roots or [] + if not roots: + return True + + for root in roots: + root_real = os.path.realpath(root) if os.path.exists(root) else os.path.abspath(root) + try: + Path(real_path).relative_to(root_real) + return True + except ValueError: + continue + + logger.warning(f"[iMessage/Security] Attachment path rejected: {file_path} not in allowed roots") + return False + + +def validate_remote_attachment_url( + url: str, + allowed_roots: list[str] | None = None, + server_url: str = "", +) -> bool: + """校验远程附件 URL 是否安全。""" + if not url: + return False + + if allowed_roots: + for root in allowed_roots: + if url.startswith(root): + return True + return False + + if server_url and url.startswith(server_url): + return True + + return False + + +def sanitize_attachment_filename(filename: str) -> str: + """净化附件文件名,移除路径分隔符。""" + if not filename: + return "attachment" + basename = os.path.basename(filename) + safe = "".join(c for c in basename if c.isalnum() or c in "._-() ") + return safe or "attachment" diff --git a/backend/package/yuxi/channels/adapters/imessage/monitor.py b/backend/package/yuxi/channels/adapters/imessage/monitor.py new file mode 100644 index 00000000..966bd5e3 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/monitor.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio +import json +import random +from collections.abc import Awaitable, Callable +from typing import Any + +import aiohttp + +from yuxi.utils.logging_config import logger + + +_MAX_ERROR_MSG_LEN = 200 + + +class IMessageMonitor: + def __init__(self, config: dict[str, Any], bridge): + self._config = config + self._bridge = bridge + self._message_handler: Callable[[dict[str, Any]], Awaitable[None]] | None = None + self._task: asyncio.Task | None = None + self._reconnect_delay = 5.0 + self._initial_reconnect_delay = config.get("ws_reconnect_initial_delay", 5.0) + self._max_reconnect_delay = config.get("ws_reconnect_max_delay", 60.0) + self._max_subscribe_attempts = config.get( + "watchSubscribeMaxAttempts", config.get("watch_subscribe_max_attempts", 3) + ) + self._subscribe_retry_delay_ms = config.get( + "watchSubscribeRetryDelayMs", config.get("watch_subscribe_retry_delay_ms", 1000) + ) + self._subscribe_attempts = 0 + + def on_message(self, handler: Callable[[dict[str, Any]], Awaitable[None]]) -> None: + self._message_handler = handler + + async def start(self) -> None: + if self._task and not self._task.done(): + return + self._task = asyncio.create_task(self._ws_loop()) + + async def stop(self) -> None: + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _ws_loop(self) -> None: + while True: + try: + self._subscribe_attempts = 0 + await self._connect_ws() + except asyncio.CancelledError: + break + except Exception as e: + self._subscribe_attempts += 1 + if self._subscribe_attempts >= self._max_subscribe_attempts: + logger.error( + f"[iMessage] Max subscribe attempts ({self._max_subscribe_attempts}) reached, stopping monitor" + ) + break + delay = self._reconnect_delay * (0.5 + random.random()) + err_msg = str(e)[:_MAX_ERROR_MSG_LEN] + logger.error( + f"[iMessage] WebSocket error (attempt {self._subscribe_attempts}), " + f"reconnecting in {delay:.1f}s: {err_msg}" + ) + await asyncio.sleep(delay) + self._reconnect_delay = min(self._reconnect_delay * 1.5, self._max_reconnect_delay) + + async def _connect_ws(self) -> None: + url = self._bridge.ws_url + logger.info(f"[iMessage] Connecting to BlueBubbles WebSocket: {url}") + + async with aiohttp.ClientSession() as session: + headers = {"X-BlueBubbles-Password": self._bridge.password} + async with session.ws_connect(url, headers=headers) as ws: + self._reconnect_delay = self._initial_reconnect_delay + logger.info("[iMessage] BlueBubbles WebSocket connected") + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + try: + data = json.loads(msg.data) + event_type = data.get("type", "") + logger.debug(f"[iMessage] WS event: {event_type}") + + if event_type == "error": + err_data = data.get("data", {}) + err_str = str(err_data) + if isinstance(err_data, dict): + err_str = err_data.get("message", err_data.get("error", str(err_data))) + logger.error(f"[iMessage] BlueBubbles error: {err_str[:_MAX_ERROR_MSG_LEN]}") + elif self._message_handler: + await self._message_handler(data) + except json.JSONDecodeError: + logger.warning(f"[iMessage] WS non-JSON message: {msg.data[:200]}") + elif msg.type == aiohttp.WSMsgType.ERROR: + logger.error(f"[iMessage] WS error: {ws.exception()}") + break + elif msg.type == aiohttp.WSMsgType.CLOSED: + logger.info("[iMessage] BlueBubbles WebSocket closed") + break diff --git a/backend/package/yuxi/channels/adapters/imessage/normalize.py b/backend/package/yuxi/channels/adapters/imessage/normalize.py new file mode 100644 index 00000000..3ca1e895 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/normalize.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import re +from typing import Any + +_E164_RE = re.compile(r"^\+[1-9]\d{1,14}$") +_NON_DIGIT_RE = re.compile(r"[^\d+]") +_PHONE_CLEAN_RE = re.compile(r"[\s\-\(\)\.]") + + +def normalize_e164(handle: str, default_country_code: str = "") -> str: + """将电话号码标准化为 E.164 格式。 + + 去除空格、括号、连字符,补全国际区号。 + 对已经是国际格式的号码保留 '+' 前缀。 + """ + clean = _PHONE_CLEAN_RE.sub("", handle.strip()) + + clean = _strip_service_prefixes(clean) + + if not clean: + return handle + + if clean.startswith("+"): + digits = clean[1:] + if digits.isdigit() and len(digits) <= 15: + return clean + return handle + + if clean.startswith("00"): + digits = clean[2:] + if digits.isdigit() and len(digits) <= 15: + return f"+{digits}" + + if clean.startswith("011"): + digits = clean[3:] + if digits.isdigit() and len(digits) <= 15: + return f"+{digits}" + + if clean.isdigit() and default_country_code: + return f"+{default_country_code}{clean}" + + if clean.isdigit() and len(clean) <= 15: + return f"+{clean}" + + return handle + + +def _strip_service_prefixes(handle: str) -> str: + for prefix in ("imessage:", "sms:", "auto:"): + if handle.lower().startswith(prefix): + return handle[len(prefix) :] + return handle + + +def is_e164(handle: str) -> bool: + return bool(_E164_RE.match(handle)) + + +def extract_digits(handle: str) -> str: + return _NON_DIGIT_RE.sub("", handle) + + +def parse_forwarded(data: dict) -> dict[str, Any] | None: + forwarded = data.get("forwarded") + if not forwarded: + return None + + result: dict[str, Any] = {"is_forwarded": True} + if isinstance(forwarded, dict): + result["forwarded_from"] = forwarded.get("handle") or forwarded.get("sender", "") + result["forwarded_date"] = forwarded.get("date") or forwarded.get("originalDate", "") + elif isinstance(forwarded, bool) and forwarded: + result["forwarded_from"] = data.get("forwardedFrom", "") + return result diff --git a/backend/package/yuxi/channels/adapters/imessage/pairing.py b/backend/package/yuxi/channels/adapters/imessage/pairing.py new file mode 100644 index 00000000..6f819328 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/pairing.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import asyncio +import json +import secrets +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from yuxi.utils.logging_config import logger + +PAIRING_CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" +PAIRING_CODE_LENGTH = 8 +PAIRING_VALIDITY_S = 3600 +PAIRING_RATE_LIMIT_S = 3600 +PAIRING_MAX_PENDING = 3 + + +@dataclass +class PairingRequest: + code: str + sender_handle: str + channel_user_id: str + chat_guid: str + requested_at: float + expires_at: float + metadata: dict[str, Any] | None = None + + +class IMessagePairingManager: + def __init__(self, storage_dir: str | None = None, account_id: str = "default", config_writes: bool = True): + self._pending: dict[str, PairingRequest] = {} + self._rate_limit: dict[str, float] = {} + self._approved: set[str] = set() + self._storage_dir = storage_dir or str(Path.home() / ".yuxi" / "imessage") + self._account_id = account_id + self._config_writes = config_writes + + def _schedule_save(self) -> None: + if not self._config_writes: + return + try: + loop = asyncio.get_running_loop() + loop.create_task(self._save_all()) + except RuntimeError: + pass + + async def _save_all(self) -> None: + await self.save_allowlist() + await self.save_pending() + + def generate_code(self) -> str: + return "".join(secrets.choice(PAIRING_CODE_CHARS) for _ in range(PAIRING_CODE_LENGTH)) + + def request_pairing( + self, + sender_handle: str, + channel_user_id: str, + chat_guid: str, + metadata: dict[str, Any] | None = None, + ) -> str | None: + if self.is_approved(channel_user_id): + return None + + now = time.monotonic() + last_request = self._rate_limit.get(channel_user_id, 0) + if now - last_request < PAIRING_RATE_LIMIT_S: + logger.info(f"[iMessage/Pairing] Rate limited for {channel_user_id}") + return None + + if len(self._pending) >= PAIRING_MAX_PENDING: + self._cleanup_expired() + if len(self._pending) >= PAIRING_MAX_PENDING: + logger.warning("[iMessage/Pairing] Max pending requests reached") + return None + + code = self.generate_code() + self._pending[code] = PairingRequest( + code=code, + sender_handle=sender_handle, + channel_user_id=channel_user_id, + chat_guid=chat_guid, + requested_at=time.time(), + expires_at=time.time() + PAIRING_VALIDITY_S, + metadata=metadata, + ) + self._rate_limit[channel_user_id] = now + logger.info(f"[iMessage/Pairing] New pairing request: code={code}, sender={sender_handle}") + self._schedule_save() + return code + + def approve(self, code: str) -> bool: + self._cleanup_expired() + req = self._pending.pop(code, None) + if req is None: + return False + self._approved.add(req.channel_user_id) + logger.info(f"[iMessage/Pairing] Approved: {req.channel_user_id}") + self._schedule_save() + return True + + def reject(self, code: str) -> bool: + req = self._pending.pop(code, None) + if req is None: + return False + logger.info(f"[iMessage/Pairing] Rejected: {req.channel_user_id}") + self._schedule_save() + return True + + def list_pending(self) -> list[dict[str, Any]]: + self._cleanup_expired() + return [ + { + "code": r.code, + "sender_handle": r.sender_handle, + "channel_user_id": r.channel_user_id, + "chat_guid": r.chat_guid, + "requested_at": r.requested_at, + "expires_at": r.expires_at, + } + for r in self._pending.values() + ] + + def is_approved(self, channel_user_id: str) -> bool: + return channel_user_id in self._approved + + def get_paired_handles(self) -> list[str]: + return sorted(self._approved) + + def is_paired(self, channel_user_id: str) -> bool: + return channel_user_id in self._approved + + def revoke_approval(self, channel_user_id: str) -> bool: + if channel_user_id in self._approved: + self._approved.discard(channel_user_id) + self._schedule_save() + return True + return False + + def _cleanup_expired(self) -> None: + now = time.time() + expired = [code for code, req in self._pending.items() if req.expires_at < now] + for code in expired: + self._pending.pop(code, None) + + async def save_allowlist(self) -> None: + dir_path = Path(self._storage_dir) / self._account_id + dir_path.mkdir(parents=True, exist_ok=True) + file_path = dir_path / "paired_handles.json" + file_path.write_text( + json.dumps(sorted(self._approved), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + async def load_allowlist(self) -> list[str]: + file_path = Path(self._storage_dir) / self._account_id / "paired_handles.json" + if not file_path.exists(): + return [] + try: + data = json.loads(file_path.read_text(encoding="utf-8")) + if isinstance(data, list): + self._approved.update(data) + return data + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"[iMessage/Pairing] Failed to load paired handles: {e}") + return [] + + async def save_pending(self) -> None: + dir_path = Path(self._storage_dir) / self._account_id + dir_path.mkdir(parents=True, exist_ok=True) + pending_data = { + code: { + "sender_handle": r.sender_handle, + "channel_user_id": r.channel_user_id, + "chat_guid": r.chat_guid, + "requested_at": r.requested_at, + "expires_at": r.expires_at, + } + for code, r in self._pending.items() + } + file_path = dir_path / "pairing-pending.json" + file_path.write_text(json.dumps(pending_data, ensure_ascii=False, indent=2), encoding="utf-8") + + async def load_pending(self) -> None: + file_path = Path(self._storage_dir) / self._account_id / "pairing-pending.json" + if not file_path.exists(): + return + try: + data = json.loads(file_path.read_text(encoding="utf-8")) + for code, info in data.items(): + if info.get("expires_at", 0) < time.time(): + continue + self._pending[code] = PairingRequest( + code=code, + sender_handle=info.get("sender_handle", ""), + channel_user_id=info.get("channel_user_id", ""), + chat_guid=info.get("chat_guid", ""), + requested_at=info.get("requested_at", time.time()), + expires_at=info.get("expires_at", time.time() + PAIRING_VALIDITY_S), + ) + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"[iMessage/Pairing] Failed to load pending: {e}") diff --git a/backend/package/yuxi/channels/adapters/imessage/polls.py b/backend/package/yuxi/channels/adapters/imessage/polls.py new file mode 100644 index 00000000..ce28fe4a --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/polls.py @@ -0,0 +1,144 @@ +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) diff --git a/backend/package/yuxi/channels/adapters/imessage/probe.py b/backend/package/yuxi/channels/adapters/imessage/probe.py new file mode 100644 index 00000000..d12be178 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/probe.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from yuxi.channels.adapters.imessage.bridge_client import BlueBubblesClient + +_rpc_support_cache: dict[str, bool] = {} + + +def get_rpc_support_cached(server_url: str) -> bool | None: + return _rpc_support_cache.get(server_url) + + +def set_rpc_support_cached(server_url: str, supported: bool) -> None: + _rpc_support_cache[server_url] = supported + + +def clear_rpc_support_cache() -> None: + _rpc_support_cache.clear() + + +async def probe_bridge(bridge_client: BlueBubblesClient) -> dict[str, Any]: + """探测 BlueBubbles Server 状态和 iMessage 登录状态""" + server_info = await bridge_client.probe_server() + handle_info = await bridge_client.get_handle() + + return { + "bridge_version": server_info.get("version"), + "bridge_os_version": server_info.get("os_version"), + "imessage_connected": handle_info.get("connected", False), + "imessage_handle": handle_info.get("handle"), + "server_info": server_info, + "handle_info": handle_info, + } + + +async def run_doctor_diagnosis( + bridge_client: BlueBubblesClient, + security_warnings: list[str], + adapter_status: str, + self_handle: str | None, + pairing_info: dict[str, Any], +) -> dict[str, Any]: + """全量 health + warning 报告。 + + Returns: + dict with status, connectivity, security, pairing sections + """ + diagnosis: dict[str, Any] = { + "status": adapter_status, + "timestamp": None, + "connectivity": {}, + "security": { + "warnings": security_warnings, + "assessment": _assess_security(security_warnings), + }, + "pairing": pairing_info, + } + + try: + server_info = await bridge_client.probe_server() + handle_info = await bridge_client.get_handle() + + diagnosis["connectivity"] = { + "server_reachable": True, + "bridge_version": server_info.get("version", "unknown"), + "bridge_os_version": server_info.get("os_version", "unknown"), + "imessage_connected": handle_info.get("connected", False), + "imessage_handle": handle_info.get("handle", ""), + "self_handle": self_handle, + } + diagnosis["timestamp"] = None + except Exception as e: + diagnosis["connectivity"] = { + "server_reachable": False, + "error": str(e)[:200], + } + + return diagnosis + + +def _assess_security(warnings: list[str]) -> str: + if not warnings: + return "secure" + critical_keywords = ["disabled", "empty", "open"] + for w in warnings: + for kw in critical_keywords: + if kw in w.lower(): + return "at_risk" + return "needs_attention" diff --git a/backend/package/yuxi/channels/adapters/imessage/rate_limiter.py b/backend/package/yuxi/channels/adapters/imessage/rate_limiter.py new file mode 100644 index 00000000..344b8d60 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/rate_limiter.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import time + + +class LoopRateLimiter: + """循环速率限制:检测持续回显循环并抑制对话。 + + 当同一会话在短时间内反复收到相同内容的消息时, + 判定为回显循环,对 conversation 进行抑制。 + """ + + def __init__(self, max_loops: int = 5, window_s: float = 30.0, cooldown_s: float = 60.0): + self._max_loops = max_loops + self._window_s = window_s + self._cooldown_s = cooldown_s + self._counters: dict[str, list[float]] = {} + self._suppressed: dict[str, float] = {} + + def check(self, conversation_key: str, content: str) -> bool: + """返回 True 表示应该抑制此消息。""" + + if conversation_key in self._suppressed: + suppressed_at = self._suppressed[conversation_key] + if time.monotonic() - suppressed_at < self._cooldown_s: + return True + del self._suppressed[conversation_key] + + now = time.monotonic() + if conversation_key not in self._counters: + self._counters[conversation_key] = [] + timestamps = self._counters[conversation_key] + + timestamps.append(now) + timestamps[:] = [t for t in timestamps if now - t <= self._window_s] + + if len(timestamps) >= self._max_loops: + self._suppressed[conversation_key] = now + self._counters.pop(conversation_key, None) + return True + + return False + + def is_suppressed(self, conversation_key: str) -> bool: + if conversation_key in self._suppressed: + if time.monotonic() - self._suppressed[conversation_key] < self._cooldown_s: + return True + del self._suppressed[conversation_key] + return False + + def reset(self, conversation_key: str) -> None: + self._counters.pop(conversation_key, None) + self._suppressed.pop(conversation_key, None) diff --git a/backend/package/yuxi/channels/adapters/imessage/reflection_guard.py b/backend/package/yuxi/channels/adapters/imessage/reflection_guard.py new file mode 100644 index 00000000..0f716225 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/reflection_guard.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import re + +_TOOL_CALL_RE = re.compile(r"\[TOOL_CALL:", re.IGNORECASE) +_ASSISTANT_MARKER_RE = re.compile(r"\[ASSISTANT_SYSTEM\]", re.IGNORECASE) + + +class ReflectionGuard: + """反射防护:检测入站消息中是否包含 Assistant 内部标记。 + + 当出站消息包含 [TOOL_CALL:...] 等内部标记,且被 Bridge 反射回 + 入站事件时,应丢弃此类消息,避免 Assistant 将其解析为指令。 + """ + + def __init__(self): + self._blocked_count = 0 + + def is_reflection(self, text: str) -> bool: + if not text: + return False + if _TOOL_CALL_RE.search(text): + return True + if _ASSISTANT_MARKER_RE.search(text): + return True + return False + + def mark_blocked(self) -> None: + self._blocked_count += 1 + + @property + def blocked_count(self) -> int: + return self._blocked_count diff --git a/backend/package/yuxi/channels/adapters/imessage/runtime_store.py b/backend/package/yuxi/channels/adapters/imessage/runtime_store.py new file mode 100644 index 00000000..bd03fee9 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/runtime_store.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +_runtime_store: dict[str, Any] = {} + + +def set_runtime(key: str, value: Any) -> None: + _runtime_store[key] = value + + +def get_runtime(key: str, default: Any = None) -> Any: + return _runtime_store.get(key, default) + + +def has_runtime(key: str) -> bool: + return key in _runtime_store + + +def delete_runtime(key: str) -> None: + _runtime_store.pop(key, None) + + +def clear_runtime() -> None: + _runtime_store.clear() diff --git a/backend/package/yuxi/channels/adapters/imessage/sanitize.py b/backend/package/yuxi/channels/adapters/imessage/sanitize.py new file mode 100644 index 00000000..41eed814 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/sanitize.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import re + +_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]") +_MULTI_NEWLINE_RE = re.compile(r"\n{3,}") +_LENGTH_PREFIX_RE = re.compile(r"^(\d+)\n") +_TERMINAL_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") +_MAX_TERMINAL_MSG_LEN = 200 + + +def sanitize_terminal_text(text: str, max_len: int = _MAX_TERMINAL_MSG_LEN) -> str: + """净化终端输出文本,移除 ANSI 转义序列和控制字符。 + + 用于日志记录和错误消息净化。 + """ + if not text: + return "" + text = _TERMINAL_ESCAPE_RE.sub("", text) + text = _CONTROL_CHAR_RE.sub("", text) + if len(text) > max_len: + text = text[:max_len] + "..." + return text + + +def sanitize_outbound_text(text: str) -> str: + """净化出站文本,移除 iMessage 中不支持的格式。 + + - 去除 \\r 字符(换行标准化) + - 去除长度前缀损坏(如 "42\\nThis is message") + - 过滤控制字符 + - 合并多余空白行 + """ + text = text.replace("\r", "") + + text = _strip_length_prefix(text) + + text = _CONTROL_CHAR_RE.sub("", text) + + text = _MULTI_NEWLINE_RE.sub("\n\n", text) + text = text.strip() + + return text + + +def _strip_length_prefix(text: str) -> str: + match = _LENGTH_PREFIX_RE.match(text) + if match: + prefix_len = int(match.group(1)) + remaining = text[match.end() :] + if abs(len(remaining) - prefix_len) <= 5: + return remaining + return text + + +def media_placeholder(media_type: str) -> str: + """为纯媒体消息生成占位文本。 + + OpenClaw 在无文本媒体消息中生成 等占位符。 + """ + placeholders = { + "image": "", + "video": "", + "audio": "", + "file": "", + } + return placeholders.get(media_type, "") diff --git a/backend/package/yuxi/channels/adapters/imessage/security.py b/backend/package/yuxi/channels/adapters/imessage/security.py new file mode 100644 index 00000000..c046c7ea --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/security.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import Any + +from yuxi.utils.logging_config import logger + + +class DmPolicy(StrEnum): + PAIRING = "pairing" + ALLOWLIST = "allowlist" + OPEN = "open" + DISABLED = "disabled" + + +class GroupPolicy(StrEnum): + OPEN = "open" + ALLOWLIST = "allowlist" + DISABLED = "disabled" + + +@dataclass +class SecurityCheckResult: + allowed: bool + reject_reason: str | None = None + reply: str | None = None + + +@dataclass +class IMessageSecurityConfig: + dm_policy: DmPolicy = DmPolicy.PAIRING + group_policy: GroupPolicy = GroupPolicy.ALLOWLIST + allow_from: list[str] = field(default_factory=list) + group_allow_from: list[str] = field(default_factory=list) + require_mention: bool = False + + +class IMessageSecurityPolicy: + def __init__(self, config: dict[str, Any], storage_dir: str | None = None, config_writes: bool = True): + self._config = config + self._dm_policy = resolve_dm_policy(config) + self._group_policy = resolve_group_policy(config) + self._allow_list = _normalize_entries(config.get("allowFrom", config.get("allow_from", []))) + self._group_allow_list = _normalize_entries(config.get("groupAllowFrom", config.get("group_allow_from", []))) + self._require_mention = config.get("requireMention", config.get("require_mention", False)) + self._groups_config: dict[str, dict] = config.get("groups", {}) + self._group_allow_from_fallback = config.get( + "groupAllowFromFallback", config.get("group_allow_from_fallback", False) + ) + self._storage_dir = storage_dir or str(Path.home() / ".yuxi" / "imessage") + self._config_writes = config_writes + self._account_id = config.get("accountId", config.get("account_id", "default")) + self._context_visibility_mode = config.get( + "contextVisibilityMode", config.get("context_visibility_mode", "allowlist") + ) + self._warned_policies: set[str] = set() + self._pinned_dm_owner: str | None = None + + def _schedule_save(self) -> None: + if not self._config_writes: + return + try: + loop = asyncio.get_running_loop() + loop.create_task(self.save_allowlist()) + except RuntimeError: + pass + + async def save_allowlist(self) -> None: + dir_path = Path(self._storage_dir) / self._account_id + dir_path.mkdir(parents=True, exist_ok=True) + data = { + "allow_from": list(self._allow_list), + "group_allow_from": list(self._group_allow_list), + } + file_path = dir_path / "allowlist.json" + file_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + async def load_allowlist(self) -> None: + file_path = Path(self._storage_dir) / self._account_id / "allowlist.json" + if not file_path.exists(): + return + try: + data = json.loads(file_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + loaded_allow = _normalize_entries(data.get("allow_from", [])) + loaded_group = _normalize_entries(data.get("group_allow_from", [])) + for entry in loaded_allow: + if entry not in self._allow_list: + self._allow_list.append(entry) + for entry in loaded_group: + if entry not in self._group_allow_list: + self._group_allow_list.append(entry) + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"[iMessage/Security] Failed to load allowlist: {e}") + + def check_dm_access(self, sender_handle: str) -> SecurityCheckResult: + if self._dm_policy == DmPolicy.OPEN: + return SecurityCheckResult(allowed=True) + if self._dm_policy == DmPolicy.DISABLED: + return SecurityCheckResult( + allowed=False, + reject_reason="dm_disabled", + reply="iMessage DM is disabled for this bot.", + ) + if self._dm_policy == DmPolicy.ALLOWLIST: + if _match_entry(sender_handle, self._allow_list): + return SecurityCheckResult(allowed=True) + return SecurityCheckResult( + allowed=False, + reject_reason="not_in_dm_allowlist", + reply="You are not authorized to message this bot.", + ) + if self._dm_policy == DmPolicy.PAIRING: + return SecurityCheckResult(allowed=True) + return SecurityCheckResult(allowed=False, reject_reason="unknown_policy") + + def check_group_access(self, chat_guid: str) -> SecurityCheckResult: + group_override = self._groups_config.get(chat_guid, {}) + if group_override.get("enabled") is False: + return SecurityCheckResult( + allowed=False, + reject_reason="group_disabled_by_override", + reply="This bot is not enabled in this group chat.", + ) + + if self._group_policy == GroupPolicy.OPEN: + return SecurityCheckResult(allowed=True) + if self._group_policy == GroupPolicy.DISABLED: + return SecurityCheckResult( + allowed=False, + reject_reason="group_disabled", + reply="This bot does not participate in group chats.", + ) + if self._group_policy == GroupPolicy.ALLOWLIST: + if _match_entry(chat_guid, self._group_allow_list): + return SecurityCheckResult(allowed=True) + if self._group_allow_from_fallback and _match_entry(chat_guid, self._allow_list): + return SecurityCheckResult(allowed=True) + return SecurityCheckResult( + allowed=False, + reject_reason="group_not_in_allowlist", + reply="This bot is not authorized in this group chat.", + ) + return SecurityCheckResult(allowed=False, reject_reason="unknown_policy") + + def check_mention_required(self, chat_guid: str, mentions, bot_handle: str) -> SecurityCheckResult: + group_override = self._groups_config.get(chat_guid, {}) + require_mention = group_override.get("require_mention", self._require_mention) + if not require_mention: + return SecurityCheckResult(allowed=True) + + mentioned_ids: list[str] = [] + if mentions: + mentioned_ids = mentions.mentioned_user_ids or [] + elif isinstance(mentions, list): + mentioned_ids = mentions + elif isinstance(mentions, dict): + mentioned_ids = mentions.get("mentioned_user_ids", []) + + bot_clean = bot_handle.strip().replace("+", "").replace(" ", "") + for uid in mentioned_ids: + uid_clean = uid.strip().replace("+", "").replace(" ", "") + if uid_clean == bot_clean: + return SecurityCheckResult(allowed=True) + + return SecurityCheckResult( + allowed=False, + reject_reason="mention_required", + reply=None, + ) + + def collect_warnings(self) -> list[str]: + warnings: list[str] = [] + if self._dm_policy == DmPolicy.OPEN: + warnings.append( + "dmPolicy='open' — anyone can message this bot; consider using 'pairing' or 'allowlist' for security" + ) + if self._dm_policy == DmPolicy.ALLOWLIST and not self._allow_list: + warnings.append("dmPolicy='allowlist' but allowFrom is empty — no one can message the bot") + if self._group_policy == GroupPolicy.OPEN and not self._group_allow_list: + warnings.append("groupPolicy='open' but no groupAllowFrom configured — consider adding a group allowlist") + if self._group_policy == GroupPolicy.ALLOWLIST and not self._group_allow_list: + warnings.append("groupPolicy='allowlist' but groupAllowFrom is empty — no group can interact") + if self._group_allow_from_fallback: + warnings.append( + "groupAllowFromFallback=true — group allowlist falls back to DM allowlist; " + "consider disabling this for stricter group access control" + ) + return warnings + + def add_to_allow_list(self, handle: str) -> None: + handle_clean = _normalize_handle(handle) + if handle_clean not in self._allow_list: + self._allow_list.append(handle_clean) + self._schedule_save() + + def remove_from_allow_list(self, handle: str) -> bool: + handle_clean = _normalize_handle(handle) + if handle_clean in self._allow_list: + self._allow_list.remove(handle_clean) + self._schedule_save() + return True + return False + + def add_to_group_allow_list(self, chat_guid: str) -> None: + if chat_guid not in self._group_allow_list: + self._group_allow_list.append(chat_guid) + self._schedule_save() + + def remove_from_group_allow_list(self, chat_guid: str) -> bool: + if chat_guid in self._group_allow_list: + self._group_allow_list.remove(chat_guid) + self._schedule_save() + return True + return False + + def resolve_runtime_group_policy(self, chat_guid: str) -> GroupPolicy: + group_override = self._groups_config.get(chat_guid, {}) + if "group_policy" in group_override: + return GroupPolicy(group_override["group_policy"]) + + warning_key = f"group_policy_fallback:{chat_guid}" + if warning_key not in self._warned_policies: + logger.warning( + f"[iMessage/Security] No per-group policy for {chat_guid}, " + f"falling back to default groupPolicy='{self._group_policy.value}'. " + f"Consider adding a group override config." + ) + self._warned_policies.add(warning_key) + + return self._group_policy + + def evaluate_context_visibility( + self, + chat_guid: str, + reply_sender_handle: str, + ) -> bool: + if self._context_visibility_mode == "open": + return True + + if self._context_visibility_mode == "disabled": + return False + + if self._context_visibility_mode == "allowlist": + chat_type = _resolve_chat_type_from_guid(chat_guid) + if chat_type == "group": + if _match_entry(chat_guid, self._group_allow_list): + return True + if self._group_allow_from_fallback and _match_entry(chat_guid, self._allow_list): + return True + if _match_entry(reply_sender_handle, self._allow_list): + return True + return False + return _match_entry(reply_sender_handle, self._allow_list) + + return False + + def resolve_pinned_dm_owner(self) -> str | None: + if self._pinned_dm_owner: + return self._pinned_dm_owner + for entry in self._allow_list: + entry_stripped, prefix = _strip_prefix(entry) + if prefix in ("imessage", "sms", None) and entry_stripped: + clean = _normalize_handle(entry_stripped) + if clean and "@" not in clean: + self._pinned_dm_owner = clean + return clean + return None + + def should_update_last_route(self, sender_handle: str) -> bool: + owner = self.resolve_pinned_dm_owner() + if owner: + sender_clean = _normalize_handle(sender_handle) + return sender_clean == owner + return True + + @property + def dm_policy(self) -> DmPolicy: + return self._dm_policy + + @property + def group_policy(self) -> GroupPolicy: + return self._group_policy + + @property + def allow_list(self) -> list[str]: + return list(self._allow_list) + + @property + def group_allow_list(self) -> list[str]: + return list(self._group_allow_list) + + @property + def require_mention(self) -> bool: + return self._require_mention + + +def resolve_dm_policy(config: dict[str, Any]) -> DmPolicy: + explicit = config.get("dmPolicy", config.get("dm_policy", "")) + if explicit: + try: + return DmPolicy(explicit) + except ValueError: + logger.warning(f"[iMessage] Invalid dmPolicy '{explicit}', falling back to auto-detect") + allow_from = config.get("allowFrom", config.get("allow_from", [])) + if not allow_from or "*" in allow_from: + return DmPolicy.OPEN + return DmPolicy.ALLOWLIST + + +def resolve_group_policy(config: dict[str, Any]) -> GroupPolicy: + explicit = config.get("groupPolicy", config.get("group_policy", "")) + if explicit: + try: + return GroupPolicy(explicit) + except ValueError: + logger.warning(f"[iMessage] Invalid groupPolicy '{explicit}', using ALLOWLIST") + return GroupPolicy.ALLOWLIST + + +def _normalize_handle(handle: str) -> str: + return handle.strip().replace(" ", "").lstrip("+") + + +def _normalize_entries(entries: list[str]) -> list[str]: + return [_normalize_handle(e) for e in entries if e] + + +PREFIX_MAP: dict[str, str] = { + "chat_id:": "chat_id", + "chat_guid:": "chat_guid", + "chat_identifier:": "chat_identifier", + "imessage:": "imessage", + "sms:": "sms", + "auto:": "auto", +} + + +def _strip_prefix(entry: str) -> tuple[str, str | None]: + entry_lower = entry.lower() + for prefix, name in PREFIX_MAP.items(): + if entry_lower.startswith(prefix): + return entry[len(prefix) :], name + return entry, None + + +def _match_entry(target: str, allow_list: list[str]) -> bool: + if "*" in allow_list: + return True + target_clean = _normalize_handle(target) + for entry in allow_list: + entry_stripped, prefix = _strip_prefix(entry) + if prefix == "chat_id" and target_clean in [_normalize_handle(entry_stripped), entry_stripped]: + return True + if prefix in ("chat_guid", "chat_identifier") and entry_stripped == target_clean: + return True + if prefix in ("imessage", "sms", "auto", None): + entry_clean = _normalize_handle(entry_stripped) + if entry_clean == target_clean: + return True + return False + + +def _resolve_chat_type_from_guid(chat_guid: str) -> str: + if ";-;" in chat_guid or "@chat" in chat_guid: + return "group" + return "direct" diff --git a/backend/package/yuxi/channels/adapters/imessage/sent_cache.py b/backend/package/yuxi/channels/adapters/imessage/sent_cache.py new file mode 100644 index 00000000..c23a7c54 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/sent_cache.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +from yuxi.utils.logging_config import logger + + +class SentMessageCache: + def __init__(self, storage_dir: str | None = None, max_entries: int = 1000): + self._storage_dir = Path(storage_dir or str(Path.home() / ".yuxi" / "imessage")) + self._max_entries = max_entries + self._entries: dict[str, dict[str, Any]] = {} + self._storage_dir.mkdir(parents=True, exist_ok=True) + self._load() + + def record(self, message_id: str, content: str, chat_guid: str, metadata: dict[str, Any] | None = None) -> None: + entry = { + "message_id": message_id, + "content": content[:500], + "chat_guid": chat_guid, + "sent_at": time.time(), + "metadata": metadata or {}, + } + self._entries[message_id] = entry + if len(self._entries) > self._max_entries: + oldest = min(self._entries.values(), key=lambda e: e["sent_at"]) + self._entries.pop(oldest["message_id"], None) + + def get(self, message_id: str) -> dict[str, Any] | None: + return self._entries.get(message_id) + + def get_content(self, message_id: str) -> str | None: + entry = self._entries.get(message_id) + return entry["content"] if entry else None + + def list_recent(self, limit: int = 50) -> list[dict[str, Any]]: + entries = sorted(self._entries.values(), key=lambda e: e["sent_at"], reverse=True) + return entries[:limit] + + async def persist(self) -> None: + file_path = self._storage_dir / "sent_messages.json" + file_path.write_text(json.dumps(self._entries, ensure_ascii=False, indent=2), encoding="utf-8") + logger.debug(f"[iMessage/SentCache] Persisted {len(self._entries)} entries") + + def _load(self) -> None: + file_path = self._storage_dir / "sent_messages.json" + if not file_path.exists(): + return + try: + self._entries = json.loads(file_path.read_text(encoding="utf-8")) + now = time.time() + self._entries = { + mid: entry for mid, entry in self._entries.items() if now - entry.get("sent_at", 0) < 86400 + } + except (json.JSONDecodeError, OSError): + self._entries = {} diff --git a/backend/package/yuxi/channels/adapters/imessage/session.py b/backend/package/yuxi/channels/adapters/imessage/session.py new file mode 100644 index 00000000..fc0e8eb7 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/session.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from yuxi.channels.models import ChannelIdentity, ChatType + + +def resolve_thread_key( + identity: ChannelIdentity, + account_id: str = "default", + pinned_dm_owner: str | None = None, +) -> str: + channel_user_id = identity.channel_user_id + chat_guid = identity.channel_chat_id + chat_type = resolve_chat_type(chat_guid) + + if chat_type == ChatType.GROUP: + return f"agent:main:imessage:group:{chat_guid}:{account_id}" + + if pinned_dm_owner: + clean_owner = pinned_dm_owner.replace("+", "").replace("-", "").replace(" ", "") + return f"agent:main:imessage:dm:{clean_owner}:{account_id}" + + clean_phone = channel_user_id.replace("+", "").replace("-", "").replace(" ", "") + return f"agent:main:imessage:dm:{clean_phone}:{account_id}" + + +def resolve_chat_type(chat_guid: str) -> ChatType: + if ";-;" in chat_guid or "@chat" in chat_guid: + return ChatType.GROUP + return ChatType.DIRECT diff --git a/backend/package/yuxi/channels/adapters/imessage/setup_wizard.py b/backend/package/yuxi/channels/adapters/imessage/setup_wizard.py new file mode 100644 index 00000000..0b03aa94 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/setup_wizard.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +_registered_plugin: bool = False + + +def register_setup_plugin() -> bool: + global _registered_plugin + if _registered_plugin: + return True + _registered_plugin = True + return True + + +def is_setup_plugin_registered() -> bool: + return _registered_plugin + + +def unregister_setup_plugin() -> None: + global _registered_plugin + _registered_plugin = False + + +@dataclass +class SetupWizardStep: + step_id: str + label: str + description: str + field: str + field_type: str + required: bool = True + default_value: Any = None + options: list[dict[str, str]] = field(default_factory=list) + hint: str = "" + + +SETUP_STEPS: list[SetupWizardStep] = [ + SetupWizardStep( + step_id="server_url", + label="BlueBubbles Server URL", + description="The URL of your BlueBubbles server (e.g. http://192.168.1.100:1234)", + field="server_url", + field_type="text", + default_value="http://localhost:1234", + ), + SetupWizardStep( + step_id="password", + label="Server Password", + description="The password configured in your BlueBubbles server settings", + field="password", + field_type="password", + ), + SetupWizardStep( + step_id="dm_policy", + label="DM Access Policy", + description="How should the bot handle direct messages?", + field="dm_policy", + field_type="select", + default_value="pairing", + options=[ + {"value": "pairing", "label": "Pairing (users must request approval)"}, + {"value": "allowlist", "label": "Allowlist (only approved contacts)"}, + {"value": "open", "label": "Open (anyone can DM)"}, + {"value": "disabled", "label": "Disabled (no DMs)"}, + ], + hint="Pairing is recommended for controlled access. Users get a code to share with admins.", + ), + SetupWizardStep( + step_id="group_policy", + label="Group Chat Policy", + description="How should the bot handle group chats?", + field="group_policy", + field_type="select", + default_value="allowlist", + options=[ + {"value": "open", "label": "Open (any group can add the bot)"}, + {"value": "allowlist", "label": "Allowlist (only approved groups)"}, + {"value": "disabled", "label": "Disabled (no group chats)"}, + ], + ), + SetupWizardStep( + step_id="allow_from", + label="Allowed Contacts", + description="List of contacts allowed to interact with the bot (one per line). Use * for everyone.", + field="allow_from", + field_type="textarea", + required=False, + hint="Phone numbers (E.164), email addresses, or * for all. Prefix with chat_id: or chat_guid: for specific chats.", + ), + SetupWizardStep( + step_id="completed", + label="Setup Complete", + description="Your iMessage channel is now configured! The bot will restart with these settings.", + field="completed", + field_type="info", + ), +] + + +def get_setup_steps() -> list[dict[str, Any]]: + return [ + { + "step_id": s.step_id, + "label": s.label, + "description": s.description, + "field": s.field, + "field_type": s.field_type, + "required": s.required, + "default_value": s.default_value, + "options": s.options, + "hint": s.hint, + } + for s in SETUP_STEPS + ] + + +def generate_permissions_summary(config: dict[str, Any]) -> str: + """生成权限提醒摘要文本。""" + dm_policy = config.get("dmPolicy", config.get("dm_policy", "pairing")) + lines = [ + "iMessage channel configured successfully!", + "", + f"DM Policy: {dm_policy}", + f"Group Policy: {config.get('groupPolicy', config.get('group_policy', 'allowlist'))}", + "", + "Notes:", + "- Ensure BlueBubbles server has 'Private API' enabled", + "- The bot must be signed into iMessage on the Mac running BlueBubbles", + "- Group chats: add the bot to a group, then approve the group GUID via allowlist", + ] + return "\n".join(lines) diff --git a/backend/package/yuxi/channels/adapters/imessage/sticker_cache.py b/backend/package/yuxi/channels/adapters/imessage/sticker_cache.py new file mode 100644 index 00000000..5725c4ee --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/sticker_cache.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + + +class StickerCache: + def __init__(self, cache_dir: str | None = None, max_entries: int = 500): + self._cache_dir = Path(cache_dir or str(Path.home() / ".yuxi" / "imessage" / "stickers")) + self._max_entries = max_entries + self._index: dict[str, dict[str, Any]] = {} + self._cache_dir.mkdir(parents=True, exist_ok=True) + self._load_index() + + def add(self, sticker_id: str, url: str, metadata: dict[str, Any] | None = None) -> None: + entry = { + "sticker_id": sticker_id, + "url": url, + "metadata": metadata or {}, + "cached_at": time.time(), + } + self._index[sticker_id] = entry + if len(self._index) > self._max_entries: + oldest = min(self._index.values(), key=lambda e: e["cached_at"]) + self._index.pop(oldest["sticker_id"], None) + self._save_index() + + def get(self, sticker_id: str) -> dict[str, Any] | None: + return self._index.get(sticker_id) + + def get_url(self, sticker_id: str) -> str | None: + entry = self._index.get(sticker_id) + return entry["url"] if entry else None + + def clear(self) -> None: + self._index.clear() + self._save_index() + + def _load_index(self) -> None: + index_path = self._cache_dir / "sticker_index.json" + if index_path.exists(): + try: + self._index = json.loads(index_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + self._index = {} + + def _save_index(self) -> None: + index_path = self._cache_dir / "sticker_index.json" + index_path.write_text(json.dumps(self._index, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/backend/package/yuxi/channels/adapters/imessage/tapback.py b/backend/package/yuxi/channels/adapters/imessage/tapback.py new file mode 100644 index 00000000..30197bd7 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/tapback.py @@ -0,0 +1,58 @@ +TAPBACK_VALUE_MAP: dict[str, int] = { + "love": 0, + "heart": 0, + "like": 1, + "thumbs_up": 1, + "dislike": 2, + "thumbs_down": 2, + "laugh": 3, + "exclaim": 4, + "question": 5, +} + +TAPBACK_EMOJI_MAP: dict[str, str] = { + "❤️": "love", + "👍": "like", + "👎": "dislike", + "😂": "laugh", + "❗": "exclaim", + "❓": "question", +} + +TAPBACK_REVERSE_MAP: dict[int, str] = { + 0: "❤️", + 1: "👍", + 2: "👎", + 3: "😂", + 4: "❗", + 5: "❓", +} + +TAPBACK_NAME_MAP: dict[int, str] = { + 0: "❤️ Loved", + 1: "👍 Liked", + 2: "👎 Disliked", + 3: "😂 Laughed", + 4: "❗ Emphasized", + 5: "❓ Questioned", +} + + +def emoji_to_tapback(emoji: str) -> str | None: + return TAPBACK_EMOJI_MAP.get(emoji) + + +def resolve_tapback_value(tapback_name: str) -> int | None: + return TAPBACK_VALUE_MAP.get(tapback_name.lower().strip().lstrip(":")) + + +def tapback_to_emoji(tapback_value: int) -> str | None: + return TAPBACK_REVERSE_MAP.get(tapback_value) + + +def describe_tapback(tapback_value: int) -> str: + return TAPBACK_NAME_MAP.get(tapback_value, "Reacted") + + +def is_valid_tapback(emoji: str) -> bool: + return emoji in TAPBACK_EMOJI_MAP diff --git a/backend/package/yuxi/channels/adapters/imessage/target_parsing_helpers.py b/backend/package/yuxi/channels/adapters/imessage/target_parsing_helpers.py new file mode 100644 index 00000000..49e5e6bd --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/target_parsing_helpers.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +TargetType = Literal["chat_id", "chat_guid", "chat_identifier", "handle", "phone", "email", "unknown"] + + +@dataclass +class ParsedTarget: + type: TargetType + value: str + + +def parse_imessage_target(raw_target: str) -> ParsedTarget: + from yuxi.channels.adapters.imessage.targets import parse_target + + result = parse_target(raw_target) + return ParsedTarget(type=result["type"], value=result["value"]) + + +def is_explicit_target_id(target: str) -> bool: + from yuxi.channels.adapters.imessage.targets import looks_like_explicit_target_id + + return looks_like_explicit_target_id(target) + + +def is_imessage_target_id(target: str) -> bool: + from yuxi.channels.adapters.imessage.targets import looks_like_imessage_target_id + + return looks_like_imessage_target_id(target) + + +def resolve_target_prefix(target: str) -> str | None: + from yuxi.channels.adapters.imessage.targets import TARGET_PREFIX_MAP + + target_lower = target.lower() + for prefix, type_name in TARGET_PREFIX_MAP.items(): + if target_lower.startswith(prefix): + return type_name + return None + + +def strip_target_prefix(target: str) -> tuple[str, str | None]: + from yuxi.channels.adapters.imessage.targets import TARGET_PREFIX_MAP + + target_lower = target.lower() + for prefix, type_name in TARGET_PREFIX_MAP.items(): + if target_lower.startswith(prefix): + return target[len(prefix) :], type_name + return target, None + + +def normalize_imessage_handle(handle: str) -> str: + from yuxi.channels.adapters.imessage.normalize import normalize_e164 + + return normalize_e164(handle) + + +def normalize_imessage_handle_preserve_alias(handle: str) -> str: + from yuxi.channels.adapters.imessage.normalize import _strip_service_prefixes + + return _strip_service_prefixes(handle) + + +def looks_like_phone(handle: str) -> bool: + cleaned = handle.strip().replace(" ", "").lstrip("+") + return cleaned.isdigit() and len(cleaned) >= 7 + + +def looks_like_email(handle: str) -> bool: + return "@" in handle and "." in handle.split("@")[-1] + + +__all__ = [ + "ParsedTarget", + "TargetType", + "parse_imessage_target", + "is_explicit_target_id", + "is_imessage_target_id", + "resolve_target_prefix", + "strip_target_prefix", + "normalize_imessage_handle", + "normalize_imessage_handle_preserve_alias", + "looks_like_phone", + "looks_like_email", +] diff --git a/backend/package/yuxi/channels/adapters/imessage/targets.py b/backend/package/yuxi/channels/adapters/imessage/targets.py new file mode 100644 index 00000000..92b1b74a --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/targets.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from yuxi.channels.models import ChatType + +TARGET_PREFIX_MAP: dict[str, str] = { + "chat_id:": "chat_id", + "chat_guid:": "chat_guid", + "chat_identifier:": "chat_identifier", + "handle:": "handle", + "phone:": "phone", + "email:": "email", +} + +EXPLICIT_TARGET_RE_PREFIXES = ( + "chat_id:", + "chat_guid:", + "chat_identifier:", + "iMessage;", + "iMessage", + "handle:", + "phone:", + "email:", +) + +IMESSAGE_TARGET_PREFIXES = ( + "chat_id:", + "chat_guid:", + "chat_identifier:", + "iMessage;", + "iMessage", + "handle:", + "phone:", + "email:", + "tel:", + "mailto:", + "+", +) + + +def parse_target(raw_target: str) -> dict[str, str]: + """解析目标字符串,返回 {type, value}。 + + 支持的类型: + - chat_id: → chat_id + - chat_guid: → chat_guid + - chat_identifier: → chat_identifier (chat_id 或 chat_guid) + - handle: → handle + - phone: → phone (E.164) + - email: → email + - 其他:自动推断为 handle + """ + if not raw_target: + return {"type": "unknown", "value": ""} + + raw_lower = raw_target.lower() + for prefix, type_name in TARGET_PREFIX_MAP.items(): + if raw_lower.startswith(prefix): + return {"type": type_name, "value": raw_target[len(prefix) :]} + + if looks_like_explicit_target_id(raw_target): + if ";-;" in raw_target or "@chat" in raw_target: + return {"type": "chat_guid", "value": raw_target} + if raw_target.startswith("iMessage;"): + return {"type": "chat_id", "value": raw_target} + + if "@" in raw_target and "." in raw_target.split("@")[-1]: + return {"type": "email", "value": raw_target} + + return {"type": "handle", "value": raw_target} + + +def looks_like_explicit_target_id(target: str) -> bool: + return any(target.lower().startswith(p.lower()) for p in EXPLICIT_TARGET_RE_PREFIXES) + + +def looks_like_imessage_target_id(target: str) -> bool: + return any(target.lower().startswith(p.lower()) for p in IMESSAGE_TARGET_PREFIXES) + + +def resolve_chat_type_from_target(target: str) -> ChatType: + parsed = parse_target(target) + value = parsed["value"] + if ";-;" in value or "@chat" in value: + return ChatType.GROUP + return ChatType.DIRECT diff --git a/backend/package/yuxi/channels/adapters/imessage/threading.py b/backend/package/yuxi/channels/adapters/imessage/threading.py new file mode 100644 index 00000000..b01c5460 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/threading.py @@ -0,0 +1,43 @@ +from __future__ import annotations + + +def format_reply_context( + reply_to_message_id: str | None = None, + reply_to_text: str | None = None, + reply_to_sender: str | None = None, +) -> str: + """格式化引用回复上下文。 + + 生成如 "[Replying to +8613800138000 id:msg_001]" 格式的上下文行, + 附加在出站消息文本前。 + """ + if not reply_to_message_id and not reply_to_text: + return "" + + parts: list[str] = ["[Replying to"] + + if reply_to_sender: + parts.append(f" {reply_to_sender}") + + if reply_to_message_id: + parts.append(f" id:{reply_to_message_id}") + + if reply_to_text: + preview = reply_to_text[:100].replace("\n", " ") + parts.append(f' "{preview}"') + + parts.append("]\n") + return " ".join(parts) if len(parts) > 2 else "" + + +def describe_reply_context(data: dict) -> str: + """从消息数据中提取并格式化回复上下文。""" + reply_to_guid = data.get("replyToGuid") + reply_to_text = data.get("threadOriginatorText") + reply_to_sender = data.get("threadOriginator") + + return format_reply_context( + reply_to_message_id=reply_to_guid, + reply_to_text=reply_to_text, + reply_to_sender=reply_to_sender, + ) diff --git a/backend/package/yuxi/channels/adapters/imessage/transport.py b/backend/package/yuxi/channels/adapters/imessage/transport.py new file mode 100644 index 00000000..1051cb07 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/imessage/transport.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +from yuxi.utils.logging_config import logger + +DEFAULT_TRANSPORT_TIMEOUT_S = 30.0 +DEFAULT_TRANSPORT_POLL_INTERVAL_S = 0.5 + + +async def wait_for_transport_ready( + probe_fn: Callable[[], Awaitable[bool]], + timeout_s: float = DEFAULT_TRANSPORT_TIMEOUT_S, + poll_interval_s: float = DEFAULT_TRANSPORT_POLL_INTERVAL_S, +) -> bool: + deadline = asyncio.get_event_loop().time() + timeout_s + + while asyncio.get_event_loop().time() < deadline: + try: + ready = await probe_fn() + if ready: + logger.info("[iMessage/Transport] Transport ready") + return True + except Exception: + pass + + await asyncio.sleep(poll_interval_s) + + logger.error(f"[iMessage/Transport] Transport not ready after {timeout_s}s") + return False