Skip to content

fix(Slack): resolve Slack channels by raw ID and enumerate joined channels - #15947

Closed
hhuang91 wants to merge 1 commit into
NousResearch:mainfrom
hhuang91:main
Closed

fix(Slack): resolve Slack channels by raw ID and enumerate joined channels#15947
hhuang91 wants to merge 1 commit into
NousResearch:mainfrom
hhuang91:main

Conversation

@hhuang91

Copy link
Copy Markdown
Contributor

What does this PR do?

When asking Hermes to send a Slack message to a channel other than the
current one, two failure modes appeared:

  1. send_message(target='slack:<channel_id>') returned Could not resolve '<channel_id>' on slack, even though the bot was a member of the channel and the ID was valid.
  2. As a fallback, the agent would retry with the bare target='slack'
    form and silently post to the platform's home channel instead of
    the requested one.

In-channel and DM conversations worked fine — the bug only surfaced
on outbound cross-channel sends.

Root cause

Three compounding gaps in the send_messagechannel_directory
path, all specific to Slack:

  1. tools/send_message_tool.py::_parse_target_ref had explicit
    handlers for Telegram/Discord/Feishu/Weixin/Matrix/phone IDs, but
    no Slack branch. Slack's uppercase-alphanumeric IDs (C…, G…,
    D…, U…, W…) failed the existing target_ref.lstrip("-").isdigit()
    check and fell through with is_explicit=False, forcing the value
    into the name-resolver.
  2. gateway/channel_directory.py::resolve_channel_name only
    matched against channel["name"], never channel["id"]. So even
    when a channel was in the directory, supplying its raw ID didn't
    resolve.
  3. gateway/channel_directory.py::_build_slack never actually
    called the Slack web API — it just delegated to
    _build_from_sessions, so the directory only knew about channels
    the bot had received inbound messages in. Channels the bot had
    joined but never been talked to in were absent from
    action='list' and unaddressable by name.

The "redirected to home channel" symptom was the model's own
fallback: after the resolver error, it retried with bare
target='slack', which the schema documents as "uses home channel"
— compounding the visible misbehavior.

Fix

  • _parse_target_ref recognizes Slack channel/user IDs as explicit
    targets via a precompiled regex ([CGDUW][A-Z0-9]{8,}), so they
    bypass name resolution entirely.
  • resolve_channel_name adds a case-sensitive raw-ID match step
    before the existing case-insensitive name match. Defense in depth
    for any platform: a raw ID always resolves to itself if present in
    the directory.
  • _build_slack now calls users.conversations against each
    workspace's AsyncWebClient, paginates via
    response_metadata.next_cursor, and merges in DM entries from
    session history. Per-workspace errors are isolated. Requires
    channels:read and groups:read Slack scopes for full
    enumeration; missing scopes degrade gracefully for that workspace
    alone.
  • build_channel_directory becomes async (Slack web calls require
    it). Two async-context callers in gateway/run.py are awaited;
    the cron-ticker thread call bridges via
    asyncio.run_coroutine_threadsafe(...).result(timeout=30).

Why this approach

  • Surgical, not architectural. The Slack ID parsing branch
    matches the pattern every other platform already uses in
    _parse_target_ref — it's the smallest change that brings Slack to
    parity.
  • ID-match-before-name in resolve_channel_name is platform-
    agnostic: it costs one cheap loop and prevents this entire class of
    bug from recurring on any future platform whose ID format isn't
    caught by _parse_target_ref.
  • users.conversations over conversations.list because it
    scopes to channels the bot is actually a member of, requires
    fewer permissions, and matches how Slack itself recommends bots
    enumerate their own workspace presence.
  • Async over sync-with-requests because the adapter already
    owns the typed AsyncWebClients with multi-workspace tokens, proxy
    config, and retry semantics — reusing them avoids a parallel
    HTTP path and keeps token plumbing in one place.

