flow_chat 0.5.1 → 0.6.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.
@@ -0,0 +1,191 @@
1
+ module FlowChat
2
+ module Whatsapp
3
+ class Renderer
4
+ attr_reader :message, :choices, :media
5
+
6
+ def initialize(message, choices: nil, media: nil)
7
+ @message = message
8
+ @choices = choices
9
+ @media = media
10
+ end
11
+
12
+ def render
13
+ if media && choices
14
+ build_selection_message_with_media
15
+ elsif media
16
+ build_media_message
17
+ elsif choices
18
+ build_selection_message
19
+ else
20
+ build_text_message
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def build_text_message
27
+ [:text, message, {}]
28
+ end
29
+
30
+ def build_media_message
31
+ media_type = media[:type] || :image
32
+ url = media[:url] || media[:path]
33
+ filename = media[:filename]
34
+
35
+ case media_type.to_sym
36
+ when :image
37
+ [:media_image, "", {url: url, caption: message}]
38
+ when :document
39
+ [:media_document, "", {url: url, caption: message, filename: filename}]
40
+ when :audio
41
+ [:media_audio, "", {url: url, caption: message}]
42
+ when :video
43
+ [:media_video, "", {url: url, caption: message}]
44
+ when :sticker
45
+ [:media_sticker, "", {url: url}] # Stickers don't support captions
46
+ else
47
+ raise ArgumentError, "Unsupported media type: #{media_type}"
48
+ end
49
+ end
50
+
51
+ def build_selection_message
52
+ # Determine the best way to present choices
53
+ if choices.is_a?(Array)
54
+ # Convert array to hash with index-based keys
55
+ choice_hash = choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
56
+ build_interactive_message(choice_hash)
57
+ elsif choices.is_a?(Hash)
58
+ build_interactive_message(choices)
59
+ else
60
+ raise ArgumentError, "choices must be an Array or Hash"
61
+ end
62
+ end
63
+
64
+ def build_selection_message_with_media
65
+ # Convert array to hash with index-based keys if needed, same as build_selection_message
66
+ if choices.is_a?(Array)
67
+ choice_hash = choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
68
+ build_buttons_message_with_media(choice_hash)
69
+ elsif choices.is_a?(Hash)
70
+ build_buttons_message_with_media(choices)
71
+ else
72
+ raise ArgumentError, "choices must be an Array or Hash"
73
+ end
74
+ end
75
+
76
+ def build_interactive_message(choice_hash)
77
+ if choice_hash.length <= 3
78
+ # Use buttons for 3 or fewer choices
79
+ build_buttons_message(choice_hash)
80
+ else
81
+ # Use list for more than 3 choices
82
+ build_list_message(choice_hash)
83
+ end
84
+ end
85
+
86
+ def build_buttons_message(choices)
87
+ buttons = choices.map do |key, value|
88
+ {
89
+ id: key.to_s,
90
+ title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
91
+ }
92
+ end
93
+
94
+ [:interactive_buttons, message, {buttons: buttons}]
95
+ end
96
+
97
+ def build_buttons_message_with_media(choices)
98
+ buttons = choices.map do |key, value|
99
+ {
100
+ id: key.to_s,
101
+ title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
102
+ }
103
+ end
104
+
105
+ # Build media header
106
+ header = build_media_header
107
+
108
+ [:interactive_buttons, message, {buttons: buttons, header: header}]
109
+ end
110
+
111
+ def build_media_header
112
+ media_type = media[:type] || :image
113
+ url = media[:url] || media[:path]
114
+ filename = media[:filename]
115
+
116
+ case media_type.to_sym
117
+ when :image
118
+ {
119
+ type: "image",
120
+ image: {link: url}
121
+ }
122
+ when :video
123
+ {
124
+ type: "video",
125
+ video: {link: url}
126
+ }
127
+ when :document
128
+ header_doc = {link: url}
129
+ header_doc[:filename] = filename if filename
130
+ {
131
+ type: "document",
132
+ document: header_doc
133
+ }
134
+ when :text
135
+ {
136
+ type: "text",
137
+ text: url # For text headers, url contains the text
138
+ }
139
+ else
140
+ raise ArgumentError, "Unsupported header media type: #{media_type}. Supported types for button headers: image, video, document, text"
141
+ end
142
+ end
143
+
144
+ def build_list_message(choices)
145
+ items = choices.map do |key, value|
146
+ original_text = value.to_s
147
+ truncated_title = truncate_text(original_text, 24)
148
+
149
+ # If title was truncated, put full text in description (up to 72 chars)
150
+ description = if original_text.length > 24
151
+ truncate_text(original_text, 72)
152
+ end
153
+
154
+ {
155
+ id: key.to_s,
156
+ title: truncated_title,
157
+ description: description
158
+ }.compact
159
+ end
160
+
161
+ # If 10 or fewer items, use single section
162
+ sections = if items.length <= 10
163
+ [
164
+ {
165
+ title: "Options",
166
+ rows: items
167
+ }
168
+ ]
169
+ else
170
+ # Paginate into multiple sections (max 10 items per section)
171
+ items.each_slice(10).with_index.map do |section_items, index|
172
+ start_num = (index * 10) + 1
173
+ end_num = start_num + section_items.length - 1
174
+
175
+ {
176
+ title: "#{start_num}-#{end_num}",
177
+ rows: section_items
178
+ }
179
+ end
180
+ end
181
+
182
+ [:interactive_list, message, {sections: sections}]
183
+ end
184
+
185
+ def truncate_text(text, length)
186
+ return text if text.length <= length
187
+ text[0, length - 3] + "..."
188
+ end
189
+ end
190
+ end
191
+ end
data/lib/flow_chat.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require "zeitwerk"
2
2
  require "active_support"
3
3
  require "active_support/core_ext/time"
4
+ require "active_support/core_ext/object/blank"
4
5
 
5
6
  loader = Zeitwerk::Loader.for_gem
6
7
  loader.enable_reloading if defined?(Rails.env) && Rails.env.development?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flow_chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
@@ -96,8 +96,11 @@ files:
96
96
  - README.md
97
97
  - Rakefile
98
98
  - SECURITY.md
99
+ - app/controllers/demo_controller.rb
100
+ - app/flow_chat/demo_restaurant_flow.rb
99
101
  - bin/console
100
102
  - bin/setup
103
+ - config/routes_demo.rb
101
104
  - examples/initializer.rb
102
105
  - examples/media_prompts_examples.rb
103
106
  - examples/multi_tenant_whatsapp_controller.rb
@@ -114,6 +117,7 @@ files:
114
117
  - lib/flow_chat/context.rb
115
118
  - lib/flow_chat/flow.rb
116
119
  - lib/flow_chat/interrupt.rb
120
+ - lib/flow_chat/prompt.rb
117
121
  - lib/flow_chat/session/cache_session_store.rb
118
122
  - lib/flow_chat/session/middleware.rb
119
123
  - lib/flow_chat/session/rails_session_store.rb
@@ -126,7 +130,6 @@ files:
126
130
  - lib/flow_chat/ussd/middleware/pagination.rb
127
131
  - lib/flow_chat/ussd/middleware/resumable_session.rb
128
132
  - lib/flow_chat/ussd/processor.rb
129
- - lib/flow_chat/ussd/prompt.rb
130
133
  - lib/flow_chat/ussd/renderer.rb
131
134
  - lib/flow_chat/version.rb
132
135
  - lib/flow_chat/whatsapp/app.rb
@@ -135,7 +138,7 @@ files:
135
138
  - lib/flow_chat/whatsapp/gateway/cloud_api.rb
136
139
  - lib/flow_chat/whatsapp/middleware/executor.rb
137
140
  - lib/flow_chat/whatsapp/processor.rb
138
- - lib/flow_chat/whatsapp/prompt.rb
141
+ - lib/flow_chat/whatsapp/renderer.rb
139
142
  - lib/flow_chat/whatsapp/send_job_support.rb
140
143
  - lib/flow_chat/whatsapp/template_manager.rb
141
144
  homepage: https://github.com/radioactive-labs/flow_chat
