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:
parent
bc334f5380
commit
7483745da7
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue