Skip to content

feat: return WhatsApp Flow submissions unwrapped from template blocks - #340

Open
santiagocardo wants to merge 3 commits into
developfrom
feat/template-flow-button-result
Open

feat: return WhatsApp Flow submissions unwrapped from template blocks#340
santiagocardo wants to merge 3 commits into
developfrom
feat/template-flow-button-result

Conversation

@santiagocardo

Copy link
Copy Markdown
Contributor

Purpose

A WhatsApp message template can carry a FLOW button that launches a WhatsApp Flow (a form). When the contact submits it, the decoded reply JSON becomes the block's user input — but it fell through to evaluate_outgoing/5's catch-all clause, which wraps everything as %{"__value__" => input, "index" => nil}.

That buried the submitted fields one level deeper than the equivalent non-template flow:

How the flow was sent Block Reading a submitted field
Directly from a journey Io.Turn.WhatsAppSendFlow @ref_Flow_1.email
Via a template FLOW button Io.Turn.WhatsAppTemplateMessage @ref_Template_1.__value__.email

This aligns the two, so journeys read flow results the same way regardless of how the flow was sent.

This is the first step of a larger piece of work in engage to support routing and results for template flow buttons in journeys. The remaining work (a flow: optional parameter on send_message_template() carrying the initial screen payload, which is what emits the sub_type: "flow" component and causes the journey to suspend) lands separately and depends on this release.

Approach

One new clause in WhatsAppTemplateMessage.evaluate_outgoing/5, returning the submission unwrapped:

def evaluate_outgoing(_container, _flow, _block, _context, %{"flow_token" => _} = flow_response) do
  {:ok, flow_response}
end
  • Identified by flow_token — the contact's reply always echoes back the token from the outbound message, so it's a reliable discriminator for a flow submission.
  • No collision with the button clause, which matches the binary "template-btn-idx-" <> index rather than a map. Existing button routing (both the prefixed and legacy-unprefixed forms) is untouched.
  • Non-submissions are unaffected. A contact who replies with plain text instead of submitting still falls through to the catch-all and routes to the fallback exit.
  • has_reply_button?/1 gains a comment noting it already admits sub_type: "flow" — a non-obvious dependency, since that predicate is what suspends the journey so there is a result to route on at all. No behaviour change.
  • @block_doc's returns: updated to describe both result shapes.

Version bumped 6.14.1 → 6.15.0 (backwards-compatible addition).

Verification

The file previously had coverage for validate_config!/1 only. Tests go 6 → 15, deliberately weighted toward the clauses that must not regress:

  • evaluate_outgoing/5 — flow submission returned unwrapped; plain-text reply still wrapped (fallback-exit path); prefixed button routing; legacy unprefixed button routing; nil passthrough.
  • evaluate_incoming/4 — suspension for flow and quick-reply buttons, no suspension for url-only and button-less templates. This pins down the behaviour the engage side depends on.

Confirmed end to end that the result actually lands where intended, by running Block.evaluate_user_input/4 against the new clause:

vars["ref_Template_1"] = %{"email" => "jane@example.com", "flow_token" => "tok"}
>>> @ref_Template_1.email resolves to: "jane@example.com"

Full suite: 201 tests, 0 failures. mix format --check-formatted, mix credo --strict, and mix dialyzer all clean.

🤖 Generated with Claude Code

A message template can carry a FLOW button that launches a WhatsApp Flow.
When the contact submits it, the decoded nfm_reply JSON became the block's
user input and fell through to the catch-all clause, which wraps everything
as %{"__value__" => input, "index" => nil}.

That buried the submitted fields one level deeper than the equivalent
non-template flow: a journey had to reach for @ref_Template_1.__value__.email
where Io.Turn.WhatsAppSendFlow exposes @ref_Flow_1.email. Match the two so
journeys read flow results the same way regardless of how the flow was sent.

A submission is identified by the flow_token the contact's reply echoes back
from the outbound message. It cannot collide with the button clause above it,
which matches a binary rather than a map.

Also document why has_reply_button?/1 keeps a flow button in scope: it is what
suspends the journey so there is a result to route on at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
santiagocardo and others added 2 commits August 6, 2026 15:40
A template's FLOW button is sent as a `sub_type: "flow"` button component whose
single parameter has `type: "action"`, carrying `flow_action_data` — the data
payload for the flow's first screen.

parse_parameter/2 had no clause for it, so building a journey with such a
template raised FunctionClauseError while loading the block config. Carry the
payload through untouched: it may be a decoded JSON object, or a string holding
an expression that is evaluated when the template is sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Santiago <santiagocardo80@gmail.com>
@santiagocardo santiagocardo self-assigned this Aug 6, 2026
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