flow_chat 0.3.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -0
  3. data/README.md +642 -86
  4. data/examples/initializer.rb +31 -0
  5. data/examples/media_prompts_examples.rb +28 -0
  6. data/examples/multi_tenant_whatsapp_controller.rb +244 -0
  7. data/examples/ussd_controller.rb +264 -0
  8. data/examples/whatsapp_controller.rb +140 -0
  9. data/examples/whatsapp_media_examples.rb +406 -0
  10. data/examples/whatsapp_message_job.rb +111 -0
  11. data/lib/flow_chat/base_processor.rb +67 -0
  12. data/lib/flow_chat/config.rb +36 -0
  13. data/lib/flow_chat/session/cache_session_store.rb +84 -0
  14. data/lib/flow_chat/session/middleware.rb +14 -6
  15. data/lib/flow_chat/simulator/controller.rb +78 -0
  16. data/lib/flow_chat/simulator/views/simulator.html.erb +1707 -0
  17. data/lib/flow_chat/ussd/app.rb +25 -0
  18. data/lib/flow_chat/ussd/gateway/nalo.rb +2 -0
  19. data/lib/flow_chat/ussd/gateway/nsano.rb +6 -0
  20. data/lib/flow_chat/ussd/middleware/resumable_session.rb +1 -1
  21. data/lib/flow_chat/ussd/processor.rb +14 -42
  22. data/lib/flow_chat/ussd/prompt.rb +39 -5
  23. data/lib/flow_chat/version.rb +1 -1
  24. data/lib/flow_chat/whatsapp/app.rb +64 -0
  25. data/lib/flow_chat/whatsapp/client.rb +439 -0
  26. data/lib/flow_chat/whatsapp/configuration.rb +113 -0
  27. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +213 -0
  28. data/lib/flow_chat/whatsapp/middleware/executor.rb +30 -0
  29. data/lib/flow_chat/whatsapp/processor.rb +26 -0
  30. data/lib/flow_chat/whatsapp/prompt.rb +251 -0
  31. data/lib/flow_chat/whatsapp/send_job_support.rb +79 -0
  32. data/lib/flow_chat/whatsapp/template_manager.rb +162 -0
  33. data/lib/flow_chat.rb +1 -0
  34. metadata +21 -3
  35. data/lib/flow_chat/ussd/simulator/controller.rb +0 -51
  36. data/lib/flow_chat/ussd/simulator/views/simulator.html.erb +0 -239
@@ -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
@@ -0,0 +1,113 @@
1
+ module FlowChat
2
+ module Whatsapp
3
+ class Configuration
4
+ attr_accessor :access_token, :phone_number_id, :verify_token, :app_id, :app_secret,
5
+ :webhook_url, :webhook_verify_token, :business_account_id, :name
6
+
7
+ # Class-level storage for named configurations
8
+ @@configurations = {}
9
+
10
+ def initialize(name)
11
+ @name = name
12
+ @access_token = nil
13
+ @phone_number_id = nil
14
+ @verify_token = nil
15
+ @app_id = nil
16
+ @app_secret = nil
17
+ @webhook_url = nil
18
+ @webhook_verify_token = nil
19
+ @business_account_id = nil
20
+
21
+ register_as(name) if name.present?
22
+ end
23
+
24
+ # Load configuration from Rails credentials or environment variables
25
+ def self.from_credentials
26
+ config = new(nil)
27
+
28
+ if defined?(Rails) && Rails.application.credentials.whatsapp
29
+ credentials = Rails.application.credentials.whatsapp
30
+ config.access_token = credentials[:access_token]
31
+ config.phone_number_id = credentials[:phone_number_id]
32
+ config.verify_token = credentials[:verify_token]
33
+ config.app_id = credentials[:app_id]
34
+ config.app_secret = credentials[:app_secret]
35
+ config.webhook_url = credentials[:webhook_url]
36
+ config.business_account_id = credentials[:business_account_id]
37
+ else
38
+ # Fallback to environment variables
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']
46
+ end
47
+
48
+ config
49
+ end
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
+
83
+ def valid?
84
+ access_token.present? && phone_number_id.present? && verify_token.present?
85
+ end
86
+
87
+ def webhook_configured?
88
+ webhook_url.present? && verify_token.present?
89
+ end
90
+
91
+ # API endpoints
92
+ def messages_url
93
+ "https://graph.facebook.com/v18.0/#{phone_number_id}/messages"
94
+ end
95
+
96
+ def media_url(media_id)
97
+ "https://graph.facebook.com/v18.0/#{media_id}"
98
+ end
99
+
100
+ def phone_numbers_url
101
+ "https://graph.facebook.com/v18.0/#{business_account_id}/phone_numbers"
102
+ end
103
+
104
+ # Headers for API requests
105
+ def api_headers
106
+ {
107
+ "Authorization" => "Bearer #{access_token}",
108
+ "Content-Type" => "application/json"
109
+ }
110
+ end
111
+ end
112
+ end
113
+ end