flow_chat 0.5.2 → 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,59 @@
1
+ # Demo Routes Configuration for FlowChat Comprehensive Demo
2
+ #
3
+ # Add these routes to your config/routes.rb file to enable the demo endpoints
4
+ #
5
+ # Example integration:
6
+ # Rails.application.routes.draw do
7
+ # # Your existing routes...
8
+ #
9
+ # # FlowChat Demo Routes
10
+ # scope :demo do
11
+ # post 'ussd' => 'demo#ussd_demo'
12
+ # match 'whatsapp' => 'demo#whatsapp_demo', via: [:get, :post]
13
+ # match 'whatsapp_custom' => 'demo#whatsapp_custom_demo', via: [:get, :post]
14
+ # match 'whatsapp_background' => 'demo#whatsapp_background_demo', via: [:get, :post]
15
+ # match 'whatsapp_simulator' => 'demo#whatsapp_simulator_demo', via: [:get, :post]
16
+ # end
17
+ # end
18
+
19
+ Rails.application.routes.draw do
20
+ # FlowChat Comprehensive Demo Routes
21
+ scope :demo do
22
+ # USSD Demo
23
+ # Endpoint: POST /demo/ussd
24
+ # Purpose: Demonstrates USSD integration with all features
25
+ # Features: Pagination, session management, complex workflows
26
+ post 'ussd' => 'demo#ussd_demo'
27
+
28
+ # WhatsApp Demo (Standard)
29
+ # Endpoint: GET/POST /demo/whatsapp
30
+ # Purpose: Standard WhatsApp integration with media support
31
+ # Features: Rich media, interactive elements, buttons/lists
32
+ match 'whatsapp' => 'demo#whatsapp_demo', via: [:get, :post]
33
+
34
+ # WhatsApp Demo (Custom Configuration)
35
+ # Endpoint: GET/POST /demo/whatsapp_custom
36
+ # Purpose: Shows multi-tenant configuration capabilities
37
+ # Features: Custom credentials, per-endpoint configuration
38
+ match 'whatsapp_custom' => 'demo#whatsapp_custom_demo', via: [:get, :post]
39
+
40
+ # WhatsApp Demo (Background Processing)
41
+ # Endpoint: GET/POST /demo/whatsapp_background
42
+ # Purpose: Demonstrates background job integration
43
+ # Features: Asynchronous response delivery, job queuing
44
+ match 'whatsapp_background' => 'demo#whatsapp_background_demo', via: [:get, :post]
45
+
46
+ # WhatsApp Demo (Simulator Mode)
47
+ # Endpoint: GET/POST /demo/whatsapp_simulator
48
+ # Purpose: Testing mode that returns response data as JSON
49
+ # Features: No actual WhatsApp API calls, perfect for testing
50
+ match 'whatsapp_simulator' => 'demo#whatsapp_simulator_demo', via: [:get, :post]
51
+ end
52
+
53
+ # Optional: Simulator interface for testing
54
+ # Uncomment if you want to add the simulator UI
55
+ # get '/simulator' => 'simulator#index'
56
+
57
+ # Optional: API documentation endpoint
58
+ # get '/demo' => 'demo#index'
59
+ end
@@ -4,6 +4,9 @@ module FlowChat
4
4
  mattr_accessor :logger, default: Logger.new($stdout)
5
5
  mattr_accessor :cache, default: nil
6
6
  mattr_accessor :simulator_secret, default: nil
7
+ # When true (default), validation errors are combined with the original message.
8
+ # When false, only the validation error message is shown to the user.
9
+ mattr_accessor :combine_validation_error_with_message, default: true
7
10
 
8
11
  # USSD-specific configuration object
9
12
  def self.ussd
@@ -2,11 +2,12 @@ module FlowChat
2
2
  module Interrupt
3
3
  # standard:disable Lint/InheritException
4
4
  class Base < Exception
5
- attr_reader :prompt
5
+ attr_reader :prompt, :media
6
6
 
7
- def initialize(prompt)
7
+ def initialize(prompt, media: nil)
8
8
  @prompt = prompt
9
- super
9
+ @media = media
10
+ super(prompt)
10
11
  end
11
12
  end
12
13
  # standard:enable Lint/InheritException
@@ -14,9 +15,9 @@ module FlowChat
14
15
  class Prompt < Base
15
16
  attr_reader :choices
16
17
 
17
- def initialize(*args, choices: nil)
18
+ def initialize(prompt, choices: nil, media: nil)
18
19
  @choices = choices
19
- super(*args)
20
+ super(prompt, media: media)
20
21
  end
21
22
  end
22
23
 
@@ -0,0 +1,91 @@
1
+ module FlowChat
2
+ class Prompt
3
+ attr_reader :user_input
4
+
5
+ def initialize(input)
6
+ @user_input = input
7
+ end
8
+
9
+ def ask(msg, choices: nil, convert: nil, validate: nil, transform: nil, media: nil)
10
+ # Validate media and choices compatibility
11
+ validate_media_choices_compatibility(media, choices)
12
+
13
+ if user_input.present?
14
+ input = user_input
15
+ input = convert.call(input) if convert.present?
16
+ validation_error = validate.call(input) if validate.present?
17
+
18
+ if validation_error.present?
19
+ # Use config to determine whether to combine validation error with original message
20
+ message = if FlowChat::Config.combine_validation_error_with_message
21
+ [validation_error, msg].join("\n\n")
22
+ else
23
+ validation_error
24
+ end
25
+ prompt!(message, choices: choices, media: media)
26
+ end
27
+
28
+ input = transform.call(input) if transform.present?
29
+ return input
30
+ end
31
+
32
+ # Pass raw message and media separately to the renderer
33
+ prompt! msg, choices: choices, media: media
34
+ end
35
+
36
+ def say(message, media: nil)
37
+ # Pass raw message and media separately to the renderer
38
+ terminate! message, media: media
39
+ end
40
+
41
+ def select(msg, choices, media: nil)
42
+ # Validate media and choices compatibility
43
+ validate_media_choices_compatibility(media, choices)
44
+
45
+ choices, choices_prompt = build_select_choices choices
46
+ ask(
47
+ msg,
48
+ choices: choices_prompt,
49
+ convert: lambda { |choice| choice.to_i },
50
+ validate: lambda { |choice| "Invalid selection:" unless (1..choices.size).cover?(choice) },
51
+ transform: lambda { |choice| choices[choice - 1] },
52
+ media: media
53
+ )
54
+ end
55
+
56
+ def yes?(msg)
57
+ select(msg, ["Yes", "No"]) == "Yes"
58
+ end
59
+
60
+ private
61
+
62
+ def validate_media_choices_compatibility(media, choices)
63
+ return unless media && choices
64
+
65
+ if choices.length > 3
66
+ raise ArgumentError, "Media with more than 3 choices is not supported. Please use either media OR choices for more than 3 options."
67
+ end
68
+ end
69
+
70
+ def build_select_choices(choices)
71
+ case choices
72
+ when Array
73
+ choices_prompt = choices.map.with_index { |c, i| [i + 1, c] }.to_h
74
+ when Hash
75
+ choices_prompt = choices.values.map.with_index { |c, i| [i + 1, c] }.to_h
76
+ choices = choices.keys
77
+ else
78
+ raise ArgumentError, "choices must be an array or hash"
79
+ end
80
+ [choices, choices_prompt]
81
+ end
82
+
83
+ def prompt!(msg, choices: nil, media: nil)
84
+ raise FlowChat::Interrupt::Prompt.new(msg, choices: choices, media: media)
85
+ end
86
+
87
+ def terminate!(msg, media: nil)
88
+ raise FlowChat::Interrupt::Terminate.new(msg, media: media)
89
+ end
90
+ end
91
+ end
@@ -395,8 +395,6 @@
395
395
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
396
396
  font-size: 14px;
397
397
  line-height: 1.4;
398
- max-height: 300px;
399
- overflow-y: auto;
400
398
  overflow-x: hidden;
401
399
  }
402
400
 
@@ -17,7 +17,7 @@ module FlowChat
17
17
  navigation_stack << key
18
18
  return session.get(key) if session.get(key).present?
19
19
 
20
- prompt = FlowChat::Ussd::Prompt.new input
20
+ prompt = FlowChat::Prompt.new input
21
21
  @input = nil # input is being submitted to prompt so we clear it
22
22
 
23
23
  value = yield prompt
@@ -25,7 +25,7 @@ module FlowChat
25
25
  value
26
26
  end
27
27
 
28
- def say(msg)
28
+ def say(msg, media: nil)
29
29
  raise FlowChat::Interrupt::Terminate.new(msg)
30
30
  end
31
31
 
@@ -20,20 +20,20 @@ module FlowChat
20
20
  # context["request.type"] = params["MSGTYPE"] ? :initial : :response
21
21
  context.input = params["USERDATA"].presence
22
22
 
23
- type, prompt, choices = @app.call(context)
23
+ type, prompt, choices, media = @app.call(context)
24
24
 
25
25
  context.controller.render json: {
26
26
  USERID: params["USERID"],
27
27
  MSISDN: params["MSISDN"],
28
- MSG: render_prompt(prompt, choices),
28
+ MSG: render_prompt(prompt, choices, media),
29
29
  MSGTYPE: type == :prompt
30
30
  }
31
31
  end
32
32
 
33
33
  private
34
34
 
35
- def render_prompt(prompt, choices)
36
- FlowChat::Ussd::Renderer.new(prompt, choices).render
35
+ def render_prompt(prompt, choices, media)
36
+ FlowChat::Ussd::Renderer.new(prompt, choices: choices, media: media).render
37
37
  end
38
38
  end
39
39
  end
@@ -11,10 +11,10 @@ module FlowChat
11
11
  flow = context.flow.new ussd_app
12
12
  flow.send context["flow.action"]
13
13
  rescue FlowChat::Interrupt::Prompt => e
14
- [:prompt, e.prompt, e.choices]
14
+ [:prompt, e.prompt, e.choices, e.media]
15
15
  rescue FlowChat::Interrupt::Terminate => e
16
16
  context.session.destroy
17
- [:terminate, e.prompt, nil]
17
+ [:terminate, e.prompt, nil, e.media]
18
18
  end
19
19
 
20
20
  private
@@ -12,14 +12,16 @@ module FlowChat
12
12
 
13
13
  if intercept?
14
14
  type, prompt = handle_intercepted_request
15
+ [type, prompt, []]
15
16
  else
16
17
  @session.delete "ussd.pagination"
17
- type, prompt, choices = @app.call(context)
18
+ type, prompt, choices, media = @app.call(context)
18
19
 
19
- prompt = FlowChat::Ussd::Renderer.new(prompt, choices).render
20
+ prompt = FlowChat::Ussd::Renderer.new(prompt, choices: choices, media: media).render
20
21
  type, prompt = maybe_paginate(type, prompt) if prompt.present?
22
+
23
+ [type, prompt, []]
21
24
  end
22
- [type, prompt, []]
23
25
  end
24
26
 
25
27
  private
@@ -34,7 +36,7 @@ module FlowChat
34
36
  FlowChat::Config.logger&.info "FlowChat::Middleware::Pagination :: Intercepted to handle pagination"
35
37
  start, finish, has_more = calculate_offsets
36
38
  type = (pagination_state["type"].to_sym == :terminal && !has_more) ? :terminal : :prompt
37
- prompt = pagination_state["prompt"][start..finish].strip + build_pagination_options(type, has_more)
39
+ prompt = pagination_state["prompt"][start..finish] + build_pagination_options(type, has_more)
38
40
  set_pagination_state(current_page, start, finish)
39
41
 
40
42
  [type, prompt]
@@ -44,11 +46,11 @@ module FlowChat
44
46
  if prompt.length > FlowChat::Config.ussd.pagination_page_size
45
47
  original_prompt = prompt
46
48
  FlowChat::Config.logger&.info "FlowChat::Middleware::Pagination :: Response length (#{prompt.length}) exceeds page size (#{FlowChat::Config.ussd.pagination_page_size}). Paginating."
47
- prompt = prompt[0..single_option_slice_size]
49
+ slice_end = single_option_slice_size
48
50
  # Ensure we do not cut words and options off in the middle.
49
- current_pagebreak = prompt[single_option_slice_size + 1].blank? ? single_option_slice_size : prompt.rindex("\n") || prompt.rindex(" ") || single_option_slice_size
51
+ 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
50
52
  set_pagination_state(1, 0, current_pagebreak, original_prompt, type)
51
- prompt = prompt[0..current_pagebreak].strip + "\n\n" + next_option
53
+ prompt = original_prompt[0..current_pagebreak] + "\n\n" + next_option
52
54
  type = :prompt
53
55
  end
54
56
  [type, prompt]
@@ -72,13 +74,27 @@ module FlowChat
72
74
  finish = start + len
73
75
  if start > pagination_state["prompt"].length
74
76
  FlowChat::Config.logger&.debug "FlowChat::Middleware::Pagination :: No content exists for page: #{page}. Reverting to page: #{page - 1}"
77
+ page -= 1
75
78
  has_more = false
76
79
  start = previous_offset["start"]
77
80
  finish = previous_offset["finish"]
78
81
  else
79
- prompt = pagination_state["prompt"][start..finish]
80
- current_pagebreak = pagination_state["prompt"][finish + 1].blank? ? len : prompt.rindex("\n") || prompt.rindex(" ") || len
81
- finish = start + current_pagebreak
82
+ # Apply word boundary logic for the new page
83
+ full_prompt = pagination_state["prompt"]
84
+ if finish < full_prompt.length
85
+ # Look for word boundary within the slice
86
+ slice_text = full_prompt[start..finish]
87
+ # Check if the character after our slice point is a word boundary
88
+ next_char = full_prompt[finish + 1]
89
+ if next_char && !next_char.match(/\s/)
90
+ # We're in the middle of a word, find the last word boundary
91
+ boundary_pos = slice_text.rindex("\n") || slice_text.rindex(" ")
92
+ if boundary_pos
93
+ finish = start + boundary_pos
94
+ end
95
+ # If no boundary found, we'll have to break mid-word (fallback)
96
+ end
97
+ end
82
98
  end
83
99
  end
84
100
  [start, finish, has_more]
@@ -134,21 +150,22 @@ module FlowChat
134
150
  end
135
151
 
136
152
  def pagination_state
137
- @pagination_state ||= @context.session.get("ussd.pagination") || {}
153
+ @context.session.get("ussd.pagination") || {}
138
154
  end
139
155
 
140
156
  def set_pagination_state(page, offset_start, offset_finish, prompt = nil, type = nil)
141
- offsets = pagination_state["offsets"] || {}
142
- offsets[page] = {start: offset_start, finish: offset_finish}
143
- prompt ||= pagination_state["prompt"]
144
- type ||= pagination_state["type"]
145
- @pagination_state = {
157
+ current_state = pagination_state
158
+ offsets = current_state["offsets"] || {}
159
+ offsets[page.to_s] = {"start" => offset_start, "finish" => offset_finish}
160
+ prompt ||= current_state["prompt"]
161
+ type ||= current_state["type"]
162
+ new_state = {
146
163
  "page" => page,
147
164
  "offsets" => offsets,
148
165
  "prompt" => prompt,
149
- "type" => type
166
+ "type" => type.to_s
150
167
  }
151
- @session.set "ussd.pagination", @pagination_state
168
+ @session.set "ussd.pagination", new_state
152
169
  end
153
170
  end
154
171
  end
@@ -1,11 +1,12 @@
1
1
  module FlowChat
2
2
  module Ussd
3
3
  class Renderer
4
- attr_reader :prompt, :choices
4
+ attr_reader :prompt, :choices, :media
5
5
 
6
- def initialize(prompt, choices)
6
+ def initialize(prompt, choices: nil, media: nil)
7
7
  @prompt = prompt
8
8
  @choices = choices
9
+ @media = media
9
10
  end
10
11
 
11
12
  def render = build_prompt
@@ -13,7 +14,8 @@ module FlowChat
13
14
  private
14
15
 
15
16
  def build_prompt
16
- [prompt, build_choices].compact.join "\n\n"
17
+ parts = [prompt, build_media, build_choices].compact
18
+ parts.join "\n\n"
17
19
  end
18
20
 
19
21
  def build_choices
@@ -21,6 +23,29 @@ module FlowChat
21
23
 
22
24
  choices.map { |i, c| "#{i}. #{c}" }.join "\n"
23
25
  end
26
+
27
+ def build_media
28
+ return unless media.present?
29
+
30
+ media_url = media[:url] || media[:path]
31
+ media_type = media[:type] || :image
32
+
33
+ # For USSD, we append the media URL to the message
34
+ case media_type.to_sym
35
+ when :image
36
+ "📷 Image: #{media_url}"
37
+ when :document
38
+ "📄 Document: #{media_url}"
39
+ when :audio
40
+ "🎵 Audio: #{media_url}"
41
+ when :video
42
+ "🎥 Video: #{media_url}"
43
+ when :sticker
44
+ "😊 Sticker: #{media_url}"
45
+ else
46
+ "📎 Media: #{media_url}"
47
+ end
48
+ end
24
49
  end
25
50
  end
26
51
  end
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.5.2"
2
+ VERSION = "0.6.0"
3
3
  end
@@ -23,7 +23,7 @@ module FlowChat
23
23
  user_input = nil
24
24
  end
25
25
 
26
- prompt = FlowChat::Whatsapp::Prompt.new user_input
26
+ prompt = FlowChat::Prompt.new user_input
27
27
  @input = nil # input is being submitted to prompt so we clear it
28
28
 
29
29
  value = yield prompt
@@ -31,8 +31,8 @@ module FlowChat
31
31
  value
32
32
  end
33
33
 
34
- def say(msg)
35
- raise FlowChat::Interrupt::Terminate.new([:text, msg, {}])
34
+ def say(msg, media: nil)
35
+ raise FlowChat::Interrupt::Terminate.new(msg, media: media)
36
36
  end
37
37
 
38
38
  # WhatsApp-specific data accessors (read-only)
@@ -192,25 +192,32 @@ module FlowChat
192
192
  text: {body: content}
193
193
  }
194
194
  when :interactive_buttons
195
+ interactive_payload = {
196
+ type: "button",
197
+ body: {text: content},
198
+ action: {
199
+ buttons: options[:buttons].map.with_index do |button, index|
200
+ {
201
+ type: "reply",
202
+ reply: {
203
+ id: button[:id] || index.to_s,
204
+ title: button[:title]
205
+ }
206
+ }
207
+ end
208
+ }
209
+ }
210
+
211
+ # Add header if provided (for media support)
212
+ if options[:header]
213
+ interactive_payload[:header] = options[:header]
214
+ end
215
+
195
216
  {
196
217
  messaging_product: "whatsapp",
197
218
  to: to,
198
219
  type: "interactive",
199
- interactive: {
200
- type: "button",
201
- body: {text: content},
202
- action: {
203
- buttons: options[:buttons].map.with_index do |button, index|
204
- {
205
- type: "reply",
206
- reply: {
207
- id: button[:id] || index.to_s,
208
- title: button[:title]
209
- }
210
- }
211
- end
212
- }
213
- }
220
+ interactive: interactive_payload
214
221
  }
215
222
  when :interactive_list
