From 740c0b71966922de30b99eb408bafdba2482b465 Mon Sep 17 00:00:00 2001 From: Santiago Date: Thu, 6 Aug 2026 14:59:13 -0500 Subject: [PATCH 1/5] feat: return WhatsApp Flow submissions unwrapped from template blocks 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) --- README.md | 2 +- .../whatsapp_template_message.ex | 12 +- mix.exs | 2 +- .../whatsapp_template_message_test.exs | 150 ++++++++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5e9d73e3..aefddd83 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ by adding `flow_runner` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:flow_runner, "~> 6.14.1"} + {:flow_runner, "~> 6.15.0"} ] end ``` diff --git a/lib/flow_runner/custom_blocks/whatsapp_template_message.ex b/lib/flow_runner/custom_blocks/whatsapp_template_message.ex index 9df1c885..b4e9f270 100644 --- a/lib/flow_runner/custom_blocks/whatsapp_template_message.ex +++ b/lib/flow_runner/custom_blocks/whatsapp_template_message.ex @@ -33,7 +33,9 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessage do send_message_template("template_name", "en", ["param-1", "param-2"]) end """, - returns: "Map with __value__ and index when template has reply buttons" + returns: + "The submitted JSON when the template has a flow button, otherwise a map " <> + "with __value__ and index when the template has reply buttons" @impl FlowRunner.Spec.Block def validate_config!(%{ "template" => @@ -154,6 +156,14 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessage do @impl FlowRunner.Spec.Block def evaluate_outgoing(_container, _flow, _block, _context, nil), do: {:ok, nil} + # A WhatsApp Flow submission arrives as a decoded JSON reply, which always + # carries the `flow_token` echoed from the outbound message. Return it unwrapped so + # the submitted fields land directly on the block's var (`@ref_Template_1.email`), + # matching how `Io.Turn.WhatsAppSendFlow` exposes its result. + def evaluate_outgoing(_container, _flow, _block, _context, %{"flow_token" => _} = flow_response) do + {:ok, flow_response} + end + @template_button_indices Enum.map(0..9, &to_string/1) def evaluate_outgoing(_container, _flow, block, _context, "template-btn-idx-" <> index) when index in @template_button_indices do diff --git a/mix.exs b/mix.exs index 7379fdef..60790d90 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule FlowRunner.MixProject do use Mix.Project - @version "6.14.1" + @version "6.15.0" def project do [ diff --git a/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs b/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs index 9d3106fb..40732331 100644 --- a/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs +++ b/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs @@ -2,6 +2,23 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessageTest do use ExUnit.Case, async: true alias FlowRunner.CustomBlocks.WhatsAppTemplateMessage + alias FlowRunner.Spec.Block + alias FlowRunner.Spec.Container + alias FlowRunner.Spec.Exit + alias FlowRunner.Spec.Flow + + defp block_with_components(components, exits \\ []) do + %Block{ + uuid: "block-uuid", + name: "ref_Template_1", + type: "Io.Turn.WhatsAppTemplateMessage", + config: %{template: %{components: components}}, + exits: exits + } + end + + defp button_component(sub_type), + do: %{type: "button", sub_type: sub_type, index: "0", parameters: []} describe "validate_config!/1 for document parameter" do test "returns document with filename when present" do @@ -145,4 +162,137 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessageTest do assert param.language == {:not_found, ["en"]} end end + + describe "evaluate_incoming/4" do + test "waits for user input when the template has a flow button" do + block = block_with_components([%{type: "body"}, button_component("flow")]) + + assert {:ok, _container, _flow, _block, %{waiting_for_user_input: true}} = + WhatsAppTemplateMessage.evaluate_incoming( + %Container{}, + %Flow{}, + block, + %FlowRunner.Context{} + ) + end + + test "waits for user input when the template has a quick reply button" do + block = block_with_components([%{type: "body"}, button_component("quick_reply")]) + + assert {:ok, _container, _flow, _block, %{waiting_for_user_input: true}} = + WhatsAppTemplateMessage.evaluate_incoming( + %Container{}, + %Flow{}, + block, + %FlowRunner.Context{} + ) + end + + test "does not wait when the template only has url buttons" do + block = block_with_components([%{type: "body"}, button_component("url")]) + + assert {:ok, _container, _flow, _block, %{waiting_for_user_input: false}} = + WhatsAppTemplateMessage.evaluate_incoming( + %Container{}, + %Flow{}, + block, + %FlowRunner.Context{} + ) + end + + test "does not wait when the template has no buttons" do + block = block_with_components([%{type: "body"}]) + + assert {:ok, _container, _flow, _block, %{waiting_for_user_input: false}} = + WhatsAppTemplateMessage.evaluate_incoming( + %Container{}, + %Flow{}, + block, + %FlowRunner.Context{} + ) + end + end + + describe "evaluate_outgoing/5 for a flow submission" do + test "returns the submitted json unwrapped so fields land on the block var" do + block = block_with_components([%{type: "body"}, button_component("flow")]) + + flow_response = %{ + "flow_token" => "a-flow-token", + "email" => "jane@example.com", + "slot" => "09:30" + } + + assert {:ok, ^flow_response} = + WhatsAppTemplateMessage.evaluate_outgoing( + %Container{}, + %Flow{}, + block, + %FlowRunner.Context{}, + flow_response + ) + end + + test "wraps a plain text reply so a non-submission still routes to the fallback exit" do + block = block_with_components([%{type: "body"}, button_component("flow")]) + + assert {:ok, %{"__value__" => "no thanks", "index" => nil}} = + WhatsAppTemplateMessage.evaluate_outgoing( + %Container{}, + %Flow{}, + block, + %FlowRunner.Context{}, + "no thanks" + ) + end + end + + describe "evaluate_outgoing/5 for button and text input" do + test "returns the prefixed value when the block exits use the prefix" do + block = + block_with_components( + [%{type: "body"}, button_component("quick_reply")], + [%Exit{uuid: "exit-uuid", test: "block.value == \"template-btn-idx-0\""}] + ) + + assert {:ok, %{"__value__" => "template-btn-idx-0", "index" => "0"}} = + WhatsAppTemplateMessage.evaluate_outgoing( + %Container{}, + %Flow{}, + block, + %FlowRunner.Context{}, + "template-btn-idx-0" + ) + end + + test "returns the bare index for legacy blocks whose exits have no prefix" do + block = + block_with_components( + [%{type: "body"}, button_component("quick_reply")], + [%Exit{uuid: "exit-uuid", test: "block.value == \"0\""}] + ) + + assert {:ok, %{"__value__" => "0", "index" => "0"}} = + WhatsAppTemplateMessage.evaluate_outgoing( + %Container{}, + %Flow{}, + block, + %FlowRunner.Context{}, + "template-btn-idx-0" + ) + end + + test "passes nil through untouched" do + block = block_with_components([%{type: "body"}]) + + assert {:ok, nil} = + WhatsAppTemplateMessage.evaluate_outgoing( + %Container{}, + %Flow{}, + block, + %FlowRunner.Context{}, + nil + ) + end + end end From 9442cf037e2092b09e1d099cdeede50bd44cf694 Mon Sep 17 00:00:00 2001 From: Santiago Date: Thu, 6 Aug 2026 15:40:13 -0500 Subject: [PATCH 2/5] feat: accept the action parameter of a template flow button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../whatsapp_template_message.ex | 11 +++ .../whatsapp_template_message_test.exs | 71 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/lib/flow_runner/custom_blocks/whatsapp_template_message.ex b/lib/flow_runner/custom_blocks/whatsapp_template_message.ex index b4e9f270..b4ddcb6f 100644 --- a/lib/flow_runner/custom_blocks/whatsapp_template_message.ex +++ b/lib/flow_runner/custom_blocks/whatsapp_template_message.ex @@ -70,6 +70,7 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessage do required(:type) => String.t(), optional(:text) => String.t(), optional(:payload) => String.t(), + optional(:flow_action_data) => term(), optional(:language) => String.t() }) } @@ -140,6 +141,16 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessage do language: payload["language"] || Expression.evaluate_block!(default_language) } + # The parameter of a flow button. `flow_action_data` is the data payload for the + # flow's first screen and is carried through untouched: it may be a decoded JSON + # object, or a string holding an expression to evaluate when the template is sent. + defp parse_parameter(%{"type" => "action"} = action, default_language), + do: %{ + type: "action", + flow_action_data: action["flow_action_data"], + language: action["language"] || Expression.evaluate_block!(default_language) + } + @impl FlowRunner.Spec.Block @decorate with_span("DSL.Blocks.WhatsAppTemplateMessage.evaluate_incoming") def evaluate_incoming(container, flow, block, context) do diff --git a/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs b/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs index 40732331..e574295e 100644 --- a/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs +++ b/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs @@ -163,6 +163,77 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessageTest do end end + describe "validate_config!/1 for a flow button" do + test "carries the flow_action_data payload through untouched" do + config = %{ + "template" => %{ + "name" => "foo", + "language" => %{"code" => "en"}, + "components" => [ + %{ + "type" => "button", + "sub_type" => "flow", + "index" => "0", + "parameters" => [ + %{"type" => "action", "flow_action_data" => %{"patient_id" => "42"}} + ] + } + ] + } + } + + result = WhatsAppTemplateMessage.validate_config!(config) + + assert [%{type: "button", sub_type: "flow", index: "0", parameters: [param]}] = + result.template.components + + assert param.type == "action" + assert param.flow_action_data == %{"patient_id" => "42"} + end + + test "keeps an unevaluated expression payload as a string" do + config = %{ + "template" => %{ + "name" => "foo", + "language" => %{"code" => "en"}, + "components" => [ + %{ + "type" => "button", + "sub_type" => "flow", + "index" => "0", + "parameters" => [%{"type" => "action", "flow_action_data" => "@some_var"}] + } + ] + } + } + + result = WhatsAppTemplateMessage.validate_config!(config) + [%{parameters: [param]}] = result.template.components + assert param.flow_action_data == "@some_var" + end + + test "tolerates a flow button with no payload" do + config = %{ + "template" => %{ + "name" => "foo", + "language" => %{"code" => "en"}, + "components" => [ + %{ + "type" => "button", + "sub_type" => "flow", + "index" => "0", + "parameters" => [%{"type" => "action"}] + } + ] + } + } + + result = WhatsAppTemplateMessage.validate_config!(config) + [%{parameters: [param]}] = result.template.components + assert param.flow_action_data == nil + end + end + describe "evaluate_incoming/4" do test "waits for user input when the template has a flow button" do block = block_with_components([%{type: "body"}, button_component("flow")]) From db357361f4355bd563acf35a49a311f16da43686 Mon Sep 17 00:00:00 2001 From: Santiago Date: Thu, 6 Aug 2026 17:57:58 -0500 Subject: [PATCH 3/5] chore: simplify code Signed-off-by: Santiago --- .../whatsapp_template_message.ex | 72 +++++-------------- 1 file changed, 19 insertions(+), 53 deletions(-) diff --git a/lib/flow_runner/custom_blocks/whatsapp_template_message.ex b/lib/flow_runner/custom_blocks/whatsapp_template_message.ex index b4ddcb6f..870a4633 100644 --- a/lib/flow_runner/custom_blocks/whatsapp_template_message.ex +++ b/lib/flow_runner/custom_blocks/whatsapp_template_message.ex @@ -70,7 +70,7 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessage do required(:type) => String.t(), optional(:text) => String.t(), optional(:payload) => String.t(), - optional(:flow_action_data) => term(), + optional(:flow_action_data) => String.t(), optional(:language) => String.t() }) } @@ -83,20 +83,18 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessage do index: component["index"], # required only for buttons sub_type: component["sub_type"], - parameters: Enum.map(parameters, &parse_parameter(&1, default_language)) + parameters: + Enum.map(parameters, fn component -> + language = component["language"] || Expression.evaluate_block!(default_language) + + Map.put(parse_parameter(component), :language, language) + end) } - defp parse_parameter(%{"type" => "text", "text" => text} = component, default_language), - do: %{ - type: "text", - text: text, - language: component["language"] || Expression.evaluate_block!(default_language) - } + defp parse_parameter(%{"type" => "text", "text" => text}), + do: %{type: "text", text: text} - defp parse_parameter( - %{"type" => "document", "document" => %{"link" => link} = document}, - default_language - ) do + defp parse_parameter(%{"type" => "document", "document" => %{"link" => link} = document}) do document = if filename = document["filename"] do %{link: link, filename: filename} @@ -104,52 +102,20 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessage do %{link: link} end - %{ - type: "document", - document: document, - language: document["language"] || Expression.evaluate_block!(default_language) - } + %{type: "document", document: document} end - defp parse_parameter( - %{"type" => "video", "video" => %{"link" => link}} = video, - default_language - ), - do: %{ - type: "video", - video: %{link: link}, - language: video["language"] || Expression.evaluate_block!(default_language) - } + defp parse_parameter(%{"type" => "video", "video" => %{"link" => link}} = video), + do: %{type: "video", video: %{link: link}} - defp parse_parameter( - %{"type" => "image", "image" => %{"link" => link}} = image, - default_language - ), - do: %{ - type: "image", - image: %{link: link}, - language: image["language"] || Expression.evaluate_block!(default_language) - } + defp parse_parameter(%{"type" => "image", "image" => %{"link" => link}}), + do: %{type: "image", image: %{link: link}} - defp parse_parameter( - %{"type" => "payload", "payload" => payload_resource_uuid} = payload, - default_language - ), - do: %{ - type: "payload", - payload: payload_resource_uuid, - language: payload["language"] || Expression.evaluate_block!(default_language) - } + defp parse_parameter(%{"type" => "payload", "payload" => payload_resource_uuid}), + do: %{type: "payload", payload: payload_resource_uuid} - # The parameter of a flow button. `flow_action_data` is the data payload for the - # flow's first screen and is carried through untouched: it may be a decoded JSON - # object, or a string holding an expression to evaluate when the template is sent. - defp parse_parameter(%{"type" => "action"} = action, default_language), - do: %{ - type: "action", - flow_action_data: action["flow_action_data"], - language: action["language"] || Expression.evaluate_block!(default_language) - } + defp parse_parameter(%{"type" => "action", "flow_action_data" => flow_action_data}), + do: %{type: "action", flow_action_data: flow_action_data} @impl FlowRunner.Spec.Block @decorate with_span("DSL.Blocks.WhatsAppTemplateMessage.evaluate_incoming") From 8a720965eead5f49174e0a308f7b62d343070cd9 Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 11 Aug 2026 17:08:58 -0500 Subject: [PATCH 4/5] fix: compilation warning Signed-off-by: Santiago --- lib/flow_runner/custom_blocks/whatsapp_template_message.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/flow_runner/custom_blocks/whatsapp_template_message.ex b/lib/flow_runner/custom_blocks/whatsapp_template_message.ex index 870a4633..50781ebc 100644 --- a/lib/flow_runner/custom_blocks/whatsapp_template_message.ex +++ b/lib/flow_runner/custom_blocks/whatsapp_template_message.ex @@ -105,7 +105,7 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessage do %{type: "document", document: document} end - defp parse_parameter(%{"type" => "video", "video" => %{"link" => link}} = video), + defp parse_parameter(%{"type" => "video", "video" => %{"link" => link}}), do: %{type: "video", video: %{link: link}} defp parse_parameter(%{"type" => "image", "image" => %{"link" => link}}), From fc4fdf67e5071bf17019aed73c8ed35806fbaa64 Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 11 Aug 2026 17:09:44 -0500 Subject: [PATCH 5/5] chore: remove irrelevant test Signed-off-by: Santiago --- .../whatsapp_template_message_test.exs | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs b/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs index e574295e..0a1c8ac5 100644 --- a/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs +++ b/test/flow_runner/custom_blocks/whatsapp_template_message_test.exs @@ -211,27 +211,6 @@ defmodule FlowRunner.CustomBlocks.WhatsAppTemplateMessageTest do [%{parameters: [param]}] = result.template.components assert param.flow_action_data == "@some_var" end - - test "tolerates a flow button with no payload" do - config = %{ - "template" => %{ - "name" => "foo", - "language" => %{"code" => "en"}, - "components" => [ - %{ - "type" => "button", - "sub_type" => "flow", - "index" => "0", - "parameters" => [%{"type" => "action"}] - } - ] - } - } - - result = WhatsAppTemplateMessage.validate_config!(config) - [%{parameters: [param]}] = result.template.components - assert param.flow_action_data == nil - end end describe "evaluate_incoming/4" do