format telephone number and mail
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user