Skip to content

feat(gateway): /access — manage DM/group allowlists from chat - #105630

Open
FarelRA wants to merge 31 commits into
NousResearch:mainfrom
FarelRA:feat/gateway-access-command
Open

FarelRA wants to merge 31 commits into
NousResearch:mainfrom
FarelRA:feat/gateway-access-command

Conversation

@FarelRA

@FarelRA FarelRA commented Sep 8, 2026 •

Copy link
Copy Markdown

What

Three stacked commits on feat/gateway-access-command:

1. /access — manage DM/group allowlists from chat (5e3110ef27)

/access list                        show both allowlists
/access allow user 0812-3456-789    local / national / +international formats
/access allow user @Niken           PushName → participants lookup
/access deny  user 6281234567890    symmetric revoke
/access allow group "Kelas XI-C"    group name → id (or raw JID)

Platform-agnostic core (gateway/slash_commands_access.py): adapters opt into rich resolution via a resolve_access_ref contract; the generic fallback covers reply-to author, event mention metadata, mention-wrapper stripping, and (WhatsApp-family) phone normalization against the bot's own country code. Ambiguous names never guess — the command replies with candidates. Writes land in three layers (live adapter sets + config.extra + routed profile's config.yaml) so the change is effective on the next message and survives restart; env-seeded adapters keep their env carrier in sync (never os.environ under multiplex, #72348). Authorization rides the standard slash-access policy (allow_admin_from / group_allow_admin_from only).

2. One canonical identity form across all three WhatsApp transports (2c18803a83)

Internal standard: @s.whatsapp.net (Baileys-native). Each engine keeps its own wire dialect — WAHA @c.us (docs-explicit), Cloud bare wa_id, Baileys @s.whatsapp.net — normalized at the inbound boundary and rendered back per-engine on the way out (to_engine_chat_id).

  • Every ingestion path accepts every operator shape: config.yaml, env CSV, webhook payloads, /access input. canonicalize_id_list folds JID dialects (@c.us/@s.whatsapp.net/bare digits/*) with duplicate collapse.
  • WAHA _map_payload canonicalizes chatId/senderId/quotedParticipant/mentionedIds — LIDs resolve through alt fields / the learned cache first (cache now stores canonical forms).
  • Cloud API canonicalizes wa_id at the event boundary (contacts roster still keyed by Meta's raw wa_id) and renders bare digits in _outbound_payload.
  • Baileys bridge canonicalizes inbound events; every wire call renders the bridge dialect.
  • allow_admin_from is canonicalized for the WhatsApp family (policy_for_source) with a canonical retry in _check_slash_access, so the exact-match admin comparison accepts @c.us, @s.whatsapp.net, bare digits, or local formats.
  • Country-aware trunk normalization (0812…) stays on the interactive /access path where the bot's own country is known; config-level normalization covers dialects and international digits without it.

3. /access standard everywhere + one session key per human (d33e10fe1d)

Session-key standardization: build_session_key canonicalizes chat_id and participant for the whole WhatsApp family (whatsapp / waha / whatsapp_cloud), so one human maps to ONE key regardless of wire dialect. _adopt_legacy_whatsapp_entry (sibling of the Slack adoption) MOVES the most recently updated dialect-variant entry onto the canonical key on first contact — transcripts survive the flip.

Resolver contract is now an official BasePlatformAdapter hook (resolve_access_ref → AccessResolution | None, documented alongside the metadata["mentions"] event contract). Fully featured resolvers on every major platform:

Platform Resolves
WAHA phones in any format, @PushName via participants, group names via /groups, mentions, JIDs, LIDs
Baileys bridge new /groups endpoint (groupFetchAllParticipating) + group-name and pushname-roster resolution
Telegram get_chat for @username/group titles; unresolvable input returns None (honest "give the numeric id") instead of a fabricated match
Discord <@id>/<#id> wrappers, member roster across guilds (same matching as the startup username resolution), channel names
Slack users.list / conversations.list by name, <@U…>/<#C…> wrappers

Mention metadata (MessageEvent.metadata["mentions"] = [{id, label}]) is now standard on every platform with a mention API: telegram (text_mention entities), discord (message.mentions), slack (<@UID> captured pre-humanize), signal (raw mentions), matrix (MSC3952 m.mentions), feishu (mention refs), teams (activity.entities), google chat (annotations), mattermost (@username), waha (mentionedIds). DingTalk's SDK routes mentions structurally (isInAtList) and exposes no roster — documented; the generic fallback covers it.

Testing

  • tests/gateway/test_whatsapp_identity_phone.py — normalizer matrix (3 home countries × local/trunk-less/dial-out/+/00/invalid).
  • tests/gateway/test_slash_access_command.py — resolution fallbacks, dual-write, routed-profile regression ([Bug]: /reasoning in a routed multiplex profile reads and writes the default profile config #87939 shape), idempotency, JID-variant loose matching, usage errors.
  • tests/gateway/test_session_key_whatsapp_family.py — one human = one key across 3 transports × 3 dialects; group participant canonicalization; legacy-key adoption (most-recent wins, no-op when the canonical key is live).
  • tests/plugins/test_waha_access_resolver.py — group-name matching, PushName → canonical JID, LID pass-through, real-mixin normalization, sender @s.whatsapp.net → @c.us wire canonicalization (stubbed _request, no WAHA instance).
  • Wire-dialect tests kept honest: TestOutbound pins the WAHA @c.us wire form; internal-form tests pin @s.whatsapp.net.
  • Full gate: scripts/run_tests.sh over tests/gateway/ + tests/plugins/ + CLI — 9734 tests, 0 failed (899 files).
  • Slack slash-cap parity updated (_SLACK_VIA_HERMES_ONLY += access).

Stacked on #104247 (WAHA platform plugin), #104245, #104133 — merge order: those first.

4. Audit close-out: gaps found and fixed (2a1e37f31e)

An honest audit against "every platform meets the highest standard" found six gaps, all closed:

  1. Baileys bridge built lidToPhone but never applied it — LID-addressed chats/senders stayed @lid. The upsert path now resolves through the map at the boundary (unresolved @lid passes to the adapter's alt/cache path).
  2. Baileys forwarded mentionedIds but the Python adapter never surfaced them — event.metadata["mentions"] now populated.
  3. @PushName was roster-dependent: group rosters (Baileys participants, WAHA NOWEB) carry JIDs without names. New shared WhatsAppBehaviorMixin pushname cache — learned from EVERY inbound event (WAHA sender.pushName, Baileys msg.pushName, Cloud contacts profile names), bounded at 512 — feeds all three transports' resolvers as the roster fallback (roster matches stay authoritative).
  4. Telegram private groups: the Bot API cannot look up by title. The adapter now learns chat name/username → id from every inbound update (_seen_chats); the resolver consults it before get_chat.
  5. Discord active threads are not in guild.channels — the resolver scans guild.threads too.
  6. Slack multi-workspace: the resolver scopes its client to the event's slack_team_id instead of the default workspace.
  7. Cloud API got an explicit resolver (wa_id pass-through, phone normalization via the mixin, @Name via learned profile names; group scope returns None honestly — Meta group support is not implemented).

Final coverage matrix

Platform phones (all formats) @name group names mentions JID/LID notes
WAHA ✅ ✅ roster + learned ✅ ✅ ✅ full
Baileys ✅ ✅ learned ✅ ✅ ✅ LID at bridge boundary
Cloud API ✅ ✅ learned n/a (groups not supported by adapter) n/a ✅ honest None
Telegram n/a ✅ @username + learned titles ✅ learned + public ✅ n/a
Discord n/a ✅ roster ✅ channels + threads ✅ n/a
Slack n/a ✅ users.list ✅ conversations.list ✅ n/a workspace-scoped
Signal / Matrix / Feishu / Teams / Google Chat / Mattermost IDs IDs + mention metadata IDs ✅ metadata n/a documented generic fallback
DingTalk IDs IDs IDs n/a (SDK routes mentions structurally) n/a documented

@FarelRA
FarelRA requested a review from a team September 8, 2026 06:43
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins platform/whatsapp WhatsApp Business adapter platform/telegram Telegram bot adapter platform/discord Discord bot adapter platform/slack Slack app adapter area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 8, 2026
@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference; please use your judgment.

PR #105630 — /access: manage DM/group allowlists from chat

Big bundled PR (44 files: /access command + observed-context platform-neutral refactor + WAHA adapter + WhatsApp bridge/conformance). Reviewed the security-sensitive core (gateway/slash_commands_access.py) plus the refactor seams. Non-blocking notes:

  • gateway/slash_commands_access.py:1529-1532 (fail-open): without allow_admin_from/group_allow_admin_from set, gating is "disabled platform-wide", so /access is runnable by anyone who can talk to the bot — including list (discloses the full allowlist, i.e. members' phone numbers, to chat) and allow/deny. For most other commands fail-open is low-stakes; for an allowlist editor, consider fail-closed: refuse /access allow|deny (or all subcommands) when no admin list is configured.
  • gateway/slash_commands_access.py:1758-1767 (_access_persist): writes via _gateway_config_home() with no profile argument, relying on the context-local HERMES_HOME override to land on the routed profile's config.yaml. The env path explicitly guards multiplex ([Bug]: Discord adapter allow/deny gates are process-global, breaking per-profile isolation under multiplex_profiles #72348) but this one trusts ambient context — please confirm the override is always set on the /access handler path under multiplex; otherwise profile B's edit lands in the default profile's config while the in-memory mutation applies to B (split-brain until restart).
  • gateway/slash_commands_access.py:1680 (generic fallback): for non-WhatsApp adapters without resolve_access_ref, any string passes through as canonical — including @name values after s/[\w.\-] stripping, which may not be a real id and will silently store a dead entry. The candidates/disambiguation design is good where resolvers exist; for adapters without one, consider resolving list output or warning when the stored value never matches an inbound sender.
  • gateway/slash_commands_access.py:1784-1794 (_access_mutate_live): replaces the adapter's set object via setattr. If anything else holds a reference to the old set object it keeps gating on stale data. Copy-into-place (set.clear(); set.update(...)) would be safer if the set is ever shared.
  • gateway/run_busy.py:1458: access is added to busy-path _PLAIN_COMMANDS — verify the busy dispatch enforces the same admin-only policy as the idle path (the docstring cites both, but a state-changing command reachable while a session is busy deserves an explicit test; test_slash_access_command.py covers the mixin — check busy-path coverage).
  • Process note: this bundles an allowlist editor, an observed-context refactor, a new WAHA adapter, and bridge JS changes. Each is fine alone, but a revert of one (e.g. a problem in /access authz) currently requires reverting all. Consider stacking next time.

@FarelRA

FarelRA commented Sep 8, 2026 •

Copy link
Copy Markdown
Author

Thank you for the review — all points are valid and addressed in commit d33e10fe1d (pushed):

1. Fail-open /access → kept, with justification + documentation. An empty allow_admin_from disabling the gate is the existing platform-wide /config-style behavior (slash_access should not silently change its semantics per-command and surprise operators who deliberately chose fail-open). That said, the risk is real for this command (/access list discloses phone numbers), so: (a) the fail-open/closed contract is now documented explicitly in the BasePlatformAdapter.resolve_access_ref docstring and the /access module docstring ("operators must set an admin list for /access to be owner-only"), and (b) the allow|deny operations follow resolver availability — platforms without a roster resolver still work via canonical IDs, no new fail-open surface added. If maintainers prefer strict fail-closed (refuse all subcommands without an admin list), that is a 5-line change — happy to flip it.

2. _access_persist + multiplex → already correct, now proven by a test. _gateway_config_home() resolves through get_hermes_home_override(), which is set by the _profile_runtime_scope(...) context manager across the multiplex handler path (the same pattern /memory and /skills use — upstream regression test test_slash_config_writes_routed_profile covers it). To harden it further: test_persist_writes_routed_profile_not_default in this PR's suite simulates exactly the split-brain scenario (default home ≠ routed home, write under _profile_runtime_scope) and asserts the write lands in the routed profile, never the launch home.

3. Free-form string pass-through on adapters without a resolver → tightened. Phone-shaped input on non-WhatsApp adapters is no longer passed through blindly: the contract is now formally documented on BasePlatformAdapter (the hook + metadata["mentions"]), and the major platforms (Telegram/Discord/Slack) are no longer resolver-less — they all get fully-featured resolvers in this commit. Remaining niche platforms can indeed only accept canonical IDs (no roster API to query), and that is now documented, defined behavior rather than an accident.

4. setattr replacing the set object → already safe, plus sync. WhatsApp-family gates read self._allow_from per message (no reference snapshot), so object replacement suffices; but the sharing concern is valid for future code, and the mutation path now also syncs config.extra + the env carrier, so no consumer holds stale data.

5. Busy-path enforcement → identical, verified. _check_slash_access is invoked on BOTH dispatch paths (idle run_turn and busy run_busy.py:910 — the busy path calls the same function); there is no bypass. It is one shared gate.

6. Bundling → accepted. The following commits in this PR already break out the logical units (session-key standardization, per-platform mention metadata, per-platform resolvers are clearly separated in history); future PRs will be stacked per-unit.

Test scale: full gate scripts/run_tests.sh over tests/gateway/ + tests/plugins/ + CLI = 9734 tests, 0 failed (899 files), including 14 new tests for session-key standardization and per-platform resolver tests.

@FarelRA

FarelRA commented Sep 8, 2026 •

Copy link
Copy Markdown
Author

Follow-up on the earlie, the gaps are now closed in commit 2a1e37f31e (pushed):

  1. Baileys bridge: lidToPhone was built from the session's lid-mapping files but never applied — LID-addressed chats/senders stayed @lid. Now resolved at the upsert boundary; unresolved @lid still flows to the adapter's alt-field/cache path.
  2. Baileys mentions: the bridge forwarded mentionedIds but the Python adapter never surfaced them into event.metadata["mentions"]. Fixed.
  3. @PushName on all three WhatsApp transports: rosters (Baileys participants, WAHA NOWEB) carry JIDs without names, so @Name failed for names the roster lacks. New shared pushname cache on WhatsAppBehaviorMixin — learned from EVERY inbound event, bounded at 512 entries — is the fallback after the (authoritative) roster lookup, for WAHA, Baileys AND Cloud API.
  4. Telegram private groups: the Bot API cannot look up by title. The adapter now learns chat name/username → id from every inbound update and the resolver consults that directory before get_chat.
  5. Discord threads: active threads aren't in guild.channels; the resolver scans guild.threads too.
  6. Slack multi-workspace: the resolver now scopes its client to the event's slack_team_id rather than the default workspace.
  7. Cloud API: explicit resolver added (wa_id pass-through, phone normalization via the shared mixin, @Name via learned profile names; group scope returns None honestly — the adapter doesn't support groups).

A coverage matrix is in the PR description. Test scale after the fixes: 9736 tests, 0 failed (899 files) via scripts/run_tests.sh.

…t-py)

Replace the regex transcoder in WhatsAppBehaviorMixin.format_message with
an AST-based CommonMark renderer so every markdown construct is handled —
emphasis, headings, fenced code (language tag kept as a caption), GFM
tables, links, images, lists, blockquotes, horizontal rules and HTML —
with nothing silently dropped.

- gateway/platforms/whatsapp_renderer.py: CommonMarkToWhatsApp walks the
  markdown-it-py token stream and emits WhatsApp's native dialect
  (*bold* / _italic_ / ~strike~ / backticks). Constructs WhatsApp cannot
  render are down-rendered, never discarded. Prose containing literal
  * _ ~ backtick passes through with stray open→close pairs broken by
  space-flanking the offending open (WhatsApp pairs delimiters per line
  only; a lone ~ meaning 'approx' would otherwise strike on-device).
- gateway/platforms/whatsapp_unicode.py: optional Unicode font styling
  (default ON, WHATSAPP_UNICODE_FORMATTING=false to disable) recovers
  heading hierarchy native WhatsApp flattens; unassigned math glyphs
  (U+1D455 → ℎ) are substituted to avoid tofu.
- Tables default to a wrap-safe key/value flatten; aligned monospace
  fence stays available via WHATSAPP_TABLE_MODE=monospace.
- MAX_MESSAGE_LENGTH raised 4096 → 65,536 (WhatsApp's real per-message
  cap): send and edit both stay ONE bubble — no UX fragmentation, and a
  split edit can no longer surface chunk 2+ as unlinked duplicates.
- Declare markdown-it-py==4.0.0 explicitly (already resolved transitively
  via rich; the renderer imports it directly) and exempt the exact pin
  from exclude-newer per the release-day brick pattern.
send() already converted markdown, but three paths sent raw text:

- edit_message: streaming edits delivered the accumulated buffer without
  conversion, so previews AND the final message showed raw # / ** markers.
  Also surface the bridge's messageIds when an oversized edit splits, so
  the stream consumer re-targets subsequent edits at the last visible
  bubble (mirrors send()'s continuation contract) instead of reporting
  the original id while chunks 2+ landed as unlinked new messages.
- media captions (bridge /send-media and Cloud API media block): captions
  are rendered with WhatsApp formatting on-device, so a raw markdown
  caption leaked # / ** onto the media bubble.
- _standalone_send (cron delivery): runs in a separate process and bypasses
  adapter.send(); body text and single-file captions now go through
  format_message with the same never-raise guard as the gateway path.
… 65,536 cap

- splitLongMessage moves to bridge_helpers.js and counts/splits by code
  points (codePointLength): astral characters (emoji, the Unicode math
  glyphs the formatter emits) count once and can never be split mid-
  surrogate-pair. The legacy UTF-16 splitter counted them at 2x, so a
  ~3000-char heading-formatted edit crossed a 4096 threshold and split.
- /send and /edit both split at WhatsApp's real per-message cap
  (WHATSAPP_MAX_MESSAGE_LENGTH, default 65,536 code points). There is no
  smaller edit cap: an edit past a smaller limit sends chunk 2+ as
  brand-new unlinked messages — the duplicate-bubble regression — and a
  smaller send cap just fragments what WhatsApp renders fine as one
  message. /edit keeps reporting messageIds so the adapter can surface
  continuation ids.
- test_whatsapp_formatting.py: full-construct CommonMark→WhatsApp matrix
  (inline emphasis, headings incl. setext, fenced code captions, links,
  images, lists, task lists, blockquotes, HR, tables both modes, HTML,
  entities, mixed documents, unterminated-fence degradation), stray
  delimiter flanking (per-line pairing, heading/list contexts, star and
  underscore literals never flanked), GlyphStyler + unassigned-glyph
  substitution, unicode_formatting toggle (env + config.extra),
  format+truncate pipeline (balanced fences per chunk at the 65,536 cap),
  and every-send-path-formats coverage (streaming edits, media captions,
  standalone/cron) incl. edit continuation ids.
- test_whatsapp_cloud.py: Cloud API caption conversion + long-message
  chunking at the real cap regressions.
- test_whatsapp_send_message_media.py: standalone body + caption
  conversion regressions.
- test_whatsapp_native_delivery.py: sanitizer output is fully consumed
  as paragraph indentation now (clean 'text', not ' text').
- generate_conformance_vectors.py: pin the whatsapp oracle to the default
  pipeline config (committed vectors must not depend on the developer's
  WHATSAPP_UNICODE_FORMATTING), add tilde-approx / tilde-space-safe /
  tilde-mixed / header-h3-italic-h grid vectors, table vectors expect
  parity under the default flatten mode, backslash-in-code documented as
  divergent (invalid CommonMark kept losslessly).
- Regenerated conformance vectors (whatsapp 4 new; telegram/slack/discord
  re-stamped with the generator run).
…nd cap

register() still advertised max_message_length=4096 while the adapter and
formatter run at the 65,536 code-point cap; setup surfaces derived from the
registry understated the real limit.
The formatter raises the per-message limit from the legacy 4,096 UX split to
WhatsApp's actual cap; the docs section still described the old behavior.
…ng compaction

- gateway/observed_context.py (new platform-neutral sibling): marker constants
  (single source for Telegram + WhatsApp prompts), injection limits
  (gateway.observed_context_max_chars default 512,000 ~ 128K tokens for common
  256K-window models; observed_context_max_rows default 4,000), read assembly,
  and rolling compaction.
- gateway/run.py: generalize the Telegram-only observed-context marker check to
  the shared predicate so WhatsApp observed rows are separated from replayable
  history the same way Telegram's are (previously they replayed as ordinary user
  turns); header text is platform-neutral; assembly delegates to the sibling.
- Rolling compaction instead of truncation: when the verbatim observed set
  overflows roughly half the char budget, a background pass summarizes the OLDEST
  rows via the aux compression LLM (task routing, secret redaction, per-row and
  total input bounds) into ONE iterative summary row appended to the transcript.
  Originals stay searchable; nothing is destroyed. Later passes update the
  summary in place. Read path serves latest summary + verbatim rows after the
  watermark; the hard cap (oldest dropped, omission note) remains as the
  last-resort valve. Per-session lock + failure cooldown; cheap pending-chars
  gate so a busy group does not reload its transcript per message.
- Write-side trigger in the shared WhatsApp mixin and the Telegram adapter
  (fire-and-forget, never raises); prompt markers built from the shared constants.
- Attribution scoping: _apply_whatsapp_group_observe_attribution applies only to
  mention-gated, allowlisted groups; open / free-response groups keep per-user
  source and clean text (ports the NousResearch#38750 post-audit fix).
- Real media in observations: the observe path downloads media to the local cache
  and records inspectable references ([image: path] + vision_analyze pointer,
  [voice note: path], [document: path]) instead of kind placeholders; failed
  downloads degrade to an unavailable note.
- End-to-end runner tests through the real _build_gateway_agent_history +
  wrapper (separation for both platforms, fail-closed replay without a marker,
  cap relationships, alternation survival, unwrapped persistence contract) plus
  compaction unit tests (watermark assembly, iterative update, redaction bounds,
  aux-failure safety, scheduling gate). Sabotage-checked.
Adds plugins/platforms/waha/ (manifest + adapter): inbound webhook receiver with
timing-safe token auth, payload mapping onto the bridge-shaped dict the shared
WhatsApp mixin gates on, outbound sendText/media/edit/typing/sendSeen, and cron
standalone sending. Zero core changes: Platform("waha") resolves via the enum's
bundled-plugin discovery; hermes-waha is the implicit plugin-platform toolset
bundle. Reuses WhatsAppBehaviorMixin for gating, attribution, observed context
(with real media references), and formatting. Docs cover transport selection and
the one-transport-per-profile rule.
NOWEB sendText returns the Baileys key ({remoteJid, fromMe, id}) with no
top-level id, so SendResult.message_id was always None and the stream
consumer fell back to fresh sends per frame. WAHA's chat-message edit
endpoint also rejects bare ids (HTTP 500, expects
<fromMe>_<chatJid>_<id>). Extract key.id, serialize it (remoteJid
preferred), and serialize bare ids on the edit path.
WhatsApp now delivers many DMs keyed by a privacy LID (addressingMode:
lid, from <n>@lid). NOWEB exposes the phone form as
_data.key.remoteJidAlt; without using it, phone-form allowlists silently
reject every LID-addressed message (200-acked, never dispatched).
The gateway converts markdown to WhatsApp formatting itself; telling the
model to emit WhatsApp syntax double-formats (literal asterisks become
Unicode bold). Ask for markdown instead, mirroring the builtin whatsapp
platform hint.
LID-addressed groups break two more gates: replies/mentions carry the
bot's LID while botIds held only me.id (phone), and the group sender
arrives as key.participant (@lid) with the phone form in participantAlt.
Track me.lid alongside me.id in _bot_ids and map participantAlt for the
sender. Also log gate rejections at debug so silent drops are
diagnosable.
Normalize every outbound chatId to the docs-recommended @c.us form
(@s.whatsapp.net is internal-only per waha.devlike.pro chat-ids; @lid
targets pass through). Inbound LID resolution now canonicalizes
remoteJidAlt/participantAlt to @c.us and remembers learned pairs so LIDs
that arrive without their alt field still resolve. Works with the NOWEB
store on or off: alt fields and the cache need no store; the Lids API
remains an optional future resolver.
The discovery-order test purges plugins.memory.mem0* from sys.modules and
re-imports them; restoring sys.modules alone left plugins.memory package
attributes pointing at the throwaway modules, so later tests importing
from plugins.memory.mem0 got fresh class identities and isinstance
routing assertions failed. Rebind the top-level attributes from the
saved modules.
_handle_webhook discarded the message handler's return value, so every
slash command (/help, /status, ...) plus drain/limit notices vanished
silently on the WAHA transport: no reply, no log trace. Route the return
through the shared _dispatch_inline_reply helper — the same contract the
other adapters use. Agent turns already delivered via streaming return
None, so this cannot double-send.
Owner command for granting and revoking chat access without a config
edit or restart: /access list, /access allow|deny user|group <ref>.

Platform-agnostic core (gateway/slash_commands_access.py): adapters opt
into richer input handling via a resolve_access_ref contract; the
generic fallback covers reply-to author, event mention metadata,
mention-wrapper stripping, and (WhatsApp-family only, via the shared
mixin) phone normalization against the bot's own country code.

Phone normalization is dependency-free (gateway/whatsapp_identity.py):
national trunk digits, dial-out prefixes (00/011/0011), and already-
international input all resolve; the home country comes from the bot's
own number via longest ITU prefix match, so 08… on an Indonesian bot
and (555)… on a US bot both work. Ambiguous bare prefixes resolve to
the home country — foreign numbers require '+'. Ambiguous NAMES never
guess: the command replies with candidate matches.

Writes land in three layers: the live adapter's in-memory sets and
config.extra (effective on the next message, no restart) plus the
routed profile's config.yaml (survives restart; the shared-key bridge
re-seeds extra on load). Env-seeded adapters keep their env carrier in
sync; under multiplex os.environ is never written (NousResearch#72348).

Authorization rides the standard slash-access policy — runnable only
by allow_admin_from / group_allow_admin_from members. Slack routes
access through /hermes (50-slash cap parity list).
Internal standard: @s.whatsapp.net (Baileys-native). Each engine keeps
its own wire dialect — WAHA @c.us (docs-explicit), Cloud bare wa_id,
Baileys @s.whatsapp.net — normalized at the inbound boundary and
rendered back per-engine on the way out (to_engine_chat_id).

Every ingestion path accepts every operator shape: config.yaml, env
CSV, webhook payloads, /access input. canonicalize_id_list folds JID
dialects, bare digits and '*' entries; WhatsAppBehaviorMixin
._coerce_allow_list applies it at adapter init; policy_for_source
canonicalizes allow_admin_from for the WhatsApp family so the
exact-match admin comparison accepts any spelling, with a canonical
retry in _check_slash_access.

WAHA _map_payload canonicalizes chatId/senderId/quotedParticipant/
mentionedIds (LIDs resolve through alt fields / the learned cache
first, and the cache now stores canonical forms). Cloud API
canonicalizes wa_id at the event boundary (contacts roster still
keyed by Meta's raw wa_id) and renders bare digits in
_outbound_payload. Baileys bridge canonicalizes inbound events and
renders @s.whatsapp.net on every wire call.

Country-aware normalization of local trunk formats (0812…) stays on
the interactive /access path (_access_phone_jid), where the bot's own
country code is known — config-level normalization covers dialects
and international digits without it.
Session-key standardization: build_session_key canonicalizes chat_id and
participant for the whole WhatsApp family (whatsapp / waha / whatsapp_cloud),
so one human maps to ONE key regardless of wire dialect (@s.whatsapp.net /
@c.us / bare wa_id).  _adopt_legacy_whatsapp_entry (sibling of the Slack
adoption) MOVES the most recently updated dialect-variant entry onto the
canonical key on first contact — transcripts survive the flip.

Resolver contract is now an official BasePlatformAdapter hook
(resolve_access_ref -> AccessResolution | None, documented alongside the
metadata[mentions] event contract).  Fully featured resolvers:
- WAHA: phones in any format, @pushName via participants, group names via
  /groups, mentions, JIDs, LIDs (already landed; unchanged).
- Baileys bridge: new /groups endpoint (groupFetchAllParticipating) +
  adapter resolver for group names and pushname roster lookups.
- Telegram: get_chat for @username/group titles; unresolvable input returns
  None (honest 'give the numeric id') instead of a fabricated match.
- Discord: <@id>/<#id> wrappers, member roster across guilds (same matching
  as the startup username resolution), channel names.
- Slack: users.list / conversations.list by name, <@U>/<#C> wrappers.

Mention metadata (MessageEvent.metadata['mentions'] = [{id, label}]) now
standard on every platform that has a mention API: telegram (text_mention
entities), discord (message.mentions), slack (<@uid> captured pre-humanize),
signal (raw mentions), matrix (MSC3952 m.mentions), feishu (mention refs),
teams (activity.entities), google chat (annotations), mattermost (@username),
waha (mentionedIds).  DingTalk's SDK routes mentions structurally
(isInAtList) and exposes no roster — documented; the generic fallback covers
it.
Honest audit against 'every platform meets the highest standard' found
six gaps; all closed:

1. Baileys bridge built lidToPhone from the session's lid-mapping files
   but never APPLIED it — LID-addressed chats/senders stay @lid.  The
   upsert path now resolves chatId/senderId through the map at the
   boundary (unresolved @lid passes through to the adapter's alt/cache
   path).
2. Baileys forwarded mentionedIds but the Python adapter never surfaced
   them — event.metadata['mentions'] is now populated.
3. Pushnames: group rosters (Baileys participants, WAHA NOWEB) carry
   JIDs without names, so @name resolution failed for names the roster
   lacks.  New shared WhatsAppBehaviorMixin pushname cache — learned
   from EVERY inbound event (WAHA sender.pushName, Baileys msg.pushName,
   Cloud contacts profile names), bounded at 512 entries — feeds all
   three transports' resolvers as the roster fallback (roster matches
   stay authoritative).
4. Telegram: the Bot API cannot look up private groups by title.  The
   adapter now learns chat names/username→id from every inbound update
   (_seen_chats) and the resolver consults it before get_chat.
5. Discord: active threads are not in guild.channels — the resolver
   scans guild.threads too.
6. Slack multi-workspace: the resolver now scopes its client to the
   event's slack_team_id instead of the default workspace.
7. Cloud API got an explicit resolver (wa_id pass-through, phone
   normalization via the mixin, @name via learned profile names, group
   scope returns None honestly — Meta group support is not implemented).

Tests: pushname learning (incl. numeric-name rejection), resolver
fallback ordering, 9736 tests / 0 failed across gateway+plugins+CLI.
@FarelRA
FarelRA force-pushed the feat/gateway-access-command branch from 2a1e37f to c1e1fe0 Compare September 8, 2026 13:49

This branch has not been deployed

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

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/discord Discord bot adapter platform/slack Slack app adapter platform/telegram Telegram bot adapter platform/whatsapp WhatsApp Business adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants