Skip to content

Add Facebook Messenger and Instagram DM gateways, unify choice resolution, and fix delivery instrumentation - #9

Merged
thedumbtechguy merged 82 commits into
masterfrom
feat/messenger-instagram
Aug 17, 2026
Merged

Add Facebook Messenger and Instagram DM gateways, unify choice resolution, and fix delivery instrumentation#9
thedumbtechguy merged 82 commits into
masterfrom
feat/messenger-instagram

Conversation

@thedumbtechguy

@thedumbtechguy thedumbtechguy commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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 of feat/coexistence-webhooks, which has
since been squash-merged as ec0fa38, so these commits were rebased onto master and
the 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.challenge verification handshake and the same X-Hub-Signature-256 signature scheme. Copying whatsapp/gateway/cloud_api.rb twice 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.

lib/flow_chat/meta/
  signature.rb               does this body carry Meta's signature for this secret
  challenge.rb               which challenge does this handshake deserve, if any
  signature_validation.rb    the gateway's way into Signature
  webhook_verification.rb    the gateway's way into Challenge
  gateway_identity.rb        what a Meta gateway must say about itself
  choice_ladder.rb           which surface renders N choices
  messaging_gateway.rb       the entry[].messaging[] envelope
lib/flow_chat/messenger/     configuration, client, renderer, gateway, choice mapper
lib/flow_chat/instagram/     same, differing in three ways (below)

The two decisions sit in plain modules, separate from the mixins that carry them
into a gateway. Signature.valid?(body, header, secret) and
Challenge.answer(params, verify_token) take what they judge and nothing else: no
config, 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 is
not asking a different question, and answering no is the safe reading.

Instagram::Gateway::SendApi subclasses Meta::MessagingGateway directly rather than Messenger's gateway. The two are siblings, not parent and child. Instagram::Renderer does 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.

Choices Messenger Instagram
1 to 13 quick replies quick replies plus a numbered body
14 to 30 carousel, 10 elements by 3 buttons carousel plus a numbered body
above 30 numbered body numbered body

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_message sliced 25 choices into three sections titled 1-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.rb asserted 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_id is the only thing that separates them and the gateway is the only place that knows our own, so it now derives echo_origin of :self, :other_app or :human_agent and 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 nil once they have logged an API error, so a non-2xx response left report_delivery_failure looking at a nil return with no exception. on_delivery_success fired 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 a FlowChat::DeliveryError so 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 populates request.location, so app.location works 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 to rake 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_utils read ActiveSupport::SecurityUtils into a local before removing it, but Zeitwerk loads flow_chat/security.rb lazily and that file is what requires SecurityUtils, so on an order reaching this test first the read raised, the local stayed nil, and the ensure put nil in the constant's place instead of restoring it. Every later secure_compare in 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 ConfigurationError classes 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 with FlowChat::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.rb was 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 constructed Event. Note publish(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 / No stays bare; two options that both truncate to Transfer 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.

Case Messages
WhatsApp, 3 or fewer choices one, buttons with a media header
WhatsApp, more than 10 choices one, the media captioned with the prompt and numbered options, up to Meta's 1024-character caption limit
WhatsApp, 4 to 10 choices two, because a list header is text-only and keeping the list widget is worth more
WhatsApp, audio or a sticker two, since neither takes a caption
Messenger and Instagram two, because a Meta attachment payload has nowhere to put the prompt

Also fixed while confirming the above: WhatsApp put a caption on audio sends, which Meta's audio schema has no field for.

send_message and send_text take an optional tag: on Messenger and Instagram, for reaching a user outside the 24-hour window. It sets messaging_type to MESSAGE_TAG with a top-level tag, replacing RESPONSE rather than accompanying it, and is deliberately not gated on the messaging_type? hook that Instagram sets to false: Meta never documented RESPONSE for Instagram but does document MESSAGE_TAG with HUMAN_AGENT. HUMAN_AGENT is 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 :facebook as the default.

