flow_chat 0.6.1 → 0.7.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +44 -0
  3. data/.gitignore +2 -1
  4. data/README.md +84 -1229
  5. data/docs/configuration.md +337 -0
  6. data/docs/flows.md +320 -0
  7. data/docs/images/simulator.png +0 -0
  8. data/docs/instrumentation.md +216 -0
  9. data/docs/media.md +153 -0
  10. data/docs/testing.md +475 -0
  11. data/docs/ussd-setup.md +306 -0
  12. data/docs/whatsapp-setup.md +162 -0
  13. data/examples/multi_tenant_whatsapp_controller.rb +9 -37
  14. data/examples/simulator_controller.rb +9 -18
  15. data/examples/ussd_controller.rb +32 -38
  16. data/examples/whatsapp_controller.rb +32 -125
  17. data/examples/whatsapp_media_examples.rb +68 -336
  18. data/examples/whatsapp_message_job.rb +5 -3
  19. data/flow_chat.gemspec +6 -2
  20. data/lib/flow_chat/base_processor.rb +48 -2
  21. data/lib/flow_chat/config.rb +5 -0
  22. data/lib/flow_chat/context.rb +13 -1
  23. data/lib/flow_chat/instrumentation/log_subscriber.rb +176 -0
  24. data/lib/flow_chat/instrumentation/metrics_collector.rb +197 -0
  25. data/lib/flow_chat/instrumentation/setup.rb +155 -0
  26. data/lib/flow_chat/instrumentation.rb +70 -0
  27. data/lib/flow_chat/prompt.rb +20 -20
  28. data/lib/flow_chat/session/cache_session_store.rb +73 -7
  29. data/lib/flow_chat/session/middleware.rb +37 -4
  30. data/lib/flow_chat/session/rails_session_store.rb +36 -1
  31. data/lib/flow_chat/simulator/controller.rb +6 -6
  32. data/lib/flow_chat/ussd/gateway/nalo.rb +30 -0
  33. data/lib/flow_chat/ussd/gateway/nsano.rb +33 -0
  34. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +109 -0
  35. data/lib/flow_chat/ussd/middleware/executor.rb +24 -2
  36. data/lib/flow_chat/ussd/middleware/pagination.rb +87 -7
  37. data/lib/flow_chat/ussd/processor.rb +14 -0
  38. data/lib/flow_chat/ussd/renderer.rb +1 -1
  39. data/lib/flow_chat/version.rb +1 -1
  40. data/lib/flow_chat/whatsapp/client.rb +99 -12
  41. data/lib/flow_chat/whatsapp/configuration.rb +35 -4
  42. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +120 -34
  43. data/lib/flow_chat/whatsapp/middleware/executor.rb +24 -2
  44. data/lib/flow_chat/whatsapp/processor.rb +8 -0
  45. data/lib/flow_chat/whatsapp/renderer.rb +4 -9
  46. data/lib/flow_chat.rb +23 -0
  47. metadata +22 -11
  48. data/.travis.yml +0 -6
  49. data/app/controllers/demo_controller.rb +0 -101
  50. data/app/flow_chat/demo_restaurant_flow.rb +0 -889
  51. data/config/routes_demo.rb +0 -59
  52. data/examples/initializer.rb +0 -86
  53. data/examples/media_prompts_examples.rb +0 -27
  54. data/images/ussd_simulator.png +0 -0
@@ -2,23 +2,44 @@ module FlowChat
2
2
  module Ussd
3
3
  module Middleware
4
4
  class Pagination
5
+ include FlowChat::Instrumentation
6
+
7
+ attr_reader :context
8
+
5
9
  def initialize(app)
6
10
  @app = app
11
+ FlowChat.logger.debug { "Ussd::Pagination: Initialized USSD pagination middleware" }
7
12
  end
8
13
 
9
14
  def call(context)
10
15
  @context = context
11
16
  @session = context.session
12
17
 
18
+ session_id = context["session.id"]
19
+ FlowChat.logger.debug { "Ussd::Pagination: Processing request for session #{session_id}" }
20
+
13
21
  if intercept?
22
+ FlowChat.logger.info { "Ussd::Pagination: Intercepting request for pagination handling - session #{session_id}" }
14
23
  type, prompt = handle_intercepted_request
