Repository navigation
Conversation
PR #105630 —
|
|
Thank you for the review — all points are valid and addressed in commit 1. Fail-open 2. 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 4. 5. Busy-path enforcement → identical, verified. 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 |
|
Follow-up on the earlie, the gaps are now closed in commit
A coverage matrix is in the PR description. Test scale after the fixes: 9736 tests, 0 failed (899 files) via |
…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.
2a1e37f to
c1e1fe0
Compare
What
Three stacked commits on
feat/gateway-access-command:1.
/access— manage DM/group allowlists from chat (5e3110ef27)Platform-agnostic core (
gateway/slash_commands_access.py): adapters opt into rich resolution via aresolve_access_refcontract; 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'sconfig.yaml) so the change is effective on the next message and survives restart; env-seeded adapters keep their env carrier in sync (neveros.environunder multiplex, #72348). Authorization rides the standard slash-access policy (allow_admin_from/group_allow_admin_fromonly).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 barewa_id, Baileys@s.whatsapp.net— normalized at the inbound boundary and rendered back per-engine on the way out (to_engine_chat_id)./accessinput.canonicalize_id_listfolds JID dialects (@c.us/@s.whatsapp.net/bare digits/*) with duplicate collapse._map_payloadcanonicalizes chatId/senderId/quotedParticipant/mentionedIds — LIDs resolve through alt fields / the learned cache first (cache now stores canonical forms).wa_idat the event boundary (contacts roster still keyed by Meta's rawwa_id) and renders bare digits in_outbound_payload.allow_admin_fromis 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.0812…) stays on the interactive/accesspath where the bot's own country is known; config-level normalization covers dialects and international digits without it.3.
/accessstandard everywhere + one session key per human (d33e10fe1d)Session-key standardization:
build_session_keycanonicalizes 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
BasePlatformAdapterhook (resolve_access_ref→AccessResolution | None, documented alongside themetadata["mentions"]event contract). Fully featured resolvers on every major platform:@PushNamevia participants, group names via/groups, mentions, JIDs, LIDs/groupsendpoint (groupFetchAllParticipating) + group-name and pushname-roster resolutionget_chatfor@username/group titles; unresolvable input returns None (honest "give the numeric id") instead of a fabricated match<@id>/<#id>wrappers, member roster across guilds (same matching as the startup username resolution), channel namesusers.list/conversations.listby name,<@U…>/<#C…>wrappersMention 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 (MSC3952m.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.uswire canonicalization (stubbed_request, no WAHA instance).@c.uswire form; internal-form tests pin@s.whatsapp.net.scripts/run_tests.shovertests/gateway/+tests/plugins/+ CLI — 9734 tests, 0 failed (899 files)._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:
lidToPhonebut never applied it — LID-addressed chats/senders stayed@lid. The upsert path now resolves through the map at the boundary (unresolved@lidpasses to the adapter's alt/cache path).mentionedIdsbut the Python adapter never surfaced them —event.metadata["mentions"]now populated.@PushNamewas roster-dependent: group rosters (Baileys participants, WAHA NOWEB) carry JIDs without names. New sharedWhatsAppBehaviorMixinpushname cache — learned from EVERY inbound event (WAHAsender.pushName, Baileysmsg.pushName, Cloud contacts profile names), bounded at 512 — feeds all three transports' resolvers as the roster fallback (roster matches stay authoritative)._seen_chats); the resolver consults it beforeget_chat.guild.channels— the resolver scansguild.threadstoo.slack_team_idinstead of the default workspace.@Namevia learned profile names; group scope returnsNonehonestly — Meta group support is not implemented).Final coverage matrix
None@username+ learned titles