diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index e3c19d8eb1541..5a68a570a2b78 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -577,18 +577,32 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]: return None -def format_directory_for_display() -> str: - """Format the channel directory as a human-readable list for the model.""" - directory = load_directory() - platforms = directory.get("platforms", {}) +def format_directory_for_display(platforms: Optional[Dict[str, Any]] = None) -> str: + """Format the channel directory as a human-readable list for the model. - if not any(platforms.values()): + ``platforms`` overrides the on-disk directory when provided (used by + ``hermes send --list`` to merge in configured-but-undiscovered + platforms). Platforms present with an empty channel list are rendered + with a "(no channels discovered yet)" hint instead of being hidden — + a configured platform is a valid send target even before discovery. + """ + if platforms is None: + directory = load_directory() + platforms = directory.get("platforms", {}) + + if not platforms: return "No messaging platforms connected or no channels discovered yet." lines = ["Available messaging targets:\n"] for plat_name, channels in sorted(platforms.items()): if not channels: + lines.append(f"{plat_name.title()}:") + lines.append( + f" (no channels discovered yet — send directly with " + f"{plat_name}:, or bare '{plat_name}' for the home channel)" + ) + lines.append("") continue # Group Discord channels by guild diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index e926ceefb54fd..f3219065923d1 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -164,6 +164,26 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int: platforms = dict(raw.get("platforms") or {}) + # Merge in configured-but-undiscovered platforms so `--list` never hides + # a working send target. The directory only contains platforms the + # gateway has discovered channels for; a platform configured via env / + # config.yaml that has never run channel discovery (e.g. a fresh SimpleX + # setup used only for outbound `hermes send`) would otherwise be + # invisible, leaving users guessing at platform names. + try: + from gateway.config import load_gateway_config + + gw_config = load_gateway_config() + for plat in gw_config.get_connected_platforms(): + plat_name = getattr(plat, "value", str(plat)) + if plat_name in ("local", "api_server", "webhook"): + continue + platforms.setdefault(plat_name, []) + except Exception: + # Directory contents alone are still useful; don't fail --list over + # a config parse problem. + pass + if platform_filter: key = platform_filter.strip().lower() filtered = {k: v for k, v in platforms.items() if k.lower() == key} @@ -180,16 +200,17 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int: print(json.dumps({"platforms": platforms}, indent=2, default=str)) return _SUCCESS_EXIT - if not any(platforms.values()): + if not platforms: print("No messaging platforms configured or no channels discovered yet.") print("Set one up with `hermes gateway setup`, or run the gateway once so") print("channel discovery can populate ~/.hermes/channel_directory.json.") return _SUCCESS_EXIT # Human display — when unfiltered, reuse the shared formatter the agent - # already sees. When filtered, build a minimal view ourselves. + # already sees (passing the merged view so configured-but-undiscovered + # platforms are listed too). When filtered, build a minimal view ourselves. if platform_filter is None: - print(format_directory_for_display()) + print(format_directory_for_display(platforms)) return _SUCCESS_EXIT for plat_name in sorted(platforms): diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py index ae4c6be34b64d..6745854d3baa0 100644 --- a/plugins/platforms/simplex/adapter.py +++ b/plugins/platforms/simplex/adapter.py @@ -853,6 +853,75 @@ class SimplexAdapter(BasePlatformAdapter): return SendResult(success=True) + # ------------------------------------------------------------------ + # Channel directory enumeration + # ------------------------------------------------------------------ + + async def list_channels(self) -> Optional[List[Dict[str, Any]]]: + """Enumerate contacts and allowed groups for the channel directory. + + Called by ``gateway.channel_directory.build_channel_directory()`` + every refresh cycle. Uses the daemon's ``/contacts`` and ``/groups`` + commands over the live WebSocket. Returns ``None`` (not ``[]``) when + the WebSocket is down so the directory falls back to session-history + discovery instead of wiping previously known targets. + + Entry ``id`` values match the send-target formats the adapter + accepts: bare contact display name for DMs (``simplex:``) and + ``group:`` for groups (``simplex:group:``). + """ + if not self._ws: + return None + + channels: List[Dict[str, Any]] = [] + + resp = await self._send_command("/contacts", timeout=10.0) + if resp is None: + # Daemon unresponsive — keep whatever the directory already has. + return None + for contact in resp.get("contacts") or []: + if not isinstance(contact, dict): + continue + contact_id = contact.get("contactId") + name = ( + contact.get("localDisplayName", "") + or (contact.get("profile", {}) or {}).get("displayName", "") + ) + if contact_id is None and not name: + continue + channels.append({ + # Display name is what the DM send path (``@``) + # actually addresses; fall back to the numeric contactId. + "id": str(name or contact_id), + "name": str(name or contact_id), + "type": "dm", + }) + + resp = await self._send_command("/groups", timeout=10.0) + if resp is not None: + for group in resp.get("groups") or []: + # The daemon returns each group as either a groupInfo dict + # or a [groupInfo, groupSummary] pair depending on version. + if isinstance(group, list) and group: + group = group[0] + if not isinstance(group, dict): + continue + group_id = group.get("groupId") + if group_id is None: + continue + name = ( + group.get("localDisplayName", "") + or (group.get("groupProfile", {}) or {}).get("displayName", "") + or str(group_id) + ) + channels.append({ + "id": f"group:{group_id}", + "name": str(name), + "type": "group", + }) + + return channels + # ------------------------------------------------------------------ # Outbound — media # ------------------------------------------------------------------ diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index 8df58f36914a9..ce97ab7095cc9 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -204,6 +204,24 @@ class TestFormatDirectoryForDisplay: result = format_directory_for_display() assert "No messaging platforms" in result + def test_platform_with_no_channels_gets_hint(self): + """A configured platform with zero discovered channels is shown with + a hint instead of being hidden entirely.""" + result = format_directory_for_display({ + "simplex": [], + "telegram": [{"id": "1", "name": "home", "type": "dm"}], + }) + assert "Simplex:" in result + assert "no channels discovered yet" in result + assert "telegram:home" in result + + def test_explicit_platforms_override_disk(self, tmp_path): + with patch("gateway.channel_directory.DIRECTORY_PATH", tmp_path / "nope.json"): + result = format_directory_for_display( + {"irc": [{"id": "#chan", "name": "#chan", "type": "channel"}]} + ) + assert "irc:#chan" in result + class TestLookupChannelType: def _setup(self, tmp_path, platforms): diff --git a/tests/gateway/test_simplex_plugin.py b/tests/gateway/test_simplex_plugin.py index 02c97525c19e8..5a9cbc34320b6 100644 --- a/tests/gateway/test_simplex_plugin.py +++ b/tests/gateway/test_simplex_plugin.py @@ -161,6 +161,72 @@ async def test_send_group(): assert result.success is True +# --------------------------------------------------------------------------- +# 7b. Channel directory enumeration (list_channels) +# --------------------------------------------------------------------------- + + +def _adapter_with_ws(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + adapter._ws = AsyncMock() + return adapter + + +@pytest.mark.asyncio +async def test_list_channels_contacts_and_groups(): + adapter = _adapter_with_ws() + + async def fake_send_command(command, timeout=30.0): + if command == "/contacts": + return { + "contacts": [ + {"contactId": 1, "localDisplayName": "alice"}, + {"contactId": 2, "profile": {"displayName": "bob"}}, + "garbage", + ] + } + if command == "/groups": + return { + "groups": [ + {"groupId": 7, "localDisplayName": "friends"}, + # [groupInfo, groupSummary] pair form + [{"groupId": 9, "groupProfile": {"displayName": "work"}}, {}], + ] + } + return None + + adapter._send_command = fake_send_command + channels = await adapter.list_channels() + + assert {"id": "alice", "name": "alice", "type": "dm"} in channels + assert {"id": "bob", "name": "bob", "type": "dm"} in channels + assert {"id": "group:7", "name": "friends", "type": "group"} in channels + assert {"id": "group:9", "name": "work", "type": "group"} in channels + + +@pytest.mark.asyncio +async def test_list_channels_returns_none_when_disconnected(): + """None (not []) so the directory falls back to session discovery.""" + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + assert adapter._ws is None + assert await adapter.list_channels() is None + + +@pytest.mark.asyncio +async def test_list_channels_returns_none_on_contacts_timeout(): + adapter = _adapter_with_ws() + + async def fake_send_command(command, timeout=30.0): + return None # daemon unresponsive + + adapter._send_command = fake_send_command + assert await adapter.list_channels() is None + + # --------------------------------------------------------------------------- # 8. Inbound: filter own-echo by corrId prefix # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_send_cmd.py b/tests/hermes_cli/test_send_cmd.py index d0eb408199fb5..d9cab6495e2bb 100644 --- a/tests/hermes_cli/test_send_cmd.py +++ b/tests/hermes_cli/test_send_cmd.py @@ -103,6 +103,77 @@ def test_file_decode_error_suggests_media_directive(fake_tool, capsys, monkeypat # --------------------------------------------------------------------------- +def test_list_includes_configured_platform_without_discovered_channels( + monkeypatch, capsys +): + """A configured platform absent from the channel directory must still be + listed (with a no-channels hint) instead of silently omitted.""" + import types + import sys + + class _FakePlatform: + def __init__(self, value): + self.value = value + + class _FakeGwConfig: + def get_connected_platforms(self): + return [_FakePlatform("simplex")] + + fake_gw_config = types.ModuleType("gateway.config") + fake_gw_config.load_gateway_config = lambda: _FakeGwConfig() + monkeypatch.setitem(sys.modules, "gateway.config", fake_gw_config) + + fake_dir = types.ModuleType("gateway.channel_directory") + fake_dir.load_directory = lambda: {"updated_at": None, "platforms": {}} + + def _format(platforms=None): + lines = [] + for name, channels in sorted((platforms or {}).items()): + lines.append(f"{name}:") + if not channels: + lines.append(" (no channels discovered yet)") + return "\n".join(lines) + + fake_dir.format_directory_for_display = _format + monkeypatch.setitem(sys.modules, "gateway.channel_directory", fake_dir) + + rc = send_cmd._list_targets(None, json_mode=False) + out = capsys.readouterr().out + assert rc == 0 + assert "simplex" in out + assert "no channels discovered yet" in out + + +def test_list_json_includes_configured_platform(monkeypatch, capsys): + import types + import sys + + class _FakePlatform: + def __init__(self, value): + self.value = value + + class _FakeGwConfig: + def get_connected_platforms(self): + return [_FakePlatform("simplex"), _FakePlatform("local")] + + fake_gw_config = types.ModuleType("gateway.config") + fake_gw_config.load_gateway_config = lambda: _FakeGwConfig() + monkeypatch.setitem(sys.modules, "gateway.config", fake_gw_config) + + fake_dir = types.ModuleType("gateway.channel_directory") + fake_dir.load_directory = lambda: { + "updated_at": None, + "platforms": {"telegram": [{"id": "1", "name": "home"}]}, + } + fake_dir.format_directory_for_display = lambda platforms=None: "" + monkeypatch.setitem(sys.modules, "gateway.channel_directory", fake_dir) + + rc = send_cmd._list_targets(None, json_mode=True) + payload = json.loads(capsys.readouterr().out) + assert rc == 0 + assert payload["platforms"]["simplex"] == [] + assert "local" not in payload["platforms"] # infra pseudo-platform skipped + assert payload["platforms"]["telegram"] # discovered entries preserved # --------------------------------------------------------------------------- diff --git a/website/docs/user-guide/messaging/simplex.md b/website/docs/user-guide/messaging/simplex.md index 7292a7eb68079..cffff51fe0ad1 100644 --- a/website/docs/user-guide/messaging/simplex.md +++ b/website/docs/user-guide/messaging/simplex.md @@ -85,6 +85,23 @@ SIMPLEX_GROUP_ALLOWED=* # any group the bot is in Address groups by prefixing the chat ID with `group:`, e.g. `simplex:group:12` as a cron `deliver=` target or in a `hermes send` call. +## Sending with `hermes send` + +SimpleX works as a standalone send target — the daemon must be running, +but a live gateway is not required for plain text: + +```bash +hermes send --to simplex:alice "hello" # DM by contact display name +hermes send --to simplex:group:12 "hello" # group by numeric ID +hermes send --to simplex "hello" # SIMPLEX_HOME_CHANNEL +``` + +While the gateway is running, the adapter enumerates your contacts and +allowed groups into the channel directory (refreshed every 5 minutes), so +`hermes send --list` shows them by name. Before the first gateway run the +platform still appears in `--list` with a "no channels discovered yet" +hint — direct targets like the ones above work regardless. + ## Attachments The adapter supports native SimpleX attachments in both directions: