flow_chat 0.2.1 → 0.4.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,63 @@
1
+ require "middleware"
2
+
3
+ module FlowChat
4
+ class BaseProcessor
5
+ attr_reader :middleware, :gateway
6
+
7
+ def initialize(controller)
8
+ @context = FlowChat::Context.new
9
+ @context["controller"] = controller
10
+ @middleware = ::Middleware::Builder.new(name: middleware_name)
11
+
12
+ yield self if block_given?
13
+ end
14
+
15
+ def use_gateway(gateway)
16
+ @gateway = gateway
17
+ self
18
+ end
19
+
20
+ def use_session_store(session_store)
21
+ @context["session.store"] = session_store
22
+ self
23
+ end
24
+
25
+ def use_middleware(middleware)
26
+ @middleware.use middleware
27
+ self
28
+ end
29
+
30
+ def run(flow_class, action)
31
+ @context["flow.name"] = flow_class.name.underscore
32
+ @context["flow.class"] = flow_class
33
+ @context["flow.action"] = action
34
+
35
+ stack = build_middleware_stack
36
+ yield stack if block_given?
37
+
38
+ stack.call(@context)
39
+ end
40
+
41
+ protected
42
+
43
+ # Subclasses should override these methods
44
+ def middleware_name
45
+ raise NotImplementedError, "Subclasses must implement middleware_name"
46
+ end
47
+
48
+ def build_middleware_stack
49
+ raise NotImplementedError, "Subclasses must implement build_middleware_stack"
50
+ end
51
+
52
+ # Helper method for building stacks
53
+ def create_middleware_stack(name)
54
+ ::Middleware::Builder.new(name: name) do |b|
55
+ configure_middleware_stack(b)
56
+ end.inject_logger(Rails.logger)
57
+ end
58
+
59
+ def configure_middleware_stack(builder)
60
+ raise NotImplementedError, "Subclasses must implement configure_middleware_stack"
61
+ end
62
+ end
63
+ end
@@ -1,16 +1,29 @@
1
1
  module FlowChat
2
2
  module Config
3
+ # General framework configuration
3
4
  mattr_accessor :logger, default: Logger.new($stdout)
4
5
  mattr_accessor :cache, default: nil
5
6
 
6
- mattr_accessor :pagination_page_size, default: 140
7
- mattr_accessor :pagination_back_option, default: "0"
8
- mattr_accessor :pagination_back_text, default: "Back"
9
- mattr_accessor :pagination_next_option, default: "#"
10
- mattr_accessor :pagination_next_text, default: "More"
7
+ # USSD-specific configuration object
8
+ def self.ussd
9
+ @ussd ||= UssdConfig.new
10
+ end
11
11
 
12
- mattr_accessor :resumable_sessions_enabled, default: false
13
- mattr_accessor :resumable_sessions_global, default: true
14
- mattr_accessor :resumable_sessions_timeout_seconds, default: 300
12
+ class UssdConfig
13
+ attr_accessor :pagination_page_size, :pagination_back_option, :pagination_back_text,
14
+ :pagination_next_option, :pagination_next_text,
15
+ :resumable_sessions_enabled, :resumable_sessions_global, :resumable_sessions_timeout_seconds
16
+
17
+ def initialize
18
+ @pagination_page_size = 140
19
+ @pagination_back_option = "0"
20
+ @pagination_back_text = "Back"
21
+ @pagination_next_option = "#"
22
+ @pagination_next_text = "More"
23
+ @resumable_sessions_enabled = false
24
+ @resumable_sessions_global = true
25
+ @resumable_sessions_timeout_seconds = 300
26
+ end
27
+ end
15
28
  end
16
29
  end
@@ -14,8 +14,16 @@ module FlowChat
14
14
 
15
15
  def input = @data["request.input"]
16
16
 
17
+ def input=(value)
18
+ @data["request.input"] = value
19
+ end
20
+
17
21
  def session = @data["session"]
18
22
 
23
+ def session=(value)
24
+ @data["session"] = value
25
+ end
26
+
19
27
  def controller = @data["controller"]
20
28
 
21
29
  # def request = controller.request
@@ -1,5 +1,6 @@
1
1
  module FlowChat
2
2
  module Interrupt
3
+ # standard:disable Lint/InheritException
3
4
  class Base < Exception
4
5
  attr_reader :prompt
5
6
 
@@ -8,6 +9,7 @@ module FlowChat
8
9
  super
9
10
  end
10
11
  end
12
+ # standard:enable Lint/InheritException
11
13
 
12
14
  class Prompt < Base
13
15
  attr_reader :choices
@@ -0,0 +1,84 @@
1
+ module FlowChat
2
+ module Session
3
+ class CacheSessionStore
4
+ def initialize(context, cache = nil)
5
+ @context = context
6
+ @cache = cache || FlowChat::Config.cache
7
+
8
+ raise ArgumentError, "Cache is required. Set FlowChat::Config.cache or pass a cache instance." unless @cache
9
+ end
10
+
11
+ def get(key)
12
+ return nil unless @context
13
+
14
+ data = @cache.read(session_key)
15
+ return nil unless data
16
+
17
+ data[key.to_s]
18
+ end
19
+
20
+ def set(key, value)
21
+ return unless @context
22
+
23
+ data = @cache.read(session_key) || {}
24
+ data[key.to_s] = value
25
+
26
+ @cache.write(session_key, data, expires_in: session_ttl)
27
+ value
28
+ end
29
+
30
+ def delete(key)
31
+ return unless @context
32
+
33
+ data = @cache.read(session_key)
34
+ return unless data
35
+
36
+ data.delete(key.to_s)
37
+ @cache.write(session_key, data, expires_in: session_ttl)
38
+ end
39
+
40
+ def clear
41
+ return unless @context
42
+
43
+ @cache.delete(session_key)
44
+ end
45
+
46
+ # Alias for compatibility
47
+ alias_method :destroy, :clear
48
+
49
+ def exists?
50
+ @cache.exist?(session_key)
51
+ end
52
+
53
+ private
54
+
55
+ def session_key
56
+ gateway = @context["request.gateway"]
57
+ msisdn = @context["request.msisdn"]
58
+
59
+ case gateway
60
+ when :whatsapp_cloud_api
61
+ "flow_chat:session:whatsapp:#{msisdn}"
62
+ when :nalo, :nsano
63
+ session_id = @context["request.id"]
64
+ "flow_chat:session:ussd:#{session_id}:#{msisdn}"
65
+ else
66
+ "flow_chat:session:unknown:#{msisdn}"
67
+ end
68
+ end
69
+
70
+ def session_ttl
71
+ gateway = @context["request.gateway"]
72
+
73
+ case gateway
74
+ when :whatsapp_cloud_api
75
+ 7.days # WhatsApp conversations can be long-lived
76
+ when :nalo, :nsano
77
+ 1.hour # USSD sessions are typically short
78
+ else
79
+ 1.day # Default
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -7,19 +7,27 @@ module FlowChat
7
7
 
8
8
  def call(context)
9
9
  context["session.id"] = session_id context
10
- context["session"] = context["session.store"].new context
10
+ context.session = context["session.store"].new(context)
11
11
  @app.call(context)
12
12
  end
13
13
 
14
14
  private
15
15
 
16
16
  def session_id(context)
17
- context["request.id"]
18
- # File.join(
19
- # context["PATH_INFO"],
20
- # (Config.resumable_sessions_enabled && Config.resumable_sessions_global) ? "global" : context["ussd.request"][:provider].to_s,
21
- # context["ussd.request"][:msisdn]
22
- # )
17
+ gateway = context["request.gateway"]
18
+ flow_name = context["flow.name"]
19
+ case gateway
20
+ when :whatsapp_cloud_api
21
+ # For WhatsApp, use phone number + flow name for consistent sessions
22
+ phone = context["request.msisdn"]
23
+ "#{gateway}:#{flow_name}:#{phone}"
24
+ # when :nalo, :nsano
25
+ # # For USSD, use the request ID from the gateway
26
+ # "#{gateway}:#{flow_name}:#{context["request.id"]}"
27
+ else
28
+ # Fallback to request ID
29
+ "#{gateway}:#{flow_name}:#{context["request.id"]}"
30
+ end
23
31
  end
24
32
  end
25
33
  end
@@ -28,6 +28,31 @@ module FlowChat
28
28
  def say(msg)
29
29
  raise FlowChat::Interrupt::Terminate.new(msg)
30
30
  end
31
+
32
+ # WhatsApp-specific data accessors (not supported in USSD)
33
+ def contact_name
34
+ nil
35
+ end
36
+
37
+ def message_id
38
+ context["request.message_id"]
39
+ end
40
+
41
+ def timestamp
42
+ context["request.timestamp"]
43
+ end
44
+
45
+ def location
46
+ nil
47
+ end
48
+
49
+ def media
50
+ nil
51
+ end
52
+
53
+ def phone_number
54
+ context["request.msisdn"]
55
+ end
31
56
  end
32
57
  end
33
58
  end
@@ -12,11 +12,13 @@ module FlowChat
12
12
  params = context.controller.request.params
13
13
 
14
14
  context["request.id"] = params["USERID"]
15
+ context["request.message_id"] = SecureRandom.uuid
16
+ context["request.timestamp"] = Time.current.iso8601
15
17
  context["request.gateway"] = :nalo
16
18
  context["request.network"] = nil
17
19
  context["request.msisdn"] = Phonelib.parse(params["MSISDN"]).e164
18
20
  # context["request.type"] = params["MSGTYPE"] ? :initial : :response
19
- context["request.input"] = params["USERDATA"].presence
21
+ context.input = params["USERDATA"].presence
20
22
 
21
23
  type, prompt, choices = @app.call(context)
22
24
 
@@ -10,7 +10,13 @@ module FlowChat
10
10
 
11
11
  def call(context)
12
12
  controller = context["controller"]
13
- request = controller.request
13
+ controller.request
14
+
15
+ # Add timestamp for all requests
16
+ context["request.timestamp"] = Time.current.iso8601
17
+
18
+ # Set a basic message_id (can be enhanced based on actual Nsano implementation)
19
+ context["request.message_id"] = SecureRandom.uuid
14
20
 
15
21
  # input = context["rack.input"].read
16
22
  # context["rack.input"].rewind
@@ -12,16 +12,14 @@ module FlowChat
12
12
 
13
13
  if intercept?
14
14
  type, prompt = handle_intercepted_request
15
- [type, prompt, []]
16
15
  else
17
16
  @session.delete "ussd.pagination"
18
17
  type, prompt, choices = @app.call(context)
19
18
 
20
19
  prompt = FlowChat::Ussd::Renderer.new(prompt, choices).render
21
20
  type, prompt = maybe_paginate(type, prompt) if prompt.present?
22
-
23
- [type, prompt, []]
24
21
  end
22
+ [type, prompt, []]
25
23
  end
26
24
 
27
25
  private
@@ -29,11 +27,11 @@ module FlowChat
29
27
  def intercept?
30
28
  pagination_state.present? &&
31
29
  (pagination_state["type"].to_sym == :terminal ||
32
- ([Config.pagination_next_option, Config.pagination_back_option].include? @context.input))
30
+ ([FlowChat::Config.ussd.pagination_next_option, FlowChat::Config.ussd.pagination_back_option].include? @context.input))
33
31
  end
34
32
 
35
33
  def handle_intercepted_request
36
- Config.logger&.info "FlowChat::Middleware::Pagination :: Intercepted to handle pagination"
34
+ FlowChat::Config.logger&.info "FlowChat::Middleware::Pagination :: Intercepted to handle pagination"
37
35
  start, finish, has_more = calculate_offsets
38
36
  type = (pagination_state["type"].to_sym == :terminal && !has_more) ? :terminal : :prompt
39
37
  prompt = pagination_state["prompt"][start..finish].strip + build_pagination_options(type, has_more)
@@ -43,9 +41,9 @@ module FlowChat
43
41
  end
44
42
 
45
43
  def maybe_paginate(type, prompt)
46
- if prompt.length > Config.pagination_page_size
44
+ if prompt.length > FlowChat::Config.ussd.pagination_page_size
47
45
  original_prompt = prompt
48
- Config.logger&.info "FlowChat::Middleware::Pagination :: Response length (#{prompt.length}) exceeds page size (#{Config.pagination_page_size}). Paginating."
46
+ FlowChat::Config.logger&.info "FlowChat::Middleware::Pagination :: Response length (#{prompt.length}) exceeds page size (#{FlowChat::Config.ussd.pagination_page_size}). Paginating."
49
47
  prompt = prompt[0..single_option_slice_size]
50
48
  # Ensure we do not cut words and options off in the middle.
51
49
  current_pagebreak = prompt[single_option_slice_size + 1].blank? ? single_option_slice_size : prompt.rindex("\n") || prompt.rindex(" ") || single_option_slice_size
@@ -60,12 +58,12 @@ module FlowChat
60
58
  page = current_page
61
59
  offset = pagination_state["offsets"][page.to_s]
62
60
  if offset.present?
63
- Config.logger&.debug "FlowChat::Middleware::Pagination :: Reusing cached offset for page: #{page}"
61
+ FlowChat::Config.logger&.debug "FlowChat::Middleware::Pagination :: Reusing cached offset for page: #{page}"
64
62
  start = offset["start"]
65
63
  finish = offset["finish"]
66
64
  has_more = pagination_state["prompt"].length > finish
67
65
  else
68
- Config.logger&.debug "FlowChat::Middleware::Pagination :: Calculating offset for page: #{page}"
66
+ FlowChat::Config.logger&.debug "FlowChat::Middleware::Pagination :: Calculating offset for page: #{page}"
69
67
  # We are guaranteed a previous offset because it was set in maybe_paginate
70
68
  previous_page = page - 1
71
69
  previous_offset = pagination_state["offsets"][previous_page.to_s]
@@ -73,8 +71,7 @@ module FlowChat
73
71
  has_more, len = (pagination_state["prompt"].length > start + single_option_slice_size) ? [true, dual_options_slice_size] : [false, single_option_slice_size]
74
72
  finish = start + len
75
73
  if start > pagination_state["prompt"].length
76
- Config.logger&.debug "FlowChat::Middleware::Pagination :: No content exists for page: #{page}. Reverting to page: #{page - 1}"
77
- page -= 1
74
+ FlowChat::Config.logger&.debug "FlowChat::Middleware::Pagination :: No content exists for page: #{page}. Reverting to page: #{page - 1}"
78
75
  has_more = false
79
76
  start = previous_offset["start"]
80
77
  finish = previous_offset["finish"]
@@ -100,11 +97,11 @@ module FlowChat
100
97
  end
101
98
 
102
99
  def next_option
103
- "#{Config.pagination_next_option} #{Config.pagination_next_text}"
100
+ "#{FlowChat::Config.ussd.pagination_next_option} #{FlowChat::Config.ussd.pagination_next_text}"
104
101
  end
105
102
 
106
103
  def back_option
107
- "#{Config.pagination_back_option} #{Config.pagination_back_text}"
104
+ "#{FlowChat::Config.ussd.pagination_back_option} #{FlowChat::Config.ussd.pagination_back_text}"
108
105
  end
109
106
 
110
107
  def single_option_slice_size
@@ -112,7 +109,7 @@ module FlowChat
112
109
  # To display a single back or next option
113
110
  # We accomodate the 2 newlines and the longest of the options
114
111
  # We subtract an additional 1 to normalize it for slicing
115
- @single_option_slice_size = Config.pagination_page_size - 2 - [next_option.length, back_option.length].max - 1
112
+ @single_option_slice_size = FlowChat::Config.ussd.pagination_page_size - 2 - [next_option.length, back_option.length].max - 1
116
113
  end
117
114
  @single_option_slice_size
118
115
  end
@@ -121,16 +118,16 @@ module FlowChat
121
118
  unless @dual_options_slice_size.present?
122
119
  # To display both back and next options