Instagram API with Instagram Login Instagram API with Facebook Login
Linked Facebook Page Not required Required
Host graph.instagram.com graph.facebook.com
Access token Instagram User token Facebook User or Page token
Account identifier Instagram account id Page id
Reaches ads and tagging No Yes

They 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_type on either, and the signature and hub.challenge handshakes 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's X-Hub-Signature-256 header." So Meta::Signature and Meta::Challenge needed no variant.

request.platform stays :instagram on both, because a flow has no business knowing how the merchant authenticated. An unknown login: 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::Client read @config.page_id directly for its startup log and its API-error context, bypassing the account_id seam the rest of the gem uses. On the Instagram Login path page_id is 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 now FlowChat::NamedConfiguration, with per-class storage: a shared @@configurations would have given every platform one merged registry, so a name registered for Messenger would resolve for WhatsApp.

Whatsapp::IdGenerator was lifted to FlowChat::IdGenerator with 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, postback and web_url only; 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 typed Yes — the text printed on button b — to choice a. The id was built by normalizing the label, and the normalization stripped punctuation, so a's id was b's label verbatim.

The defect was not the stripping 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. The same mismatch turned out to exist, in a different form, in every mapper except USSD:

Mapper Resolved on Ambiguity checked under Result
WhatsApp, Messenger, Instagram normalized label raw titles typed reply hit the wrong choice
HTTP, Intercom strip.downcase nothing ||= dropped the second of two matching labels
Telegram first 64 characters, against a 64-byte field nothing multibyte labels rejected by the API; shared prefixes collided and the choice became unpickable
USSD position injective by construction no bug

USSD is safe because its equivalence relation is its uniqueness guarantee. Every other mapper now gets that guarantee explicitly, and the displayed title — which ChoiceTitles already 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 removes IdGenerator: no platform we target restricts the character set, only the length.

Verified against vendor docs rather than recalled: WhatsApp button id 256 chars and list row id 200, Messenger/Instagram payload 1000, Telegram callback_data 1–64 bytes. None restricts characters.

Two behaviour changes for existing platforms:

  • Intercom resolves numbers only, as USSD does. It already prints a number beside every option and asks for one; matching labels case-insensitively as well is what let two options reading the same collapse onto one entry. Typing Sales now falls through to the flow's own validation.
  • HTTP matches replies exactly — no case or whitespace folding. A web client echoes back the string it was handed, so nothing drifts on the way, and every transform that could absorb a drift can also merge two choices.

Both replaced pre-existing tests rather than deleting them.

Delivery instrumentation

The same review found message.sent firing for sends the platform had refused. It was worse than reported: the WhatsApp, Messenger and Intercom clients each wrapped their send in their own instrument(MESSAGE_SENT) block while the gateway instrumented the same send again, so every successful delivery published the event twice and MetricsCollector has been counting <platform>.messages.sent at 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_ms on the payload rather than relying on the notification's own duration.

Telegram could not participate at all — 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 on_delivery_failure. It now answers nil on both the !ok and rescue paths, matching every other client.

Also fixed: 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. 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_object returns "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 in entry.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_id is 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 standby and messaging_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 test gives 1380 tests, 0 failures. Minitest randomizes the order, so if you hit a failure, re-run with TESTOPTS="--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 by publish_side_events in 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.rb and lib/flow_chat/intercom/middleware/choice_mapper.rb, the two behaviour changes for existing platforms.
  • lib/flow_chat/whatsapp/renderer.rb and middleware/choice_mapper.rb, the behaviour changes for an existing platform.

docs/gateway-development.md gained a section on what a choice mapper owes its resolver, which is the short version of the rule above.

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.
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.
@thedumbtechguy thedumbtechguy changed the title Add Facebook Messenger and Instagram DM gateways, and fix two WhatsApp bugs Add Facebook Messenger and Instagram DM gateways, unify choice resolution, and fix delivery instrumentation Aug 17, 2026
@thedumbtechguy
thedumbtechguy merged commit d0f6e73 into master Aug 17, 2026
5 checks passed
@thedumbtechguy
thedumbtechguy deleted the feat/messenger-instagram branch August 17, 2026 02:27
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant