Add Facebook Messenger and Instagram DM gateways, unify choice resolution, and fix delivery instrumentation - #9
Merged
Conversation
Two Meta messaging surfaces are missing, and both share webhook plumbing with the WhatsApp Cloud API gateway already in the tree. Copying that gateway would put a third and fourth copy of the hub-verification and signature logic in the gem, logic that changed twice this branch. The design shares one envelope implementation across all three Meta platforms and records the API limits it depends on, verified rather than recalled: quick replies cap at 13, carousels at 10 elements by 3 buttons, and both of Instagram's interactive surfaces are mobile only, so its renderer always numbers the options in the body as well. Also records two WhatsApp bugs found while designing the choice ladder: lists above 10 choices build payloads Meta rejects, since the 10-row cap is for all sections combined rather than per section, and coexistence echoes are published without saying whether a human or another app sent them.
Shared extractions and the two WhatsApp fixes land first, so the new platforms build on code the existing suites already prove. Messenger follows, then Instagram, which is mostly different constants. Two facts the plan cannot settle on its own are a task rather than an assumption: which webhook object Instagram messaging arrives under, which only the Meta app dashboard shows, and whether a carousel is legible for a plain option menu, which needs a real device.
Plain ruby -Itest bypasses Bundler and dies on minitest/mock.
…cision Zeitwerk maps meta/signature_validation.rb to Meta::SignatureValidation only, so a cold reference to Meta::ConfigurationError raised NameError. Move it to its own file so Zeitwerk can resolve it by name. Also record and test that a whitespace-only app_secret is treated as missing: signing with it would silently accept traffic under a "secret" that offers no protection, so the blank? check is deliberately wider than nil-or-empty. Drop the now-dead require "openssl" from the WhatsApp gateway; the shared module requires it itself.
The drafted verification module called log_tag and platform without defining either, so it would have worked only because the signature module happened to be included in the same class. Both now take those values from one identity module, which also makes a gateway that forgets one fail loudly instead of borrowing another platform's name.
Make valid_webhook_signature? private again, matching the Intercom and Telegram gateways it sat alongside before extraction; it had silently become public API when it moved into a module. Require platform_label and configuration_error_class from the including gateway instead of defaulting to Meta's; a forgotten override used to inherit FlowChat::Meta::ConfigurationError and a wrongly-labelled message with no failure signal. NotImplementedError is a ScriptError, so it still escapes the method's own rescue => e instead of collapsing into "invalid signature". Remove WhatsApp's log_tag override, a no-op duplicating the module's class-name-derived default, so it stops reading as boilerplate the next platform needs to copy. Cover the module's body directly - header lookup, sha256= stripping, the rewind/read/rewind sequence, and secure_compare - instead of only transitively through the WhatsApp gateway test. Move the fake gateway used to do this into test/support/ since more platforms will share it.
…y seam The verification module needs the same few facts about a gateway that the signature module does. Taking them from whichever module happened to be included first was a coupling waiting to break, so both now include one identity module that states the contract.
GatewayIdentity declares its hooks public, but WhatsApp overrode them below its own private, so identity was public on some Meta gateways and private on others. A gateway saying which platform it speaks for is not a secret, unlike how it validates a signature, so the overrides move above private and every Meta gateway now answers them the same way. Also pins that SignatureValidation stands alone. The shared test fake includes both behavior modules, so neither test file proved isolation any more, and gateways include them separately.
Extracting the registry turned a @@Configurations class variable, which was internal, into a public class method on all five configuration classes. configuration_names is the public way to ask what is registered, so the hash goes back to being private. Also records that the metrics collector test is flaky on wall-clock timings, so later work does not chase it.
Meta allows ten rows for all sections combined, not ten per section, so slicing twenty-five choices into three sections produced a payload that failed on send. The section titles read like pagination but nothing was paged: no second message, no stored offset. Above ten choices the options now go in the body numbered, and the choice mapper stores their positions so a typed digit resolves. Ids are resolved before positions because a choice labelled "1" generates the id "1".
create_id_mapping only runs when the next screen has choices, so a screen without any (a plain prompt.ask) never touched whatsapp.position_mapping. should_clear_for_new_flow? only ever inspected the id map, so a position map left behind by a numbered rung survived into that choice-less screen and rewrote its next typed digit into the earlier menu's key. Both maps are now cleared together, and should_clear_for_new_flow? treats a match against either map as still being on the same screen. Id-before- position resolution order is unchanged.
Instagram stores positions at every rung, so clearing only one map there would hijack every typed digit after any menu.
An echo carrying no app_id is a human replying from the business inbox, which usually means the application wants the flow to stand down. One carrying our own app_id is just our send coming back. Only this gateway knows our app_id, so it derives the origin rather than leaving every subscriber to compare ids itself.
The helper instrumented a block that really slept for the requested duration and relied on wall-clock measurement, so a "250ms" event took 250ms and recorded 250ms plus whatever jitter the scheduler added. That made this file the whole suite's runtime and made its timing assertions fail intermittently: three identical runs gave three, one and zero failures. Durations are now stated by publishing a constructed Event, so they are exact. Note publish(name, start, finish, id, payload) does not work for this: an Event-object subscriber receives no duration from it. With exact durations the tolerances drop from 5-15ms to 0.001, which is the point rather than a side effect. The old windows were wide enough to hide wrong expectations: p95 and p99 were asserted as 280 and 290 where the implementation interpolates 290 and 298. The percentile arithmetic is now pinned and documented. The sleep 0.01 calls after each publish went too. Notifications are delivered synchronously, so there was never anything to wait for.
Quick replies and carousels render on mobile Instagram only, so the renderer lists the options numbered in the body at every rung and the choice mapper always keeps the positions. Without that a user on a browser sees a prompt with nothing tappable and no way to answer. Text is measured in bytes, since Meta caps Instagram messages at 1,000 bytes rather than 1,000 characters. The gateway subclasses Meta::MessagingGateway directly rather than Messenger's gateway: the two are siblings, not parent and child.
Covers the full webhook-to-send cycle: a tapped quick reply and a typed number both advance the flow, the prompt body carries the numbered options Instagram always shows, and a webhook for an object other than "instagram" is dropped with 200. Client#send_message can't be patched at the class level the way the Messenger test does it, because Instagram's numbering happens inside the renderer that send_message calls internally; patching that high up would skip the rendering this test needs to see. Patching #post_message instead lets the real render and deliver logic run and stops one step short of the network call.
The view asked processor_type === 'whatsapp' in six places to mean "this platform draws chat bubbles". That is now one predicate over a list, so the two new platforms reuse the bubble screen instead of copying it. The two request-building branches still need per-platform shape, not just the shared predicate: WhatsApp keeps its own Cloud API envelope, while Messenger and Instagram share one entry[].messaging[] builder. displaySimulatorResponse also gains a branch for their [type, content, options] rendered tuple, since it otherwise only understands the WhatsApp Cloud API envelope shape and would show every simulated message as an unsupported type.
The choice mapper re-derived which rung a count landed on from its own count <= MAX_BUTTONS / count <= MAX_LIST_ROWS comparisons, with a comment admitting it repeats what the renderer's build_interactive_message does, because the mapper runs first and has no way to ask the renderer which rung it chose. Messenger and Instagram already avoid this by having both their renderer and mapper go through the shared FlowChat::Meta::ChoiceLadder. WhatsApp's two thresholds are flat (3 buttons, 10 list rows), unlike Messenger/Instagram's carousel of elements each holding several buttons, so FlowChat::Config::WhatsappConfig#ladder_limits bridges max_buttons/max_list_rows to the shape ChoiceLadder expects (a single element holding every row) without putting "carousel" or "buttons per element" language on WhatsApp's own public config, which has neither. The renderer and the mapper both consult ladder_limits now, so the two cannot drift on which rung a given count lands on.
ChoiceAliasBuilder was 11 lines of code under 17 lines of comment, its whole body delegated to FlowChat::ChoiceTitles, and its own comment explained that its correctness rests entirely on a guarantee ChoiceTitles makes. One concept split across two top-level constants for no benefit. Folded in as ChoiceTitles.aliases_for, a second entry point alongside .build, keeping every comment that explains why. Callers in the Messenger and WhatsApp choice mappers, and the doc comments pointing at the old name, are updated to match.
The renderer took a media: argument and never referenced it, so a flow that sent an image reached Intercom with the image discarded and nothing said about it. Every other platform rendered it. Routed by what Intercom can actually take. An image url becomes attachment_urls, which its reply API documents for images specifically. Any other type with a url becomes a link in the body instead, since a non-image url in attachment_urls may not render, and a link always does. Media carrying only an id cannot be sent at all: an id here is another platform's upload handle and means nothing to Intercom, so it warns and sends the text alone rather than raising, because a multi-platform flow legitimately sets an id for whichever platform uploaded it. Two existing tests had encoded the silent drop and now assert the attachment.
Intercom supports reply_options with message_type quick_reply, so its choices were text pretending to be buttons. They are buttons now. body is forbidden on a quick_reply, which the documented example confirms, so a choice screen is two replies: a comment carrying the prompt, then the options. The numbered list stays in that comment on purpose. A tap comes back as quick_reply_uuid in the created part's metadata, and the reference does not say what that part's body contains, so the path we read it from is a guess until a real delivery confirms it. Numbering means a tap resolves either way, and typing still works, which is the same reason Instagram numbers a body it also puts quick replies on. The uuid is the number the choice mapper already assigned, not the flow's own key: the mapper renumbers choices before the renderer sees them, so the semantic key never reaches it. A tapped uuid and a typed number therefore resolve through one session entry rather than two lookups. An empty reply_options array is not sent. Intercom requires the field to be present for a quick_reply, so an empty one buys a rejected request rather than a screen without buttons.
…ring" This reverts commit d60593c.
So the next person reading Intercom's API reference does not rebuild what was just reverted. The reference documents reply_options on an admin reply; what it does not say is that the clicked option's uuid arrives as quick_reply_option_uuid and only on the Unstable API version, which webhooks inherit, so a stable app receives no metadata at all.
The previous version said an Instagram Login delivery "is signed with the Instagram product's secret" as plain fact. Nobody has confirmed that. It came from a claim relayed in conversation, which I wrote up as an observation and used to overrule Meta's own webhooks page, which says to use the app's App Secret from App settings and names no separate one. Both are now given as candidates, with the symptom that distinguishes them (a signature warning on every delivery, or your own sends echoing back as other_app), and the note that a multi-tenant endpoint should not pick at all: Meta::Signature takes the secret as an argument so a caller can try each one an account could legitimately have used, which is right whichever answer Meta gives.
A delivery's top-level object decides which id space entry.id is in: `page` names the Page, `instagram` names the Instagram professional account. That holds on both login paths, so an account reached through a Page is still named by itself and not by the Page it answers as. account_ids accepted either id rather than settle which arrives, and account_id's comment asserted the Page was the inbound key while returning the right value for sending. Between them the rule was unstated, and an application keying its own records on the Page read the comment as confirmation and never received anything. webhook_account_id now answers the inbound question on its own, leaving account_id to answer only what a send is addressed to. Confirmed against a live delivery for a page-linked account, which also settles FACEBOOK_LOGIN_WEBHOOK_OBJECT.
A sender action rather than a message, so Messenger and Instagram get what WhatsApp and Telegram already have. Meta clears the bubble when the next message arrives or after about twenty seconds, so a caller holding one open for longer has to repeat it. Instagram inherits it rather than overriding. Meta documents sender actions under the Messenger Platform and lists only react and unreact for Instagram, but an Instagram send of typing_on is accepted and answered with the recipient id, same as Messenger.
Meta retires a version roughly every two years and renders webhook payloads at whatever the app's dashboard says, so an application can find itself calling one version and being sent another. Closing that gap should not need a release here. Only the base urls open up. The limits beside them are facts about the platform that an application cannot change by disagreeing. Instagram's two hosts move separately, since the login paths are configured independently at Meta.
valid? checked account_id, which is page_id on the default :facebook login, while the gateway matches a delivery against webhook_account_id, which is instagram_account_id on both paths. A configuration given only a page id therefore reported itself valid, answered the webhook handshake, and then rejected every delivery that followed with 403 - the id it compared against was blank, and a blank expectation matches nothing. Meta treats sustained rejections as failure and eventually disables the subscription, so this presents as a working integration that goes silent. The docs asserted the opposite of what the code does, claiming the facebook path matches against the linked Page. send_api.rb records the live delivery that settled it: entry.id names the Instagram professional account whichever login connected it. Corrected to say so.
Telegram's callback_data is 1-64 bytes and Meta sizes message bodies the same way. Counting characters lets multibyte text through to be rejected, which is the reason Instagram's client already overrides #measure. Byte truncation walks characters rather than slicing bytes: byteslice can cut a multi-byte sequence in half and produce a string that is no longer valid UTF-8. The ellipsis is charged in whichever unit is in force. Characters remain the default, so no existing call site changes.
A choice set {a: "Yes!", b: "Yes"} routed a user who typed "Yes" - the
text printed on button b - to choice a. IdGenerator built each id by
normalizing the label, and its normalization stripped punctuation, so
a's id was b's label verbatim; the id map was consulted before the alias
map, so the id won.
The defect was not the stripping itself but that nothing checked for it:
the resolver matched on a normalized form while ChoiceTitles decided
ambiguity on the raw titles, so two choices could collapse into one key
space with nothing noticing. ChoiceTitles now takes the fold its caller
resolves under, and treats titles equal after that fold as duplicates -
numbering the whole set, as it already did for truncation and duplicate
labels.
With that in place a generated id has nothing left to do. The displayed
title is already unique within its set, so it becomes the value on the
wire: a tap sends it as the payload and a user typing what they read
sends the same string, and one map resolves both. That collapses the id
map and the alias map into one, and removes IdGenerator entirely - no
platform we target restricts the character set, only the length, and a
title is bounded by its rung's cap well inside every id limit.
On the numbered rung, where the body prints each full label, the label
stays resolvable but one shared by two choices is dropped rather than
resolved to the first: it identifies neither, and the number does.
remember() built its map with `mapping[label.strip.downcase] ||= key`, which lost twice. The ||= dropped the second of any repeated label, so that choice could not be picked at all; and downcasing meant "Yes" and "YES" collapsed onto one entry the same way. Labels are now checked for collision and the set numbered when two are identical, so each stays nameable. Matching itself is exact: a web client echoes back the string it was handed rather than a person typing it, so nothing drifts on the way, and every transform that could absorb such a drift can also merge two choices into one entry. Distinct labels are still passed through exactly as the flow wrote them.
The renderer prints a number beside every option and asks the reader to reply with one, but the mapper also matched labels case insensitively - and that matching carried the same `mapping[label] ||= key` defect as HTTP, so two options reading the same collapsed onto one entry and the second could only ever be reached by its number. Resolving on position alone removes the defect rather than guarding it. Positions are unique whatever the labels say, so there is no equivalence under which two choices could collapse and nothing to check them against, which is the same reason USSD has never needed any of this. Also clears the mapping once a screen carries no choices. It was never cleared, so an answer typed into a later free-text question could be rewritten into an earlier menu's key - the stale-mapping bug WhatsApp and Messenger each had fixed twice.
callback_data is 1-64 bytes. The renderer cut it with key.to_s[0, 64], which counts characters, and the mapper never resolved anything - it stored the choices and logged, relying on callback_data being the raw key. Two failures followed: a label with multibyte characters overflowed the field and was rejected by the API, and two keys sharing their first 64 characters were cut to the same callback_data, which then matched neither key and failed the flow's own validation, so the choice could not be picked at all. The mapper now builds titles to a byte budget and resolves against a real map. A set that would collide at that budget is numbered, and numbering survives the cut because a position prefix sits at the front - a hash suffix would be the first thing a truncating platform removes. The renderer keeps a byte-safe cut as a no-op for mapper-built titles, for a renderer driven without the mapper.
USSD is the one choice mapper that needs no fold, and it is worth saying why so a later change does not unify it into a bug: a numeric keypad can only send a position, and positions are unique by construction, so the relation it resolves on is already injective. Every other mapper has to be given that guarantee by numbering an ambiguous set. Pinned with a test proving duplicate labels still resolve distinctly.
One rule covers every choice bug found in review: decide ambiguity under the same equivalence the resolver matches on. Documents it for anyone writing a gateway, with the platform limits that were verified against vendor docs, and leads with the stronger form - resolve on a printed position and the question does not arise. Includes the plan the work was carried out from.
Three clients wrapped their own send in instrument(MESSAGE_SENT) { ... }
while the gateway instrumented the same send afterwards, so every
successful delivery published the event twice. ActiveSupport::Notifications
publishes a block event once the block returns whatever it returned, so
the client's copy also fired when the send had failed and the method was
about to answer nil.
MetricsCollector subscribes to that event and increments
<platform>.messages.sent, so the count has been double the real rate on
WhatsApp, Messenger, Instagram and Intercom, and has counted refused sends
as delivered.
The gateway is now the only publisher, and it skips the event when
report_delivery_failure hands back nil - the same nil that already routes
the turn to on_delivery_failure. One send, one event, and only when the
platform took it.
Telegram could not participate in any of this: its api_request answered
with the parsed error envelope rather than nil, so a refused send was
indistinguishable from a delivered one and could never reach
report_delivery_failure's nil branch. It now answers nil on both the
!ok response and the rescue path, matching every other client, which also
means file_url has to stop assuming a hash back.
Tests subscribe to the real notification channels rather than stubbing a
gateway's #instrument. That distinction is why this went unseen: a stub on
the gateway never sees what the client publishes.
Removing the clients' own instrument blocks took the real timing with them: MESSAGE_SENT is now published after the send returns rather than wrapped around it, so ActiveSupport::Notifications reports a duration of zero and MetricsCollector's <platform>.api.response_time flatlined. The send is timed where it is already wrapped - report_delivery_failure - and left on the context beside the platform message id, which is the same route the delivered id already takes to reach the gateway. Gateways put it on the event as duration_ms, and MetricsCollector prefers it over the event's own duration. Timed on both paths. A send that raised took no less time for having failed, and the figure is what says whether it failed slowly.
html_to_plain_text made one non-greedy pass over <ul>(.*?)</ul>, which pairs an outer opening tag with the *inner* list's closing tag. On a nested list only the first item kept its bullet, the rest lost theirs, and the leftover </li></ul> was stripped later as a bare tag - so a user read stray indented lines, and on some inputs raw markup. Messenger and Instagram send every prompt through to_plain_text, so this reached anyone whose flow wrote a nested list. Lists are now rendered innermost first, repeating until none remain: by the time an outer list is matched its children are already plain text and there is no list markup left to mispair with. Nesting shows as indentation, which is the only thing that can carry it once tags are gone. Whitespace around list tags is removed once, up front. The markdown's own indentation survives into the HTML, and left in place it compounds with the indentation added per level, stepping each one further right than the last.
Standby carries the same events for a thread another app owns, which is what a secondary receiver sees under the handover protocol. It is published whole and never run: a flow answering here would be talking over whoever Meta handed the thread to, and the send would be refused in any case. Nothing has to be subscribed for it to arrive - a delivery switches to standby the moment a business names another app the primary receiver. Reading it out of the same slot as `messaging` would have let it drive a flow, so it is walked separately. That put a fourth kind of event in a walk that was already publishing receipts, echoes and unmodelled fields, and made an existing problem worth fixing rather than working around: with async enabled the foreground pass published all of them and then enqueued, and the job re-entered handle_webhook on the same body and published every one again. Delivery and read receipts double-counted for every app using use_async. They are published by the request now and skipped by the job, which is the pass already holding the delivery: a receipt is announced when it arrives rather than when the queue reaches it, and still announced if the job is never picked up. An app that does not use async never takes the other branch, so nothing changes for it. Collecting them in publish_side_events also leaves the flow loop asking one question. A receipt carries neither a message nor a postback, so drives_flow? already excludes it; an echo does carry a message and is the one thing that has to be named, or a reply of our own would drive a turn. Mirrored into the WhatsApp gateway, which walks changes rather than messaging events but had the same shape and the same double-publish.
This was referenced Aug 17, 2026
thedumbtechguy
added a commit
that referenced
this pull request
Aug 17, 2026
release:prepare ran git-cliff with `-o CHANGELOG.md`, which rewrites the file from the commit history on every release. Anything hand-written was discarded at the next one. Not hypothetical. #9 was squash-merged with a non-conventional title, so git-cliff could not categorise it and the largest change in v0.10.0 - Messenger, Instagram, the choice resolution rework, the delivery instrumentation fixes - was missing from the notes entirely and had to be written in by hand. The next release would have erased it. --unreleased limits the run to commits since the last tag and --prepend inserts that section under the header, so everything below is left alone. Verified against a copy of the current file: a hand-written marker survived a prepend that added a new section correctly.
thedumbtechguy
added a commit
that referenced
this pull request
Aug 17, 2026
#9 was squash-merged with a non-conventional title, so git-cliff skipped it: the largest change in the release was absent from both CHANGELOG.md and the published GitHub Release, while every other PR in it appeared. Added by hand, along with the two behaviour changes that reach existing users on upgrade and had no entry anywhere: Intercom now resolving numbers only, and HTTP matching replies exactly. Durable now that releases prepend rather than regenerate. The published release notes were corrected to match when this was found.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Facebook Messenger and Instagram DMs as first-class platforms, on one shared implementation of the Meta webhook plumbing, and fixes two WhatsApp bugs found while designing them.
Targets
master. The work was written on top offeat/coexistence-webhooks, which hassince been squash-merged as
ec0fa38, so these commits were rebased onto master andthe diff here is only this branch's own work.
Design: docs/superpowers/specs/2026-08-10-messenger-instagram-design.md.
Plan: docs/superpowers/plans/2026-08-10-messenger-instagram.md.
Why the shared layer
All three Meta platforms use the same
hub.challengeverification handshake and the sameX-Hub-Signature-256signature scheme. Copyingwhatsapp/gateway/cloud_api.rbtwice would have put four copies of logic that changed twice on the base branch (76b141b,f4fce7a) in the gem. Messenger and Instagram also share an envelope (entry[].messaging[]) and a Send API with each other, so that is implemented once too.WhatsApp is migrated onto the shared modules rather than left on its own copies.
The two decisions sit in plain modules, separate from the mixins that carry them
into a gateway.
Signature.valid?(body, header, secret)andChallenge.answer(params, verify_token)take what they judge and nothing else: noconfig, no controller, no platform identity, no logging. A single endpoint serving
several tenants needs both answers before it knows whose delivery it is, which is
before it has a gateway to ask. The mixins add the parts only a gateway wants: the
opt out, treating a missing secret as the developer's mistake rather than an answer,
reading a Rack body, logging and instrumentation.
Signature.valid?is total where the mixin raises. A caller holding no secret isnot asking a different question, and answering no is the safe reading.
Instagram::Gateway::SendApisubclassesMeta::MessagingGatewaydirectly rather than Messenger's gateway. The two are siblings, not parent and child.Instagram::Rendererdoes subclass Messenger's, because there the algorithm really is the same and only the constants differ.The choice ladder
Neither platform has a WhatsApp-style list picker, so choices render down a ladder whose rung is decided by one helper that both the renderer and the choice mapper consult. Two copies of that arithmetic would drift into a screen whose replies cannot be resolved.
Instagram always carries the numbered list because both of its interactive surfaces render on the mobile app only, not desktop or web. Without it, a user on a browser gets a prompt with nothing tappable and no way to answer.
A reply can therefore arrive as a tapped payload or as a typed digit, so the mapper keeps two maps and resolves titles before positions. The order matters: a choice labelled
"1"has the title"1", which is not necessarily the first choice, and would be unreachable if positions won.Two WhatsApp fixes
Interactive lists above 10 choices were rejected on send.
build_list_messagesliced 25 choices into three sections titled1-10,11-20,21-25, but Meta allows "up to 10 sections, with up to 10 rows for all sections combined". The section titles read like pagination while nothing was paged: no second message, no stored offset. The ladder is now buttons at 3 or fewer, a single-section list to 10, and a numbered body above that.test/unit/whatsapp/renderer_test.rbasserted the broken behaviour and is rewritten.Coexistence echoes did not say who sent them. An echo reports a message sent on the thread by someone other than the user, and which someone decides what the application does: a human replying from the business inbox usually means the bot should stand down, while our own send coming back means nothing.
app_idis the only thing that separates them and the gateway is the only place that knows our own, so it now derivesecho_originof:self,:other_appor:human_agentand publishes it alongside the raw value.Other fixes found along the way
A quiet send failure was reported as a success. Clients answer with the platform's parsed response on success and
nilonce they have logged an API error, so a non-2xx response leftreport_delivery_failurelooking at a nil return with no exception.on_delivery_successfired for a message that was never delivered, and a nil id was stamped onto the context as though the platform had named one. A nil result now reports the failure both ways, carrying aFlowChat::DeliveryErrorso a subscriber written against a raising client sees the same shape. It still does not raise: the client already decided to swallow the error, and an exception here would fail the webhook for a reply the platform merely declined. This affects all four sending gateways.Shared locations landed on
request.media. Meta delivers a location as an attachment like any image. It now populatesrequest.location, soapp.locationworks without the flow knowing its platform.Benchmarks ran inside
rake test. They assert against wall-clock timings and failed on a loaded machine for reasons unrelated to the code under test. They move torake benchmark.One security test could poison the whole run. The suite failed on some random seeds and passed on others, with cascades of thirty or more unrelated failures across Intercom, WhatsApp and the gateways.
without_security_utilsreadActiveSupport::SecurityUtilsinto a local before removing it, but Zeitwerk loadsflow_chat/security.rblazily and that file is what requires SecurityUtils, so on an order reaching this test first the read raised, the local stayed nil, and theensureput nil in the constant's place instead of restoring it. Every latersecure_comparein the process then saw a defined-but-nil SecurityUtils: signature validation, simulator cookies, webhook verification. The Intercom failures were downstream of that rather than Intercom's doing. The helper now restores only what it removed, and the file requires what it compares against.Error classes were invisible to Zeitwerk. All seven
ConfigurationErrorclasses were declared inside files Zeitwerk maps to another constant, so each existed only once that file had loaded for some other reason. Seed 1 failed on exactly this withFlowChat::Http::ConfigurationError. Each now has its own file and all seven resolve on a cold load.Seeds 1 to 140 pass. Before these two fixes, several did not. The suite runs in well under a second.
metrics_collector_test.rbwas flaky and slow. Its helper instrumented a block that really slept for the requested duration, so a "250ms" event cost 250ms and recorded 250ms plus scheduler jitter. Three identical runs gave three, one and zero failures, and the file was the whole suite's runtime. Durations are now stated by publishing a constructedEvent. Notepublish(name, start, finish, id, payload)does not work for this: an Event-object subscriber receives no duration from it.Tightening the tolerances from 5-15ms to 0.001 exposed two wrong expectations the wide windows had hidden: p95 and p99 were asserted as 280 and 290 where the implementation interpolates 290 and 298. The collector was correct; the test was not.
Choices, media and the messaging window
Choices can be tapped, typed as the visible title, or typed as a number. A tap sends the generated id. The visible title is registered as an alias, so typing what is on screen works. A screen is numbered only when its titles would otherwise be ambiguous: something was truncated, or two titles came out identical.
Yes/Nostays bare; two options that both truncate toTransfer to sa...are numbered so each can still be picked. One helper decides this for both the renderer and the choice mapper, because a screen whose replies cannot be resolved is the failure mode to avoid. Instagram numbers unconditionally, since its interactive surfaces render on mobile only.Media alongside choices used to be dropped or misrendered on every platform. WhatsApp sent any media-plus-choices screen as reply buttons regardless of count, and Meta caps reply buttons at 3, so 4 or more choices with media built a payload Meta rejects. Messenger and Instagram dropped the media silently. Media is now additive: it does not change which choice surface is used.
Also fixed while confirming the above: WhatsApp put a
captionon audio sends, which Meta's audio schema has no field for.send_messageandsend_texttake an optionaltag:on Messenger and Instagram, for reaching a user outside the 24-hour window. It setsmessaging_typetoMESSAGE_TAGwith a top-leveltag, replacingRESPONSErather than accompanying it, and is deliberately not gated on themessaging_type?hook that Instagram sets to false: Meta never documentedRESPONSEfor Instagram but does documentMESSAGE_TAGwithHUMAN_AGENT.HUMAN_AGENTis the only tag Meta still accepts, and it needs the Human Agent app feature approved. The value is passed through unvalidated, since Meta refuses an unknown tag clearly and an allowlist is one more thing to maintain.The tag lands on every part of a multi-part send. A long reply splits, and a media-plus-choices send posts twice, and a tag on only the first part means the second is refused outside the window and the customer receives half a message with no sign anything is missing.
Both Instagram integration paths
Meta offers two ways to reach Instagram messaging, and both are supported, selected by
login:on the configuration with:facebookas the default.graph.instagram.comgraph.facebook.comThey are one gateway rather than two, because only the transport and the credentials differ. The request body is the same shape and carries no
messaging_typeon either, and the signature andhub.challengehandshakes are Graph-API-wide standards rather than per-product: "We sign all Event Notification payloads with a SHA256 signature and include the signature in the request'sX-Hub-Signature-256header." SoMeta::SignatureandMeta::Challengeneeded no variant.request.platformstays:instagramon both, because a flow has no business knowing how the merchant authenticated. An unknownlogin:raises rather than silently picking a host.This matters for coverage rather than neatness: an Instagram professional account no longer needs a linked Page, and Meta built a whole separate API surface for that population, so accounts the Facebook Login path cannot serve are not a rare edge case.
Adding the second path surfaced one bug in shared code:
Messenger::Clientread@config.page_iddirectly for its startup log and its API-error context, bypassing theaccount_idseam the rest of the gem uses. On the Instagram Login pathpage_idis legitimately unset, so both would have reported a blank account. Harmless on the paths that existed before, which is why nothing caught it until a second credential shape existed.Deduplication
The named-configuration registry (
register,get,exists?,configuration_names,clear_all!,register_as) was duplicated verbatim in three configuration classes and the new platforms would have made five. It is nowFlowChat::NamedConfiguration, with per-class storage: a shared@@configurationswould have given every platform one merged registry, so a name registered for Messenger would resolve for WhatsApp.Whatsapp::IdGeneratorwas lifted toFlowChat::IdGeneratorwith a configurable cap, and has since been removed altogether: the displayed title is now the value on the wire, so there is no second id space to keep unique. See "Choice resolution" below.Verified against Meta's docs
The rendering depends on exact numbers, so these were checked rather than recalled: quick replies cap at 13 with 20-character titles and 1000-character payloads; the generic template allows 10 elements of 3 buttons,
postbackandweb_urlonly; Instagram text is capped at 1000 bytes UTF-8, not characters; Instagram quick replies and carousels are mobile-only.One value could not be confirmed. Meta states no text limit for Messenger on any current reference page. 2000 stays as the long-cited figure with a comment saying it is unverified, and it is safe to be wrong about in this direction because the client splits at this value rather than truncating.
Choice resolution, unified
A review of this branch found a choice set
{a: "Yes!", b: "Yes"}routed a user who typedYes— the text printed on button b — to choice a. The id was built by normalizing the label, and the normalization stripped punctuation, soa's id wasb's label verbatim.The defect was not the stripping but that nothing checked for it: the resolver matched on a normalized form while
ChoiceTitlesdecided ambiguity on the raw titles, so two choices could collapse into one key space with nothing noticing. The same mismatch turned out to exist, in a different form, in every mapper except USSD:strip.downcase||=dropped the second of two matching labelsUSSD is safe because its equivalence relation is its uniqueness guarantee. Every other mapper now gets that guarantee explicitly, and the displayed title — which
ChoiceTitlesalready guarantees distinct, numbering the set when it must — became the value on the wire. That collapses the id map and the alias map into one, and removesIdGenerator: no platform we target restricts the character set, only the length.Verified against vendor docs rather than recalled: WhatsApp button
id256 chars and list rowid200, Messenger/Instagram payload 1000, Telegramcallback_data1–64 bytes. None restricts characters.Two behaviour changes for existing platforms:
Salesnow falls through to the flow's own validation.Both replaced pre-existing tests rather than deleting them.
Delivery instrumentation
The same review found
message.sentfiring for sends the platform had refused. It was worse than reported: the WhatsApp, Messenger and Intercom clients each wrapped their send in their owninstrument(MESSAGE_SENT)block while the gateway instrumented the same send again, so every successful delivery published the event twice andMetricsCollectorhas been counting<platform>.messages.sentat double the real rate.The gateway is now the only publisher and skips the event when the send returned nil. Timing moved with it: the event is emitted after the fact, so it carries a measured
duration_mson the payload rather than relying on the notification's own duration.Telegram could not participate at all — its
api_requestanswered with the parsed error envelope rather than nil, so a refused send was indistinguishable from a delivered one and could never reachon_delivery_failure. It now answers nil on both the!okand rescue paths, matching every other client.Also fixed:
html_to_plain_textmade one non-greedy pass over<ul>(.*?)</ul>, which pairs an outer opening tag with the inner list's closing tag. Nested lists lost their bullets and leaked raw markup to users on Messenger and Instagram, which route every prompt through it.Confirmed since
Instagram::Gateway::SendApi#expected_webhook_objectreturns"instagram", and this has now been confirmed against a live delivery for a page-linked account rather than inferred: the delivery arrived under"instagram"and named the Instagram professional account inentry.id, not the linked Page. That settled a second question too, and corrected the docs, which had claimed the Facebook Login path matches against the Page id. It does not;instagram_account_idis what an inbound delivery names on both paths, and is now required on both.Still unverified and cosmetic only: whether the Instagram carousel reads acceptably for a plain option menu, whose cards are titled
"Options 1 to 3"because a generic-template card requires a title and a menu has none. Mobile-only question, and the numbered body means nothing is broken either way.Out of scope
The handover protocol beyond publishing
standbyandmessaging_handovers, which this now does; persistent menus and ice breakers; Facebook Page feed and comment events. Message tags and the Instagram Login path were in the original non-goals and have since been added, see above.Reviewing
bundle exec rake testgives 1380 tests, 0 failures. Minitest randomizes the order, so if you hit a failure, re-run withTESTOPTS="--seed=N"using the seed from the output: seeds 1 to 140 pass here, and anything that only appears on one order is worth reporting rather than retrying.Worth a close look:
lib/flow_chat/meta/messaging_gateway.rb, the largest new unit. Receipts, echoes, standby and unmodelled events are published bypublish_side_eventsin the foreground only: with async enabled the job re-enters on the same body, and publishing in both announced every receipt twice.lib/flow_chat/choice_titles.rb, which now decides ambiguity under the fold its caller resolves with. This is the invariant the whole choice change rests on.lib/flow_chat/instrumentation.rb, since the nil-result and single-publisher changes affect every sending gateway.lib/flow_chat/telegram/client.rb, for the nil-on-refusal contract change.lib/flow_chat/http/middleware/choice_mapper.rbandlib/flow_chat/intercom/middleware/choice_mapper.rb, the two behaviour changes for existing platforms.lib/flow_chat/whatsapp/renderer.rbandmiddleware/choice_mapper.rb, the behaviour changes for an existing platform.docs/gateway-development.mdgained a section on what a choice mapper owes its resolver, which is the short version of the rule above.