216
223
  {
@@ -114,14 +114,26 @@ module FlowChat
114
114
  handler_mode = determine_message_handler(context)
115
115
 
116
116
  # Process the message based on handling mode
117
- case handler_mode
118
- when :inline
119
- handle_message_inline(context, controller)
120
- when :background
121
- handle_message_background(context, controller)
122
- when :simulator
123
- # Return early from simulator mode to preserve the JSON response
124
- return handle_message_simulator(context, controller)
117
+ begin
118
+ case handler_mode
119
+ when :inline
120
+ handle_message_inline(context, controller)
121
+ when :background
122
+ handle_message_background(context, controller)
123
+ when :simulator
124
+ # Return early from simulator mode to preserve the JSON response
125
+ return handle_message_simulator(context, controller)
126
+ end
127
+ rescue => e
128
+ # Log the error and set appropriate HTTP status
129
+ Rails.logger.error "Error processing WhatsApp message: #{e.message}"
130
+ Rails.logger.error e.backtrace&.join("\n") if e.backtrace
131
+
132
+ # Return error status to WhatsApp so they know processing failed
133
+ controller.head :internal_server_error
134
+
135
+ # Re-raise the error to bubble it up for proper error tracking/monitoring
136
+ raise e
125
137
  end
126
138
  end
127
139
 
@@ -217,7 +229,9 @@ module FlowChat
217
229
  def handle_message_inline(context, controller)
218
230
  response = @app.call(context)
219
231
  if response
220
- result = @client.send_message(context["request.msisdn"], response)
232
+ _type, prompt, choices, media = response
233
+ rendered_message = render_response(prompt, choices, media)
234
+ result = @client.send_message(context["request.msisdn"], rendered_message)
221
235
  context["whatsapp.message_result"] = result
222
236
  end
223
237
  end
@@ -227,10 +241,13 @@ module FlowChat
227
241
  response = @app.call(context)
228
242
 
229
243
  if response
244
+ _type, prompt, choices, media = response
245
+ rendered_message = render_response(prompt, choices, media)
246
+
230
247
  # Queue only the response delivery asynchronously
231
248
  send_data = {
232
249
  msisdn: context["request.msisdn"],
233
- response: response,
250
+ response: rendered_message,
234
251
  config_name: @config.name
235
252
  }
236
253
 
@@ -244,7 +261,7 @@ module FlowChat
244
261
  rescue NameError
245
262
  # Fallback to inline sending if no job system
246
263
  Rails.logger.warn "Background mode requested but no #{job_class_name} found. Falling back to inline sending."
247
- result = @client.send_message(context["request.msisdn"], response)
264
+ result = @client.send_message(context["request.msisdn"], rendered_message)
248
265
  context["whatsapp.message_result"] = result
249
266
  end
250
267
  end
@@ -254,9 +271,12 @@ module FlowChat
254
271
  response = @app.call(context)
255
272
 
256
273
  if response
274
+ _type, prompt, choices, media = response
275
+ rendered_message = render_response(prompt, choices, media)
276
+
257
277
  # For simulator mode, return the response data in the HTTP response
258
278
  # instead of actually sending via WhatsApp API
259
- message_payload = @client.build_message_payload(response, context["request.msisdn"])
279
+ message_payload = @client.build_message_payload(rendered_message, context["request.msisdn"])
260
280
 
261
281
  simulator_response = {
262
282
  mode: "simulator",
@@ -317,6 +337,10 @@ module FlowChat
317
337
  def parse_request_body(request)
318
338
  @body ||= JSON.parse(request.body.read)
319
339
  end
340
+
341
+ def render_response(prompt, choices, media)
342
+ FlowChat::Whatsapp::Renderer.new(prompt, choices: choices, media: media).render
343
+ end
320
344
  end
321
345
  end
322
346
  end
@@ -11,12 +11,12 @@ module FlowChat
11
11
  flow = context.flow.new whatsapp_app
12
12
  flow.send context["flow.action"]
13
13
  rescue FlowChat::Interrupt::Prompt => e
14
- # Return the interrupt data for WhatsApp message formatting
15
- e.prompt
14
+ # Return the same triplet format as USSD for consistency
15
+ [:prompt, e.prompt, e.choices, e.media]
16
16
  rescue FlowChat::Interrupt::Terminate => e
17
17
  # Clean up session and return terminal message
18
18
  context.session.destroy
19
- e.prompt
19
+ [:terminate, e.prompt, nil, e.media]
20
20
  end
21
21
 
22
22
  private