ussd_engine 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 57122947aff50821a4a6ec61d38501ce9f525e516be998874590d6104bb1a754
4
+ data.tar.gz: 581faff4ae10681400a32c21cc72fc5b94525a8e0f7e7a4f1867b7916838238e
5
+ SHA512:
6
+ metadata.gz: a8f7753521a5405026b20c78072c5009bdb952d79599ec735bd0fcd79d9ed6de96f61975a7c89310da9956ef43ea89055416e881e0214094d10b68e7037e75d2
7
+ data.tar.gz: 1b67596120f200d6a1f9f830f83052d4d444df48fb77a0719cf2b5fdfd6dae3dcae2e12c92f7043916831ec6013c0bbb91da1965d3d554760bb78044ada96012
data/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+
13
+ # Ignore Gemfile.lock for our gem
14
+ /Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.travis.yml ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ language: ruby
3
+ cache: bundler
4
+ rvm:
5
+ - 2.7.1
6
+ before_install: gem install bundler -v 2.1.4
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in ussd_engine.gemspec
4
+ gemspec
5
+
6
+ gem "rake", "~> 12.0"
7
+ gem "rspec", "~> 3.0"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 Stefan Froelich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # UssdEngine
2
+
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/ussd_engine`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'ussd_engine'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle install
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install ussd_engine
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/ussd_engine.
36
+
37
+
38
+ ## License
39
+
40
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "ussd_engine"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,18 @@
1
+ require "active_support" unless defined?(Rails)
2
+
3
+ module UssdEngine
4
+ module Config
5
+ mattr_accessor :logger, default: Logger.new($stdout)
6
+ mattr_accessor :cache, default: nil
7
+
8
+ mattr_accessor :pagination_page_size, default: 140
9
+ mattr_accessor :pagination_back_option, default: "0"
10
+ mattr_accessor :pagination_back_text, default: "Back"
11
+ mattr_accessor :pagination_next_option, default: "#"
12
+ mattr_accessor :pagination_next_text, default: "More"
13
+
14
+ mattr_accessor :resumable_sessions_enabled, default: false
15
+ mattr_accessor :resumable_sessions_global, default: true
16
+ mattr_accessor :resumable_sessions_timeout_seconds, default: 300
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ module UssdEngine
2
+ module Controller
3
+ module Io
4
+ protected
5
+
6
+ def basic_prompt(message)
7
+ Config.logger&.debug "UssdEngine::Controller::Io :: Sending prompt -> \n\n#{message}\n"
8
+ {
9
+ body: message,
10
+ type: :prompt,
11
+ }
12
+ end
13
+
14
+ def terminate(message)
15
+ Config.logger&.debug "UssdEngine::Controller::Io :: Terminating session -> \n\n#{message}\n"
16
+ {
17
+ body: message,
18
+ type: :terminal,
19
+ }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,61 @@
1
+ require "ussd_engine/controller/io"
2
+ require "ussd_engine/controller/options"
3
+ require "ussd_engine/controller/params"
4
+ require "ussd_engine/controller/storable"
5
+
6
+ module UssdEngine
7
+ module Controller
8
+ module Mixin
9
+ include UssdEngine::Controller::Io
10
+ include UssdEngine::Controller::Options
11
+ include UssdEngine::Controller::Params
12
+
13
+ def self.included(base)
14
+ base.send :include, UssdEngine::Controller::Storable
15
+ base.send :skip_before_action, :verify_authenticity_token, only: :ussd_controller, raise: false
16
+ end
17
+
18
+ def ussd_controller
19
+ unless request.env["ussd_engine.request"].present?
20
+ Config.logger&.warn "UssdEngine::Controller::Mixin :: Unknown request type"
21
+ return render(status: :bad_request)
22
+ end
23
+
24
+ process_ussd_request build_ussd_request
25
+ render body: nil
26
+ end
27
+
28
+ protected
29
+
30
+ def process_ussd_request(ussd_request)
31
+ display(ussd_request[:screen], ussd_request[:input])
32
+ end
33
+
34
+ def display(screen, input = nil)
35
+ Config.logger&.debug "UssdEngine::Controller::Mixin :: Display #{screen}"
36
+ session["ussd_engine.screen"] = screen
37
+ request.env["ussd_engine.response"] = send screen.to_sym, input
38
+ end
39
+
40
+ def build_ussd_request
41
+ if ussd_request_type == :initial
42
+ Config.logger&.debug "UssdEngine::Controller::Mixin :: Starting new session"
43
+ reset_session
44
+ ussd_screen = :index
45
+ else
46
+ Config.logger&.debug "UssdEngine::Controller::Mixin :: Continuing existing session"
47
+ user_input = ussd_user_input
48
+ ussd_screen = session["ussd_engine.screen"] || :index
49
+ end
50
+
51
+ { screen: ussd_screen, input: user_input }
52
+ end
53
+
54
+ def prompt(message, options = nil)
55
+ return basic_prompt(message) if options.blank?
56
+
57
+ options_prompt(message, options)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,26 @@
1
+ module UssdEngine
2
+ module Controller
3
+ module Options
4
+ protected
5
+
6
+ def options_prompt(message, options)
7
+ message += build_options_nav(options)
8
+ Config.logger&.debug "UssdEngine::Controller::Options :: Sending prompt -> \n\n#{message}\n"
9
+ {
10
+ body: message,
11
+ type: :prompt,
12
+ }
13
+ end
14
+
15
+ def build_options_nav(options)
16
+ "\n\n" + options.each_with_index.map { |x, i| "#{i + 1} #{x[1]}" }.join("\n")
17
+ end
18
+
19
+ def resolve_option(input, options)
20
+ return unless input.match? /^[1-9](\d)?$/
21
+
22
+ options.keys[input.to_i - 1]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ require "ussd_engine/controller/io"
2
+ require "ussd_engine/controller/options"
3
+
4
+ module UssdEngine
5
+ module Controller
6
+ module Params
7
+ protected
8
+
9
+ def ussd_request_id
10
+ request.env["ussd_engine.request"][:id]
11
+ end
12
+
13
+ def ussd_request_type
14
+ request.env["ussd_engine.request"][:type]
15
+ end
16
+
17
+ def ussd_request_msisdn
18
+ request.env["ussd_engine.request"][:msisdn]
19
+ end
20
+
21
+ def ussd_request_provider
22
+ request.env["ussd_engine.request"][:provider]
23
+ end
24
+
25
+ def ussd_user_input
26
+ request.env["ussd_engine.request"][:input]
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UssdEngine
4
+ module Controller
5
+ module Storable
6
+ def self.included(base)
7
+ base.send :extend, StorableClassMethods
8
+ end
9
+
10
+ protected
11
+
12
+ module StorableClassMethods
13
+ def stores(field, accessor = nil, &block)
14
+ # Given
15
+ # field = :user_id
16
+ instance_prop = "@#{field}" # @user_id
17
+ key_method_name = "#{field}_key" # user_id_key
18
+ getter_method_name = field # user_id
19
+ setter_method_name = "#{field}=" # user_id=
20
+
21
+ # def user_id_key
22
+ # File.join ussd_request_id, "user_id"
23
+ # end
24
+ define_method key_method_name do
25
+ File.join ussd_request_id, field.to_s
26
+ end
27
+
28
+ # def user_id
29
+ # session[user_id_key]
30
+ # end
31
+ define_method getter_method_name do
32
+ session[send(key_method_name)]
33
+ end
34
+
35
+ # def user_id=(value)
36
+ # session[user_id_key] = value
37
+ # end
38
+ define_method setter_method_name do |value|
39
+ session[send(key_method_name)] = value
40
+ end
41
+
42
+ if accessor.present?
43
+ raise "A block is required if you pass an accessor" unless block_given?
44
+
45
+ # Given
46
+ # accessor = :user
47
+ cache_method_name = "#{accessor}_cache" # user_cache
48
+ cache_instance_prop = "@#{cache_method_name}" # @user_cache
49
+
50
+ # def user_cache
51
+ # @user_cache ||= {}
52
+ # end
53
+ define_method cache_method_name do
54
+ unless instance_variable_defined? cache_instance_prop
55
+ instance_variable_set cache_instance_prop, {}
56
+ end
57
+ instance_variable_get cache_instance_prop
58
+ end
59
+
60
+ # Given
61
+ # block = do |user_id|
62
+ # User.find user_id
63
+ # end
64
+ #
65
+ # def user
66
+ # return if user_id.blank?
67
+ #
68
+ # user_cache[user_id] ||= block(user_id)
69
+ # end
70
+ define_method accessor do
71
+ field_value = send(getter_method_name)
72
+ return if field_value.blank?
73
+
74
+ send(cache_method_name)[field_value] ||= instance_exec(field_value, &block)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,44 @@
1
+ require "phonelib"
2
+
3
+ module UssdEngine
4
+ module Middleware
5
+ class NaloProcessor
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ input = env["rack.input"].read
12
+ env["rack.input"].rewind
13
+ if input.present?
14
+ params = JSON.parse input
15
+ if params["USERID"].present? && params["MSISDN"].present?
16
+ env["ussd_engine.request"] = {
17
+ provider: :nalo,
18
+ network: nil,
19
+ msisdn: Phonelib.parse(params["MSISDN"]).e164,
20
+ type: params["MSGTYPE"] ? :initial : :response,
21
+ input: params["USERDATA"].presence,
22
+ }
23
+ end
24
+ end
25
+
26
+ status, headers, response = @app.call(env)
27
+
28
+ if env["ussd_engine.response"].present? && env["ussd_engine.request"][:provider] == :nalo
29
+ status = 200
30
+ response =
31
+ {
32
+ USERID: params["USERID"],
33
+ MSISDN: params["MSISDN"],
34
+ MSG: env["ussd_engine.response"][:body],
35
+ MSGTYPE: env["ussd_engine.response"][:type] != :terminal,
36
+ }.to_json
37
+ headers = headers.merge({ "Content-Type" => "application/json", "Content-Length" => response.bytesize.to_s })
38
+ response = [response]
39
+ end
40
+ [status, headers, response]
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,52 @@
1
+ require "phonelib"
2
+
3
+ module UssdEngine
4
+ module Middleware
5
+ class NsanoProcessor
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ input = env["rack.input"].read
12
+ env["rack.input"].rewind
13
+ if input.present?
14
+ params = JSON.parse input
15
+ if params["network"].present? && params["UserSessionID"].present?
16
+ request_id = "nsano::request_id::#{params["UserSessionID"]}"
17
+ env["ussd_engine.request"] = {
18
+ provider: :nsano,
19
+ network: params["network"].to_sym,
20
+ msisdn: Phonelib.parse(params["msisdn"]).e164,
21
+ type: Config.cache&.read(request_id).present? ? :response : :initial,
22
+ input: params["msg"].presence,
23
+ }
24
+ end
25
+ end
26
+
27
+ status, headers, response = @app.call(env)
28
+
29
+ if env["ussd_engine.response"].present? && env["ussd_engine.request"][:provider] == :nsano
30
+ if env["ussd_engine.response"][:type] == :terminal
31
+ Config.cache&.write(request_id, nil)
32
+ else
33
+ Config.cache&.write(request_id, 1)
34
+ end
35
+
36
+ status = 200
37
+ response =
38
+ {
39
+ USSDResp: {
40
+ action: env["ussd_engine.response"][:type] == :terminal ? :prompt : :input,
41
+ menus: "",
42
+ title: env["ussd_engine.response"][:body],
43
+ },
44
+ }.to_json
45
+ headers = headers.merge({ "Content-Type" => "application/json", "Content-Length" => response.bytesize.to_s })
46
+ response = [response]
47
+ end
48
+ [status, headers, response]
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,154 @@
1
+ module UssdEngine
2
+ module Middleware
3
+ class Pagination
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ @env = env
10
+ request = Rack::Request.new(env)
11
+ @session = request.session
12
+
13
+ if intercept?
14
+ @env["ussd_engine.response"] = handle_intercepted_request
15
+ [200, {}, [""]]
16
+ else
17
+ @session.delete "ussd_engine.pagination"
18
+ res = @app.call(env)
19
+
20
+ if @env["ussd_engine.response"].present?
21
+ @env["ussd_engine.response"] = maybe_paginate @env["ussd_engine.response"]
22
+ end
23
+
24
+ res
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def intercept?
31
+ pagination_state.present? &&
32
+ (pagination_state[:type].to_sym == :terminal ||
33
+ ([Config.pagination_next_option, Config.pagination_back_option].include? @env["ussd_engine.request"][:input]))
34
+ end
35
+
36
+ def handle_intercepted_request
37
+ Config.logger&.info "UssdEngine::Middleware::Pagination :: Intercepted to handle pagination"
38
+ start, finish, has_more = calculate_offsets
39
+ type = pagination_state[:type].to_sym == :terminal && !has_more ? :terminal : :prompt
40
+ body = pagination_state[:body][start..finish].strip + build_pagination_options(type, has_more)
41
+ set_pagination_state(current_page, start, finish)
42
+
43
+ { body: body, type: type }
44
+ end
45
+
46
+ def maybe_paginate(response)
47
+ if response[:body].length > Config.pagination_page_size
48
+ Config.logger&.info "UssdEngine::Middleware::Pagination :: Response length (#{response[:body].length}) exceeds page size (#{Config.pagination_page_size}). Paginating."
49
+ body = response[:body][0..single_option_slice_size]
50
+ # Ensure we do not cut words and options off in the middle.
51
+ current_pagebreak = response[:body][single_option_slice_size + 1].blank? ? single_option_slice_size : body.rindex("\n") || body.rindex(" ") || single_option_slice_size
52
+ set_pagination_state(1, 0, current_pagebreak, response[:body], response[:type])
53
+ response[:body] = body[0..current_pagebreak].strip + "\n\n" + next_option
54
+ response[:type] = :prompt
55
+ end
56
+ response
57
+ end
58
+
59
+ def calculate_offsets
60
+ page = current_page
61
+ offset = pagination_state[:offsets][page]
62
+ if offset.present?
63
+ Config.logger&.debug "UssdEngine::Middleware::Pagination :: Reusing cached offset for page: #{page}"
64
+ start = offset[:start]
65
+ finish = offset[:finish]
66
+ has_more = pagination_state[:body].length > finish
67
+ else
68
+ Config.logger&.debug "UssdEngine::Middleware::Pagination :: Calculating offset for page: #{page}"
69
+ # We are guaranteed a previous offset because it was set in maybe_paginate
70
+ previous_offset = pagination_state[:offsets][page - 1]
71
+ start = previous_offset[:finish] + 1
72
+ has_more, len = pagination_state[:body].length > start + single_option_slice_size ? [true, dual_options_slice_size] : [false, single_option_slice_size]
73
+ finish = start + len
74
+ if start > pagination_state[:body].length
75
+ Config.logger&.debug "UssdEngine::Middleware::Pagination :: No content exists for page: #{page}. Reverting to page: #{page - 1}"
76
+ page -= 1
77
+ has_more = false
78
+ start = previous_offset[:start]
79
+ finish = previous_offset[:finish]
80
+ else
81
+ body = pagination_state[:body][start..finish]
82
+ current_pagebreak = pagination_state[:body][finish + 1].blank? ? len : body.rindex("\n") || body.rindex(" ") || len
83
+ finish = start + current_pagebreak
84
+ end
85
+ end
86
+ [start, finish, has_more]
87
+ end
88
+
89
+ def build_pagination_options(type, has_more)
90
+ options_str = ""
91
+ has_less = current_page > 1
92
+ if type.to_sym == :prompt
93
+ options_str += "\n\n"
94
+ next_opt = has_more ? next_option : ""
95
+ back_opt = has_less ? back_option : ""
96
+ options_str += [next_opt, back_opt].join("\n").strip
97
+ end
98
+ options_str
99
+ end
100
+
101
+ def next_option
102
+ "#{Config.pagination_next_option} #{Config.pagination_next_text}"
103
+ end
104
+
105
+ def back_option
106
+ "#{Config.pagination_back_option} #{Config.pagination_back_text}"
107
+ end
108
+
109
+ def single_option_slice_size
110
+ unless @single_option_slice_size.present?
111
+ # To display a single back or next option
112
+ # We accomodate the 2 newlines and the longest of the options
113
+ # We subtract an additional 1 to normalize it for slicing
114
+ @single_option_slice_size = Config.pagination_page_size - 2 - [next_option.length, back_option.length].max - 1
115
+ end
116
+ @single_option_slice_size
117
+ end
118
+
119
+ def dual_options_slice_size
120
+ unless @dual_options_slice_size.present?
121
+ # To display both back and next options
122
+ # We accomodate the 3 newlines and both of the options
123
+ @dual_options_slice_size = Config.pagination_page_size - 3 - [next_option.length, back_option.length].sum - 1
124
+ end
125
+ @dual_options_slice_size
126
+ end
127
+
128
+ def current_page
129
+ current_page = pagination_state[:page]
130
+ if @env["ussd_engine.request"][:input] == Config.pagination_back_option
131
+ current_page -= 1
132
+ elsif @env["ussd_engine.request"][:input] == Config.pagination_next_option
133
+ current_page += 1
134
+ end
135
+ [current_page, 1].max
136
+ end
137
+
138
+ def pagination_state
139
+ @session["ussd_engine.pagination"] || {}
140
+ end
141
+
142
+ def set_pagination_state(page, offset_start, offset_finish, body = nil, type = nil)
143
+ offsets = pagination_state[:offsets] || {}
144
+ offsets[page] = { start: offset_start, finish: offset_finish }
145
+ @session["ussd_engine.pagination"] = {
146
+ page: page,
147
+ offsets: offsets,
148
+ body: body || pagination_state[:body],
149
+ type: type || pagination_state[:type],
150
+ }
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,24 @@
1
+ module UssdEngine
2
+ module Middleware
3
+ class RequestId
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ env["ussd_engine.request"][:id] = get_request_identifier(env) if env["ussd_engine.request"].present?
10
+ @app.call(env)
11
+ end
12
+
13
+ private
14
+
15
+ def get_request_identifier(env)
16
+ File.join(
17
+ env["PATH_INFO"],
18
+ Config.resumable_sessions_enabled && Config.resumable_sessions_global ? "global" : env["ussd_engine.request"][:provider].to_s,
19
+ env["ussd_engine.request"][:msisdn]
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,50 @@
1
+ module UssdEngine
2
+ module Middleware
3
+ class ResumableSessions
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ if Config.resumable_sessions_enabled && env["ussd_engine.request"].present?
10
+ request = Rack::Request.new(env)
11
+ session = request.session
12
+
13
+ env["ussd_engine.resumable_sessions"] = {}
14
+
15
+ # If this is a new session but we have the flag set, this means the call terminated before
16
+ # the session closed. Force it to resume.
17
+ # This is safe since a new session is started if the old session does not indeed exist.
18
+ if env["ussd_engine.request"][:type] == :initial && can_resume_session?(session)
19
+ env["ussd_engine.request"][:type] = :response
20
+ env["ussd_engine.resumable_sessions"][:resumed] = true
21
+ end
22
+
23
+ res = @app.call(env)
24
+
25
+ if env["ussd_engine.response"].present?
26
+ if env["ussd_engine.response"][:type] == :terminal || env["ussd_engine.resumable_sessions"][:disable]
27
+ session.delete "ussd_engine.resumable_sessions"
28
+ else
29
+ session["ussd_engine.resumable_sessions"] = Time.now.to_i
30
+ end
31
+ end
32
+
33
+ res
34
+ else
35
+ @app.call(env)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def can_resume_session?(session)
42
+ return unless session["ussd_engine.resumable_sessions"].present?
43
+ return true unless Config.resumable_sessions_timeout_seconds
44
+
45
+ last_active_at = Time.at(session["ussd_engine.resumable_sessions"])
46
+ return (Time.now - Config.resumable_sessions_timeout_seconds) < last_active_at
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,49 @@
1
+ require "action_dispatch" unless defined?(Rails)
2
+ require "redis-session-store"
3
+
4
+ module UssdEngine
5
+ module Session
6
+ class RedisStore < ::RedisSessionStore
7
+ def initialize(app, options = {})
8
+ # Disable cookies
9
+ options[:cookie_only] = false
10
+ options[:defer] = true
11
+
12
+ super app, options
13
+ end
14
+
15
+ def extract_session_id(request)
16
+ get_request_identifier(request.env) || super
17
+ end
18
+
19
+ def current_session_id(request)
20
+ get_request_identifier(request.env) || super
21
+ end
22
+
23
+ private
24
+
25
+ def get_request_identifier(env)
26
+ return unless env["ussd_engine.request"].present?
27
+
28
+ env["ussd_engine.request"][:id]
29
+ end
30
+
31
+ def set_cookie(*)
32
+ raise "This should never be called"
33
+ end
34
+
35
+ def session_default_values(sid)
36
+ [sid, USE_INDIFFERENT_ACCESS ? {}.with_indifferent_access : {}]
37
+ end
38
+
39
+ def get_session(env, sid)
40
+ sid && (session = load_session_from_redis(sid)) ? [sid, session] : session_default_values(sid)
41
+ rescue Errno::ECONNREFUSED, Redis::CannotConnectError => e
42
+ on_redis_down.call(e, env, sid) if on_redis_down
43
+ session_default_values(sid)
44
+ end
45
+
46
+ alias find_session get_session
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ module UssdEngine
2
+ module Simulator
3
+ def ussd_simulator
4
+ respond_to do |format|
5
+ format.html do
6
+ render inline: simulator_view_template, layout: false, locals: simulator_locals
7
+ end
8
+ end
9
+ end
10
+
11
+ protected
12
+
13
+ def show_options
14
+ true
15
+ end
16
+
17
+ def default_msisdn
18
+ "233200123456"
19
+ end
20
+
21
+ def default_endpoint
22
+ "/ussd"
23
+ end
24
+
25
+ def default_provider
26
+ :nalo
27
+ end
28
+
29
+ def simulator_view_template
30
+ File.read simulator_view_path
31
+ end
32
+
33
+ def simulator_view_path
34
+ File.join UssdEngine.root, "ussd_engine", "views", "simulator.erb"
35
+ end
36
+
37
+ def simulator_locals
38
+ {
39
+ pagesize: Config.pagination_page_size,
40
+ show_options: show_options,
41
+ default_msisdn: default_msisdn,
42
+ default_endpoint: default_endpoint,
43
+ default_provider: default_provider,
44
+ }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,3 @@
1
+ module UssdEngine
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,244 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>USSDEngine Simulator</title>
5
+ <style>
6
+ .content {
7
+ width: 320px;
8
+ margin: 100px auto;
9
+ }
10
+ .label {
11
+ display: inline-block;
12
+ width: 80px;
13
+ font-weight: bold;
14
+ }
15
+ .value {
16
+ display: inline;
17
+ }
18
+ .value select, input {
19
+ width: 200px;
20
+ }
21
+ .field {
22
+ margin: 5px;
23
+ }
24
+ #screen {
25
+ border: 1px black solid;
26
+ height:400px;
27
+ width:300px;
28
+ margin-top: 10px;
29
+ margin-bottom: 10px;
30
+ padding: 10px 5px;
31
+ }
32
+ #char-count {
33
+ text-align: center;
34
+ font-size: 10px;
35
+ }
36
+ .hidden {
37
+ display: none;
38
+ }
39
+ </style>
40
+ </head>
41
+ <body>
42
+ <div class="content">
43
+ <div class="field <%= show_options ? '' : 'hidden' %>">
44
+ <div class="label">Provider </div>
45
+ <div class="value">
46
+ <select id="provider">
47
+ <option <%= default_provider == :nalo ? 'selected' : '' %> value="nalo">Nalo</option>
48
+ <option <%= default_provider == :nsano ? 'selected' : '' %> value="nsano">Nsano</option>
49
+ </select>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="field <%= show_options ? '' : 'hidden' %>">
54
+ <div class="label">Endpoint </div>
55
+ <div class="value">
56
+ <input id="endpoint" value="<%= default_endpoint %>" />
57
+ </div>
58
+ </div>
59
+
60
+ <div class="field <%= show_options ? '' : 'hidden' %>">
61
+ <div class="label">MSISDN </div>
62
+ <div class="value">
63
+ <input id="msisdn" value="<%= default_msisdn %>" />
64
+ </div>
65
+ </div>
66
+
67
+ <div id="screen"></div>
68
+ <div id="char-count"></div>
69
+
70
+ <div class="field">
71
+ <input id="data" disabled> <button id="respond" disabled>Respond</button>
72
+ </div>
73
+
74
+ <div class="field">
75
+ <button id="initiate" disabled>Initiate</button>
76
+ <button id="reset" disabled>Reset</button>
77
+ </div>
78
+ </div>
79
+
80
+ <script>
81
+ // Config
82
+ const pagesize = <%= pagesize %>
83
+
84
+ // View
85
+ const $screen = document.getElementById('screen')
86
+ const $charCount = document.getElementById('char-count')
87
+
88
+ const $provider = document.getElementById('provider')
89
+ const $endpoint = document.getElementById('endpoint')
90
+ const $msisdn = document.getElementById('msisdn')
91
+
92
+ const $data = document.getElementById('data')
93
+ const $respondBtn = document.getElementById('respond')
94
+ const $initiateBtn = document.getElementById('initiate')
95
+ const $resetBtn = document.getElementById('reset')
96
+
97
+ $provider.addEventListener('change', function (e) {
98
+ state.provider = $provider.value
99
+ render()
100
+ }, false)
101
+
102
+ $endpoint.addEventListener('keyup', function (e) {
103
+ state.endpoint = $endpoint.value
104
+ render()
105
+ }, false)
106
+
107
+ $msisdn.addEventListener('keyup', function (e) {
108
+ state.msisdn = $msisdn.value
109
+ render()
110
+ }, false)
111
+
112
+ $initiateBtn.addEventListener('click', function (e) {
113
+ makeRequest()
114
+ }, false)
115
+
116
+ $resetBtn.addEventListener('click',function(e){
117
+ reset()
118
+ }, false)
119
+
120
+ $respondBtn.addEventListener('click', function (e) {
121
+ makeRequest()
122
+ }, false)
123
+
124
+ function disableInputs() {
125
+ $data.disabled = 'disabled'
126
+ $respondBtn.disabled = 'disabled'
127
+ $initiateBtn.disabled = 'disabled'
128
+ $resetBtn.disabled = 'disabled'
129
+ $data.disabled = 'disabled'
130
+ }
131
+
132
+ function enableResponse() {
133
+ $data.disabled = false
134
+ $respondBtn.disabled = false
135
+ $resetBtn.disabled = false
136
+ }
137
+
138
+ function display(text) {
139
+ $screen.innerText = text.substr(0, pagesize)
140
+ if(text.length > 0)
141
+ $charCount.innerText = `${text.length} chars`
142
+ else
143
+ $charCount.innerText = ''
144
+ }
145
+
146
+ function render() {
147
+ disableInputs()
148
+
149
+ if(!state.isRunning){
150
+ if(state.provider && state.endpoint && state.msisdn)
151
+ $initiateBtn.disabled = false
152
+ else
153
+ $initiateBtn.disabled = 'disabled'
154
+ }
155
+ else {
156
+ enableResponse()
157
+ }
158
+ }
159
+
160
+ // State
161
+ const state = {}
162
+
163
+ function reset(shouldRender) {
164
+ state.isRunning = false
165
+ state.provider = $provider.value
166
+ state.endpoint = $endpoint.value
167
+ state.msisdn = $msisdn.value
168
+
169
+ $data.value = null
170
+
171
+ display("")
172
+ if(shouldRender !== false) render()
173
+ }
174
+
175
+
176
+ // API
177
+
178
+ function makeRequest() {
179
+ var data = {}
180
+
181
+ switch (state.provider) {
182
+ case "nalo":
183
+ data = {
184
+ USERID: '12345678',
185
+ MSISDN: state.msisdn,
186
+ USERDATA: $data.value,
187
+ MSGTYPE: !state.isRunning,
188
+ }
189
+ break;
190
+ case "nsano":
191
+ data = {
192
+ network: 'MTN',
193
+ msisdn: state.msisdn,
194
+ msg: $data.value,
195
+ UserSessionID: "12345",
196
+ }
197
+ break;
198
+
199
+ default:
200
+ alert(`Unhandled provider request: ${state.provider}`)
201
+ return
202
+ }
203
+
204
+ disableInputs()
205
+ fetch(state.endpoint, {
206
+ method: 'POST',
207
+ headers: {
208
+ 'Content-Type': 'application/json'
209
+ },
210
+ redirect: 'error',
211
+ body: JSON.stringify(data)
212
+ })
213
+ .then(response => {
214
+ if (!response.ok) {
215
+ throw Error(`${response.status}: ${response.statusText}`);
216
+ }
217
+ return response.json()
218
+ })
219
+ .then(data => {
220
+ switch (state.provider) {
221
+ case "nalo":
222
+ display(data.MSG)
223
+ state.isRunning = data.MSGTYPE
224
+ break;
225
+ case "nsano":
226
+ display(data.USSDResp.title)
227
+ state.isRunning = data.USSDResp.action == "input"
228
+ break;
229
+
230
+ default:
231
+ alert(`Unhandled provider response: ${state.provider}`)
232
+ return
233
+ }
234
+ $data.value = null
235
+ })
236
+ .catch(error => alert(error.message))
237
+ .finally(render);
238
+ }
239
+
240
+ // run the app
241
+ reset()
242
+ </script>
243
+ </body>
244
+ </html>
@@ -0,0 +1,16 @@
1
+ require "ussd_engine/version"
2
+ require "ussd_engine/config"
3
+ require "ussd_engine/session/redis_store"
4
+ require "ussd_engine/middleware/request_id"
5
+ require "ussd_engine/middleware/nalo_processor"
6
+ require "ussd_engine/middleware/nsano_processor"
7
+ require "ussd_engine/middleware/resumable_sessions"
8
+ require "ussd_engine/middleware/pagination"
9
+ require "ussd_engine/controller/mixin"
10
+ require "ussd_engine/simulator"
11
+
12
+ module UssdEngine
13
+ def self.root
14
+ __dir__
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ require_relative "lib/ussd_engine/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "ussd_engine"
5
+ spec.version = UssdEngine::VERSION
6
+ spec.authors = ["Stefan Froelich"]
7
+ spec.email = ["sfroelich01@gmail.com"]
8
+
9
+ spec.summary = "Enable USSD processing support in Rails."
10
+ spec.description = "Enable USSD processing support in Rails."
11
+ spec.homepage = "https://github.com/ussd-engine/api-base"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
14
+
15
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = spec.homepage
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
24
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
25
+ end
26
+ spec.bindir = "exe"
27
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ["lib"]
29
+
30
+ spec.add_runtime_dependency "activesupport", ">= 6"
31
+ spec.add_runtime_dependency "actionpack", ">= 6"
32
+ spec.add_runtime_dependency "redis-session-store"
33
+ spec.add_runtime_dependency "phonelib"
34
+ end
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ussd_engine
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stefan Froelich
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-07-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: actionpack
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: redis-session-store
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: phonelib
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: Enable USSD processing support in Rails.
70
+ email:
71
+ - sfroelich01@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".gitignore"
77
+ - ".rspec"
78
+ - ".travis.yml"
79
+ - Gemfile
80
+ - LICENSE.txt
81
+ - README.md
82
+ - Rakefile
83
+ - bin/console
84
+ - bin/setup
85
+ - lib/ussd_engine.rb
86
+ - lib/ussd_engine/config.rb
87
+ - lib/ussd_engine/controller/io.rb
88
+ - lib/ussd_engine/controller/mixin.rb
89
+ - lib/ussd_engine/controller/options.rb
90
+ - lib/ussd_engine/controller/params.rb
91
+ - lib/ussd_engine/controller/storable.rb
92
+ - lib/ussd_engine/middleware/nalo_processor.rb
93
+ - lib/ussd_engine/middleware/nsano_processor.rb
94
+ - lib/ussd_engine/middleware/pagination.rb
95
+ - lib/ussd_engine/middleware/request_id.rb
96
+ - lib/ussd_engine/middleware/resumable_sessions.rb
97
+ - lib/ussd_engine/session/redis_store.rb
98
+ - lib/ussd_engine/simulator.rb
99
+ - lib/ussd_engine/version.rb
100
+ - lib/ussd_engine/views/simulator.erb
101
+ - ussd_engine.gemspec
102
+ homepage: https://github.com/ussd-engine/api-base
103
+ licenses:
104
+ - MIT
105
+ metadata:
106
+ allowed_push_host: https://rubygems.org
107
+ homepage_uri: https://github.com/ussd-engine/api-base
108
+ source_code_uri: https://github.com/ussd-engine/api-base
109
+ changelog_uri: https://github.com/ussd-engine/api-base
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: 2.3.0
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 3.1.2
126
+ signing_key:
127
+ specification_version: 4
128
+ summary: Enable USSD processing support in Rails.
129
+ test_files: []