diff --git a/cron/scheduler.py b/cron/scheduler.py index e20a0dfc4..e6bc09e2a 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -98,24 +98,26 @@ 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) + platform_key = platform_name.lower() + + from tools.send_message_tool import _parse_target_ref + + parsed_chat_id, parsed_thread_id, is_explicit = _parse_target_ref(platform_key, rest) + if is_explicit: + chat_id, thread_id = parsed_chat_id, parsed_thread_id else: chat_id, thread_id = rest, None # Resolve human-friendly labels like "Alice (dm)" to real IDs. - # send_message(action="list") shows labels with display suffixes - # that aren't valid platform IDs (e.g. WhatsApp JIDs). try: from gateway.channel_directory import resolve_channel_name - target = chat_id - # Strip display suffix like " (dm)" or " (group)" - if target.endswith(")") and " (" in target: - target = target.rsplit(" (", 1)[0].strip() - resolved = resolve_channel_name(platform_name.lower(), target) + resolved = resolve_channel_name(platform_key, chat_id) if resolved: - chat_id = resolved + parsed_chat_id, parsed_thread_id, resolved_is_explicit = _parse_target_ref(platform_key, resolved) + if resolved_is_explicit: + chat_id, thread_id = parsed_chat_id, parsed_thread_id + else: + chat_id = resolved except Exception: pass diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 235f11f59..cdd2ff9a2 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -18,6 +18,20 @@ logger = logging.getLogger(__name__) DIRECTORY_PATH = get_hermes_home() / "channel_directory.json" +def _normalize_channel_query(value: str) -> str: + return value.lstrip("#").strip().lower() + + +def _channel_target_name(platform_name: str, channel: Dict[str, Any]) -> str: + """Return the human-facing target label shown to users for a channel entry.""" + name = channel["name"] + if platform_name == "discord" and channel.get("guild"): + return f"#{name}" + if platform_name != "discord" and channel.get("type"): + return f"{name} ({channel['type']})" + return name + + def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]: chat_id = origin.get("chat_id") if not chat_id: @@ -188,23 +202,25 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]: if not channels: return None - query = name.lstrip("#").lower() + query = _normalize_channel_query(name) - # 1. Exact name match + # 1. Exact name match, including the display labels shown by send_message(action="list") for ch in channels: - if ch["name"].lower() == query: + if _normalize_channel_query(ch["name"]) == query: + return ch["id"] + if _normalize_channel_query(_channel_target_name(platform_name, ch)) == query: return ch["id"] # 2. Guild-qualified match for Discord ("GuildName/channel") if "/" in query: guild_part, ch_part = query.rsplit("/", 1) for ch in channels: - guild = ch.get("guild", "").lower() - if guild == guild_part and ch["name"].lower() == ch_part: + guild = ch.get("guild", "").strip().lower() + if guild == guild_part and _normalize_channel_query(ch["name"]) == ch_part: 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 _normalize_channel_query(ch["name"]).startswith(query)] if len(matches) == 1: return matches[0]["id"] @@ -239,17 +255,16 @@ def format_directory_for_display() -> str: for guild_name, guild_channels in sorted(guilds.items()): lines.append(f"Discord ({guild_name}):") for ch in sorted(guild_channels, key=lambda c: c["name"]): - lines.append(f" discord:#{ch['name']}") + lines.append(f" discord:{_channel_target_name(plat_name, ch)}") if dms: lines.append("Discord (DMs):") for ch in dms: - lines.append(f" discord:{ch['name']}") + lines.append(f" discord:{_channel_target_name(plat_name, ch)}") lines.append("") else: lines.append(f"{plat_name.title()}:") for ch in channels: - type_label = f" ({ch['type']})" if ch.get("type") else "" - lines.append(f" {plat_name}:{ch['name']}{type_label}") + lines.append(f" {plat_name}:{_channel_target_name(plat_name, ch)}") lines.append("") lines.append('Use these as the "target" parameter when sending.') diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index afec21ce7..06df5c351 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -90,8 +90,9 @@ class TestResolveDeliveryTarget: with patch( "gateway.channel_directory.resolve_channel_name", return_value="12345678901234@lid", - ): + ) as resolve_mock: result = _resolve_delivery_target(job) + resolve_mock.assert_called_once_with("whatsapp", "Alice (dm)") assert result == { "platform": "whatsapp", "chat_id": "12345678901234@lid", @@ -112,6 +113,20 @@ class TestResolveDeliveryTarget: "thread_id": None, } + def test_human_friendly_topic_label_preserves_thread_id(self): + """Resolved Telegram topic labels should split chat_id and thread_id.""" + job = {"deliver": "telegram:Coaching Chat / topic 17585 (group)"} + with patch( + "gateway.channel_directory.resolve_channel_name", + return_value="-1009999:17585", + ): + result = _resolve_delivery_target(job) + assert result == { + "platform": "telegram", + "chat_id": "-1009999", + "thread_id": "17585", + } + def test_raw_id_not_mangled_when_directory_returns_none(self): """deliver: 'whatsapp:12345@lid' passes through when directory has no match.""" job = {"deliver": "whatsapp:12345@lid"} diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index 2ecacc457..8981be6be 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -119,6 +119,19 @@ class TestResolveChannelName: with self._setup(tmp_path, platforms): assert resolve_channel_name("telegram", "Coaching Chat / topic 17585") == "-1001:17585" + def test_display_label_with_type_suffix_resolves(self, tmp_path): + platforms = { + "telegram": [ + {"id": "123", "name": "Alice", "type": "dm"}, + {"id": "456", "name": "Dev Group", "type": "group"}, + {"id": "-1001:17585", "name": "Coaching Chat / topic 17585", "type": "group"}, + ] + } + with self._setup(tmp_path, platforms): + assert resolve_channel_name("telegram", "Alice (dm)") == "123" + assert resolve_channel_name("telegram", "Dev Group (group)") == "456" + assert resolve_channel_name("telegram", "Coaching Chat / topic 17585 (group)") == "-1001:17585" + class TestBuildFromSessions: def _write_sessions(self, tmp_path, sessions_data): diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 058678d36..7b4643af8 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -203,6 +203,44 @@ class TestSendMessageTool: media_files=[], ) + def test_display_label_target_resolves_via_channel_directory(self, tmp_path): + config, telegram_cfg = _make_config() + cache_file = tmp_path / "channel_directory.json" + cache_file.write_text(json.dumps({ + "updated_at": "2026-01-01T00:00:00", + "platforms": { + "telegram": [ + {"id": "-1001:17585", "name": "Coaching Chat / topic 17585", "type": "group"} + ] + }, + })) + + with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file), \ + patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + 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": "telegram:Coaching Chat / topic 17585 (group)", + "message": "hello", + } + ) + ) + + assert result["success"] is True + send_mock.assert_awaited_once_with( + Platform.TELEGRAM, + telegram_cfg, + "-1001", + "hello", + thread_id="17585", + media_files=[], + ) + def test_media_only_message_uses_placeholder_for_mirroring(self): config, telegram_cfg = _make_config()