flow_chat 0.8.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/pages.yml +43 -0
  5. data/.github/workflows/release.yml +56 -0
  6. data/.standard.yml +4 -0
  7. data/CHANGELOG.md +48 -0
  8. data/CLAUDE.md +327 -0
  9. data/CONTRIBUTING.md +134 -0
  10. data/Gemfile +1 -0
  11. data/README.md +189 -133
  12. data/Rakefile +17 -2
  13. data/SECURITY.md +42 -349
  14. data/docs/architecture.md +83 -0
  15. data/docs/async-background-processing.md +64 -0
  16. data/docs/configuration.md +110 -287
  17. data/docs/factory-pattern.md +58 -0
  18. data/docs/gateway-context-variables.md +168 -0
  19. data/docs/gateway-development.md +159 -0
  20. data/docs/getting-started.md +90 -0
  21. data/docs/instrumentation.md +95 -175
  22. data/docs/platforms/instagram.md +278 -0
  23. data/docs/platforms/messenger.md +205 -0
  24. data/docs/platforms/telegram.md +109 -0
  25. data/docs/platforms/ussd.md +78 -0
  26. data/docs/platforms/whatsapp.md +147 -0
  27. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  28. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  29. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  30. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  31. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  32. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  33. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  34. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  35. data/docs/testing.md +33 -426
  36. data/examples/custom_session_id_example.rb +119 -0
  37. data/examples/http_controller.rb +22 -20
  38. data/examples/intercom_configuration_example.rb +113 -0
  39. data/examples/intercom_controller.rb +182 -0
  40. data/examples/multi_tenant_whatsapp_controller.rb +63 -168
  41. data/examples/simulator_controller.rb +0 -1
  42. data/examples/ussd_controller.rb +88 -160
  43. data/examples/whatsapp_controller.rb +18 -17
  44. data/examples/whatsapp_media_examples.rb +27 -79
  45. data/flow_chat.gemspec +4 -0
  46. data/lib/flow_chat/app.rb +211 -0
  47. data/lib/flow_chat/async_job.rb +176 -0
  48. data/lib/flow_chat/choice_titles.rb +95 -0
  49. data/lib/flow_chat/config.rb +126 -23
  50. data/lib/flow_chat/delivery_error.rb +9 -0
  51. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  52. data/lib/flow_chat/factory.rb +94 -0
  53. data/lib/flow_chat/gateway_async_support.rb +106 -0
  54. data/lib/flow_chat/generic_async_job.rb +30 -0
  55. data/lib/flow_chat/http/configuration_error.rb +9 -0
  56. data/lib/flow_chat/http/gateway/simple.rb +104 -36
  57. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  58. data/lib/flow_chat/http/renderer.rb +3 -3
  59. data/lib/flow_chat/input.rb +86 -0
  60. data/lib/flow_chat/instagram/client.rb +32 -0
  61. data/lib/flow_chat/instagram/configuration.rb +147 -0
  62. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  63. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  64. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  65. data/lib/flow_chat/instagram/renderer.rb +23 -0
  66. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  67. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  68. data/lib/flow_chat/instrumentation.rb +182 -0
  69. data/lib/flow_chat/intercom/client.rb +161 -0
  70. data/lib/flow_chat/intercom/configuration.rb +102 -0
  71. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  72. data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
  73. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  74. data/lib/flow_chat/intercom/renderer.rb +123 -0
  75. data/lib/flow_chat/media.rb +121 -0
  76. data/lib/flow_chat/messenger/client.rb +264 -0
  77. data/lib/flow_chat/messenger/configuration.rb +103 -0
  78. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  79. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  80. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  81. data/lib/flow_chat/messenger/renderer.rb +150 -0
  82. data/lib/flow_chat/meta/challenge.rb +24 -0
  83. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  84. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  85. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  86. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  87. data/lib/flow_chat/meta/signature.rb +30 -0
  88. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  89. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  90. data/lib/flow_chat/named_configuration.rb +65 -0
  91. data/lib/flow_chat/phone_number_util.rb +37 -35
  92. data/lib/flow_chat/processor.rb +188 -0
  93. data/lib/flow_chat/prompt.rb +13 -16
  94. data/lib/flow_chat/renderers/markdown_support.rb +167 -0
  95. data/lib/flow_chat/security.rb +76 -0
  96. data/lib/flow_chat/session/middleware.rb +36 -11
  97. data/lib/flow_chat/simulator/controller.rb +31 -15
  98. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  99. data/lib/flow_chat/telegram/client.rb +283 -0
  100. data/lib/flow_chat/telegram/configuration.rb +78 -0
  101. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  102. data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
  103. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
  104. data/lib/flow_chat/telegram/renderer.rb +133 -0
  105. data/lib/flow_chat/telegram.rb +7 -0
  106. data/lib/flow_chat/text_truncator.rb +75 -0
  107. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  108. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  109. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  110. data/lib/flow_chat/ussd/renderer.rb +1 -1
  111. data/lib/flow_chat/version.rb +1 -1
  112. data/lib/flow_chat/whatsapp/client.rb +158 -20
  113. data/lib/flow_chat/whatsapp/configuration.rb +13 -52
  114. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  115. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
  116. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
  117. data/lib/flow_chat/whatsapp/renderer.rb +259 -64
  118. data/lib/flow_chat.rb +1 -1
  119. data/lib/tasks/release.rake +165 -0
  120. data/site/.nojekyll +0 -0
  121. data/site/.og-card.html +89 -0
  122. data/site/favicon.svg +6 -0
  123. data/site/index.html +209 -0
  124. data/site/og.png +0 -0
  125. metadata +132 -25
  126. data/docs/flows.md +0 -320
  127. data/docs/http-gateway-protocol.md +0 -432
  128. data/docs/images/simulator.png +0 -0
  129. data/docs/media.md +0 -153
  130. data/docs/sessions.md +0 -433
  131. data/docs/ussd-setup.md +0 -322
  132. data/docs/whatsapp-setup.md +0 -162
  133. data/examples/whatsapp_message_job.rb +0 -113
  134. data/lib/flow_chat/base_app.rb +0 -86
  135. data/lib/flow_chat/base_processor.rb +0 -146
  136. data/lib/flow_chat/http/app.rb +0 -6
  137. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  138. data/lib/flow_chat/http/processor.rb +0 -33
  139. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  140. data/lib/flow_chat/ussd/app.rb +0 -6
  141. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  142. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  143. data/lib/flow_chat/ussd/processor.rb +0 -39
  144. data/lib/flow_chat/whatsapp/app.rb +0 -29
  145. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  146. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  147. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -3,6 +3,7 @@ module FlowChat
3
3
  module Gateway
4
4
  class Nalo
5
5
  include FlowChat::Instrumentation
6
+ include FlowChat::GatewayAsyncSupport
6
7
 
7
8
  attr_reader :context
8
9
 
@@ -10,9 +11,15 @@ module FlowChat
10
11
  @app = app
11
12
  end
12
13
 
14
+ # USSD does not support async processing due to synchronous protocol requirements
15
+ def async_supported?
16
+ false
17
+ end
18
+
13
19
  def call(context)
14
20
  @context = context
15
- params = context.controller.request.params
21
+ @controller = context.controller
22
+ params = @controller.request.params
16
23
 
17
24
  context["request.id"] = params["USERID"]
18
25
  context["request.msisdn"] = FlowChat::PhoneNumberUtil.to_e164(params["MSISDN"])
@@ -21,9 +28,9 @@ module FlowChat
21
28
  context["request.timestamp"] = Time.current.iso8601
22
29
  context["request.gateway"] = :nalo
23
30
  context["request.platform"] = :ussd
24
- context["request.network"] = nil
31
+ context["request.body"] = (params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h).transform_keys(&:to_s)
25
32
  # context["request.type"] = params["MSGTYPE"] ? :initial : :response
26
- context.input = params["USERDATA"].presence
33
+ context.input = params["USERDATA"].presence || ""
27
34
 
28
35
  # Instrument message received when user provides input using new scalable approach
29
36
  if context.input.present?
@@ -49,7 +56,7 @@ module FlowChat
49
56
  timestamp: context["request.timestamp"]
50
57
  })
51
58
 
52
- context.controller.render json: {
59
+ @controller.render json: {
53
60
  USERID: params["USERID"],
54
61
  MSISDN: params["MSISDN"],
55
62
  MSG: render_prompt(prompt, choices, media),
@@ -57,6 +64,19 @@ module FlowChat
57
64
  }
58
65
  end
59
66
 
67
+ def self.configure_middleware_stack(builder, custom_middleware)
68
+ FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Configuring middleware stack" }
69
+
70
+ builder.use FlowChat::Ussd::Middleware::Pagination
71
+ FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Added Ussd::Middleware::Pagination" }
72
+
73
+ builder.use custom_middleware
74
+ FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Added custom middleware" }
75
+
76
+ builder.use FlowChat::Ussd::Middleware::ChoiceMapper
77
+ FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Added Ussd::Middleware::ChoiceMapper" }
78
+ end
79
+
60
80
  private
61
81
 
62
82
  def render_prompt(prompt, choices, media)
@@ -57,6 +57,16 @@ module FlowChat
57
57
  @context.input = original_choice
58
58
  end
59
59
 
60
+ # USSD is deliberately exempt from the fold every other choice mapper
61
+ # now takes. A numeric keypad can only ever send a position, and
62
+ # positions are unique by construction, so the relation this resolves
63
+ # on is already injective - there is no equivalence under which two
64
+ # choices could collapse into one another.
65
+ #
66
+ # Every other mapper has to be *given* that guarantee, by asking
67
+ # FlowChat::ChoiceTitles to number a set whose titles collide under
68
+ # its own resolver's fold. Nothing here needs to, and adding aliasing
69
+ # to this mapper would introduce the very ambiguity it does not have.
60
70
  def create_numbered_mapping(choices)
61
71
  # Choices are always a hash after normalize_choices
62
72
  numbered_choices = {}
@@ -21,7 +21,6 @@ module FlowChat
21
21
  if intercept?
22
22
  FlowChat.logger.info { "Ussd::Pagination: Intercepting request for pagination handling - session #{session_id}" }
23
23
  type, prompt = handle_intercepted_request
24
- [type, prompt, []]
25
24
  else
26
25
  # Clear pagination state for new flows
27
26
  if pagination_state.present?
@@ -41,8 +40,8 @@ module FlowChat
41
40
  end
42
41
  end
43
42
 
44
- [type, prompt, []]
45
43
  end
44
+ [type, prompt, []]
46
45
  end
47
46
 
48
47
  private
@@ -61,15 +60,17 @@ module FlowChat
61
60
 
62
61
  def handle_intercepted_request
63
62
  FlowChat.logger.info { "Ussd::Pagination: Handling paginated request" }
64
- start, finish, has_more = calculate_offsets
63
+ # Cache current_page to avoid multiple calculations
64
+ page = current_page
65
+ start, finish, has_more = calculate_offsets_for_page(page)
65
66
  type = (pagination_state["type"].to_sym == :terminal && !has_more) ? :terminal : :prompt
66
67
  prompt = pagination_state["prompt"][start..finish] + build_pagination_options(type, has_more)
67
- set_pagination_state(current_page, start, finish)
68
+ set_pagination_state(page, start, finish)
68
69
 
69
70
  # Instrument pagination navigation
70
71
  instrument(Events::PAGINATION_TRIGGERED, {
71
72
  session_id: @context["session.id"],
72
- current_page: current_page,
73
+ current_page: page,
73
74
  total_pages: calculate_total_pages,
74
75
  content_length: pagination_state["prompt"].length,
75
76
  page_limit: FlowChat::Config.ussd.pagination_page_size,
@@ -113,7 +114,10 @@ module FlowChat
113
114
 
114
115
  def calculate_offsets
115
116
  page = current_page
117
+ calculate_offsets_for_page(page)
118
+ end
116
119
 
120
+ def calculate_offsets_for_page(page)
117
121
  FlowChat.logger.debug { "Ussd::Pagination: Calculating offsets for page #{page}" }
118
122
 
119
123
  offset = pagination_state["offsets"][page.to_s]
@@ -27,7 +27,7 @@ module FlowChat
27
27
  def build_media
28
28
  return unless media.present?
29
29
 
30
- media_url = media[:url] || media[:path]
30
+ media_url = media[:url]
31
31
  media_type = media[:type] || :image
32
32
 
33
33
  # For USSD, we append the media URL to the message
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.8.2"
2
+ VERSION = "0.10.0"
3
3
  end
@@ -19,17 +19,27 @@ module FlowChat
19
19
  # @param to [String] Phone number in E.164 format
20
20
  # @param response [Array] FlowChat response array [type, content, options]
21
21
  # @return [Hash] API response or nil on error
22
- def send_message(to, response)
23
- type, content, _ = response
24
- FlowChat.logger.info { "WhatsApp::Client: Sending #{type} message to #{to}" }
25
- FlowChat.logger.debug { "WhatsApp::Client: Message content: '#{content.to_s.truncate(100)}'" }
26
-
27
- result = instrument(Events::MESSAGE_SENT, {
28
- to: to,
29
- message_type: type.to_s,
30
- content_length: content.to_s.length,
31
- platform: :whatsapp
32
- }) do
22
+ def send_message(to, prompt, choices: nil, media: nil)
23
+ FlowChat.logger.info { "WhatsApp::Client: Sending message to #{to}" }
24
+ FlowChat.logger.debug { "WhatsApp::Client: Message content: '#{prompt.to_s.truncate(100)}'" }
25
+
26
+ # Use renderer to convert to structured response
27
+ response = FlowChat::Whatsapp::Renderer.new(prompt, choices: choices, media: media).render
28
+ _, _, options = response
29
+
30
+ # MESSAGE_SENT is instrumented by the gateway, not here. This wrapped
31
+ # the send in its own instrument block, and ActiveSupport::Notifications
32
+ # publishes a block event once the block returns whatever it returned -
33
+ # so the event fired even when the send had failed and this method was
34
+ # about to answer nil, and fired a second time when the gateway
35
+ # instrumented the same send. Every subscriber counted a successful
36
+ # send twice and a failed one once.
37
+ result = begin
38
+ # Above the button cap there is no interactive surface that can
39
+ # carry media (see the renderer), so it rides in options[:media]
40
+ # and goes out as its own message ahead of the choice message.
41
+ send_message_payload(build_message_payload(options[:media], to)) if options[:media]
42
+
33
43
  message_data = build_message_payload(response, to)
34
44
  send_message_payload(message_data)
35
45
  end
@@ -50,7 +60,7 @@ module FlowChat
50
60
  # @return [Hash] API response or nil on error
51
61
  def send_text(to, text)
52
62
  FlowChat.logger.debug { "WhatsApp::Client: Sending text message to #{to}" }
53
- send_message(to, [:text, text, {}])
63
+ send_message(to, text)
54
64
  end
55
65
 
56
66
  # Send interactive buttons
@@ -60,7 +70,8 @@ module FlowChat
60
70
  # @return [Hash] API response or nil on error
61
71
  def send_buttons(to, text, buttons)
62
72
  FlowChat.logger.debug { "WhatsApp::Client: Sending interactive buttons to #{to} with #{buttons.size} buttons" }
63
- send_message(to, [:interactive_buttons, text, {buttons: buttons}])
73
+ choices = buttons.each_with_object({}) { |button, hash| hash[button[:id]] = button[:title] }
74
+ send_message(to, text, choices: choices)
64
75
  end
65
76
 
66
77
  # Send interactive list
@@ -72,7 +83,9 @@ module FlowChat
72
83
  def send_list(to, text, sections, button_text = "Choose")
73
84
  total_items = sections.sum { |section| section[:rows]&.size || 0 }
74
85
  FlowChat.logger.debug { "WhatsApp::Client: Sending interactive list to #{to} with #{sections.size} sections, #{total_items} total items" }
75
- send_message(to, [:interactive_list, text, {sections: sections, button_text: button_text}])
86
+ choices = {}
87
+ sections.each { |section| section[:rows]&.each { |row| choices[row[:id]] = row[:title] } }
88
+ send_message(to, text, choices: choices)
76
89
  end
77
90
 
78
91
  # Send a template message
@@ -83,11 +96,13 @@ module FlowChat
83
96
  # @return [Hash] API response or nil on error
84
97
  def send_template(to, template_name, components = [], language = "en_US")
85
98
  FlowChat.logger.debug { "WhatsApp::Client: Sending template '#{template_name}' to #{to} in #{language}" }
86
- send_message(to, [:template, "", {
99
+ media = {
100
+ type: :template,
87
101
  template_name: template_name,
88
102
  components: components,
89
103
  language: language
90
- }])
104
+ }
105
+ send_message(to, "", media: media)
91
106
  end
92
107
 
93
108
  # Send image message
@@ -98,7 +113,12 @@ module FlowChat
98
113
  # @return [Hash] API response
99
114
  def send_image(to, image_url_or_id, caption = nil, mime_type = nil)
100
115
  FlowChat.logger.debug { "WhatsApp::Client: Sending image to #{to} - #{url?(image_url_or_id) ? "URL" : "Media ID"}" }
101
- send_media_message(to, :image, image_url_or_id, caption: caption, mime_type: mime_type)
116
+ media = if url?(image_url_or_id)
117
+ {type: :image, url: image_url_or_id}
118
+ else
119
+ {type: :image, id: image_url_or_id}
120
+ end
121
+ send_message(to, caption, media: media)
102
122
  end
103
123
 
104
124
  # Send document message
@@ -145,6 +165,41 @@ module FlowChat
145
165
  send_media_message(to, :sticker, sticker_url_or_id, mime_type: mime_type)
146
166
  end
147
167
 
168
+ # Mark an inbound message as read, optionally showing a typing indicator.
169
+ #
170
+ # WhatsApp Cloud API ties the typing indicator to the mark-as-read call:
171
+ # passing `typing: true` adds a `typing_indicator` field to the same
172
+ # request. The indicator auto-dismisses on the next outbound message or
173
+ # after ~25 seconds; there is no separate "stop typing" call.
174
+ #
175
+ # @param message_id [String] the inbound message id (wamid.*) to mark as read
176
+ # @param typing [Boolean] when true, also show a typing indicator
177
+ # @return [Hash, nil] parsed API response, or nil on failure
178
+ def mark_as_read(message_id, typing: false)
179
+ payload = {
180
+ messaging_product: "whatsapp",
181
+ status: "read",
182
+ message_id: message_id
183
+ }
184
+ payload[:typing_indicator] = {type: "text"} if typing
185
+
186
+ send_read_receipt_payload(payload, message_id)
187
+ end
188
+
189
+ # Show a typing indicator in response to an inbound message.
190
+ #
191
+ # Convenience wrapper around `mark_as_read(message_id, typing: true)`.
192
+ # Note that WhatsApp ties the typing indicator to read-receipt delivery,
193
+ # so calling this also marks the message as read. There is no stop-typing
194
+ # call — the indicator auto-dismisses on the next outbound message or
195
+ # after ~25 seconds.
196
+ #
197
+ # @param message_id [String] the inbound message id (wamid.*)
198
+ # @return [Hash, nil] parsed API response, or nil on failure
199
+ def indicate_typing(message_id)
200
+ mark_as_read(message_id, typing: true)
201
+ end
202
+
148
203
  # Upload media file and return media ID
149
204
  # @param file_path_or_io [String, IO] File path or IO object
150
205
  # @param mime_type [String] MIME type of the file (required)
@@ -379,7 +434,7 @@ module FlowChat
379
434
  data = JSON.parse(response.body)
380
435
  data["url"]
381
436
  else
382
- Rails.logger.error "WhatsApp Media API error: #{response.body}"
437
+ FlowChat.logger.error { "WhatsApp::Client: Media API error: #{response.body}" }
383
438
  nil
384
439
  end
385
440
  end
@@ -403,7 +458,7 @@ module FlowChat
403
458
  if response.is_a?(Net::HTTPSuccess)
404
459
  response.body
405
460
  else
406
- Rails.logger.error "WhatsApp Media download error: #{response.body}"
461
+ FlowChat.logger.error { "WhatsApp::Client: Media download error: #{response.body}" }
407
462
  nil
408
463
  end
409
464
  end
@@ -420,7 +475,7 @@ module FlowChat
420
475
  response = http.head(uri.path)
421
476
  response["content-type"]
422
477
  rescue => e
423
- Rails.logger.warn "Could not detect MIME type for #{url}: #{e.message}"
478
+ FlowChat.logger.warn { "WhatsApp::Client: Could not detect MIME type for #{url}: #{e.message}" }
424
479
  nil
425
480
  end
426
481
 
@@ -489,6 +544,13 @@ module FlowChat
489
544
  result
490
545
  else
491
546
  FlowChat.logger.error { "WhatsApp::Client: API request failed - #{response.code}: #{response.body}" }
547
+ report_api_error(
548
+ "WhatsApp API request failed",
549
+ response_code: response.code,
550
+ response_body: response.body,
551
+ recipient: to,
552
+ message_type: message_type
553
+ )
492
554
  nil
493
555
  end
494
556
  rescue Net::OpenTimeout, Net::ReadTimeout => network_error
@@ -497,9 +559,85 @@ module FlowChat
497
559
  raise network_error
498
560
  rescue => error
499
561
  FlowChat.logger.error { "WhatsApp::Client: API request exception: #{error.class.name}: #{error.message}" }
562
+ report_api_error(
563
+ "WhatsApp API request exception: #{error.class.name}",
564
+ error: error,
565
+ recipient: to,
566
+ message_type: message_type
567
+ )
500
568
  nil
501
569
  end
502
570
 
571
+ def send_read_receipt_payload(payload, message_id)
572
+ FlowChat.logger.debug { "WhatsApp::Client: Marking message #{message_id} as read (typing=#{payload.key?(:typing_indicator)})" }
573
+
574
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
575
+ http = Net::HTTP.new(uri.host, uri.port)
576
+ http.use_ssl = true
577
+
578
+ request = Net::HTTP::Post.new(uri)
579
+ request["Authorization"] = "Bearer #{@config.access_token}"
580
+ request["Content-Type"] = "application/json"
581
+ request.body = payload.to_json
582
+
583
+ response = http.request(request)
584
+
585
+ if response.is_a?(Net::HTTPSuccess)
586
+ JSON.parse(response.body)
587
+ else
588
+ FlowChat.logger.error { "WhatsApp::Client: mark_as_read failed - #{response.code}: #{response.body}" }
589
+ report_api_error(
590
+ "WhatsApp mark_as_read failed",
591
+ response_code: response.code,
592
+ response_body: response.body,
593
+ message_type: "read_receipt"
594
+ )
595
+ nil
596
+ end
597
+ rescue Net::OpenTimeout, Net::ReadTimeout => network_error
598
+ FlowChat.logger.error { "WhatsApp::Client: Network timeout marking read: #{network_error.class.name}: #{network_error.message}" }
599
+ raise network_error
600
+ rescue => error
601
+ FlowChat.logger.error { "WhatsApp::Client: mark_as_read exception: #{error.class.name}: #{error.message}" }
602
+ report_api_error(
603
+ "WhatsApp mark_as_read exception: #{error.class.name}",
604
+ error: error,
605
+ message_type: "read_receipt"
606
+ )
607
+ nil
608
+ end
609
+
610
+ def report_api_error(message, response_code: nil, response_body: nil, error: nil, recipient: nil, message_type: nil)
611
+ error_details = parse_error_response(response_body)
612
+
613
+ FlowChat::Instrumentation.report_api_error(
614
+ message,
615
+ error: error,
616
+ platform: :whatsapp,
617
+ phone_number_id: @config.phone_number_id,
618
+ recipient: recipient,
619
+ message_type: message_type,
620
+ response_code: response_code,
621
+ **error_details
622
+ )
623
+ end
624
+
625
+ def parse_error_response(response_body)
626
+ return {} unless response_body
627
+
628
+ parsed = JSON.parse(response_body)
629
+ return {} unless parsed.is_a?(Hash) && parsed["error"]
630
+
631
+ {
632
+ error_type: parsed.dig("error", "type"),
633
+ error_code: parsed.dig("error", "code"),
634
+ error_subcode: parsed.dig("error", "error_subcode"),
635
+ error_message: parsed.dig("error", "message")
636
+ }.compact
637
+ rescue JSON::ParserError
638
+ {}
639
+ end
640
+
503
641
  def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
504
642
  media_object = if url?(url_or_id)
505
643
  {link: url_or_id}
@@ -1,12 +1,16 @@
1
1
  module FlowChat
2
2
  module Whatsapp
3
3
  class Configuration
4
+ include FlowChat::NamedConfiguration
5
+
6
+ # "Whatsapp" is the constant, "WhatsApp" is the product.
7
+ def self.configuration_label
8
+ "WhatsApp"
9
+ end
10
+
4
11
  attr_accessor :access_token, :phone_number_id, :verify_token, :app_id, :app_secret,
5
12
  :webhook_verify_token, :business_account_id, :name, :skip_signature_validation
6
13
 
7
- # Class-level storage for named configurations
8
- @@configurations = {}
9
-
10
14
  def initialize(name)
11
15
  @name = name
12
16
  @access_token = nil
@@ -29,7 +33,7 @@ module FlowChat
29
33
 
30
34
  config = new(nil)
31
35
 
32
- if defined?(Rails) && Rails.application.credentials.whatsapp
36
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.whatsapp
33
37
  FlowChat.logger.debug { "WhatsApp::Configuration: Loading from Rails credentials" }
34
38
  credentials = Rails.application.credentials.whatsapp
35
39
  config.access_token = credentials[:access_token]
@@ -60,56 +64,13 @@ module FlowChat
60
64
  config
61
65
  end
62
66
 
63
- # Register a named configuration
64
- def self.register(name, config)
65
- FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration '#{name}'" }
66
- @@configurations[name.to_sym] = config
67
- end
68
-
69
- # Get a named configuration
70
- def self.get(name)
71
- config = @@configurations[name.to_sym]
72
- if config
73
- FlowChat.logger.debug { "WhatsApp::Configuration: Retrieved configuration '#{name}'" }
74
- config
75
- else
76
- FlowChat.logger.error { "WhatsApp::Configuration: Configuration '#{name}' not found" }
77
- raise ArgumentError, "WhatsApp configuration '#{name}' not found"
78
- end
79
- end
80
-
81
- # Check if a named configuration exists
82
- def self.exists?(name)
83
- exists = @@configurations.key?(name.to_sym)
84
- FlowChat.logger.debug { "WhatsApp::Configuration: Configuration '#{name}' exists: #{exists}" }
85
- exists
86
- end
87
-
88
- # Get all configuration names
89
- def self.configuration_names
90
- names = @@configurations.keys
91
- FlowChat.logger.debug { "WhatsApp::Configuration: Available configurations: #{names}" }
92
- names
93
- end
94
-
95
- # Clear all registered configurations (useful for testing)
96
- def self.clear_all!
97
- FlowChat.logger.debug { "WhatsApp::Configuration: Clearing all registered configurations" }
98
- @@configurations.clear
99
- end
100
-
101
- # Register this configuration with a name
102
- def register_as(name)
103
- FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration as '#{name}'" }
104
- @name = name.to_sym
105
- self.class.register(@name, self)
106
- self
107
- end
108
-
109
67
  def valid?
110
- is_valid = access_token && !access_token.to_s.empty? &&
68
+ # Wrapped so a predicate answers true or false rather than nil, which
69
+ # the bare && chain returns for a missing first field. Intercom and
70
+ # Telegram already do this and pin it in their tests.
71
+ is_valid = !!(access_token && !access_token.to_s.empty? &&
111
72
  phone_number_id && !phone_number_id.to_s.empty? &&
112
- verify_token && !verify_token.to_s.empty?
73
+ verify_token && !verify_token.to_s.empty?)
113
74
 
114
75
  FlowChat.logger.debug { "WhatsApp::Configuration: Configuration valid: #{is_valid}" }
115
76
  is_valid
@@ -0,0 +1,9 @@
1
+ module FlowChat
2
+ module Whatsapp
3
+ # Its own file so Zeitwerk can resolve it by name. Declared inside
4
+ # gateway/cloud_api.rb, which Zeitwerk maps to a different constant, it existed only
5
+ # once that file had loaded for some other reason, so an application
6
+ # rescuing it, or a test naming it first, got an uninitialized constant.
7
+ class ConfigurationError < StandardError; end
8
+ end
9
+ end