format telephone number and mail

This commit is contained in:
MuXinCG
2026-02-04 18:46:04 +08:00
parent 9f12a35b7f
commit b6f778e725
4 changed files with 540 additions and 34 deletions
+19 -1
View File
@@ -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",
]
+183 -33
View File
@@ -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
+106
View File
@@ -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
+232
View File
@@ -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}"