flow_chat 0.8.1 → 0.9.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 (91) 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/release.yml +56 -0
  5. data/.standard.yml +4 -0
  6. data/CHANGELOG.md +22 -0
  7. data/CLAUDE.md +327 -0
  8. data/CONTRIBUTING.md +134 -0
  9. data/Gemfile +1 -0
  10. data/README.md +290 -105
  11. data/Rakefile +5 -1
  12. data/SECURITY.md +42 -349
  13. data/docs/architecture.md +510 -0
  14. data/docs/async-background-processing.md +298 -0
  15. data/docs/configuration.md +556 -226
  16. data/docs/factory-pattern.md +355 -0
  17. data/docs/gateway-context-variables.md +171 -0
  18. data/docs/gateway-development.md +723 -0
  19. data/docs/getting-started.md +429 -0
  20. data/docs/instrumentation.md +264 -153
  21. data/docs/platforms/telegram.md +1013 -0
  22. data/docs/platforms/ussd.md +693 -0
  23. data/docs/platforms/whatsapp.md +1395 -0
  24. data/docs/testing.md +243 -365
  25. data/examples/custom_session_id_example.rb +119 -0
  26. data/examples/http_controller.rb +154 -0
  27. data/examples/intercom_configuration_example.rb +118 -0
  28. data/examples/intercom_controller.rb +194 -0
  29. data/examples/multi_tenant_whatsapp_controller.rb +4 -4
  30. data/examples/simulator_controller.rb +21 -1
  31. data/examples/ussd_controller.rb +10 -10
  32. data/examples/whatsapp_controller.rb +2 -2
  33. data/flow_chat.gemspec +4 -0
  34. data/lib/flow_chat/{base_app.rb → app.rb} +28 -9
  35. data/lib/flow_chat/async_job.rb +176 -0
  36. data/lib/flow_chat/config.rb +23 -28
  37. data/lib/flow_chat/{base_executor.rb → executor.rb} +7 -12
  38. data/lib/flow_chat/factory.rb +94 -0
  39. data/lib/flow_chat/gateway_async_support.rb +106 -0
  40. data/lib/flow_chat/generic_async_job.rb +30 -0
  41. data/lib/flow_chat/http/gateway/simple.rb +125 -0
  42. data/lib/flow_chat/http/renderer.rb +41 -0
  43. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  44. data/lib/flow_chat/instrumentation.rb +25 -0
  45. data/lib/flow_chat/intercom/client.rb +155 -0
  46. data/lib/flow_chat/intercom/configuration.rb +149 -0
  47. data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
  48. data/lib/flow_chat/intercom/renderer.rb +71 -0
  49. data/lib/flow_chat/phone_number_util.rb +49 -0
  50. data/lib/flow_chat/processor.rb +188 -0
  51. data/lib/flow_chat/renderers/markdown_support.rb +58 -0
  52. data/lib/flow_chat/session/cache_session_store.rb +1 -17
  53. data/lib/flow_chat/session/middleware.rb +43 -26
  54. data/lib/flow_chat/simulator/controller.rb +17 -5
  55. data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
  56. data/lib/flow_chat/telegram/client.rb +240 -0
  57. data/lib/flow_chat/telegram/configuration.rb +118 -0
  58. data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
  59. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
  60. data/lib/flow_chat/telegram/renderer.rb +125 -0
  61. data/lib/flow_chat/telegram.rb +7 -0
  62. data/lib/flow_chat/ussd/gateway/nalo.rb +27 -11
  63. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  64. data/lib/flow_chat/ussd/renderer.rb +1 -1
  65. data/lib/flow_chat/version.rb +1 -1
  66. data/lib/flow_chat/whatsapp/client.rb +144 -13
  67. data/lib/flow_chat/whatsapp/configuration.rb +1 -1
  68. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +144 -106
  69. data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
  70. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
  71. data/lib/flow_chat/whatsapp/renderer.rb +145 -11
  72. data/lib/flow_chat.rb +11 -1
  73. data/lib/tasks/release.rake +165 -0
  74. metadata +88 -21
  75. data/docs/flows.md +0 -320
  76. data/docs/images/simulator.png +0 -0
  77. data/docs/media.md +0 -153
  78. data/docs/sessions.md +0 -433
  79. data/docs/ussd-setup.md +0 -322
  80. data/docs/whatsapp-setup.md +0 -162
  81. data/examples/whatsapp_message_job.rb +0 -113
  82. data/lib/flow_chat/base_processor.rb +0 -145
  83. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  84. data/lib/flow_chat/ussd/app.rb +0 -6
  85. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -98
  86. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  87. data/lib/flow_chat/ussd/processor.rb +0 -39
  88. data/lib/flow_chat/whatsapp/app.rb +0 -29
  89. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  90. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  91. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -19,10 +19,13 @@ 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)}'" }
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
+ type, content, _options = response
26
29
 
