diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 3d9da96ae..626179de1 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -770,6 +770,46 @@ class TestParseTargetRefMatrix: assert is_explicit is False +class TestParseTargetRefE164: + """_parse_target_ref accepts E.164 phone numbers for phone-based platforms.""" + + def test_signal_e164_preserves_plus_prefix(self): + """signal:+E164 is explicit and preserves the leading '+' for signal-cli.""" + chat_id, thread_id, is_explicit = _parse_target_ref("signal", "+41791234567") + assert chat_id == "+41791234567" + assert thread_id is None + assert is_explicit is True + + def test_sms_e164_is_explicit(self): + chat_id, _, is_explicit = _parse_target_ref("sms", "+15551234567") + assert chat_id == "+15551234567" + assert is_explicit is True + + def test_whatsapp_e164_is_explicit(self): + chat_id, _, is_explicit = _parse_target_ref("whatsapp", "+15551234567") + assert chat_id == "+15551234567" + assert is_explicit is True + + def test_signal_bare_digits_still_work(self): + """Bare digit strings continue to match the generic numeric branch.""" + chat_id, _, is_explicit = _parse_target_ref("signal", "15551234567") + assert chat_id == "15551234567" + assert is_explicit is True + + def test_signal_invalid_e164_rejected(self): + """Too-short, too-long, and non-numeric E.164 strings are not explicit.""" + assert _parse_target_ref("signal", "+123")[2] is False + assert _parse_target_ref("signal", "+1234567890123456")[2] is False + assert _parse_target_ref("signal", "+12abc4567890")[2] is False + assert _parse_target_ref("signal", "+")[2] is False + + def test_e164_prefix_only_matches_phone_platforms(self): + """'+' prefix must NOT be treated as explicit for non-phone platforms.""" + assert _parse_target_ref("telegram", "+15551234567")[2] is False + assert _parse_target_ref("discord", "+15551234567")[2] is False + assert _parse_target_ref("matrix", "+15551234567")[2] is False + + class TestSendDiscordThreadId: """_send_discord uses thread_id when provided.""" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index eef267368..534426607 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -23,6 +23,13 @@ _FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::( _WEIXIN_TARGET_RE = re.compile(r"^\s*((?:wxid|gh|v\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Za-z0-9._-]+@chatroom|filehelper)\s*$") # Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets. _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE +# Platforms that address recipients by phone number and accept E.164 format +# (with a leading '+'). Without this, "+15551234567" fails the isdigit() check +# below and falls through to channel-name resolution, which has no way to +# resolve a raw phone number. Keeping the '+' preserves the E.164 form that +# downstream adapters (signal, etc.) expect. +_PHONE_PLATFORMS = frozenset({"signal", "sms", "whatsapp"}) +_E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$") _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"} _AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"} @@ -317,6 +324,12 @@ def _parse_target_ref(platform_name: str, target_ref: str): match = _WEIXIN_TARGET_RE.fullmatch(target_ref) if match: return match.group(1), None, True + if platform_name in _PHONE_PLATFORMS: + match = _E164_TARGET_RE.fullmatch(target_ref) + if match: + # Preserve the leading '+' — signal-cli and sms/whatsapp adapters + # expect E.164 format for direct recipients. + return target_ref.strip(), None, True if target_ref.lstrip("-").isdigit(): return target_ref, None, True # Matrix room IDs (start with !) and user IDs (start with @) are explicit