flow_chat 0.4.0 → 0.4.1

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.
@@ -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
@@ -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.1"
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
@@ -0,0 +1,439 @@
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
+ if data['id']
175
+ data['id']
176
+ else
177
+ raise StandardError, "Failed to upload media: #{data}"
178
+ end
179
+ else
180
+ Rails.logger.error "WhatsApp Media Upload error: #{response.body}"
181
+ raise StandardError, "Media upload failed: #{response.body}"
182
+ end
183
+ ensure
184
+ file&.close if file_path_or_io.is_a?(String)
185
+ end
186
+
187
+ # Build message payload for WhatsApp API
188
+ # This method is exposed so the gateway can use it for simulator mode
189
+ def build_message_payload(response, to)
190
+ type, content, options = response
191
+
192
+ case type
193
+ when :text
194
+ {
195
+ messaging_product: "whatsapp",
196
+ to: to,
197
+ type: "text",
198
+ text: { body: content }
199
+ }
200
+ when :interactive_buttons
201
+ {
202
+ messaging_product: "whatsapp",
203
+ to: to,
204
+ type: "interactive",
205
+ interactive: {
206
+ type: "button",
207
+ body: { text: content },
208
+ action: {
209
+ buttons: options[:buttons].map.with_index do |button, index|
210
+ {
211
+ type: "reply",
212
+ reply: {
213
+ id: button[:id] || index.to_s,
214
+ title: button[:title]
215
+ }
216
+ }
217
+ end
218
+ }
219
+ }
220
+ }
221
+ when :interactive_list
222
+ {
223
+ messaging_product: "whatsapp",
224
+ to: to,
225
+ type: "interactive",
226
+ interactive: {
227
+ type: "list",
228
+ body: { text: content },
229
+ action: {
230
+ button: options[:button_text] || "Choose",
231
+ sections: options[:sections]
232
+ }
233
+ }
234
+ }
235
+ when :template
236
+ {
237
+ messaging_product: "whatsapp",
238
+ to: to,
239
+ type: "template",
240
+ template: {
241
+ name: options[:template_name],
242
+ language: { code: options[:language] || "en_US" },
243
+ components: options[:components] || []
244
+ }
245
+ }
246
+ when :media_image
247
+ {
248
+ messaging_product: "whatsapp",
249
+ to: to,
250
+ type: "image",
251
+ image: build_media_object(options)
252
+ }
253
+ when :media_document
254
+ {
255
+ messaging_product: "whatsapp",
256
+ to: to,
257
+ type: "document",
258
+ document: build_media_object(options)
259
+ }
260
+ when :media_audio
261
+ {
262
+ messaging_product: "whatsapp",
263
+ to: to,
264
+ type: "audio",
265
+ audio: build_media_object(options)
266
+ }
267
+ when :media_video
268
+ {
269
+ messaging_product: "whatsapp",
270
+ to: to,
271
+ type: "video",
272
+ video: build_media_object(options)
273
+ }
274
+ when :media_sticker
275
+ {
276
+ messaging_product: "whatsapp",
277
+ to: to,
278
+ type: "sticker",
279
+ sticker: build_media_object(options)
280
+ }
281
+ else
282
+ # Default to text message
283
+ {
284
+ messaging_product: "whatsapp",
285
+ to: to,
286
+ type: "text",
287
+ text: { body: content.to_s }
288
+ }
289
+ end
290
+ end
291
+
292
+ # Get media URL from media ID
293
+ # @param media_id [String] Media ID from WhatsApp
294
+ # @return [String] Media URL or nil on error
295
+ def get_media_url(media_id)
296
+ uri = URI("#{WHATSAPP_API_URL}/#{media_id}")
297
+ http = Net::HTTP.new(uri.host, uri.port)
298
+ http.use_ssl = true
299
+
300
+ request = Net::HTTP::Get.new(uri)
301
+ request["Authorization"] = "Bearer #{@config.access_token}"
302
+
303
+ response = http.request(request)
304
+
305
+ if response.is_a?(Net::HTTPSuccess)
306
+ data = JSON.parse(response.body)
307
+ data["url"]
308
+ else
309
+ Rails.logger.error "WhatsApp Media API error: #{response.body}"
310
+ nil
311
+ end
312
+ end
313
+
314
+ # Download media content
315
+ # @param media_id [String] Media ID from WhatsApp
316
+ # @return [String] Media content or nil on error
317
+ def download_media(media_id)
318
+ media_url = get_media_url(media_id)
319
+ return nil unless media_url
320
+
321
+ uri = URI(media_url)
322
+ http = Net::HTTP.new(uri.host, uri.port)
323
+ http.use_ssl = true
324
+
325
+ request = Net::HTTP::Get.new(uri)
326
+ request["Authorization"] = "Bearer #{@config.access_token}"
327
+
328
+ response = http.request(request)
329
+
330
+ if response.is_a?(Net::HTTPSuccess)
331
+ response.body
332
+ else
333
+ Rails.logger.error "WhatsApp Media download error: #{response.body}"
334
+ nil
335
+ end
336
+ end
337
+
338
+ # Get MIME type from URL without downloading (HEAD request)
339
+ def get_media_mime_type(url)
340
+ require 'net/http'
341
+
342
+ uri = URI(url)
343
+ http = Net::HTTP.new(uri.host, uri.port)
344
+ http.use_ssl = (uri.scheme == 'https')
345
+
346
+ # Use HEAD request to get headers without downloading content
347
+ response = http.head(uri.path)
348
+ response['content-type']
349
+ rescue => e
350
+ Rails.logger.warn "Could not detect MIME type for #{url}: #{e.message}"
351
+ nil
352
+ end
353
+
354
+ private
355
+
356
+ # Build media object for WhatsApp API, handling both URLs and media IDs
357
+ # @param options [Hash] Options containing url/id, caption, filename
358
+ # @return [Hash] Media object for WhatsApp API
359
+ def build_media_object(options)
360
+ media_obj = {}
361
+
362
+ # Handle URL or ID
363
+ if options[:url]
364
+ # Use URL directly
365
+ media_obj[:link] = options[:url]
366
+ elsif options[:id]
367
+ # Use provided media ID directly
368
+ media_obj[:id] = options[:id]
369
+ end
370
+
371
+ # Add optional fields
372
+ media_obj[:caption] = options[:caption] if options[:caption]
373
+ media_obj[:filename] = options[:filename] if options[:filename]
374
+
375
+ media_obj
376
+ end
377
+
378
+ # Check if input is a URL or file path/media ID
379
+ def url?(input)
380
+ input.to_s.start_with?('http://', 'https://')
381
+ end
382
+
383
+ # Extract filename from URL
384
+ def extract_filename_from_url(url)
385
+ uri = URI(url)
386
+ filename = File.basename(uri.path)
387
+ filename.empty? ? "document" : filename
388
+ rescue
389
+ "document"
390
+ end
391
+
392
+ # Send message payload to WhatsApp API
393
+ # @param message_data [Hash] Message payload
394
+ # @return [Hash] API response or nil on error
395
+ def send_message_payload(message_data)
396
+ uri = URI("#{WHATSAPP_API_URL}/#{@config.phone_number_id}/messages")
397
+ http = Net::HTTP.new(uri.host, uri.port)
398
+ http.use_ssl = true
399
+
400
+ request = Net::HTTP::Post.new(uri)
401
+ request["Authorization"] = "Bearer #{@config.access_token}"
402
+ request["Content-Type"] = "application/json"
403
+ request.body = message_data.to_json
404
+
405
+ response = http.request(request)
406
+
407
+ if response.is_a?(Net::HTTPSuccess)
408
+ JSON.parse(response.body)
409
+ else
410
+ Rails.logger.error "WhatsApp API error: #{response.body}"
411
+ nil
412
+ end
413
+ end
414
+
415
+ def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
416
+ media_object = if url?(url_or_id)
417
+ { link: url_or_id }
418
+ else
419
+ { id: url_or_id }
420
+ end
421
+
422
+ # Add caption if provided (stickers don't support captions)
423
+ media_object[:caption] = caption if caption && media_type != :sticker
424
+
425
+ # Add filename for documents
426
+ media_object[:filename] = filename if filename && media_type == :document
427
+
428
+ message = {
429
+ messaging_product: "whatsapp",
430
+ to: to,
431
+ type: media_type.to_s,
432
+ media_type.to_s => media_object
433
+ }
434
+
435
+ send_message_payload(message)
436
+ end
437
+ end
438
+ end
439
+ 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,11 +17,13 @@ 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
26
+ config = new(nil)
21
27
 
22
28
  if defined?(Rails) && Rails.application.credentials.whatsapp
23
29
  credentials = Rails.application.credentials.whatsapp
@@ -42,6 +48,38 @@ module FlowChat
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