27
30
  result = instrument(Events::MESSAGE_SENT, {
28
31
  to: to,
@@ -50,7 +53,7 @@ module FlowChat
50
53
  # @return [Hash] API response or nil on error
51
54
  def send_text(to, text)
52
55
  FlowChat.logger.debug { "WhatsApp::Client: Sending text message to #{to}" }
53
- send_message(to, [:text, text, {}])
56
+ send_message(to, text)
54
57
  end
55
58
 
56
59
  # Send interactive buttons
@@ -60,7 +63,8 @@ module FlowChat
60
63
  # @return [Hash] API response or nil on error
61
64
  def send_buttons(to, text, buttons)
62
65
  FlowChat.logger.debug { "WhatsApp::Client: Sending interactive buttons to #{to} with #{buttons.size} buttons" }
63
- send_message(to, [:interactive_buttons, text, {buttons: buttons}])
66
+ choices = buttons.each_with_object({}) { |button, hash| hash[button[:id]] = button[:title] }
67
+ send_message(to, text, choices: choices)
64
68
  end
65
69
 
66
70
  # Send interactive list
@@ -72,7 +76,9 @@ module FlowChat
72
76
  def send_list(to, text, sections, button_text = "Choose")
73
77
  total_items = sections.sum { |section| section[:rows]&.size || 0 }
74
78
  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}])
79
+ choices = {}
80
+ sections.each { |section| section[:rows]&.each { |row| choices[row[:id]] = row[:title] } }
81
+ send_message(to, text, choices: choices)
76
82
  end
77
83
 
78
84
  # Send a template message
@@ -83,11 +89,13 @@ module FlowChat
83
89
  # @return [Hash] API response or nil on error
84
90
  def send_template(to, template_name, components = [], language = "en_US")
85
91
  FlowChat.logger.debug { "WhatsApp::Client: Sending template '#{template_name}' to #{to} in #{language}" }
86
- send_message(to, [:template, "", {
92
+ media = {
93
+ type: :template,
87
94
  template_name: template_name,
88
95
  components: components,
89
96
  language: language
90
- }])
97
+ }
98
+ send_message(to, "", media: media)
91
99
  end
92
100
 
93
101
  # Send image message
@@ -98,7 +106,12 @@ module FlowChat
98
106
  # @return [Hash] API response
99
107
  def send_image(to, image_url_or_id, caption = nil, mime_type = nil)
100
108
  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)
109
+ media = if url?(image_url_or_id)
110
+ {type: :image, url: image_url_or_id}
111
+ else
112
+ {type: :image, id: image_url_or_id}
113
+ end
114
+ send_message(to, caption, media: media)
102
115
  end
103
116
 
104
117
  # Send document message
@@ -145,6 +158,41 @@ module FlowChat
145
158
  send_media_message(to, :sticker, sticker_url_or_id, mime_type: mime_type)
146
159
  end
147
160
 
161
+ # Mark an inbound message as read, optionally showing a typing indicator.
162
+ #
163
+ # WhatsApp Cloud API ties the typing indicator to the mark-as-read call:
164
+ # passing `typing: true` adds a `typing_indicator` field to the same
165
+ # request. The indicator auto-dismisses on the next outbound message or
166
+ # after ~25 seconds; there is no separate "stop typing" call.
167
+ #
168
+ # @param message_id [String] the inbound message id (wamid.*) to mark as read
169
+ # @param typing [Boolean] when true, also show a typing indicator
170
+ # @return [Hash, nil] parsed API response, or nil on failure
171
+ def mark_as_read(message_id, typing: false)
172
+ payload = {
173
+ messaging_product: "whatsapp",
174
+ status: "read",
175
+ message_id: message_id
176
+ }
177
+ payload[:typing_indicator] = {type: "text"} if typing
178
+
179
+ send_read_receipt_payload(payload, message_id)
180
+ end
181
+
182
+ # Show a typing indicator in response to an inbound message.
183
+ #
184
+ # Convenience wrapper around `mark_as_read(message_id, typing: true)`.
185
+ # Note that WhatsApp ties the typing indicator to read-receipt delivery,
186
+ # so calling this also marks the message as read. There is no stop-typing
187
+ # call — the indicator auto-dismisses on the next outbound message or
188
+ # after ~25 seconds.
189
+ #
190
+ # @param message_id [String] the inbound message id (wamid.*)
191
+ # @return [Hash, nil] parsed API response, or nil on failure
192
+ def indicate_typing(message_id)
193
+ mark_as_read(message_id, typing: true)
194
+ end
195
+
148
196
  # Upload media file and return media ID
149
197
  # @param file_path_or_io [String, IO] File path or IO object
150
198
  # @param mime_type [String] MIME type of the file (required)
@@ -379,7 +427,7 @@ module FlowChat
379
427
  data = JSON.parse(response.body)
380
428
  data["url"]
381
429
  else
382
- Rails.logger.error "WhatsApp Media API error: #{response.body}"
430
+ FlowChat.logger.error { "WhatsApp::Client: Media API error: #{response.body}" }
383
431
  nil
384
432
  end
385
433
  end
@@ -403,7 +451,7 @@ module FlowChat
403
451
  if response.is_a?(Net::HTTPSuccess)
404
452
  response.body
405
453
  else