15
24
  [type, prompt, []]
16
25
  else
26
+ # Clear pagination state for new flows
27
+ if pagination_state.present?
28
+ FlowChat.logger.debug { "Ussd::Pagination: Clearing pagination state for new flow - session #{session_id}" }
29
+ end
17
30
  @session.delete "ussd.pagination"
31
+
18
32
  type, prompt, choices, media = @app.call(context)
19
33
 
20
34
  prompt = FlowChat::Ussd::Renderer.new(prompt, choices: choices, media: media).render
21
- type, prompt = maybe_paginate(type, prompt) if prompt.present?
35
+
36
+ if prompt.present?
37
+ original_length = prompt.length
38
+ type, prompt = maybe_paginate(type, prompt)
39
+ if prompt.length != original_length
40
+ FlowChat.logger.info { "Ussd::Pagination: Content paginated - original: #{original_length} chars, paginated: #{prompt.length} chars" }
41
+ end
42
+ end
22
43
 
23
44
  [type, prompt, []]
24
45
  end
@@ -27,53 +48,91 @@ module FlowChat
27
48
  private
28
49
 
29
50
  def intercept?
30
- pagination_state.present? &&
51
+ should_intercept = pagination_state.present? &&
31
52
  (pagination_state["type"].to_sym == :terminal ||
32
53
  ([FlowChat::Config.ussd.pagination_next_option, FlowChat::Config.ussd.pagination_back_option].include? @context.input))
54
+
55
+ if should_intercept
56
+ FlowChat.logger.debug { "Ussd::Pagination: Intercepting - input: #{@context.input}, pagination type: #{pagination_state["type"]}" }
57
+ end
58
+
59
+ should_intercept
33
60
  end
34
61
 
35
62
  def handle_intercepted_request
36
- FlowChat::Config.logger&.info "FlowChat::Middleware::Pagination :: Intercepted to handle pagination"
63
+ FlowChat.logger.info { "Ussd::Pagination: Handling paginated request" }
37
64
  start, finish, has_more = calculate_offsets
38
65
  type = (pagination_state["type"].to_sym == :terminal && !has_more) ? :terminal : :prompt
39
66
  prompt = pagination_state["prompt"][start..finish] + build_pagination_options(type, has_more)
40
67
  set_pagination_state(current_page, start, finish)
41
68
 
69
+ # Instrument pagination navigation
70
+ instrument(Events::PAGINATION_TRIGGERED, {
71
+ session_id: @context["session.id"],
72
+ current_page: current_page,
73
+ total_pages: calculate_total_pages,
74
+ content_length: pagination_state["prompt"].length,
75
+ page_limit: FlowChat::Config.ussd.pagination_page_size,
76
+ navigation_action: (@context.input == FlowChat::Config.ussd.pagination_next_option) ? "next" : "back"
77
+ })
78
+
79
+ FlowChat.logger.debug { "Ussd::Pagination: Serving page content - start: #{start}, finish: #{finish}, has_more: #{has_more}, type: #{type}" }
42
80
  [type, prompt]
43
81
  end
44
82
 
45
83
  def maybe_paginate(type, prompt)
46
84
  if prompt.length > FlowChat::Config.ussd.pagination_page_size
47
85
  original_prompt = prompt
48
- FlowChat::Config.logger&.info "FlowChat::Middleware::Pagination :: Response length (#{prompt.length}) exceeds page size (#{FlowChat::Config.ussd.pagination_page_size}). Paginating."
86
+ FlowChat.logger.info { "Ussd::Pagination: Content exceeds page size (#{prompt.length} > #{FlowChat::Config.ussd.pagination_page_size}), initiating pagination" }
87
+
49
88
  slice_end = single_option_slice_size
50
89
  # Ensure we do not cut words and options off in the middle.
51
90
  current_pagebreak = original_prompt[slice_end + 1].blank? ? slice_end : original_prompt[0..slice_end].rindex("\n") || original_prompt[0..slice_end].rindex(" ") || slice_end
91
+
92
+ FlowChat.logger.debug { "Ussd::Pagination: First page break at position #{current_pagebreak}" }
93
+
52
94
  set_pagination_state(1, 0, current_pagebreak, original_prompt, type)
53
95
  prompt = original_prompt[0..current_pagebreak] + "\n\n" + next_option
54
96
  type = :prompt
97
+
98
+ # Instrument initial pagination setup
99
+ total_pages = calculate_total_pages(original_prompt)
100
+ instrument(Events::PAGINATION_TRIGGERED, {
101
+ session_id: @context["session.id"],
102
+ current_page: 1,
103
+ total_pages: total_pages,
104
+ content_length: original_prompt.length,
105
+ page_limit: FlowChat::Config.ussd.pagination_page_size,
106
+ navigation_action: "initial"
107
+ })
108
+
109
+ FlowChat.logger.debug { "Ussd::Pagination: First page prepared with #{prompt.length} characters" }
55
110
  end
56
111
  [type, prompt]
57
112
  end
58
113
 
59
114
  def calculate_offsets
60
115
  page = current_page
116
+
117
+ FlowChat.logger.debug { "Ussd::Pagination: Calculating offsets for page #{page}" }
118
+
61
119
  offset = pagination_state["offsets"][page.to_s]
62
120
  if offset.present?
63
- FlowChat::Config.logger&.debug "FlowChat::Middleware::Pagination :: Reusing cached offset for page: #{page}"
121
+ FlowChat.logger.debug { "Ussd::Pagination: Using cached offset for page #{page}" }
64
122
  start = offset["start"]
65
123
  finish = offset["finish"]
66
124
  has_more = pagination_state["prompt"].length > finish
67
125
  else
68
- FlowChat::Config.logger&.debug "FlowChat::Middleware::Pagination :: Calculating offset for page: #{page}"
126
+ FlowChat.logger.debug { "Ussd::Pagination: Computing new offset for page #{page}" }
69
127
  # We are guaranteed a previous offset because it was set in maybe_paginate
70
128
  previous_page = page - 1
71
129
  previous_offset = pagination_state["offsets"][previous_page.to_s]
72
130
  start = previous_offset["finish"] + 1
73
131
  has_more, len = (pagination_state["prompt"].length > start + single_option_slice_size) ? [true, dual_options_slice_size] : [false, single_option_slice_size]
74
132
  finish = start + len
133
+
75
134
  if start > pagination_state["prompt"].length
76
- FlowChat::Config.logger&.debug "FlowChat::Middleware::Pagination :: No content exists for page: #{page}. Reverting to page: #{page - 1}"
135
+ FlowChat.logger.warn { "Ussd::Pagination: No content for page #{page}, reverting to page #{page - 1}" }
77
136
  page -= 1
78
137
  has_more = false
79
138
  start = previous_offset["start"]
@@ -90,19 +149,26 @@ module FlowChat
90
149
  # We're in the middle of a word, find the last word boundary
91
150
  boundary_pos = slice_text.rindex("\n") || slice_text.rindex(" ")
92
151
  if boundary_pos
152
+ old_finish = finish
93
153
  finish = start + boundary_pos
154
+ FlowChat.logger.debug { "Ussd::Pagination: Adjusted finish for word boundary - #{old_finish} -> #{finish}" }
94
155
  end
95
156
  # If no boundary found, we'll have to break mid-word (fallback)
96
157
  end
97
158
  end
98
159
  end
99
160
  end
161
+
162
+ FlowChat.logger.debug { "Ussd::Pagination: Page #{page} offsets - start: #{start}, finish: #{finish}, has_more: #{has_more}" }
100
163
  [start, finish, has_more]
101
164
  end
102
165
 
103
166
  def build_pagination_options(type, has_more)
104
167
  options_str = ""
105
168
  has_less = current_page > 1
169
+
170
+ FlowChat.logger.debug { "Ussd::Pagination: Building pagination options - type: #{type}, has_more: #{has_more}, has_less: #{has_less}" }
171
+
106
172
  if type.to_sym == :prompt
107
173
  options_str += "\n\n"
108
174
  next_opt = has_more ? next_option : ""
@@ -126,6 +192,7 @@ module FlowChat
126
192
  # We accomodate the 2 newlines and the longest of the options
127
193
  # We subtract an additional 1 to normalize it for slicing
128
194
  @single_option_slice_size = FlowChat::Config.ussd.pagination_page_size - 2 - [next_option.length, back_option.length].max - 1
195
+ FlowChat.logger.debug { "Ussd::Pagination: Calculated single option slice size: #{@single_option_slice_size}" }
129
196
  end
130
197
  @single_option_slice_size
131
198
  end
@@ -135,6 +202,7 @@ module FlowChat
135
202
  # To display both back and next options
136
203
  # We accomodate the 3 newlines and both of the options
137
204
  @dual_options_slice_size = FlowChat::Config.ussd.pagination_page_size - 3 - [next_option.length, back_option.length].sum - 1
205
+ FlowChat.logger.debug { "Ussd::Pagination: Calculated dual options slice size: #{@dual_options_slice_size}" }
138
206
  end
139
207
  @dual_options_slice_size
140
208
  end
@@ -143,8 +211,10 @@ module FlowChat
143
211
  page = pagination_state["page"]
144
212
  if @context.input == FlowChat::Config.ussd.pagination_back_option
145
213
  page -= 1
214
+ FlowChat.logger.debug { "Ussd::Pagination: Moving to previous page: #{page}" }
146
215
  elsif @context.input == FlowChat::Config.ussd.pagination_next_option
147
216
  page += 1
217
+ FlowChat.logger.debug { "Ussd::Pagination: Moving to next page: #{page}" }
148
218
  end
149
219
  [page, 1].max
150
220
  end
@@ -165,8 +235,18 @@ module FlowChat
165
235
  "prompt" => prompt,
166
236
  "type" => type.to_s
167
237
  }
238
+
239
+ FlowChat.logger.debug { "Ussd::Pagination: Saving pagination state - page: #{page}, total_content: #{prompt&.length || 0} chars" }
168
240
  @session.set "ussd.pagination", new_state
169
241
  end
242
+
243
+ def calculate_total_pages(content = nil)
244
+ content ||= pagination_state["prompt"]
245
+ return 1 unless content&.length&.> FlowChat::Config.ussd.pagination_page_size
246
+
247
+ # Rough estimation - actual pages may vary due to word boundaries
248
+ (content.length.to_f / single_option_slice_size).ceil
249
+ end
170
250
  end
171
251
  end
172
252
  end
@@ -2,6 +2,7 @@ module FlowChat
2
2
  module Ussd
3
3
  class Processor < FlowChat::BaseProcessor
4
4
  def use_resumable_sessions
5
+ FlowChat.logger.debug { "Ussd::Processor: Enabling resumable sessions middleware" }
5
6
  middleware.insert_before 0, FlowChat::Ussd::Middleware::ResumableSession
6
7
  self
7
8
  end
@@ -13,14 +14,27 @@ module FlowChat
13
14
  end
14
15
 
15
16
  def build_middleware_stack
17
+ FlowChat.logger.debug { "Ussd::Processor: Building USSD middleware stack" }
16
18
  create_middleware_stack("ussd")
17
19
  end
18
20
 
19
21
  def configure_middleware_stack(builder)
22
+ FlowChat.logger.debug { "Ussd::Processor: Configuring USSD middleware stack" }
23
+
20
24
  builder.use FlowChat::Session::Middleware
25
+ FlowChat.logger.debug { "Ussd::Processor: Added Session::Middleware" }
26
+
21
27
  builder.use FlowChat::Ussd::Middleware::Pagination
28
+ FlowChat.logger.debug { "Ussd::Processor: Added Ussd::Middleware::Pagination" }
29
+
22
30
  builder.use middleware
31
+ FlowChat.logger.debug { "Ussd::Processor: Added custom middleware" }
32
+
33
+ builder.use FlowChat::Ussd::Middleware::ChoiceMapper
34
+ FlowChat.logger.debug { "Ussd::Processor: Added Ussd::Middleware::ChoiceMapper" }
35
+
23
36
  builder.use FlowChat::Ussd::Middleware::Executor
37
+ FlowChat.logger.debug { "Ussd::Processor: Added Ussd::Middleware::Executor" }
24
38
  end
25
39
  end
26
40
  end
@@ -14,7 +14,7 @@ module FlowChat
14
14
  private
15
15
 
16
16
  def build_prompt
17
- parts = [prompt, build_media, build_choices].compact
17
+ parts = [build_media, prompt, build_choices].compact
18
18
  parts.join "\n\n"
19
19
  end
20
20
 
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.6.1"
2
+ VERSION = "0.7.0"
3
3
  end
@@ -7,8 +7,12 @@ require "securerandom"
7
7
  module FlowChat
8
8
  module Whatsapp
9
9
  class Client
10
+ include FlowChat::Instrumentation
11
+
10
12
  def initialize(config)
11
13
  @config = config
14
+ FlowChat.logger.info { "WhatsApp::Client: Initialized WhatsApp client for phone_number_id: #{@config.phone_number_id}" }
15
+ FlowChat.logger.debug { "WhatsApp::Client: API base URL: #{FlowChat::Config.whatsapp.api_base_url}" }
12
16
  end
13
17
 
14
18
  # Send a message to a WhatsApp number
@@ -16,8 +20,28 @@ module FlowChat
16
20
  # @param response [Array] FlowChat response array [type, content, options]
17
21
  # @return [Hash] API response or nil on error
18
22
  def send_message(to, response)
19
- message_data = build_message_payload(response, to)
20
- send_message_payload(message_data)
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
33
+ message_data = build_message_payload(response, to)
34
+ send_message_payload(message_data)
35
+ end
36
+
37
+ if result
38
+ message_id = result.dig("messages", 0, "id")
39
+ FlowChat.logger.debug { "WhatsApp::Client: Message sent successfully to #{to}, message_id: #{message_id}" }
40
+ else
41
+ FlowChat.logger.error { "WhatsApp::Client: Failed to send message to #{to}" }
42
+ end
43
+
44
+ result
21
45
  end
22
46
 
23
47
  # Send a text message
@@ -25,6 +49,7 @@ module FlowChat
25
49
  # @param text [String] Message text
26
50
  # @return [Hash] API response or nil on error
27
51
  def send_text(to, text)
52
+ FlowChat.logger.debug { "WhatsApp::Client: Sending text message to #{to}" }
28
53
  send_message(to, [:text, text, {}])
29
54
  end
30
55
 
@@ -34,6 +59,7 @@ module FlowChat
34
59
  # @param buttons [Array] Array of button hashes with :id and :title
35
60
  # @return [Hash] API response or nil on error
36
61
  def send_buttons(to, text, buttons)
62
+ FlowChat.logger.debug { "WhatsApp::Client: Sending interactive buttons to #{to} with #{buttons.size} buttons" }
37
63
  send_message(to, [:interactive_buttons, text, {buttons: buttons}])
38
64
  end
39
65
 
@@ -44,6 +70,8 @@ module FlowChat
44
70
  # @param button_text [String] Button text (default: "Choose")
45
71
  # @return [Hash] API response or nil on error
46
72
  def send_list(to, text, sections, button_text = "Choose")
73
+ total_items = sections.sum { |section| section[:rows]&.size || 0 }
74
+ FlowChat.logger.debug { "WhatsApp::Client: Sending interactive list to #{to} with #{sections.size} sections, #{total_items} total items" }
47
75
  send_message(to, [:interactive_list, text, {sections: sections, button_text: button_text}])
48
76
  end
49
77
 
@@ -54,6 +82,7 @@ module FlowChat
54
82
  # @param language [String] Language code (default: "en_US")
55
83
  # @return [Hash] API response or nil on error
56
84
  def send_template(to, template_name, components = [], language = "en_US")
85
+ FlowChat.logger.debug { "WhatsApp::Client: Sending template '#{template_name}' to #{to} in #{language}" }
57
86
  send_message(to, [:template, "", {
58
87
  template_name: template_name,
59
88
  components: components,
@@ -68,6 +97,7 @@ module FlowChat
68
97
  # @param mime_type [String] Optional MIME type for URLs (e.g., 'image/jpeg')
69
98
  # @return [Hash] API response
70
99
  def send_image(to, image_url_or_id, caption = nil, mime_type = nil)
100
+ FlowChat.logger.debug { "WhatsApp::Client: Sending image to #{to} - #{url?(image_url_or_id) ? "URL" : "Media ID"}" }
71
101
  send_media_message(to, :image, image_url_or_id, caption: caption, mime_type: mime_type)
72
102
  end
73
103
 
@@ -80,6 +110,7 @@ module FlowChat
80
110
  # @return [Hash] API response
81
111
  def send_document(to, document_url_or_id, caption = nil, filename = nil, mime_type = nil)
82
112
  filename ||= extract_filename_from_url(document_url_or_id) if url?(document_url_or_id)
113
+ FlowChat.logger.debug { "WhatsApp::Client: Sending document to #{to} - filename: #{filename}" }
83
114
  send_media_message(to, :document, document_url_or_id, caption: caption, filename: filename, mime_type: mime_type)
84
115
  end
85
116
 
@@ -90,6 +121,7 @@ module FlowChat
90
121
  # @param mime_type [String] Optional MIME type for URLs (e.g., 'video/mp4')
91
122
  # @return [Hash] API response
92
123
  def send_video(to, video_url_or_id, caption = nil, mime_type = nil)
124
+ FlowChat.logger.debug { "WhatsApp::Client: Sending video to #{to}" }
93
125
  send_media_message(to, :video, video_url_or_id, caption: caption, mime_type: mime_type)
94
126
  end
95
127
 
@@ -99,6 +131,7 @@ module FlowChat
99
131
  # @param mime_type [String] Optional MIME type for URLs (e.g., 'audio/mpeg')
100
132
  # @return [Hash] API response
101
133
  def send_audio(to, audio_url_or_id, mime_type = nil)
134
+ FlowChat.logger.debug { "WhatsApp::Client: Sending audio to #{to}" }
102
135
  send_media_message(to, :audio, audio_url_or_id, mime_type: mime_type)
103
136
  end
104
137
 
@@ -108,6 +141,7 @@ module FlowChat
108
141
  # @param mime_type [String] Optional MIME type for URLs (e.g., 'image/webp')
109
142
  # @return [Hash] API response
110
143
  def send_sticker(to, sticker_url_or_id, mime_type = nil)
144
+ FlowChat.logger.debug { "WhatsApp::Client: Sending sticker to #{to}" }
111
145
  send_media_message(to, :sticker, sticker_url_or_id, mime_type: mime_type)
112
146
  end
113
147
 
@@ -118,17 +152,23 @@ module FlowChat
118
152
  # @return [String] Media ID
119
153
  # @raise [StandardError] If upload fails
120
154
  def upload_media(file_path_or_io, mime_type, filename = nil)
155
+ FlowChat.logger.info { "WhatsApp::Client: Uploading media file - type: #{mime_type}, filename: #{filename}" }
156
+
121
157
  raise ArgumentError, "mime_type is required" if mime_type.nil? || mime_type.empty?
122
158
 
159
+ file_size = nil
123
160
  if file_path_or_io.is_a?(String)
124
161
  # File path
125
162
  raise ArgumentError, "File not found: #{file_path_or_io}" unless File.exist?(file_path_or_io)
126
163
  filename ||= File.basename(file_path_or_io)
164
+ file_size = File.size(file_path_or_io)
165
+ FlowChat.logger.debug { "WhatsApp::Client: Uploading file from path: #{file_path_or_io} (#{file_size} bytes)" }
127
166
  file = File.open(file_path_or_io, "rb")
128
167
  else
129
168
  # IO object
130
169
  file = file_path_or_io
131
170
  filename ||= "upload"
171
+ FlowChat.logger.debug { "WhatsApp::Client: Uploading file from IO object" }
132
172
  end
133
173
 
134
174
  # Upload directly via HTTP
@@ -136,6 +176,8 @@ module FlowChat
136
176
  http = Net::HTTP.new(uri.host, uri.port)
137
177
  http.use_ssl = true
138
178
 
179
+ FlowChat.logger.debug { "WhatsApp::Client: Uploading to #{uri}" }
180
+
139
181
  # Prepare multipart form data
140
182
  boundary = "----WebKitFormBoundary#{SecureRandom.hex(16)}"
141
183
 
@@ -165,15 +207,45 @@ module FlowChat
165
207
  request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
166
208
  request.body = body
167
209
 
168
- response = http.request(request)
169
-
170
- if response.is_a?(Net::HTTPSuccess)
171
- data = JSON.parse(response.body)
172
- data["id"] || raise(StandardError, "Failed to upload media: #{data}")
173
- else
174
- Rails.logger.error "WhatsApp Media Upload error: #{response.body}"
175
- raise StandardError, "Media upload failed: #{response.body}"
210
+ result = instrument(Events::MEDIA_UPLOAD, {
211
+ filename: filename,
212
+ mime_type: mime_type,
213
+ size: file_size,
214
+ platform: :whatsapp
215
+ }) do
216
+ response = http.request(request)
217
+
218
+ if response.is_a?(Net::HTTPSuccess)
219
+ data = JSON.parse(response.body)
220
+ media_id = data["id"]
221
+ if media_id
222
+ FlowChat.logger.info { "WhatsApp::Client: Media upload successful - media_id: #{media_id}" }
223
+ {success: true, media_id: media_id}
224
+ else
225
+ FlowChat.logger.error { "WhatsApp::Client: Media upload failed - no media_id in response: #{data}" }
226
+ raise StandardError, "Failed to upload media: #{data}"
227
+ end
228
+ else
229
+ FlowChat.logger.error { "WhatsApp::Client: Media upload error - #{response.code}: #{response.body}" }
230
+ raise StandardError, "Media upload failed: #{response.body}"
231
+ end
176
232
  end
233
+
234
+ result[:media_id]
235
+ rescue => error
236
+ FlowChat.logger.error { "WhatsApp::Client: Media upload exception: #{error.class.name}: #{error.message}" }
237
+
238
+ # Instrument the error
239
+ instrument(Events::MEDIA_UPLOAD, {
240
+ filename: filename,
241
+ mime_type: mime_type,
242
+ size: file_size,
243
+ success: false,
244
+ error: error.message,
245
+ platform: :whatsapp
246
+ })
247
+
248
+ raise
177
249
  ensure
178
250
  file&.close if file_path_or_io.is_a?(String)
179
251
  end
@@ -394,6 +466,11 @@ module FlowChat
394
466
  # @param message_data [Hash] Message payload
395
467
  # @return [Hash] API response or nil on error
396
468
  def send_message_payload(message_data)
469
+ to = message_data[:to]
470
+ message_type = message_data[:type]
471
+
472
+ FlowChat.logger.debug { "WhatsApp::Client: Sending API request to #{to} - type: #{message_type}" }
473
+
397
474
  uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
398
475
  http = Net::HTTP.new(uri.host, uri.port)
399
476
  http.use_ssl = true
@@ -403,14 +480,24 @@ module FlowChat
403
480
  request["Content-Type"] = "application/json"
404
481
  request.body = message_data.to_json
405
482
 
483
+ FlowChat.logger.debug { "WhatsApp::Client: Making HTTP request to WhatsApp API" }
406
484
  response = http.request(request)
407
485
 
408
486
  if response.is_a?(Net::HTTPSuccess)
409
- JSON.parse(response.body)
487
+ result = JSON.parse(response.body)
488
+ FlowChat.logger.debug { "WhatsApp::Client: API request successful - response: #{result}" }
489
+ result
410
490
  else
411
- Rails.logger.error "WhatsApp API error: #{response.body}"
491
+ FlowChat.logger.error { "WhatsApp::Client: API request failed - #{response.code}: #{response.body}" }
412
492
  nil
413
493
  end
494
+ rescue Net::OpenTimeout, Net::ReadTimeout => network_error
495
+ # Let network timeouts bubble up for proper error handling
496
+ FlowChat.logger.error { "WhatsApp::Client: Network timeout: #{network_error.class.name}: #{network_error.message}" }
497
+ raise network_error
498
+ rescue => error
499
+ FlowChat.logger.error { "WhatsApp::Client: API request exception: #{error.class.name}: #{error.message}" }
500
+ nil
414
501
  end
415
502
 
416
503
  def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
@@ -18,14 +18,19 @@ module FlowChat
18
18
  @business_account_id = nil
19
19
  @skip_signature_validation = false
20
20
 
21
+ FlowChat.logger.debug { "WhatsApp::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
22
+
21
23
  register_as(name) if name.present?
22
24
  end
23
25
 
24
26
  # Load configuration from Rails credentials or environment variables
25
27
  def self.from_credentials
28
+ FlowChat.logger.info { "WhatsApp::Configuration: Loading configuration from credentials/environment" }
29
+
26
30
  config = new(nil)
27
31
 
28
32
  if defined?(Rails) && Rails.application.credentials.whatsapp
33
+ FlowChat.logger.debug { "WhatsApp::Configuration: Loading from Rails credentials" }
29
34
  credentials = Rails.application.credentials.whatsapp
30
35
  config.access_token = credentials[:access_token]
31
36
  config.phone_number_id = credentials[:phone_number_id]
@@ -35,6 +40,7 @@ module FlowChat
35
40
  config.business_account_id = credentials[:business_account_id]
36
41
  config.skip_signature_validation = credentials[:skip_signature_validation] || false
37
42
  else
43
+ FlowChat.logger.debug { "WhatsApp::Configuration: Loading from environment variables" }
38
44
  # Fallback to environment variables
39
45
  config.access_token = ENV["WHATSAPP_ACCESS_TOKEN"]
40
46
  config.phone_number_id = ENV["WHATSAPP_PHONE_NUMBER_ID"]
@@ -45,43 +51,68 @@ module FlowChat
45
51
  config.skip_signature_validation = ENV["WHATSAPP_SKIP_SIGNATURE_VALIDATION"] == "true"
46
52
  end
47
53
 
54
+ if config.valid?
55
+ FlowChat.logger.info { "WhatsApp::Configuration: Configuration loaded successfully - phone_number_id: #{config.phone_number_id}" }
56
+ else
57
+ FlowChat.logger.warn { "WhatsApp::Configuration: Incomplete configuration loaded - missing required fields" }
58
+ end
59
+
48
60
  config
49
61
  end
50
62
 
51
63
  # Register a named configuration
52
64
  def self.register(name, config)
65
+ FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration '#{name}'" }
53
66
  @@configurations[name.to_sym] = config
54
67
  end
55
68
 
56
69
  # Get a named configuration
57
70
  def self.get(name)
58
- @@configurations[name.to_sym] || raise(ArgumentError, "WhatsApp configuration '#{name}' not found")
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
59
79
  end
60
80
 
61
81
  # Check if a named configuration exists
62
82
  def self.exists?(name)
63
- @@configurations.key?(name.to_sym)
83
+ exists = @@configurations.key?(name.to_sym)
84
+ FlowChat.logger.debug { "WhatsApp::Configuration: Configuration '#{name}' exists: #{exists}" }
85
+ exists
64
86
  end
65
87
 
66
88
  # Get all configuration names
67
89
  def self.configuration_names
68
- @@configurations.keys
90
+ names = @@configurations.keys
91
+ FlowChat.logger.debug { "WhatsApp::Configuration: Available configurations: #{names}" }
92
+ names
69
93
  end
70
94
 
71
95
  # Clear all registered configurations (useful for testing)
72
96
  def self.clear_all!
97
+ FlowChat.logger.debug { "WhatsApp::Configuration: Clearing all registered configurations" }
73
98
  @@configurations.clear
74
99
  end
75
100
 
76
101
  # Register this configuration with a name
77
102
  def register_as(name)
103
+ FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration as '#{name}'" }
78
104
  @name = name.to_sym
79
105
  self.class.register(@name, self)
80
106
  self
81
107
  end
82
108
 
83
109
  def valid?
84
- access_token && !access_token.to_s.empty? && phone_number_id && !phone_number_id.to_s.empty? && verify_token && !verify_token.to_s.empty?
110
+ is_valid = access_token && !access_token.to_s.empty? &&
111
+ phone_number_id && !phone_number_id.to_s.empty? &&
112
+ verify_token && !verify_token.to_s.empty?
113
+
114
+ FlowChat.logger.debug { "WhatsApp::Configuration: Configuration valid: #{is_valid}" }
115
+ is_valid
85
116
  end
86
117
 
87
118
  # API endpoints