diff --git a/cron/scheduler.py b/cron/scheduler.py index 0bb266d34799..b53eed909605 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -62,6 +62,42 @@ def _resolve_origin(job: dict) -> Optional[dict]: return None +def _resolve_explicit_delivery_target(platform_name: str, target_ref: str) -> dict: + """Resolve an explicit platform target, including display labels from the channel directory.""" + from gateway.channel_directory import resolve_channel_name + from gateway.delivery import parse_platform_target_ref + + platform_name = platform_name.strip().lower() + target_ref = target_ref.strip() + + chat_id, thread_id, is_explicit = parse_platform_target_ref(platform_name, target_ref) + if is_explicit: + return { + "platform": platform_name, + "chat_id": chat_id, + "thread_id": thread_id, + } + + try: + resolved = resolve_channel_name(platform_name, target_ref) + except Exception: + resolved = None + + if resolved: + chat_id, thread_id, is_explicit = parse_platform_target_ref(platform_name, resolved) + return { + "platform": platform_name, + "chat_id": chat_id if is_explicit else resolved, + "thread_id": thread_id, + } + + return { + "platform": platform_name, + "chat_id": target_ref, + "thread_id": None, + } + + def _resolve_delivery_target(job: dict) -> Optional[dict]: """Resolve the concrete auto-delivery target for a cron job, if any.""" deliver = job.get("deliver", "local") @@ -80,17 +116,8 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]: } if ":" in deliver: - platform_name, rest = deliver.split(":", 1) - # Check for thread_id suffix (e.g. "telegram:-1003724596514:17") - if ":" in rest: - chat_id, thread_id = rest.split(":", 1) - else: - chat_id, thread_id = rest, None - return { - "platform": platform_name, - "chat_id": chat_id, - "thread_id": thread_id, - } + platform_name, target_ref = deliver.split(":", 1) + return _resolve_explicit_delivery_target(platform_name, target_ref) platform_name = deliver if origin and origin.get("platform") == platform_name: diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index ec8d2a84b379..f8d8e5976695 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -176,6 +176,19 @@ def load_directory() -> Dict[str, Any]: return {"updated_at": None, "platforms": {}} +def _channel_match_names(channel: Dict[str, Any]) -> List[str]: + """Return accepted lookup labels for a channel entry.""" + name = str(channel.get("name") or "").strip() + if not name: + return [] + + names = [name.lower()] + channel_type = str(channel.get("type") or "").strip().lower() + if channel_type: + names.append(f"{name} ({channel_type})".lower()) + return names + + def resolve_channel_name(platform_name: str, name: str) -> Optional[str]: """ Resolve a human-friendly channel name to a numeric ID. @@ -194,7 +207,7 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]: # 1. Exact name match for ch in channels: - if ch["name"].lower() == query: + if query in _channel_match_names(ch): return ch["id"] # 2. Guild-qualified match for Discord ("GuildName/channel") @@ -206,7 +219,10 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]: return ch["id"] # 3. Partial prefix match (only if unambiguous) - matches = [ch for ch in channels if ch["name"].lower().startswith(query)] + matches = [ + ch for ch in channels + if any(candidate.startswith(query) for candidate in _channel_match_names(ch)) + ] if len(matches) == 1: return matches[0]["id"] diff --git a/gateway/delivery.py b/gateway/delivery.py index 28b7cf754aff..d88575644c22 100644 --- a/gateway/delivery.py +++ b/gateway/delivery.py @@ -9,6 +9,7 @@ Routes messages to the appropriate destination based on: """ import logging +import re from pathlib import Path from datetime import datetime from dataclasses import dataclass @@ -21,11 +22,36 @@ logger = logging.getLogger(__name__) MAX_PLATFORM_OUTPUT = 4000 TRUNCATED_VISIBLE = 3800 +_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$") +_PHONE_TARGET_RE = re.compile(r"^\+?\d+$") +_EMAIL_TARGET_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") from .config import Platform, GatewayConfig from .session import SessionSource +def parse_platform_target_ref(platform_name: str, target_ref: str): + """Parse a platform target into chat_id/thread_id and whether it is explicit.""" + platform_name = str(platform_name or "").strip().lower() + target_ref = str(target_ref or "").strip() + + if platform_name == "telegram": + match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref) + if match: + return match.group(1), match.group(2), True + if target_ref.lstrip("-").isdigit(): + return target_ref, None, True + if platform_name in {"signal", "sms"} and _PHONE_TARGET_RE.fullmatch(target_ref): + return target_ref, None, True + if platform_name == "signal" and target_ref.startswith("group:"): + return target_ref, None, True + if platform_name == "email" and _EMAIL_TARGET_RE.fullmatch(target_ref): + return target_ref, None, True + if platform_name == "whatsapp" and "@" in target_ref and not any(ch.isspace() for ch in target_ref): + return target_ref, None, True + return None, None, False + + @dataclass class DeliveryTarget: """ diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 2e98d64b1e75..57761f9b1c6b 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -116,6 +116,36 @@ class TestResolveDeliveryTarget: "thread_id": None, } + def test_explicit_whatsapp_display_label_resolves_via_channel_directory(self): + job = { + "deliver": "whatsapp:Alice (dm)", + } + + with patch( + "gateway.channel_directory.resolve_channel_name", + return_value="12345678901234@lid", + ): + assert _resolve_delivery_target(job) == { + "platform": "whatsapp", + "chat_id": "12345678901234@lid", + "thread_id": None, + } + + def test_explicit_whatsapp_jid_preserved_when_no_directory_match_exists(self): + job = { + "deliver": "whatsapp:12345678901234@lid", + } + + with patch( + "gateway.channel_directory.resolve_channel_name", + return_value=None, + ): + assert _resolve_delivery_target(job) == { + "platform": "whatsapp", + "chat_id": "12345678901234@lid", + "thread_id": None, + } + class TestDeliverResultWrapping: """Verify that cron deliveries are wrapped with header/footer and no longer mirrored.""" diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index 2ecacc457db0..73be79318c8d 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -119,6 +119,13 @@ class TestResolveChannelName: with self._setup(tmp_path, platforms): assert resolve_channel_name("telegram", "Coaching Chat / topic 17585") == "-1001:17585" + def test_display_label_resolves_to_channel_id(self, tmp_path): + platforms = { + "whatsapp": [{"id": "12345678901234@lid", "name": "Alice", "type": "dm"}] + } + with self._setup(tmp_path, platforms): + assert resolve_channel_name("whatsapp", "Alice (dm)") == "12345678901234@lid" + class TestBuildFromSessions: def _write_sessions(self, tmp_path, sessions_data): diff --git a/tests/gateway/test_delivery.py b/tests/gateway/test_delivery.py index 3894897f42c5..c71c34320883 100644 --- a/tests/gateway/test_delivery.py +++ b/tests/gateway/test_delivery.py @@ -1,7 +1,7 @@ """Tests for the delivery routing module.""" from gateway.config import Platform, GatewayConfig, PlatformConfig, HomeChannel -from gateway.delivery import DeliveryRouter, DeliveryTarget, parse_deliver_spec +from gateway.delivery import DeliveryRouter, DeliveryTarget, parse_deliver_spec, parse_platform_target_ref from gateway.session import SessionSource @@ -41,6 +41,32 @@ class TestParseTargetPlatformChat: assert target.platform == Platform.LOCAL +class TestParsePlatformTargetRef: + def test_whatsapp_jid_is_explicit(self): + assert parse_platform_target_ref("whatsapp", "12345678901234@lid") == ( + "12345678901234@lid", + None, + True, + ) + + def test_signal_phone_is_explicit(self): + assert parse_platform_target_ref("signal", "+15551234567") == ( + "+15551234567", + None, + True, + ) + + def test_email_address_is_explicit(self): + assert parse_platform_target_ref("email", "alice@example.com") == ( + "alice@example.com", + None, + True, + ) + + def test_human_label_is_not_explicit(self): + assert parse_platform_target_ref("whatsapp", "Alice (dm)") == (None, None, False) + + class TestParseDeliverSpec: def test_none_returns_default(self): result = parse_deliver_spec(None) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 058678d36a99..306e79607460 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -16,12 +16,12 @@ def _run_async_immediately(coro): return asyncio.run(coro) -def _make_config(): - telegram_cfg = SimpleNamespace(enabled=True, token="***", extra={}) +def _make_config(platform=Platform.TELEGRAM, *, token="***", extra=None): + platform_cfg = SimpleNamespace(enabled=True, token=token, extra=extra or {}) return SimpleNamespace( - platforms={Platform.TELEGRAM: telegram_cfg}, + platforms={platform: platform_cfg}, get_home_channel=lambda _platform: None, - ), telegram_cfg + ), platform_cfg def _install_telegram_mock(monkeypatch, bot): @@ -203,6 +203,65 @@ class TestSendMessageTool: media_files=[], ) + def test_resolved_whatsapp_display_label_uses_resolved_jid(self): + config, whatsapp_cfg = _make_config(Platform.WHATSAPP, token=None, extra={"bridge_port": 3000}) + + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch("gateway.channel_directory.resolve_channel_name", return_value="12345678901234@lid"), \ + patch("model_tools._run_async", side_effect=_run_async_immediately), \ + patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ + patch("gateway.mirror.mirror_to_session", return_value=True): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "whatsapp:Alice (dm)", + "message": "hello", + } + ) + ) + + assert result["success"] is True + send_mock.assert_awaited_once_with( + Platform.WHATSAPP, + whatsapp_cfg, + "12345678901234@lid", + "hello", + thread_id=None, + media_files=[], + ) + + def test_explicit_whatsapp_jid_bypasses_channel_name_resolution(self): + config, whatsapp_cfg = _make_config(Platform.WHATSAPP, token=None, extra={"bridge_port": 3000}) + + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch("gateway.channel_directory.resolve_channel_name") as resolve_mock, \ + patch("model_tools._run_async", side_effect=_run_async_immediately), \ + patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ + patch("gateway.mirror.mirror_to_session", return_value=True): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "whatsapp:12345678901234@lid", + "message": "hello", + } + ) + ) + + assert result["success"] is True + resolve_mock.assert_not_called() + send_mock.assert_awaited_once_with( + Platform.WHATSAPP, + whatsapp_cfg, + "12345678901234@lid", + "hello", + thread_id=None, + media_files=[], + ) + def test_media_only_message_uses_placeholder_for_mirroring(self): config, telegram_cfg = _make_config() diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index ed0a5cb60e2d..8e785c929a8e 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -12,9 +12,10 @@ import re import ssl import time +from gateway.delivery import parse_platform_target_ref + logger = logging.getLogger(__name__) -_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$") _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"} _AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"} @@ -86,7 +87,7 @@ def _handle_send(args): thread_id = None if target_ref: - chat_id, thread_id, is_explicit = _parse_target_ref(platform_name, target_ref) + chat_id, thread_id, is_explicit = parse_platform_target_ref(platform_name, target_ref) else: is_explicit = False @@ -96,7 +97,7 @@ def _handle_send(args): from gateway.channel_directory import resolve_channel_name resolved = resolve_channel_name(platform_name, target_ref) if resolved: - chat_id, thread_id, _ = _parse_target_ref(platform_name, resolved) + chat_id, thread_id, _ = parse_platform_target_ref(platform_name, resolved) else: return json.dumps({ "error": f"Could not resolve '{target_ref}' on {platform_name}. " @@ -191,18 +192,6 @@ def _handle_send(args): except Exception as e: return json.dumps({"error": f"Send failed: {e}"}) - -def _parse_target_ref(platform_name: str, target_ref: str): - """Parse a tool target into chat_id/thread_id and whether it is explicit.""" - if platform_name == "telegram": - match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref) - if match: - return match.group(1), match.group(2), True - if target_ref.lstrip("-").isdigit(): - return target_ref, None, True - return None, None, False - - def _describe_media_for_mirror(media_files): """Return a human-readable mirror summary when a message only contains media.""" if not media_files: