diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 469db24fc20..fba3d58d51a 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -49,12 +49,16 @@ def _apply_channel_aliases(platforms: Dict[str, Any]) -> None: if not isinstance(id_map, dict): continue entries = platforms.setdefault(plat_name, []) + if not isinstance(entries, list): + continue for chat_id, friendly in id_map.items(): - if not friendly: + if not isinstance(friendly, str) or not friendly.strip(): continue + chat_id = str(chat_id) + friendly = friendly.strip() matched = False for e in entries: - if e.get("id") == chat_id: + if isinstance(e, dict) and e.get("id") == chat_id: e["name"] = friendly matched = True if not matched: diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 62997528bd8..e7e1e8ed908 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -510,6 +510,7 @@ _QUICK_STATE_FILES = ( "cron/jobs.json", "gateway_state.json", "channel_directory.json", + "channel_aliases.json", "processes.json", # Pairing stores (generic + per-platform JSONs outside state.db) "pairing", # legacy location (gateway/pairing.py) diff --git a/scripts/release.py b/scripts/release.py index d316e36b1c0..95d12106ff3 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -87,6 +87,7 @@ AUTHOR_MAP = { "dirtyren@users.noreply.github.com": "dirtyren", "tharushkadinujaya05@gmail.com": "0xneobyte", "138671361+Veritas-7@users.noreply.github.com": "Veritas-7", + "keiron@onehanded.com": "kmccammon", "895252509@qq.com": "895252509", "35259607+zxcasongs@users.noreply.github.com": "zxcasongs", "alfred@my-cloud.me": "alfred-smith-0", diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index 3224e6941b6..8c32eb8f409 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -566,9 +566,13 @@ class TestChannelAliases: assert names == ["general"] def test_apply_aliases_handles_malformed_map(self): - """Non-dict alias entries must not raise.""" + """Non-dict alias maps and non-string aliases must not raise.""" platforms = {"whatsapp": [{"id": "1@g.us", "name": "1", "type": "group"}]} with patch("gateway.channel_directory._load_channel_aliases", - return_value={"whatsapp": "not-a-dict", "telegram": None}): + return_value={ + "whatsapp": "not-a-dict", + "telegram": None, + "signal": {"+15551234567": 123}, + }): _apply_channel_aliases(platforms) # should not raise assert platforms["whatsapp"][0]["name"] == "1" diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 15a2112ac26..07a6c55466a 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -1199,6 +1199,9 @@ class TestQuickSnapshot: (home / "config.yaml").write_text("model:\n provider: openrouter\n") (home / ".env").write_text("OPENROUTER_API_KEY=test-key-123\n") (home / "auth.json").write_text('{"providers": {}}\n') + (home / "channel_aliases.json").write_text( + '{"whatsapp": {"120363408391911677@g.us": "general"}}\n' + ) (home / "cron").mkdir() (home / "cron" / "jobs.json").write_text('{"jobs": []}\n') @@ -1241,6 +1244,13 @@ class TestQuickSnapshot: snap_id = create_quick_snapshot(hermes_home=hermes_home) assert (hermes_home / "state-snapshots" / snap_id / "cron" / "jobs.json").exists() + def test_copies_channel_aliases(self, hermes_home): + from hermes_cli.backup import create_quick_snapshot + snap_id = create_quick_snapshot(hermes_home=hermes_home) + copied = hermes_home / "state-snapshots" / snap_id / "channel_aliases.json" + assert copied.exists() + assert "120363408391911677@g.us" in copied.read_text() + def test_missing_files_skipped(self, hermes_home): from hermes_cli.backup import create_quick_snapshot snap_id = create_quick_snapshot(hermes_home=hermes_home) diff --git a/tests/tools/test_send_message_target_parse.py b/tests/tools/test_send_message_target_parse.py index c3ad24576fb..6b122658005 100644 --- a/tests/tools/test_send_message_target_parse.py +++ b/tests/tools/test_send_message_target_parse.py @@ -1,10 +1,20 @@ -"""Parser-only tests for send_message targets. +"""Parser-only and lightweight routing tests for send_message targets. These stay separate from ``test_send_message_tool.py`` because that module skips wholesale when optional Telegram dependencies are not installed. """ -from tools.send_message_tool import _parse_target_ref +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from gateway.config import Platform +from tools.send_message_tool import _parse_target_ref, send_message_tool + + +def _run_async_immediately(coro): + return asyncio.run(coro) def test_photon_e164_target_is_explicit() -> None: @@ -18,3 +28,65 @@ def test_photon_e164_target_is_explicit() -> None: def test_e164_target_still_requires_phone_platform() -> None: assert _parse_target_ref("matrix", "+15551234567")[2] is False + +def test_whatsapp_group_jid_target_is_explicit() -> None: + chat_id, thread_id, is_explicit = _parse_target_ref( + "whatsapp", "120363408391911677@g.us" + ) + + assert chat_id == "120363408391911677@g.us" + assert thread_id is None + assert is_explicit is True + + +def test_whatsapp_native_jids_are_explicit() -> None: + assert _parse_target_ref("whatsapp", "19255551234@s.whatsapp.net")[2] is True + assert _parse_target_ref("whatsapp", "149606612619433@lid")[2] is True + assert _parse_target_ref("whatsapp", "status@broadcast")[2] is True + assert _parse_target_ref("whatsapp", "120363000000000000@newsletter")[2] is True + + +def test_whatsapp_jid_suffix_only_matches_whatsapp() -> None: + assert _parse_target_ref("telegram", "120363408391911677@g.us")[2] is False + assert _parse_target_ref("signal", "149606612619433@lid")[2] is False + + +def test_whatsapp_friendly_name_still_uses_directory_resolution() -> None: + assert _parse_target_ref("whatsapp", "general")[2] is False + + +def test_send_message_routes_whatsapp_group_jid_without_home_fallback() -> None: + whatsapp_cfg = SimpleNamespace(enabled=True, token=None, extra={"api_url": "http://bridge"}) + config = SimpleNamespace( + platforms={Platform.WHATSAPP: whatsapp_cfg}, + get_home_channel=lambda _platform: SimpleNamespace(chat_id="15551234567@s.whatsapp.net"), + ) + + 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", side_effect=AssertionError("raw JID should not resolve via directory")), \ + 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:120363408391911677@g.us", + "message": "hello group", + } + ) + ) + + assert result["success"] is True + assert "note" not in result + send_mock.assert_awaited_once_with( + Platform.WHATSAPP, + whatsapp_cfg, + "120363408391911677@g.us", + "hello group", + thread_id=None, + media_files=[], + force_document=False, + ) +