{"repo":"NousResearch/hermes-agent","public_id":"15927","display_label":"Slack send_message channel targeting and ID resolution","summary":"These items all concern Slack's send_message behavior when targeting a channel other than the current one. They involve resolving channels by raw or alphanumeric ID and enumerating joined channels, spanning one issue report and several fix-oriented pull requests or changes; their status is not established here.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16198,"merged_at":"2026-04-26T19:29:03+00:00"},"resolution":{"state":"resolved","by":"pr-16198"},"freshness":{"state":"stale","last_verified_at":"2026-04-26T19:29:03+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":15927,"pr_number":16198,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-26T19:29:03+00:00"}],"members":[{"kind":"issue","number":15927,"title":"Slack: Hermes fails send message to a channel different that current one","url":null,"state":"closed"},{"kind":"pr","number":15939,"title":"fix(slack): recognize alphanumeric channel IDs as explicit send_message targets (#15927)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"708f8a635","number":0},{"kind":"pr","value":"#16198","number":16198}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":15947,"title":"fix(Slack): resolve Slack channels by raw ID and enumerate joined channels","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d889ad72a","number":0},{"kind":"pr","value":"#16198","number":16198},{"kind":"pr","value":"#15939","number":15939}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16198,"title":"fix(slack): resolve channels by raw ID + enumerate joined channels for send_message","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/channel_directory.py","gateway/run.py","scripts/release.py","tests/gateway/test_channel_directory.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"gateway/channel_directory.py","hunks":["@@ -57,7 +57,7 @@ def _session_entry_name(origin: Dict[str, Any]) -> str:\n # Build / refresh\n # ---------------------------------------------------------------------------\n \n-def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:\n+async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:\n     \"\"\"\n     Build a channel directory from connected platform adapters and session data.\n ","@@ -72,7 +72,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:\n             if platform == Platform.DISCORD:\n                 platforms[\"discord\"] = _build_discord(adapter)\n             elif platform == Platform.SLACK:\n-                platforms[\"slack\"] = _build_slack(adapter)\n+                platforms[\"slack\"] = await _build_slack(adapter)\n         except Exception as e:\n             logger.warning(\"Channel directory: failed to build %s: %s\", platform.value, e)\n ","@@ -136,21 +136,66 @@ def _build_discord(adapter) -> List[Dict[str, str]]:\n     return channels\n \n \n-def _build_slack(adapter) -> List[Dict[str, str]]:\n-    \"\"\"List Slack channels the bot has joined.\"\"\"\n-    # Slack adapter may expose a web client\n-    client = getattr(adapter, \"_app\", None) or getattr(adapter, \"_client\", None)\n-    if not client:\n+async def _build_slack(adapter) -> List[Dict[str, Any]]:\n+    \"\"\"List Slack channels the bot has joined across all workspaces.\n+\n+    Uses ``users.conversations`` against each workspace's web client. Pulls\n+    public + private channels the bot is a member of, then merges in DMs\n+    discovered from session history (IMs aren't useful to enumerate\n+    proactively).\n+    \"\"\"\n+    team_clients = getattr(adapter, \"_team_clients\", None) or {}\n+    if not team_clients:\n         return _build_from_sessions(\"slack\")\n \n-    try:\n-        from tools.send_message_tool import _send_slack  # noqa: F401\n-        # Use the Slack Web API directly if available\n-    except Exception:\n-        pass\n+    channels: List[Dict[str, Any]] = []\n+    seen_ids: set = set()\n+\n+    for team_id, client in team_clients.items():\n+        try:\n+            cursor: Optional[str] = None\n+            for _page in range(20):  # safety cap on pagination\n+                response = await client.users_conversations(\n+                    types=\"public_channel,private_channel\",\n+                    exclude_archived=True,\n+                    limit=200,\n+                    cursor=cursor,\n+                )\n+                if not response.get(\"ok\"):\n+                    logger.warning(\n+                        \"Channel directory: users.conversations not ok for team %s: %s\",\n+                        team_id,\n+                        response.get(\"error\", \"unknown\"),\n+                    )\n+                    break\n+                for ch in response.get(\"channels\", []):\n+                    cid = ch.get(\"id\")\n+                    name = ch.get(\"name\")\n+                    if not cid or not name or cid in seen_ids:\n+                        continue\n+                    seen_ids.add(cid)\n+                    channels.append({\n+                        \"id\": cid,\n+                        \"name\": name,\n+                        \"type\": \"private\" if ch.get(\"is_private\") else \"channel\",\n+                    })\n+                cursor = (response.get(\"response_metadata\") or {}).get(\"next_cursor\")\n+                if not cursor:\n+                    break\n+        except Exception as e:\n+            logger.warning(\n+                \"Channel directory: failed to list Slack channels for team %s: %s\",\n+                team_id, e,\n+            )\n+            continue\n \n-    # Fallback to session data\n-    return _build_from_sessions(\"slack\")\n+    # Merge in DM/group entries discovered from session history.\n+    for entry in _build_from_sessions(\"slack\"):\n+        if entry.get(\"id\") not in seen_ids:\n+            channels.append(entry)\n+            seen_ids.add(entry.get(\"id\"))\n+\n+    return channels\n \n \n def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:","@@ -223,6 +268,14 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:\n     if not channels:\n         return None\n \n+    # 0. Exact ID match — case-sensitive, no normalization. Lets callers pass\n+    # raw platform IDs (e.g. Slack \"C0B0QV5434G\") even when the format guard\n+    # in _parse_target_ref hasn't recognized them as explicit.\n+    raw = name.strip()\n+    for ch in channels:\n+        if ch.get(\"id\") == raw:\n+            return ch[\"id\"]\n+\n     query = _normalize_channel_query(name)\n \n     # 1. Exact name match, including the display labels shown by send_message(action=\"list\")"]},{"file":"gateway/run.py","hunks":["@@ -2334,7 +2334,7 @@ async def start(self) -> bool:\n         # Build initial channel directory for send_message name resolution\n         try:\n             from gateway.channel_directory import build_channel_directory\n-            directory = build_channel_directory(self.adapters)\n+            directory = await build_channel_directory(self.adapters)\n             ch_count = sum(len(chs) for chs in directory.get(\"platforms\", {}).values())\n             logger.info(\"Channel directory built: %d target(s)\", ch_count)\n         except Exception as e:","@@ -2618,7 +2618,7 @@ async def _platform_reconnect_watcher(self) -> None:\n                         # Rebuild channel directory with the new adapter\n                         try:\n                             from gateway.channel_directory import build_channel_directory\n-                            build_channel_directory(self.adapters)\n+                            await build_channel_directory(self.adapters)\n                         except Exception:\n                             pass\n                     else:","@@ -10978,7 +10978,15 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in\n         if tick_count % CHANNEL_DIR_EVERY == 0 and adapters:\n             try:\n                 from gateway.channel_directory import build_channel_directory\n-                build_channel_directory(adapters)\n+                if loop is not None:\n+                    # build_channel_directory is async (Slack web calls), and\n+                    # this ticker runs in a background thread. Schedule onto\n+                    # the gateway event loop and wait briefly for completion\n+                    # so refresh failures are still logged via the except.\n+                    fut = asyncio.run_coroutine_threadsafe(\n+                        build_channel_directory(adapters), loop\n+                    )\n+                    fut.result(timeout=30)\n             except Exception as e:\n                 logger.debug(\"Channel directory refresh error: %s\", e)\n "]},{"file":"scripts/release.py","hunks":["@@ -120,6 +120,7 @@\n     \"135070653+sgaofen@users.noreply.github.com\": \"sgaofen\",\n     \"[redacted:email]\": \"zhiyanliu\",\n     \"[redacted:email]\": \"hhhonzik\",\n+    \"139848623+hhuang91@users.noreply.github.com\": \"hhuang91\",\n     \"nocoo@users.noreply.github.com\": \"nocoo\",\n     \"30841158+n-WN@users.noreply.github.com\": \"n-WN\",\n     \"[redacted:email]\": \"hiddenpuppy\","]},{"file":"tests/gateway/test_channel_directory.py","hunks":["@@ -1,9 +1,11 @@\n \"\"\"Tests for gateway/channel_directory.py — channel resolution and display.\"\"\"\n \n+import asyncio\n import json\n import os\n from pathlib import Path\n-from unittest.mock import patch\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock, patch\n \n from gateway.channel_directory import (\n     build_channel_directory,","@@ -12,6 +14,7 @@\n     format_directory_for_display,\n     load_directory,\n     _build_from_sessions,\n+    _build_slack,\n     DIRECTORY_PATH,\n )\n ","@@ -62,7 +65,7 @@ def broken_dump(data, fp, *args, **kwargs):\n         monkeypatch.setattr(json, \"dump\", broken_dump)\n \n         with patch(\"gateway.channel_directory.DIRECTORY_PATH\", cache_file):\n-            build_channel_directory({})\n+            asyncio.run(build_channel_directory({}))\n             result = load_directory()\n \n         assert result == previous","@@ -142,6 +145,21 @@ def test_topic_name_resolves_to_composite_id(self, tmp_path):\n         with self._setup(tmp_path, platforms):\n             assert resolve_channel_name(\"telegram\", \"Coaching Chat / topic 17585\") == \"-1001:17585\"\n \n+    def test_id_match_takes_precedence_over_name(self, tmp_path):\n+        \"\"\"A raw channel ID resolves to itself, even when a different\n+        channel happens to be named the same string. Case-sensitive: Slack\n+        IDs are uppercase and must not be normalized away.\"\"\"\n+        platforms = {\n+            \"slack\": [\n+                {\"id\": \"C0B0QV5434G\", \"name\": \"engineering\", \"type\": \"channel\"},\n+                {\"id\": \"C99\", \"name\": \"c0b0qv5434g\", \"type\": \"channel\"},\n+            ]\n+        }\n+        with self._setup(tmp_path, platforms):\n+            assert resolve_channel_name(\"slack\", \"C0B0QV5434G\") == \"C0B0QV5434G\"\n+            # Lowercase still falls through to name matching (case-insensitive)\n+            assert resolve_channel_name(\"slack\", \"c0b0qv5434g\") == \"C99\"\n+\n     def test_display_label_with_type_suffix_resolves(self, tmp_path):\n         platforms = {\n             \"telegram\": [","@@ -332,3 +350,135 @@ def test_channel_without_type_key_returns_none(self, tmp_path):\n         }\n         with self._setup(tmp_path, platforms):\n             assert lookup_channel_type(\"discord\", \"300\") is None\n+\n+\n+def _make_slack_adapter(team_clients):\n+    \"\"\"Build a stand-in for SlackAdapter exposing only ``_team_clients``.\"\"\"\n+    return SimpleNamespace(_team_clients=team_clients)\n+\n+\n+def _make_slack_client(pages):\n+    \"\"\"Build an AsyncWebClient mock whose ``users_conversations`` returns pages.\"\"\"\n+    client = MagicMock()\n+    client.users_conversations = AsyncMock(side_effect=pages)\n+    return client\n+\n+\n+class TestBuildSlack:\n+    \"\"\"_build_slack actually calls users.conversations on each workspace client.\"\"\"\n+\n+    def test_no_team_clients_falls_back_to_sessions(self, tmp_path):\n+        sessions_path = tmp_path / \"sessions\" / \"sessions.json\"\n+        sessions_path.parent.mkdir(parents=True)\n+        sessions_path.write_text(json.dumps({\n+            \"s1\": {\"origin\": {\"platform\": \"slack\", \"chat_id\": \"D123\", \"chat_name\": \"Alice\"}},\n+        }))\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            entries = asyncio.run(_build_slack(_make_slack_adapter({})))\n+\n+        assert len(entries) == 1\n+        assert entries[0][\"id\"] == \"D123\"\n+\n+    def test_lists_channels_from_users_conversations(self, tmp_path):\n+        client = _make_slack_client([\n+            {\n+                \"ok\": True,\n+                \"channels\": [\n+                    {\"id\": \"C0B0QV5434G\", \"name\": \"engineering\", \"is_private\": False},\n+                    {\"id\": \"G123ABCDEF\", \"name\": \"secret-chat\", \"is_private\": True},\n+                ],\n+                \"response_metadata\": {},\n+            },\n+        ])\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            entries = asyncio.run(_build_slack(_make_slack_adapter({\"T1\": client})))\n+\n+        ids = {e[\"id\"] for e in entries}\n+        assert ids == {\"C0B0QV5434G\", \"G123ABCDEF\"}\n+        types = {e[\"id\"]: e[\"type\"] for e in entries}\n+        assert types[\"C0B0QV5434G\"] == \"channel\"\n+        assert types[\"G123ABCDEF\"] == \"private\"\n+        client.users_conversations.assert_awaited_once()\n+\n+    def test_paginates_via_response_metadata_cursor(self, tmp_path):\n+        client = _make_slack_client([\n+            {\n+                \"ok\": True,\n+                \"channels\": [{\"id\": \"C001\", \"name\": \"first\", \"is_private\": False}],\n+                \"response_metadata\": {\"next_cursor\": \"cur1\"},\n+            },\n+            {\n+                \"ok\": True,\n+                \"channels\": [{\"id\": \"C002\", \"name\": \"second\", \"is_private\": False}],\n+                \"response_metadata\": {\"next_cursor\": \"\"},\n+            },\n+        ])\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            entries = asyncio.run(_build_slack(_make_slack_adapter({\"T1\": client})))\n+\n+        assert {e[\"id\"] for e in entries} == {\"C001\", \"C002\"}\n+        assert client.users_conversations.await_count == 2\n+\n+    def test_per_workspace_error_does_not_block_others(self, tmp_path):\n+        bad = MagicMock()\n+        bad.users_conversations = AsyncMock(side_effect=RuntimeError(\"boom\"))\n+        good = _make_slack_client([\n+            {\n+                \"ok\": True,\n+                \"channels\": [{\"id\": \"C999\", \"name\": \"ok-channel\", \"is_private\": False}],\n+                \"response_metadata\": {},\n+            },\n+        ])\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            entries = asyncio.run(_build_slack(_make_slack_adapter({\"BAD\": bad, \"GOOD\": good})))\n+\n+        assert {e[\"id\"] for e in entries} == {\"C999\"}\n+\n+    def test_session_dms_merged_when_not_in_api_results(self, tmp_path):\n+        sessions_path = tmp_path / \"sessions\" / \"sessions.json\"\n+        sessions_path.parent.mkdir(parents=True)\n+        sessions_path.write_text(json.dumps({\n+            \"s1\": {\"origin\": {\"platform\": \"slack\", \"chat_id\": \"D456\", \"chat_name\": \"Bob\"}},\n+            \"dup\": {\"origin\": {\"platform\": \"slack\", \"chat_id\": \"C001\", \"chat_name\": \"first\"}},\n+        }))\n+        client = _make_slack_client([\n+            {\n+                \"ok\": True,\n+                \"channels\": [{\"id\": \"C001\", \"name\": \"first\", \"is_private\": False}],\n+                \"response_metadata\": {},\n+            },\n+        ])\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            entries = asyncio.run(_build_slack(_make_slack_adapter({\"T1\": client})))\n+\n+        ids = {e[\"id\"] for e in entries}\n+        assert \"C001\" in ids and \"D456\" in ids\n+        # Channel ID from API should not be duplicated by the session merge\n+        assert sum(1 for e in entries if e[\"id\"] == \"C001\") == 1\n+\n+    def test_skips_channels_with_no_id_or_name(self, tmp_path):\n+        client = _make_slack_client([\n+            {\n+                \"ok\": True,\n+                \"channels\": [\n+                    {\"id\": \"C001\", \"name\": \"good\", \"is_private\": False},\n+                    {\"id\": \"\", \"name\": \"no-id\"},\n+                    {\"id\": \"C002\"},  # no name (e.g. IM)\n+                ],\n+                \"response_metadata\": {},\n+            },\n+        ])\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            entries = asyncio.run(_build_slack(_make_slack_adapter({\"T1\": client})))\n+\n+        assert {e[\"id\"] for e in entries} == {\"C001\"}\n+\n+    def test_response_not_ok_breaks_pagination_for_that_workspace(self, tmp_path):\n+        client = _make_slack_client([\n+            {\"ok\": False, \"error\": \"missing_scope\"},\n+        ])\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            entries = asyncio.run(_build_slack(_make_slack_adapter({\"T1\": client})))\n+\n+        assert entries == []"]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -810,6 +810,44 @@ def test_e164_prefix_only_matches_phone_platforms(self):\n         assert _parse_target_ref(\"matrix\", \"+15551234567\")[2] is False\n \n \n+class TestParseTargetRefSlack:\n+    \"\"\"_parse_target_ref recognizes Slack channel/user IDs as explicit.\"\"\"\n+\n+    def test_public_channel_id_is_explicit(self):\n+        chat_id, thread_id, is_explicit = _parse_target_ref(\"slack\", \"C0B0QV5434G\")\n+        assert chat_id == \"C0B0QV5434G\"\n+        assert thread_id is None\n+        assert is_explicit is True\n+\n+    def test_private_channel_id_is_explicit(self):\n+        assert _parse_target_ref(\"slack\", \"G123ABCDEF\")[2] is True\n+\n+    def test_dm_id_is_explicit(self):\n+        assert _parse_target_ref(\"slack\", \"D123ABCDEF\")[2] is True\n+\n+    def test_user_id_is_not_explicit(self):\n+        \"\"\"Slack user IDs (U...) and workspace IDs (W...) are NOT explicit send\n+        targets. chat.postMessage rejects them — a DM must be opened first via\n+        conversations.open to obtain a D... conversation ID.\n+        \"\"\"\n+        assert _parse_target_ref(\"slack\", \"U123ABCDEF\")[2] is False\n+        assert _parse_target_ref(\"slack\", \"W123ABCDEF\")[2] is False\n+\n+    def test_whitespace_is_stripped(self):\n+        chat_id, _, is_explicit = _parse_target_ref(\"slack\", \"  C0B0QV5434G  \")\n+        assert chat_id == \"C0B0QV5434G\"\n+        assert is_explicit is True\n+\n+    def test_lowercase_or_short_id_is_not_explicit(self):\n+        assert _parse_target_ref(\"slack\", \"c0b0qv5434g\")[2] is False\n+        assert _parse_target_ref(\"slack\", \"C123\")[2] is False\n+        assert _parse_target_ref(\"slack\", \"X0B0QV5434G\")[2] is False\n+\n+    def test_slack_id_not_explicit_for_other_platforms(self):\n+        assert _parse_target_ref(\"discord\", \"C0B0QV5434G\")[2] is False\n+        assert _parse_target_ref(\"telegram\", \"C0B0QV5434G\")[2] is False\n+\n+\n class TestSendDiscordThreadId:\n     \"\"\"_send_discord uses thread_id when provided.\"\"\"\n "]},{"file":"tools/send_message_tool.py","hunks":["@@ -20,6 +20,13 @@\n \n _TELEGRAM_TOPIC_TARGET_RE = re.compile(r\"^\\s*(-?\\d+)(?::(\\d+))?\\s*$\")\n _FEISHU_TARGET_RE = re.compile(r\"^\\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\\s*$\")\n+# Slack conversation IDs: C (public channel), G (private/group channel), D (DM).\n+# Must be uppercase alphanumeric, 9+ chars. User IDs (U...) and workspace IDs\n+# (W...) are NOT valid chat.postMessage channel values — posting to them fails\n+# because the API requires a conversation ID. To DM a user you must first call\n+# conversations.open to obtain a D... ID. Without this gate, Slack IDs fall\n+# through to channel-name resolution, which only matches by name and fails.\n+_SLACK_TARGET_RE = re.compile(r\"^\\s*([CGD][A-Z0-9]{8,})\\s*$\")\n _WEIXIN_TARGET_RE = re.compile(r\"^\\s*((?:wxid|gh|v\\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Za-z0-9._-]+@chatroom|filehelper)\\s*$\")\n # Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets.\n _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE","@@ -318,6 +325,10 @@ def _parse_target_ref(platform_name: str, target_ref: str):\n         match = _NUMERIC_TOPIC_RE.fullmatch(target_ref)\n         if match:\n             return match.group(1), match.group(2), True\n+    if platform_name == \"slack\":\n+        match = _SLACK_TARGET_RE.fullmatch(target_ref)\n+        if match:\n+            return match.group(1), None, True\n     if platform_name == \"weixin\":\n         match = _WEIXIN_TARGET_RE.fullmatch(target_ref)\n         if match:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}