flow_chat 0.4.0 → 0.4.2

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.
@@ -14,7 +14,7 @@ module FlowChat
14
14
 
15
15
  # Add timestamp for all requests
16
16
  context["request.timestamp"] = Time.current.iso8601
17
-
17
+
18
18
  # Set a basic message_id (can be enhanced based on actual Nsano implementation)
19
19
  context["request.message_id"] = SecureRandom.uuid
20
20
 
@@ -17,7 +17,6 @@ module FlowChat
17
17
  end
18
18
 
19
19
  def configure_middleware_stack(builder)
20
- builder.use gateway
21
20
  builder.use FlowChat::Session::Middleware
22
21
  builder.use FlowChat::Ussd::Middleware::Pagination
23
22
  builder.use middleware
@@ -25,4 +24,4 @@ module FlowChat
25
24
  end
26
25
  end
27
26
  end
28
- end
27
+ end
@@ -7,23 +7,31 @@ module FlowChat
7
7
  @user_input = input
8
8
  end
9
9
 
10
- def ask(msg, choices: nil, convert: nil, validate: nil, transform: nil)
10
+ def ask(msg, choices: nil, convert: nil, validate: nil, transform: nil, media: nil)
11
11
  if user_input.present?
12
12
  input = user_input
13
13
  input = convert.call(input) if convert.present?
14
14
  validation_error = validate.call(input) if validate.present?
15
15
 
16
- prompt!([validation_error, msg].join("\n\n"), choices:) if validation_error.present?
16
+ if validation_error.present?
17
+ # Include media URL in validation error message
18
+ original_message_with_media = build_message_with_media(msg, media)
19
+ prompt!([validation_error, original_message_with_media].join("\n\n"), choices:)
20
+ end
17
21
 
18
22
  input = transform.call(input) if transform.present?
19
23
  return input
20
24
  end
21
25
 
22
- prompt! msg, choices:
26
+ # Include media URL in the message for USSD
27
+ final_message = build_message_with_media(msg, media)
28
+ prompt! final_message, choices:
23
29
  end
24
30
 
25
- def say(message)
26
- terminate! message
31
+ def say(message, media: nil)
32
+ # Include media URL in the message for USSD
33
+ final_message = build_message_with_media(message, media)
34
+ terminate! final_message
27
35
  end
28
36
 
29
37
  def select(msg, choices)
@@ -43,6 +51,32 @@ module FlowChat
43
51
 
44
52
  private
45
53
 
54
+ def build_message_with_media(message, media)
55
+ return message unless media
56
+
57
+ media_url = media[:url] || media[:path]
58
+ media_type = media[:type] || :image
59
+
60
+ # For USSD, we append the media URL to the message
61
+ media_text = case media_type.to_sym
62
+ when :image
63
+ "📷 Image: #{media_url}"
64
+ when :document
65
+ "📄 Document: #{media_url}"
66
+ when :audio
67
+ "🎵 Audio: #{media_url}"
68
+ when :video
69
+ "🎥 Video: #{media_url}"
70
+ when :sticker
71
+ "😊 Sticker: #{media_url}"
72
+ else
73
+ "📎 Media: #{media_url}"
74
+ end
75
+
76
+ # Combine message with media information
77
+ "#{message}\n\n#{media_text}"
78
+ end
79
+
46
80
  def build_select_choices(choices)
47
81
  case choices
48
82
  when Array
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.4.0"
2
+ VERSION = "0.4.2"
3
3
  end
@@ -17,7 +17,13 @@ module FlowChat
17
17
  navigation_stack << key
18
18
  return session.get(key) if session.get(key).present?
19
19
 
20
- prompt = FlowChat::Whatsapp::Prompt.new input
20
+ user_input = input
21
+ if session.get("$started_at$").nil?
22
+ session.set("$started_at$", Time.current.iso8601)
23
+ user_input = nil
24
+ end
25
+
26
+ prompt = FlowChat::Whatsapp::Prompt.new user_input
21
27
  @input = nil # input is being submitted to prompt so we clear it
22
28
 
23
29
  value = yield prompt
@@ -55,4 +61,4 @@ module FlowChat
55
61
  end
56
62
  end
57
63
  end
58
- end
64
+ end
@@ -0,0 +1,435 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+ require "tempfile"
5
+ require "securerandom"
6
+
7
+ module FlowChat
8
+ module Whatsapp
9
+ class Client
10
+ WHATSAPP_API_URL = "https://graph.facebook.com/v18.0"
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ # Send a message to a WhatsApp number
17
+ # @param to [String] Phone number in E.164 format
18
+ # @param response [Array] FlowChat response array [type, content, options]
19
+ # @return [Hash] API response or nil on error
20
+ def send_message(to, response)
21
+ message_data = build_message_payload(response, to)
22
+ send_message_payload(message_data)
23
+ end
24
+
25
+ # Send a text message
26
+ # @param to [String] Phone number in E.164 format
27
+ # @param text [String] Message text
28
+ # @return [Hash] API response or nil on error
29
+ def send_text(to, text)
30
+ send_message(to, [:text, text, {}])
31
+ end
32
+
33
+ # Send interactive buttons
34
+ # @param to [String] Phone number in E.164 format
35
+ # @param text [String] Message text
36
+ # @param buttons [Array] Array of button hashes with :id and :title
37
+ # @return [Hash] API response or nil on error
38
+ def send_buttons(to, text, buttons)
39
+ send_message(to, [:interactive_buttons, text, {buttons: buttons}])
40
+ end
41
+
42
+ # Send interactive list
43
+ # @param to [String] Phone number in E.164 format
44
+ # @param text [String] Message text
45
+ # @param sections [Array] List sections
46
+ # @param button_text [String] Button text (default: "Choose")
47
+ # @return [Hash] API response or nil on error
48
+ def send_list(to, text, sections, button_text = "Choose")
49
+ send_message(to, [:interactive_list, text, {sections: sections, button_text: button_text}])
50
+ end
51
+
52
+ # Send a template message
53
+ # @param to [String] Phone number in E.164 format
54
+ # @param template_name [String] Template name
55
+ # @param components [Array] Template components
56
+ # @param language [String] Language code (default: "en_US")
57
+ # @return [Hash] API response or nil on error
58
+ def send_template(to, template_name, components = [], language = "en_US")
59
+ send_message(to, [:template, "", {
60
+ template_name: template_name,
61
+ components: components,
62
+ language: language
63
+ }])
64
+ end
65
+
66
+ # Send image message
67
+ # @param to [String] Phone number in E.164 format
68
+ # @param image_url_or_id [String] Image URL or WhatsApp media ID
69
+ # @param caption [String] Optional caption
70
+ # @param mime_type [String] Optional MIME type for URLs (e.g., 'image/jpeg')
71
+ # @return [Hash] API response
72
+ def send_image(to, image_url_or_id, caption = nil, mime_type = nil)
73
+ send_media_message(to, :image, image_url_or_id, caption: caption, mime_type: mime_type)
74
+ end
75
+
76
+ # Send document message
77
+ # @param to [String] Phone number in E.164 format
78
+ # @param document_url_or_id [String] Document URL or WhatsApp media ID
79
+ # @param caption [String] Optional caption
80
+ # @param filename [String] Optional filename
81
+ # @param mime_type [String] Optional MIME type for URLs (e.g., 'application/pdf')
82
+ # @return [Hash] API response
83
+ def send_document(to, document_url_or_id, caption = nil, filename = nil, mime_type = nil)
84
+ filename ||= extract_filename_from_url(document_url_or_id) if url?(document_url_or_id)
85
+ send_media_message(to, :document, document_url_or_id, caption: caption, filename: filename, mime_type: mime_type)
86
+ end
87
+
88
+ # Send video message
89
+ # @param to [String] Phone number in E.164 format
90
+ # @param video_url_or_id [String] Video URL or WhatsApp media ID
91
+ # @param caption [String] Optional caption
92
+ # @param mime_type [String] Optional MIME type for URLs (e.g., 'video/mp4')
93
+ # @return [Hash] API response
94
+ def send_video(to, video_url_or_id, caption = nil, mime_type = nil)
95
+ send_media_message(to, :video, video_url_or_id, caption: caption, mime_type: mime_type)
96
+ end
97
+
98
+ # Send audio message
99
+ # @param to [String] Phone number in E.164 format
100
+ # @param audio_url_or_id [String] Audio URL or WhatsApp media ID
101
+ # @param mime_type [String] Optional MIME type for URLs (e.g., 'audio/mpeg')
102
+ # @return [Hash] API response
103
+ def send_audio(to, audio_url_or_id, mime_type = nil)
104
+ send_media_message(to, :audio, audio_url_or_id, mime_type: mime_type)
105
+ end
106
+
107
+ # Send sticker message
108
+ # @param to [String] Phone number in E.164 format
109
+ # @param sticker_url_or_id [String] Sticker URL or WhatsApp media ID
110
+ # @param mime_type [String] Optional MIME type for URLs (e.g., 'image/webp')
111
+ # @return [Hash] API response
112
+ def send_sticker(to, sticker_url_or_id, mime_type = nil)
113
+ send_media_message(to, :sticker, sticker_url_or_id, mime_type: mime_type)
114
+ end
115
+
116
+ # Upload media file and return media ID
117
+ # @param file_path_or_io [String, IO] File path or IO object
118
+ # @param mime_type [String] MIME type of the file (required)
119
+ # @param filename [String] Optional filename for the upload
120
+ # @return [String] Media ID
121
+ # @raise [StandardError] If upload fails
122
+ def upload_media(file_path_or_io, mime_type, filename = nil)
123
+ raise ArgumentError, "mime_type is required" if mime_type.nil? || mime_type.empty?
124
+
125
+ if file_path_or_io.is_a?(String)
126
+ # File path
127
+ raise ArgumentError, "File not found: #{file_path_or_io}" unless File.exist?(file_path_or_io)
128
+ filename ||= File.basename(file_path_or_io)
129
+ file = File.open(file_path_or_io, "rb")
130
+ else
131
+ # IO object
132
+ file = file_path_or_io
133
+ filename ||= "upload"
134
+ end
135
+
136
+ # Upload directly via HTTP
137
+ uri = URI("#{WHATSAPP_API_URL}/#{@config.phone_number_id}/media")
138
+ http = Net::HTTP.new(uri.host, uri.port)
139
+ http.use_ssl = true
140
+
141
+ # Prepare multipart form data
142
+ boundary = "----WebKitFormBoundary#{SecureRandom.hex(16)}"
143
+
144
+ form_data = []
145
+ form_data << "--#{boundary}"
146
+ form_data << 'Content-Disposition: form-data; name="messaging_product"'
147
+ form_data << ""
148
+ form_data << "whatsapp"
149
+
150
+ form_data << "--#{boundary}"
151
+ form_data << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\""
152
+ form_data << "Content-Type: #{mime_type}"
153
+ form_data << ""
154
+ form_data << file.read
155
+
156
+ form_data << "--#{boundary}"
157
+ form_data << 'Content-Disposition: form-data; name="type"'
158
+ form_data << ""
159
+ form_data << mime_type
160
+
161
+ form_data << "--#{boundary}--"
162
+
163
+ body = form_data.join("\r\n")
164
+
165
+ request = Net::HTTP::Post.new(uri)
166
+ request["Authorization"] = "Bearer #{@config.access_token}"
167
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
168
+ request.body = body
169
+
170
+ response = http.request(request)
171
+
172
+ if response.is_a?(Net::HTTPSuccess)
173
+ data = JSON.parse(response.body)
174
+ data["id"] || raise(StandardError, "Failed to upload media: #{data}")
175
+ else
176
+ Rails.logger.error "WhatsApp Media Upload error: #{response.body}"
177
+ raise StandardError, "Media upload failed: #{response.body}"
178
+ end
179
+ ensure
180
+ file&.close if file_path_or_io.is_a?(String)
181
+ end
182
+
183
+ # Build message payload for WhatsApp API
184
+ # This method is exposed so the gateway can use it for simulator mode
185
+ def build_message_payload(response, to)
186
+ type, content, options = response
187
+
188
+ case type
189
+ when :text
190
+ {
191
+ messaging_product: "whatsapp",
192
+ to: to,
193
+ type: "text",
194
+ text: {body: content}
195
+ }
196
+ when :interactive_buttons
197
+ {
198
+ messaging_product: "whatsapp",
199
+ to: to,
200
+ type: "interactive",
201
+ interactive: {
202
+ type: "button",
203
+ body: {text: content},
204
+ action: {
205
+ buttons: options[:buttons].map.with_index do |button, index|
206
+ {
207
+ type: "reply",
208
+ reply: {
209
+ id: button[:id] || index.to_s,
210
+ title: button[:title]
211
+ }
212
+ }
213
+ end
214
+ }
215
+ }
216
+ }
217
+ when :interactive_list
218
+ {
219
+ messaging_product: "whatsapp",
220
+ to: to,
221
+ type: "interactive",
222
+ interactive: {
223
+ type: "list",
224
+ body: {text: content},
225
+ action: {
226
+ button: options[:button_text] || "Choose",
227
+ sections: options[:sections]
228
+ }
229
+ }
230
+ }
231
+ when :template
232
+ {
233
+ messaging_product: "whatsapp",
234
+ to: to,
235
+ type: "template",
236
+ template: {
237
+ name: options[:template_name],
238
+ language: {code: options[:language] || "en_US"},
239
+ components: options[:components] || []
240
+ }
241
+ }
242
+ when :media_image
243
+ {
244
+ messaging_product: "whatsapp",
245
+ to: to,
246
+ type: "image",
247
+ image: build_media_object(options)
248
+ }
249
+ when :media_document
250
+ {
251
+ messaging_product: "whatsapp",
252
+ to: to,
253
+ type: "document",
254
+ document: build_media_object(options)
255
+ }
256
+ when :media_audio
257
+ {
258
+ messaging_product: "whatsapp",
259
+ to: to,
260
+ type: "audio",
261
+ audio: build_media_object(options)
262
+ }
263
+ when :media_video
264
+ {
265
+ messaging_product: "whatsapp",
266
+ to: to,
267
+ type: "video",
268
+ video: build_media_object(options)
269
+ }
270
+ when :media_sticker
271
+ {
272
+ messaging_product: "whatsapp",
273
+ to: to,
274
+ type: "sticker",
275
+ sticker: build_media_object(options)
276
+ }
277
+ else
278
+ # Default to text message
279
+ {
280
+ messaging_product: "whatsapp",
281
+ to: to,
282
+ type: "text",
283
+ text: {body: content.to_s}
284
+ }
285
+ end
286
+ end
287
+
288
+ # Get media URL from media ID
289
+ # @param media_id [String] Media ID from WhatsApp
290
+ # @return [String] Media URL or nil on error
291
+ def get_media_url(media_id)
292
+ uri = URI("#{WHATSAPP_API_URL}/#{media_id}")
293
+ http = Net::HTTP.new(uri.host, uri.port)
294
+ http.use_ssl = true
295
+
296
+ request = Net::HTTP::Get.new(uri)
297
+ request["Authorization"] = "Bearer #{@config.access_token}"
298
+
299
+ response = http.request(request)
300
+
301
+ if response.is_a?(Net::HTTPSuccess)
302
+ data = JSON.parse(response.body)
303
+ data["url"]
304
+ else
305
+ Rails.logger.error "WhatsApp Media API error: #{response.body}"
306
+ nil
307
+ end
308
+ end
309
+
310
+ # Download media content
311
+ # @param media_id [String] Media ID from WhatsApp
312
+ # @return [String] Media content or nil on error
313
+ def download_media(media_id)
314
+ media_url = get_media_url(media_id)
315
+ return nil unless media_url
316
+
317
+ uri = URI(media_url)
318
+ http = Net::HTTP.new(uri.host, uri.port)
319
+ http.use_ssl = true
320
+
321
+ request = Net::HTTP::Get.new(uri)
322
+ request["Authorization"] = "Bearer #{@config.access_token}"
323
+
324
+ response = http.request(request)
325
+
326
+ if response.is_a?(Net::HTTPSuccess)
327
+ response.body
328
+ else
329
+ Rails.logger.error "WhatsApp Media download error: #{response.body}"
330
+ nil
331
+ end
332
+ end
333
+
334
+ # Get MIME type from URL without downloading (HEAD request)
335
+ def get_media_mime_type(url)
336
+ require "net/http"
337
+
338
+ uri = URI(url)
339
+ http = Net::HTTP.new(uri.host, uri.port)
340
+ http.use_ssl = (uri.scheme == "https")
341
+
342
+ # Use HEAD request to get headers without downloading content
343
+ response = http.head(uri.path)
344
+ response["content-type"]
345
+ rescue => e
346
+ Rails.logger.warn "Could not detect MIME type for #{url}: #{e.message}"
347
+ nil
348
+ end
349
+
350
+ private
351
+
352
+ # Build media object for WhatsApp API, handling both URLs and media IDs
353
+ # @param options [Hash] Options containing url/id, caption, filename
354
+ # @return [Hash] Media object for WhatsApp API
355
+ def build_media_object(options)
356
+ media_obj = {}
357
+
358
+ # Handle URL or ID
359
+ if options[:url]
360
+ # Use URL directly
361
+ media_obj[:link] = options[:url]
362
+ elsif options[:id]
363
+ # Use provided media ID directly
364
+ media_obj[:id] = options[:id]
365
+ end
366
+
367
+ # Add optional fields
368
+ media_obj[:caption] = options[:caption] if options[:caption]
369
+ media_obj[:filename] = options[:filename] if options[:filename]
370
+
371
+ media_obj
372
+ end
373
+
374
+ # Check if input is a URL or file path/media ID
375
+ def url?(input)
376
+ input.to_s.start_with?("http://", "https://")
377
+ end
378
+
379
+ # Extract filename from URL
380
+ def extract_filename_from_url(url)
381
+ uri = URI(url)
382
+ filename = File.basename(uri.path)
383
+ filename.empty? ? "document" : filename
384
+ rescue
385
+ "document"
386
+ end
387
+
388
+ # Send message payload to WhatsApp API
389
+ # @param message_data [Hash] Message payload
390
+ # @return [Hash] API response or nil on error
391
+ def send_message_payload(message_data)
392
+ uri = URI("#{WHATSAPP_API_URL}/#{@config.phone_number_id}/messages")
393
+ http = Net::HTTP.new(uri.host, uri.port)
394
+ http.use_ssl = true
395
+
396
+ request = Net::HTTP::Post.new(uri)
397
+ request["Authorization"] = "Bearer #{@config.access_token}"
398
+ request["Content-Type"] = "application/json"
399
+ request.body = message_data.to_json
400
+
401
+ response = http.request(request)
402
+
403
+ if response.is_a?(Net::HTTPSuccess)
404
+ JSON.parse(response.body)
405
+ else
406
+ Rails.logger.error "WhatsApp API error: #{response.body}"
407
+ nil
408
+ end
409
+ end
410
+
411
+ def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
412
+ media_object = if url?(url_or_id)
413
+ {link: url_or_id}
414
+ else
415
+ {id: url_or_id}
416
+ end
417
+
418
+ # Add caption if provided (stickers don't support captions)
419
+ media_object[:caption] = caption if caption && media_type != :sticker
420
+
421
+ # Add filename for documents
422
+ media_object[:filename] = filename if filename && media_type == :document
423
+
424
+ message = {
425
+ :messaging_product => "whatsapp",
426
+ :to => to,
427
+ :type => media_type.to_s,
428
+ media_type.to_s => media_object
429
+ }
430
+
431
+ send_message_payload(message)
432
+ end
433
+ end
434
+ end
435
+ end
@@ -2,9 +2,13 @@ module FlowChat
2
2
  module Whatsapp
3
3
  class Configuration
4
4
  attr_accessor :access_token, :phone_number_id, :verify_token, :app_id, :app_secret,
5
- :webhook_url, :webhook_verify_token, :business_account_id
5
+ :webhook_url, :webhook_verify_token, :business_account_id, :name
6
6
 
7
- def initialize
7
+ # Class-level storage for named configurations
8
+ @@configurations = {}
9
+
10
+ def initialize(name)
11
+ @name = name
8
12
  @access_token = nil
9
13
  @phone_number_id = nil
10
14
  @verify_token = nil
@@ -13,12 +17,14 @@ module FlowChat
13
17
  @webhook_url = nil
14
18
  @webhook_verify_token = nil
15
19
  @business_account_id = nil
20
+
21
+ register_as(name) if name.present?
16
22
  end
17
23
 
18
24
  # Load configuration from Rails credentials or environment variables
19
25
  def self.from_credentials
20
- config = new
21
-
26
+ config = new(nil)
27
+
22
28
  if defined?(Rails) && Rails.application.credentials.whatsapp
23
29
  credentials = Rails.application.credentials.whatsapp
24
30
  config.access_token = credentials[:access_token]
@@ -30,18 +36,50 @@ module FlowChat
30
36
  config.business_account_id = credentials[:business_account_id]