@@ -1,102 +0,0 @@
1
- module FlowChat
2
- module Ussd
3
- class Prompt
4
- attr_reader :user_input
5
-
6
- def initialize(input)
7
- @user_input = input
8
- end
9
-
10
- def ask(msg, choices: nil, convert: nil, validate: nil, transform: nil, media: nil)
11
- if user_input.present?
12
- input = user_input
13
- input = convert.call(input) if convert.present?
14
- validation_error = validate.call(input) if validate.present?
15
-
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
21
-
22
- input = transform.call(input) if transform.present?
23
- return input
24
- end
25
-
26
- # Include media URL in the message for USSD
27
- final_message = build_message_with_media(msg, media)
28
- prompt! final_message, choices:
29
- end
30
-
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
35
- end
36
-
37
- def select(msg, choices)
38
- choices, choices_prompt = build_select_choices choices
39
- ask(
40
- msg,
41
- choices: choices_prompt,
42
- convert: lambda { |choice| choice.to_i },
43
- validate: lambda { |choice| "Invalid selection:" unless (1..choices.size).cover?(choice) },
44
- transform: lambda { |choice| choices[choice - 1] }
45
- )
46
- end
47
-
48
- def yes?(msg)
49
- select(msg, ["Yes", "No"]) == "Yes"
50
- end
51
-
52
- private
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
-
80
- def build_select_choices(choices)
81
- case choices
82
- when Array
83
- choices_prompt = choices.map.with_index { |c, i| [i + 1, c] }.to_h
84
- when Hash
85
- choices_prompt = choices.values.map.with_index { |c, i| [i + 1, c] }.to_h
86
- choices = choices.keys
87
- else
88
- raise ArgumentError, "choices must be an array or hash"
89
- end
90
- [choices, choices_prompt]
91
- end
92
-
93
- def prompt!(msg, choices:)
94
- raise FlowChat::Interrupt::Prompt.new(msg, choices:)
95
- end
96
-
97
- def terminate!(msg)
98
- raise FlowChat::Interrupt::Terminate.new(msg)
99
- end
100
- end
101
- end
102
- end
@@ -1,247 +0,0 @@
1
- module FlowChat
2
- module Whatsapp
3
- class Prompt
4
- attr_reader :input
5
-
6
- def initialize(input)
7
- @input = input
8
- end
9
-
10
- def ask(message, transform: nil, validate: nil, convert: nil, media: nil)
11
- if input.present?
12
- begin
13
- processed_input = process_input(input, transform, validate, convert)
14
- return processed_input unless processed_input.nil?
15
- rescue FlowChat::Interrupt::Prompt => validation_error
16
- # If validation failed, include the error message with the original prompt
17
- error_message = validation_error.prompt[1]
18
- combined_message = "#{message}\n\n#{error_message}"
19
- raise FlowChat::Interrupt::Prompt.new([:text, combined_message, {}])
20
- end
21
- end
22
-
23
- # Send message and wait for response, optionally with media
24
- if media
25
- raise FlowChat::Interrupt::Prompt.new(build_media_prompt(message, media))
26
- else
27
- raise FlowChat::Interrupt::Prompt.new([:text, message, {}])
28
- end
29
- end
30
-
31
- def say(message, media: nil)
32
- if media
33
- raise FlowChat::Interrupt::Terminate.new(build_media_prompt(message, media))
34
- else
35
- raise FlowChat::Interrupt::Terminate.new([:text, message, {}])
36
- end
37
- end
38
-
39
- def select(message, choices, transform: nil, validate: nil, convert: nil)
40
- if input.present?
41
- processed_input = process_selection(input, choices, transform, validate, convert)
42
- return processed_input unless processed_input.nil?
43
- end
44
-
45
- # Validate choices
46
- validate_choices(choices)
47
-
48
- # Standard selection without media support
49
- interactive_prompt = build_selection_prompt(message, choices)
50
- raise FlowChat::Interrupt::Prompt.new(interactive_prompt)
51
- end
52
-
53
- def yes?(message, transform: nil, validate: nil, convert: nil)
54
- if input.present?
55
- processed_input = process_boolean(input, transform, validate, convert)
56
- return processed_input unless processed_input.nil?
57
- end
58
-
59
- buttons = [
60
- {id: "yes", title: "Yes"},
61
- {id: "no", title: "No"}
62
- ]
63
- raise FlowChat::Interrupt::Prompt.new([:interactive_buttons, message, {buttons: buttons}])
64
- end
65
-
66
- private
67
-
68
- def build_media_prompt(message, media)
69
- media_type = media[:type] || :image
70
- url = media[:url] || media[:path]
71
- filename = media[:filename]
72
-
73
- case media_type.to_sym
74
- when :image
75
- [:media_image, "", {url: url, caption: message}]
76
- when :document
77
- [:media_document, "", {url: url, caption: message, filename: filename}]
78
- when :audio
79
- [:media_audio, "", {url: url, caption: message}]
80
- when :video
81
- [:media_video, "", {url: url, caption: message}]
82
- when :sticker
83
- [:media_sticker, "", {url: url}] # Stickers don't support captions
84
- else
85
- raise ArgumentError, "Unsupported media type: #{media_type}"
86
- end
87
- end
88
-
89
- def build_selection_prompt(message, choices)
90
- # Determine the best way to present choices
91
- if choices.is_a?(Array)
92
- # Convert array to hash with index-based keys
93
- choice_hash = choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
94
- build_list_prompt(message, choice_hash)
95
- elsif choices.is_a?(Hash)
96
- if choices.length <= 3
97
- # Use buttons for 3 or fewer choices
98
- build_buttons_prompt(message, choices)
99
- else
100
- # Use list for more than 3 choices
101
- build_list_prompt(message, choices)
102
- end
103
- else
104
- raise ArgumentError, "choices must be an Array or Hash"
105
- end
106
- end
107
-
108
- def build_buttons_prompt(message, choices)
109
- buttons = choices.map do |key, value|
110
- {
111
- id: key.to_s,
112
- title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
113
- }
114
- end
115
-
116
- [:interactive_buttons, message, {buttons: buttons}]
117
- end
118
-
119
- def build_list_prompt(message, choices)
120
- items = choices.map do |key, value|
121
- original_text = value.to_s
122
- truncated_title = truncate_text(original_text, 24)
123
-
124
- # If title was truncated, put full text in description (up to 72 chars)
125
- description = if original_text.length > 24
126
- truncate_text(original_text, 72)
127
- end
128
-
129
- {
130
- id: key.to_s,
131
- title: truncated_title,
132
- description: description
133
- }.compact
134
- end
135
-
136
- # If 10 or fewer items, use single section
137
- sections = if items.length <= 10
138
- [
139
- {
140
- title: "Options",
141
- rows: items
142
- }
143
- ]
144
- else
145
- # Paginate into multiple sections (max 10 items per section)
146
- items.each_slice(10).with_index.map do |section_items, index|
147
- start_num = (index * 10) + 1
148
- end_num = start_num + section_items.length - 1
149
-
150
- {
151
- title: "#{start_num}-#{end_num}",
152
- rows: section_items
153
- }
154
- end
155
- end
156
-
157
- [:interactive_list, message, {sections: sections}]
158
- end
159
-
160
- def process_input(input, transform, validate, convert)
161
- # Apply transformation
162
- transformed_input = transform ? transform.call(input) : input
163
-
164
- # Apply conversion first, then validation
165
- converted_input = convert ? convert.call(transformed_input) : transformed_input
166
-
167
- # Apply validation on converted value
168
- if validate
169
- error_message = validate.call(converted_input)
170
- if error_message
171
- raise FlowChat::Interrupt::Prompt.new([:text, error_message, {}])
172
- end
173
- end
174
-
175
- converted_input
176
- end
177
-
178
- def process_selection(input, choices, transform, validate, convert)
179
- choice_hash = choices.is_a?(Array) ?
180
- choices.each_with_index.to_h { |choice, index| [index.to_s, choice] } :
181
- choices
182
-
183
- # Check if input matches a valid choice
184
- if choice_hash.key?(input)
185
- selected_value = choice_hash[input]
186
- process_input(selected_value, transform, validate, convert)
187
- elsif choice_hash.value?(input)
188
- # Input matches a choice value directly
189
- process_input(input, transform, validate, convert)
190
- else
191
- # Invalid choice
192
- choice_list = choice_hash.map { |key, value| "#{key}: #{value}" }.join("\n")
193
- error_message = "Invalid choice. Please select one of:\n#{choice_list}"
194
- raise FlowChat::Interrupt::Prompt.new([:text, error_message, {}])
195
- end
196
- end
197
-
198
- def process_boolean(input, transform, validate, convert)
199
- boolean_value = case input.to_s.downcase
200
- when "yes", "y", "1", "true"
201
- true
202
- when "no", "n", "0", "false"
203
- false
204
- end
205
-
206
- if boolean_value.nil?
207
- raise FlowChat::Interrupt::Prompt.new([:text, "Please answer with Yes or No.", {}])
208
- end
209
-
210
- process_input(boolean_value, transform, validate, convert)
211
- end
212
-
213
- def validate_choices(choices)
214
- # Check for empty choices
215
- if choices.nil? || choices.empty?
216
- raise ArgumentError, "choices cannot be empty"
217
- end
218
-
219
- choice_count = choices.length
220
-
221
- # WhatsApp supports max 100 total items across all sections
222
- if choice_count > 100
223
- raise ArgumentError, "WhatsApp supports maximum 100 choice options, got #{choice_count}"
224
- end
225
-
226
- # Validate individual choice values
227
- choices_to_validate = choices.is_a?(Array) ? choices : choices.values
228
-
229
- choices_to_validate.each_with_index do |choice, index|
230
- if choice.nil? || choice.to_s.strip.empty?
231
- raise ArgumentError, "choice at index #{index} cannot be empty"
232
- end
233
-
234
- choice_text = choice.to_s
235
- if choice_text.length > 100
236
- raise ArgumentError, "choice '#{choice_text[0..20]}...' is too long (#{choice_text.length} chars). Maximum is 100 characters"
237
- end
238
- end
239
- end
240
-
241
- def truncate_text(text, length)
242
- return text if text.length <= length
243
- text[0, length - 3] + "..."
244
- end
245
- end
246
- end
247
- end