Tests

24 new test cases:

  • tests/tools/test_send_message_tool.py::TestParseTargetRefSlack (7) —
    public/private/DM/user IDs, whitespace, lowercase/short rejection,
    isolation from other platforms.
  • tests/gateway/test_channel_directory.py::TestResolveChannelName::test_id_match_takes_precedence_over_name (1) —
    raw IDs resolve to themselves, lowercase still falls through to
    name matching.
  • tests/gateway/test_channel_directory.py::TestBuildSlack (7) —
    no-clients fallback, single-page list, pagination via cursor,
    per-workspace error isolation, session-DM merge with dedup,
    missing-id/name skip, ok=False handling.

Existing test_failed_write_preserves_previous_cache updated to
asyncio.run(build_channel_directory({})).

Full test run: 118 passed (1 pre-existing Windows /tmp/ failure
unrelated to this change).

Related Issue

Fixes #15927

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

Three fixes:

  • _parse_target_ref recognizes Slack IDs (C/G/D/U/W prefix) as explicit targets so the name-resolver is bypassed entirely.
  • resolve_channel_name tries a case-sensitive raw-ID match before the existing name match, so any platform's IDs resolve cleanly.
  • _build_slack now actually calls users.conversations against each workspace's AsyncWebClient (paginated), instead of only returning session-history entries. This populates the directory with public and private channels the bot has joined, so action='list' shows them and they can also be addressed by name. Errors from one workspace don't block others.

How to Test

  1. Add Hermes to at least two Slack channels (e.g. #general, and #social)
  2. In channel 1 (#general) ask Hermes to send a message (e.g. "hello world") in channel 2 (#social)
  3. Hermes should say hello world in channel 2, while reporting message successfully sent in channel 1.

Checklist

Code

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

…nnels

send_message(target='slack:<channel_id>') failed with "Could not
resolve" because _parse_target_ref had no Slack branch — Slack's
uppercase alphanumeric IDs fell through to channel-name resolution,
which only matched by name. As a fallback, the agent would retry with
bare target='slack' and post to the home channel instead.

Three fixes:

- _parse_target_ref recognizes Slack IDs (C/G/D/U/W prefix) as
  explicit targets so the name-resolver is bypassed entirely.
- resolve_channel_name tries a case-sensitive raw-ID match before
  the existing name match, so any platform's IDs resolve cleanly.
- _build_slack now actually calls users.conversations against each
  workspace's AsyncWebClient (paginated), instead of only returning
  session-history entries. This populates the directory with public
  and private channels the bot has joined, so action='list' shows
  them and they can also be addressed by name. Errors from one
  workspace don't block others.

build_channel_directory becomes async (Slack web calls require it).
The two async-context callers in gateway/run.py are awaited; the
cron ticker thread call bridges via asyncio.run_coroutine_threadsafe.

Slack bot needs channels:read and groups:read scopes for full
enumeration; missing scopes degrade gracefully per-workspace.

addressing NousResearch#15927
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/slack Slack app adapter labels Apr 26, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Fixes #15927. Related to #15939 which addresses the same Slack channel ID parsing issue — this PR is more comprehensive (also fixes channel directory and enumeration).

@teknium1

Copy link
Copy Markdown
Contributor

Merged via #16198 — your commit was cherry-picked onto current main with your authorship preserved (d889ad7). Thanks for both filing the issue with a clear three-layer fix plan AND following through with the full implementation. The users.conversations enumeration is the real fix for this class of bug — name-based addressing won't work reliably without it.

Small tweak in a follow-up commit: tightened the regex from [CGDUW] to [CGD] and salvaged that from @briandevans's #15939, because chat.postMessage rejects user IDs directly (U/W) — they'd fail at the API level, which is worse than falling through to name resolution and returning a clear error.
#16198

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/slack Slack app adapter type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slack: Hermes fails send message to a channel different that current one

3 participants