31
37
  else
32
38
  # Fallback to environment variables
33
- config.access_token = ENV['WHATSAPP_ACCESS_TOKEN']
34
- config.phone_number_id = ENV['WHATSAPP_PHONE_NUMBER_ID']
35
- config.verify_token = ENV['WHATSAPP_VERIFY_TOKEN']
36
- config.app_id = ENV['WHATSAPP_APP_ID']
37
- config.app_secret = ENV['WHATSAPP_APP_SECRET']
38
- config.webhook_url = ENV['WHATSAPP_WEBHOOK_URL']
39
- config.business_account_id = ENV['WHATSAPP_BUSINESS_ACCOUNT_ID']
39
+ config.access_token = ENV["WHATSAPP_ACCESS_TOKEN"]
40
+ config.phone_number_id = ENV["WHATSAPP_PHONE_NUMBER_ID"]
41
+ config.verify_token = ENV["WHATSAPP_VERIFY_TOKEN"]
42
+ config.app_id = ENV["WHATSAPP_APP_ID"]
43
+ config.app_secret = ENV["WHATSAPP_APP_SECRET"]
44
+ config.webhook_url = ENV["WHATSAPP_WEBHOOK_URL"]
45
+ config.business_account_id = ENV["WHATSAPP_BUSINESS_ACCOUNT_ID"]
40
46
  end
41
47
 
42
48
  config
43
49
  end
44
50
 
51
+ # Register a named configuration
52
+ def self.register(name, config)
53
+ @@configurations[name.to_sym] = config
54
+ end
55
+
56
+ # Get a named configuration
57
+ def self.get(name)
58
+ @@configurations[name.to_sym] || raise(ArgumentError, "WhatsApp configuration '#{name}' not found")
59
+ end
60
+
61
+ # Check if a named configuration exists
62
+ def self.exists?(name)
63
+ @@configurations.key?(name.to_sym)
64
+ end
65
+
66
+ # Get all configuration names
67
+ def self.configuration_names
68
+ @@configurations.keys
69
+ end
70
+
71
+ # Clear all registered configurations (useful for testing)
72
+ def self.clear_all!
73
+ @@configurations.clear
74
+ end
75
+
76
+ # Register this configuration with a name
77
+ def register_as(name)
78
+ @name = name.to_sym
79
+ self.class.register(@name, self)
80
+ self
81
+ end
82
+
45
83
  def valid?
46
84
  access_token.present? && phone_number_id.present? && verify_token.present?
47
85
  end
@@ -72,4 +110,4 @@ module FlowChat
72
110
  end
73
111
  end
74
112
  end
75
- end
113
+ end