123
120
  # We accomodate the 3 newlines and both of the options
124
- @dual_options_slice_size = Config.pagination_page_size - 3 - [next_option.length, back_option.length].sum - 1
121
+ @dual_options_slice_size = FlowChat::Config.ussd.pagination_page_size - 3 - [next_option.length, back_option.length].sum - 1
125
122
  end
126
123
  @dual_options_slice_size
127
124
  end
128
125
 
129
126
  def current_page
130
127
  page = pagination_state["page"]
131
- if @context.input == Config.pagination_back_option
128
+ if @context.input == FlowChat::Config.ussd.pagination_back_option
132
129
  page -= 1
133
- elsif @context.input == Config.pagination_next_option
130
+ elsif @context.input == FlowChat::Config.ussd.pagination_next_option
134
131
  page += 1
135
132
  end
136
133
  [page, 1].max
@@ -7,44 +7,31 @@ module FlowChat
7
7
  end
8
8
 
9
9
  def call(context)
10
- if Config.resumable_sessions_enabled && context["ussd.request"].present?
11
- request = Rack::Request.new(context)
12
- session = request.session
13
-
14
- context["ussd.resumable_sessions"] = {}
15
-
16
- # If this is a new session but we have the flag set, this means the call terminated before
17
- # the session closed. Force it to resume.
18
- # This is safe since a new session is started if the old session does not indeed exist.
19
- if context["ussd.request"][:type] == :initial && can_resume_session?(session)
20
- context["ussd.request"][:type] = :response
21
- context["ussd.resumable_sessions"][:resumed] = true
10
+ if FlowChat::Config.ussd.resumable_sessions_enabled && context["ussd.request"].present?
11
+ # First, try to find any interruption session.
12
+ # The session key can be:
13
+ # - a global session (key: "global")
14
+ # - a provider-specific session (key: <provider>)
15
+ session_key = self.class.session_key(context)
16
+ resumable_session = context["session.store"].get(session_key)
17
+
18
+ if resumable_session.present? && valid?(resumable_session)
19
+ context.merge! resumable_session
22
20
  end
23
-
24
- res = @app.call(context)
25
-
26
- if context["ussd.response"].present?
27
- if context["ussd.response"][:type] == :terminal || context["ussd.resumable_sessions"][:disable]
28
- session.delete "ussd.resumable_sessions"
29
- else
30
- session["ussd.resumable_sessions"] = Time.now.to_i
31
- end
32
- end
33
-
34
- res
35
- else
36
- @app.call(context)
37
21
  end
22
+
23
+ @app.call(context)
38
24
  end
39
25
 
40
26
  private
41
27
 
42
- def can_resume_session?(session)
43
- return unless session["ussd.resumable_sessions"].present?
44
- return true unless Config.resumable_sessions_timeout_seconds
28
+ def valid?(session)
29
+ return true unless FlowChat::Config.ussd.resumable_sessions_timeout_seconds
45
30
 
46
- last_active_at = Time.at(session["ussd.resumable_sessions"])
47
- (Time.now - Config.resumable_sessions_timeout_seconds) < last_active_at
31
+ last_active_at = Time.parse session.dig("context", "last_active_at")
32
+ (Time.current - FlowChat::Config.ussd.resumable_sessions_timeout_seconds) < last_active_at
33
+ rescue
34
+ false
48
35
  end
49
36
  end
50
37
  end
@@ -1,55 +1,28 @@
1
- require "middleware"
2
-
3
1
  module FlowChat
4
2
  module Ussd
5
- class Processor
6
- attr_reader :middleware, :gateway
7
-
8
- def initialize(controller)
9
- @context = FlowChat::Context.new
10
- @context["controller"] = controller
11
- @middleware = ::Middleware::Builder.new(name: "ussd.middleware")
12
-
13
- yield self if block_given?
14
- end
15
-
16
- def use_gateway(gateway)
17
- @gateway = gateway
18
- self
19
- end
20
-
21
- def use_session_store(session_store)
22
- @context["session.store"] = session_store
23
- self
24
- end
25
-
26
- def use_middleware(middleware)
27
- @middleware.use middleware
28
- self
29
- end
30
-
3
+ class Processor < FlowChat::BaseProcessor
31
4
  def use_resumable_sessions
32
5
  middleware.insert_before 0, FlowChat::Ussd::Middleware::ResumableSession
33
6
  self
34
7
  end
35
8
 
36
- def run(flow_class, action)
37
- @context["flow.name"] = flow_class.name.underscore
38
- @context["flow.class"] = flow_class
39
- @context["flow.action"] = action
9
+ protected
40
10
 
41
- stack = ::Middleware::Builder.new name: "ussd" do |b|
42
- b.use gateway
43
- b.use FlowChat::Session::Middleware
44
- b.use FlowChat::Ussd::Middleware::Pagination
45
- b.use middleware
46
- b.use FlowChat::Ussd::Middleware::Executor
47
- end.inject_logger(Rails.logger)
11
+ def middleware_name
12
+ "ussd.middleware"
13
+ end
48
14
 
49
- yield stack if block_given?
15
+ def build_middleware_stack
16
+ create_middleware_stack("ussd")
17
+ end
50
18
 
51
- stack.call(@context)
19
+ def configure_middleware_stack(builder)
20
+ builder.use gateway
21
+ builder.use FlowChat::Session::Middleware
22
+ builder.use FlowChat::Ussd::Middleware::Pagination
23
+ builder.use middleware
24
+ builder.use FlowChat::Ussd::Middleware::Executor
52
25
  end
53
26
  end
54
27
  end
55
- end
28
+ end
@@ -32,7 +32,7 @@ module FlowChat
32
32
  msg,
33
33
  choices: choices_prompt,
34
34
  convert: lambda { |choice| choice.to_i },
35
- validate: lambda { |choice| "Invalid selection:" unless (1..choices.size).include?(choice) },
35
+ validate: lambda { |choice| "Invalid selection:" unless (1..choices.size).cover?(choice) },
36
36
  transform: lambda { |choice| choices[choice - 1] }
37
37
  )
38
38
  end
@@ -38,7 +38,7 @@ module FlowChat
38
38
 
39
39
  def simulator_locals
40
40
  {
41
- pagesize: Config.pagination_page_size,
41
+ pagesize: FlowChat::Config.ussd.pagination_page_size,
42
42
  show_options: show_options,
43
43
  default_msisdn: default_msisdn,
44
44
  default_endpoint: default_endpoint,
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.2.1"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -0,0 +1,58 @@
1
+ module FlowChat
2
+ module Whatsapp
3
+ class App
4
+ attr_reader :session, :input, :context, :navigation_stack
5
+
6
+ def initialize(context)
7
+ @context = context
8
+ @session = context.session
9
+ @input = context.input
10
+ @navigation_stack = []
11
+ end
12
+
13
+ def screen(key)
14
+ raise ArgumentError, "a block is expected" unless block_given?
15
+ raise ArgumentError, "screen has been presented" if navigation_stack.include?(key)
16
+
17
+ navigation_stack << key
18
+ return session.get(key) if session.get(key).present?
19
+
20
+ prompt = FlowChat::Whatsapp::Prompt.new input
21
+ @input = nil # input is being submitted to prompt so we clear it
22
+
23
+ value = yield prompt
24
+ session.set(key, value)
25
+ value
26
+ end
27
+
28
+ def say(msg)
29
+ raise FlowChat::Interrupt::Terminate.new([:text, msg, {}])
30
+ end
31
+
32
+ # WhatsApp-specific data accessors (read-only)
33
+ def contact_name
34
+ context["request.contact_name"]
35
+ end
36
+
37
+ def message_id
38
+ context["request.message_id"]
39
+ end
40
+
41
+ def timestamp
42
+ context["request.timestamp"]
43
+ end
44
+
45
+ def location
46
+ context["request.location"]
47
+ end
48
+
49
+ def media
50
+ context["request.media"]
51
+ end
52
+
53
+ def phone_number
54
+ context["request.msisdn"]
55
+ end
56
+ end
57
+ end
58
+ end