406
- Rails.logger.error "WhatsApp Media download error: #{response.body}"
454
+ FlowChat.logger.error { "WhatsApp::Client: Media download error: #{response.body}" }
407
455
  nil
408
456
  end
409
457
  end
@@ -420,7 +468,7 @@ module FlowChat
420
468
  response = http.head(uri.path)
421
469
  response["content-type"]
422
470
  rescue => e
423
- Rails.logger.warn "Could not detect MIME type for #{url}: #{e.message}"
471
+ FlowChat.logger.warn { "WhatsApp::Client: Could not detect MIME type for #{url}: #{e.message}" }
424
472
  nil
425
473
  end
426
474
 
@@ -489,6 +537,13 @@ module FlowChat
489
537
  result
490
538
  else
491
539
  FlowChat.logger.error { "WhatsApp::Client: API request failed - #{response.code}: #{response.body}" }
540
+ report_api_error(
541
+ "WhatsApp API request failed",
542
+ response_code: response.code,
543
+ response_body: response.body,
544
+ recipient: to,
545
+ message_type: message_type
546
+ )
492
547
  nil
493
548
  end
494
549
  rescue Net::OpenTimeout, Net::ReadTimeout => network_error
@@ -497,9 +552,85 @@ module FlowChat
497
552
  raise network_error
498
553
  rescue => error
499
554
  FlowChat.logger.error { "WhatsApp::Client: API request exception: #{error.class.name}: #{error.message}" }
555
+ report_api_error(
556
+ "WhatsApp API request exception: #{error.class.name}",
557
+ error: error,
558
+ recipient: to,
559
+ message_type: message_type
560
+ )
500
561
  nil
501
562
  end
502
563
 
564
+ def send_read_receipt_payload(payload, message_id)
565
+ FlowChat.logger.debug { "WhatsApp::Client: Marking message #{message_id} as read (typing=#{payload.key?(:typing_indicator)})" }
566
+
567
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
568
+ http = Net::HTTP.new(uri.host, uri.port)
569
+ http.use_ssl = true
570
+
571
+ request = Net::HTTP::Post.new(uri)
572
+ request["Authorization"] = "Bearer #{@config.access_token}"
573
+ request["Content-Type"] = "application/json"
574
+ request.body = payload.to_json
575
+
576
+ response = http.request(request)
577
+
578
+ if response.is_a?(Net::HTTPSuccess)
579
+ JSON.parse(response.body)
580
+ else
581
+ FlowChat.logger.error { "WhatsApp::Client: mark_as_read failed - #{response.code}: #{response.body}" }
582
+ report_api_error(
583
+ "WhatsApp mark_as_read failed",
584
+ response_code: response.code,
585
+ response_body: response.body,
586
+ message_type: "read_receipt"
587
+ )
588
+ nil
589
+ end
590
+ rescue Net::OpenTimeout, Net::ReadTimeout => network_error
591
+ FlowChat.logger.error { "WhatsApp::Client: Network timeout marking read: #{network_error.class.name}: #{network_error.message}" }
592
+ raise network_error
593
+ rescue => error
594
+ FlowChat.logger.error { "WhatsApp::Client: mark_as_read exception: #{error.class.name}: #{error.message}" }
595
+ report_api_error(
596
+ "WhatsApp mark_as_read exception: #{error.class.name}",
597
+ error: error,
598
+ message_type: "read_receipt"
599
+ )
600
+ nil
601
+ end
602
+
603
+ def report_api_error(message, response_code: nil, response_body: nil, error: nil, recipient: nil, message_type: nil)
604
+ error_details = parse_error_response(response_body)
605
+
606
+ FlowChat::Instrumentation.report_api_error(
607
+ message,
608
+ error: error,
609
+ platform: :whatsapp,
610
+ phone_number_id: @config.phone_number_id,
611
+ recipient: recipient,
612
+ message_type: message_type,
613
+ response_code: response_code,
614
+ **error_details
615
+ )
616
+ end
617
+
618
+ def parse_error_response(response_body)
619
+ return {} unless response_body
620
+
621
+ parsed = JSON.parse(response_body)
622
+ return {} unless parsed.is_a?(Hash) && parsed["error"]
623
+
624
+ {
625
+ error_type: parsed.dig("error", "type"),
626
+ error_code: parsed.dig("error", "code"),
627
+ error_subcode: parsed.dig("error", "error_subcode"),
628
+ error_message: parsed.dig("error", "message")
629
+ }.compact
630
+ rescue JSON::ParserError
631
+ {}
632
+ end
633
+
503
634
  def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
504
635
  media_object = if url?(url_or_id)
505
636
  {link: url_or_id}
@@ -29,7 +29,7 @@ module FlowChat
29
29
 
30
30
  config = new(nil)
31
31
 
32
- if defined?(Rails) && Rails.application.credentials.whatsapp
32
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.whatsapp
33
33
  FlowChat.logger.debug { "WhatsApp::Configuration: Loading from Rails credentials" }
34
34
  credentials = Rails.application.credentials.whatsapp
35
35
  config.access_token = credentials[:access_token]