feat(gateway): simplex channel enumeration + show configured platforms in hermes send --list

Builds on the adapter list_channels() hook (cherry-picked from #43545 by
@Guoen0):

- plugins/platforms/simplex: implement list_channels() — enumerates
  contacts (/contacts) and groups (/groups) over the live daemon
  WebSocket into the channel directory. Returns None when the WS is
  down so the directory falls back to session discovery instead of
  wiping known targets.
- hermes send --list: merge configured-but-undiscovered platforms into
  the listing. Previously a platform configured only via env (e.g. a
  fresh SimpleX setup used for outbound sends) was silently omitted,
  leaving users guessing at platform names.
- format_directory_for_display(): accept an explicit platforms view and
  render empty platforms with a targeting hint instead of hiding them.
- docs: simplex hermes-send section.

Reported by Fedpostoffice on Discord (simplex missing from
hermes send --list; guessed platform names simplex-chat/simplex-relay).
This commit is contained in:
Teknium 2026-08-02 14:49:38 -07:00
parent bc334f5380
commit 7483745da7
7 changed files with 284 additions and 8 deletions

View File

@ -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}:<chat_id>, or bare '{plat_name}' for the home channel)"
)
lines.append("")
continue
# Group Discord channels by guild

View File

@ -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):

View File

@ -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:<name>``) and
``group:<groupId>`` for groups (``simplex:group:<id>``).
"""
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 (``@<name>``)
# 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
# ------------------------------------------------------------------

View File

@ -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):

View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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: