From b6f778e725500c4e681c1bd3e6a74b65be769bb7 Mon Sep 17 00:00:00 2001 From: MuXinCG <202322130196@mail.sdu.edu.cn> Date: Wed, 4 Feb 2026 18:46:04 +0800 Subject: [PATCH] format telephone number and mail --- EvoScientist/channels/imessage/__init__.py | 20 +- EvoScientist/channels/imessage/channel_rpc.py | 216 +++++++++++++--- EvoScientist/channels/imessage/probe.py | 106 ++++++++ EvoScientist/channels/imessage/targets.py | 232 ++++++++++++++++++ 4 files changed, 540 insertions(+), 34 deletions(-) create mode 100644 EvoScientist/channels/imessage/probe.py create mode 100644 EvoScientist/channels/imessage/targets.py diff --git a/EvoScientist/channels/imessage/__init__.py b/EvoScientist/channels/imessage/__init__.py index 083c5cc..cfc980f 100644 --- a/EvoScientist/channels/imessage/__init__.py +++ b/EvoScientist/channels/imessage/__init__.py @@ -11,5 +11,23 @@ Requirements: from .channel_rpc import IMessageChannelRpc as IMessageChannel from .channel_rpc import IMessageConfig +from .probe import probe_imessage, ProbeResult +from .targets import ( + parse_target, + normalize_handle, + normalize_e164, + IMessageTarget, + IMessageService, +) -__all__ = ["IMessageChannel", "IMessageConfig"] +__all__ = [ + "IMessageChannel", + "IMessageConfig", + "probe_imessage", + "ProbeResult", + "parse_target", + "normalize_handle", + "normalize_e164", + "IMessageTarget", + "IMessageService", +] diff --git a/EvoScientist/channels/imessage/channel_rpc.py b/EvoScientist/channels/imessage/channel_rpc.py index 53aa369..2a6635b 100644 --- a/EvoScientist/channels/imessage/channel_rpc.py +++ b/EvoScientist/channels/imessage/channel_rpc.py @@ -6,12 +6,19 @@ via JSON-RPC, similar to OpenClaw's approach. import asyncio import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime -from typing import AsyncIterator, Set +from typing import AsyncIterator from ..base import Channel, IncomingMessage, OutgoingMessage, ChannelError from .rpc_client import ImsgRpcClient, RpcNotification +from .targets import ( + normalize_handle, + parse_target, + ChatIdTarget, + ChatGuidTarget, + ChatIdentifierTarget, +) logger = logging.getLogger(__name__) @@ -22,10 +29,11 @@ class IMessageConfig: cli_path: str = "imsg" db_path: str | None = None - allowed_senders: Set[str] | None = None + allowed_senders: list[str] = field(default_factory=list) include_attachments: bool = False text_chunk_limit: int = 4000 service: str = "auto" # imessage, sms, or auto + region: str = "US" class IMessageChannelRpc(Channel): @@ -70,7 +78,9 @@ class IMessageChannelRpc(Channel): return # Check allowed senders - if not self._is_sender_allowed(sender): + chat_id = message.get("chat_id") + chat_guid = message.get("chat_guid") + if not self._is_sender_allowed(sender, chat_id, chat_guid): logger.debug(f"Ignoring message from {sender}") return @@ -113,31 +123,82 @@ class IMessageChannelRpc(Channel): except asyncio.QueueFull: logger.warning("Message queue full, dropping message") - def _is_sender_allowed(self, sender: str) -> bool: - """Check if sender is in allowed list.""" + def _is_sender_allowed( + self, + sender: str, + chat_id: int | None = None, + chat_guid: str | None = None, + ) -> bool: + """Check if sender is in allowed list. + + Supports: + - Wildcard "*" to allow all + - chat_id:123 to match by chat ID + - chat_guid:abc to match by chat GUID + - Normalized phone/email matching + """ if not self.config.allowed_senders: return True - return sender in self.config.allowed_senders + + # Wildcard allows all + if "*" in self.config.allowed_senders: + return True + + sender_normalized = normalize_handle(sender) + + for entry in self.config.allowed_senders: + entry = entry.strip() + if not entry: + continue + + lower = entry.lower() + + # Check chat_id match + if lower.startswith("chat_id:") or lower.startswith("chatid:"): + if chat_id is not None: + try: + allowed_id = int(entry.split(":", 1)[1].strip()) + if allowed_id == chat_id: + return True + except ValueError: + pass + continue + + # Check chat_guid match + if lower.startswith("chat_guid:") or lower.startswith("chatguid:"): + if chat_guid: + allowed_guid = entry.split(":", 1)[1].strip() + if allowed_guid == chat_guid: + return True + continue + + # Normalize and compare handle + entry_normalized = normalize_handle(entry) + if entry_normalized == sender_normalized: + return True + + return False def add_allowed_sender(self, sender: str) -> None: """Add a sender to the allowed list.""" - if self.config.allowed_senders is None: - self.config.allowed_senders = set() - self.config.allowed_senders.add(sender) - logger.info(f"Added allowed sender: {sender}") + normalized = normalize_handle(sender) if not sender.startswith("chat") else sender + if normalized not in self.config.allowed_senders: + self.config.allowed_senders.append(normalized) + logger.info(f"Added allowed sender: {normalized}") def remove_allowed_sender(self, sender: str) -> None: """Remove a sender from the allowed list.""" - if self.config.allowed_senders: - self.config.allowed_senders.discard(sender) - logger.info(f"Removed allowed sender: {sender}") + normalized = normalize_handle(sender) if not sender.startswith("chat") else sender + if normalized in self.config.allowed_senders: + self.config.allowed_senders.remove(normalized) + logger.info(f"Removed allowed sender: {normalized}") def clear_allowed_senders(self) -> None: """Clear allowed list (allow all).""" - self.config.allowed_senders = None + self.config.allowed_senders = [] logger.info("Cleared allowed senders (allowing all)") - def list_allowed_senders(self) -> set[str] | None: + def list_allowed_senders(self) -> list[str]: """Get current allowed senders.""" return self.config.allowed_senders @@ -243,23 +304,8 @@ class IMessageChannelRpc(Channel): segments = self._segment_message(message.content) for segment in segments: - # Prefer chat_id over recipient (to) for replies - chat_id = message.metadata.get("chat_id") - - if chat_id: - params = { - "chat_id": chat_id, - "text": segment, - "service": self.config.service, - } - elif message.recipient: - params = { - "to": message.recipient, - "text": segment, - "service": self.config.service, - } - else: - logger.error("Cannot send: no recipient or chat_id") + params = self._build_send_params(message, segment) + if not params: return False try: @@ -269,3 +315,107 @@ class IMessageChannelRpc(Channel): return False return True + + def _build_send_params( + self, message: OutgoingMessage, text: str + ) -> dict | None: + """Build send parameters from message.""" + params: dict = { + "text": text, + "service": self.config.service, + "region": self.config.region, + } + + # Check metadata for chat targets + chat_id = message.metadata.get("chat_id") + chat_guid = message.metadata.get("chat_guid") + chat_identifier = message.metadata.get("chat_identifier") + + if chat_id: + params["chat_id"] = chat_id + elif chat_guid: + params["chat_guid"] = chat_guid + elif chat_identifier: + params["chat_identifier"] = chat_identifier + elif message.recipient: + # Parse recipient to determine target type + try: + target = parse_target(message.recipient) + if isinstance(target, ChatIdTarget): + params["chat_id"] = target.chat_id + elif isinstance(target, ChatGuidTarget): + params["chat_guid"] = target.chat_guid + elif isinstance(target, ChatIdentifierTarget): + params["chat_identifier"] = target.chat_identifier + else: + params["to"] = target.to + params["service"] = target.service.value + except ValueError: + params["to"] = message.recipient + else: + logger.error("Cannot send: no recipient or chat target") + return None + + return params + + async def send_media( + self, + recipient: str, + file_path: str, + caption: str = "", + metadata: dict | None = None, + ) -> bool: + """Send a media file via iMessage. + + Args: + recipient: Target recipient or chat target + file_path: Local path to the media file + caption: Optional caption text + metadata: Optional metadata with chat_id etc. + + Returns: + True if sent successfully + """ + if not self._client: + logger.error("Cannot send media: client not running") + return False + + metadata = metadata or {} + params: dict = { + "file": file_path, + "service": self.config.service, + "region": self.config.region, + } + + if caption: + params["text"] = caption + + # Determine target + chat_id = metadata.get("chat_id") + chat_guid = metadata.get("chat_guid") + + if chat_id: + params["chat_id"] = chat_id + elif chat_guid: + params["chat_guid"] = chat_guid + elif recipient: + try: + target = parse_target(recipient) + if isinstance(target, ChatIdTarget): + params["chat_id"] = target.chat_id + elif isinstance(target, ChatGuidTarget): + params["chat_guid"] = target.chat_guid + else: + params["to"] = target.to + except ValueError: + params["to"] = recipient + else: + logger.error("Cannot send media: no recipient") + return False + + try: + await self._client.request("send", params) + return True + except Exception as e: + logger.error(f"Send media failed: {e}") + return False diff --git a/EvoScientist/channels/imessage/probe.py b/EvoScientist/channels/imessage/probe.py new file mode 100644 index 0000000..24702a9 --- /dev/null +++ b/EvoScientist/channels/imessage/probe.py @@ -0,0 +1,106 @@ +"""iMessage environment probe/diagnostics. + +Provides utilities to detect and verify the imsg CLI environment. +""" + +import asyncio +import shutil +from dataclasses import dataclass + + +@dataclass +class ProbeResult: + """Result of iMessage environment probe.""" + + available: bool = False + cli_path: str | None = None + cli_version: str | None = None + rpc_supported: bool = False + error: str | None = None + + +def find_cli(cli_path: str = "imsg") -> str | None: + """Find the imsg CLI binary. + + Args: + cli_path: Path or command name to search + + Returns: + Full path to CLI or None if not found + """ + return shutil.which(cli_path) + + +async def get_cli_version(cli_path: str) -> str | None: + """Get the imsg CLI version. + + Args: + cli_path: Path to CLI binary + + Returns: + Version string or None + """ + try: + proc = await asyncio.create_subprocess_exec( + cli_path, "--version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0) + return stdout.decode().strip() or None + except Exception: + return None + + +async def check_rpc_support(cli_path: str) -> bool: + """Check if CLI supports RPC mode. + + Args: + cli_path: Path to CLI binary + + Returns: + True if RPC is supported + """ + try: + proc = await asyncio.create_subprocess_exec( + cli_path, "rpc", "--help", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await asyncio.wait_for(proc.communicate(), timeout=5.0) + return proc.returncode == 0 + except Exception: + return False + + +async def probe_imessage( + cli_path: str = "imsg", + timeout_ms: int = 10000, +) -> ProbeResult: + """Probe the iMessage environment. + + Args: + cli_path: Path or name of imsg CLI + timeout_ms: Timeout in milliseconds + + Returns: + ProbeResult with environment details + """ + result = ProbeResult() + + # Find CLI + found_path = find_cli(cli_path) + if not found_path: + result.error = f"imsg CLI not found: {cli_path}" + return result + + result.cli_path = found_path + result.available = True + + # Get version + result.cli_version = await get_cli_version(found_path) + + # Check RPC support + result.rpc_supported = await check_rpc_support(found_path) + + return result diff --git a/EvoScientist/channels/imessage/targets.py b/EvoScientist/channels/imessage/targets.py new file mode 100644 index 0000000..c4287f4 --- /dev/null +++ b/EvoScientist/channels/imessage/targets.py @@ -0,0 +1,232 @@ +"""iMessage target parsing and normalization. + +Provides utilities for parsing iMessage targets and normalizing +phone numbers and email addresses, similar to OpenClaw's approach. +""" + +import re +from dataclasses import dataclass +from enum import Enum +from typing import Union + + +class IMessageService(Enum): + """iMessage service type.""" + IMESSAGE = "imessage" + SMS = "sms" + AUTO = "auto" + + +@dataclass +class ChatIdTarget: + """Target by chat ID.""" + kind: str = "chat_id" + chat_id: int = 0 + + +@dataclass +class ChatGuidTarget: + """Target by chat GUID.""" + kind: str = "chat_guid" + chat_guid: str = "" + + +@dataclass +class ChatIdentifierTarget: + """Target by chat identifier.""" + kind: str = "chat_identifier" + chat_identifier: str = "" + + +@dataclass +class HandleTarget: + """Target by handle (phone/email).""" + kind: str = "handle" + to: str = "" + service: IMessageService = IMessageService.AUTO + + +IMessageTarget = Union[ChatIdTarget, ChatGuidTarget, ChatIdentifierTarget, HandleTarget] + + +# Prefix constants +CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"] +CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"] +CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"] +SERVICE_PREFIXES = [ + ("imessage:", IMessageService.IMESSAGE), + ("sms:", IMessageService.SMS), + ("auto:", IMessageService.AUTO), +] + + +def normalize_e164(phone: str) -> str | None: + """Normalize phone number to E.164 format. + + Args: + phone: Raw phone number string + + Returns: + Normalized E.164 format or None if invalid + """ + # Remove all non-digit characters except leading + + cleaned = re.sub(r"[^\d+]", "", phone) + + if not cleaned: + return None + + # Already has + prefix + if cleaned.startswith("+"): + digits = cleaned[1:] + if len(digits) >= 10 and len(digits) <= 15: + return cleaned + return None + + # US/Canada number without country code + if len(cleaned) == 10: + return f"+1{cleaned}" + + # Has country code + if len(cleaned) >= 11 and len(cleaned) <= 15: + return f"+{cleaned}" + + return None + + +def normalize_handle(raw: str) -> str: + """Normalize an iMessage handle (phone or email). + + Args: + raw: Raw handle string + + Returns: + Normalized handle + """ + trimmed = raw.strip() + if not trimmed: + return "" + + lowered = trimmed.lower() + + # Strip service prefixes + for prefix, _ in SERVICE_PREFIXES: + if lowered.startswith(prefix): + return normalize_handle(trimmed[len(prefix):]) + + # Normalize chat_id/chat_guid/chat_identifier prefixes + for prefix in CHAT_ID_PREFIXES: + if lowered.startswith(prefix): + value = trimmed[len(prefix):].strip() + return f"chat_id:{value}" + + for prefix in CHAT_GUID_PREFIXES: + if lowered.startswith(prefix): + value = trimmed[len(prefix):].strip() + return f"chat_guid:{value}" + + for prefix in CHAT_IDENTIFIER_PREFIXES: + if lowered.startswith(prefix): + value = trimmed[len(prefix):].strip() + return f"chat_identifier:{value}" + + # Email - lowercase + if "@" in trimmed: + return trimmed.lower() + + # Phone number - normalize to E.164 + normalized = normalize_e164(trimmed) + if normalized: + return normalized + + # Fallback: remove whitespace + return re.sub(r"\s+", "", trimmed) + + +def parse_target(raw: str) -> IMessageTarget: + """Parse an iMessage target string. + + Supports formats: + - chat_id:123 + - chat_guid:abc-def + - chat_identifier:iMessage;+;chat123 + - imessage:+1234567890 + - sms:+1234567890 + - +1234567890 (auto service) + - email@example.com (auto service) + + Args: + raw: Raw target string + + Returns: + Parsed IMessageTarget + + Raises: + ValueError: If target is invalid + """ + trimmed = raw.strip() + if not trimmed: + raise ValueError("iMessage target is required") + + lower = trimmed.lower() + + # Check service prefixes first + for prefix, service in SERVICE_PREFIXES: + if lower.startswith(prefix): + remainder = trimmed[len(prefix):].strip() + if not remainder: + raise ValueError(f"{prefix} target is required") + + remainder_lower = remainder.lower() + + # Check if remainder is a chat target + is_chat = any( + remainder_lower.startswith(p) + for p in CHAT_ID_PREFIXES + CHAT_GUID_PREFIXES + CHAT_IDENTIFIER_PREFIXES + ) + if is_chat: + return parse_target(remainder) + + return HandleTarget(to=remainder, service=service) + + # Check chat_id prefixes + for prefix in CHAT_ID_PREFIXES: + if lower.startswith(prefix): + value = trimmed[len(prefix):].strip() + try: + chat_id = int(value) + return ChatIdTarget(chat_id=chat_id) + except ValueError: + raise ValueError(f"Invalid chat_id: {value}") + + # Check chat_guid prefixes + for prefix in CHAT_GUID_PREFIXES: + if lower.startswith(prefix): + value = trimmed[len(prefix):].strip() + if not value: + raise ValueError("chat_guid is required") + return ChatGuidTarget(chat_guid=value) + + # Check chat_identifier prefixes + for prefix in CHAT_IDENTIFIER_PREFIXES: + if lower.startswith(prefix): + value = trimmed[len(prefix):].strip() + if not value: + raise ValueError("chat_identifier is required") + return ChatIdentifierTarget(chat_identifier=value) + + # Default: handle with auto service + return HandleTarget(to=trimmed, service=IMessageService.AUTO) + + +def format_chat_target(chat_id: int | None) -> str: + """Format a chat ID as a target string. + + Args: + chat_id: Chat ID number + + Returns: + Formatted target string or empty string + """ + if chat_id is None: + return "" + return f"chat_id:{chat_id}"