flow_chat 0.3.0 → 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.
- checksums.yaml +4 -4
- data/README.md +266 -16
- data/examples/initializer.rb +31 -0
- data/examples/multi_tenant_whatsapp_controller.rb +248 -0
- data/examples/ussd_controller.rb +264 -0
- data/examples/whatsapp_controller.rb +141 -0
- data/lib/flow_chat/base_processor.rb +63 -0
- data/lib/flow_chat/session/cache_session_store.rb +84 -0
- data/lib/flow_chat/session/middleware.rb +14 -6
- data/lib/flow_chat/ussd/app.rb +25 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +2 -0
- data/lib/flow_chat/ussd/gateway/nsano.rb +6 -0
- data/lib/flow_chat/ussd/middleware/resumable_session.rb +1 -1
- data/lib/flow_chat/ussd/processor.rb +15 -42
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/app.rb +58 -0
- data/lib/flow_chat/whatsapp/configuration.rb +75 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +213 -0
- data/lib/flow_chat/whatsapp/middleware/executor.rb +30 -0
- data/lib/flow_chat/whatsapp/processor.rb +36 -0
- data/lib/flow_chat/whatsapp/prompt.rb +206 -0
- data/lib/flow_chat/whatsapp/template_manager.rb +162 -0
- data/lib/flow_chat.rb +1 -0
- metadata +14 -1
@@ -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
|
-
|
37
|
-
@context["flow.name"] = flow_class.name.underscore
|
38
|
-
@context["flow.class"] = flow_class
|
39
|
-
@context["flow.action"] = action
|
9
|
+
protected
|
40
10
|
|
41
|
-
|
42
|
-
|
43
|
-
|
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
|
-
|
15
|
+
def build_middleware_stack
|
16
|
+
create_middleware_stack("ussd")
|
17
|
+
end
|
50
18
|
|
51
|
-
|
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
|
data/lib/flow_chat/version.rb
CHANGED
@@ -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
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module FlowChat
|
2
|
+
module Whatsapp
|
3
|
+
class Configuration
|
4
|
+
attr_accessor :access_token, :phone_number_id, :verify_token, :app_id, :app_secret,
|
5
|
+
:webhook_url, :webhook_verify_token, :business_account_id
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@access_token = nil
|
9
|
+
@phone_number_id = nil
|
10
|
+
@verify_token = nil
|
11
|
+
@app_id = nil
|
12
|
+
@app_secret = nil
|
13
|
+
@webhook_url = nil
|
14
|
+
@webhook_verify_token = nil
|
15
|
+
@business_account_id = nil
|
16
|
+
end
|
17
|
+
|
18
|
+
# Load configuration from Rails credentials or environment variables
|
19
|
+
def self.from_credentials
|
20
|
+
config = new
|
21
|
+
|
22
|
+
if defined?(Rails) && Rails.application.credentials.whatsapp
|
23
|
+
credentials = Rails.application.credentials.whatsapp
|
24
|
+
config.access_token = credentials[:access_token]
|
25
|
+
config.phone_number_id = credentials[:phone_number_id]
|
26
|
+
config.verify_token = credentials[:verify_token]
|
27
|
+
config.app_id = credentials[:app_id]
|
28
|
+
config.app_secret = credentials[:app_secret]
|
29
|
+
config.webhook_url = credentials[:webhook_url]
|
30
|
+
config.business_account_id = credentials[:business_account_id]
|
31
|
+
else
|
32
|
+
# Fallback to environment variables
|
33
|
+
config.access_token = ENV['WHATSAPP_ACCESS_TOKEN']
|
34
|
+
config.phone_number_id = ENV['WHATSAPP_PHONE_NUMBER_ID']
|
35
|
+
config.verify_token = ENV['WHATSAPP_VERIFY_TOKEN']
|
36
|
+
config.app_id = ENV['WHATSAPP_APP_ID']
|
37
|
+
config.app_secret = ENV['WHATSAPP_APP_SECRET']
|
38
|
+
config.webhook_url = ENV['WHATSAPP_WEBHOOK_URL']
|
39
|
+
config.business_account_id = ENV['WHATSAPP_BUSINESS_ACCOUNT_ID']
|
40
|
+
end
|
41
|
+
|
42
|
+
config
|
43
|
+
end
|
44
|
+
|
45
|
+
def valid?
|
46
|
+
access_token.present? && phone_number_id.present? && verify_token.present?
|
47
|
+
end
|
48
|
+
|
49
|
+
def webhook_configured?
|
50
|
+
webhook_url.present? && verify_token.present?
|
51
|
+
end
|
52
|
+
|
53
|
+
# API endpoints
|
54
|
+
def messages_url
|
55
|
+
"https://graph.facebook.com/v18.0/#{phone_number_id}/messages"
|
56
|
+
end
|
57
|
+
|
58
|
+
def media_url(media_id)
|
59
|
+
"https://graph.facebook.com/v18.0/#{media_id}"
|
60
|
+
end
|
61
|
+
|
62
|
+
def phone_numbers_url
|
63
|
+
"https://graph.facebook.com/v18.0/#{business_account_id}/phone_numbers"
|
64
|
+
end
|
65
|
+
|
66
|
+
# Headers for API requests
|
67
|
+
def api_headers
|
68
|
+
{
|
69
|
+
"Authorization" => "Bearer #{access_token}",
|
70
|
+
"Content-Type" => "application/json"
|
71
|
+
}
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,213 @@
|
|
1
|
+
require "net/http"
|
2
|
+
require "json"
|
3
|
+
require "phonelib"
|
4
|
+
|
5
|
+
module FlowChat
|
6
|
+
module Whatsapp
|
7
|
+
module Gateway
|
8
|
+
class CloudApi
|
9
|
+
WHATSAPP_API_URL = "https://graph.facebook.com/v18.0"
|
10
|
+
|
11
|
+
def initialize(app, config = nil)
|
12
|
+
@app = app
|
13
|
+
@config = config || FlowChat::Whatsapp::Configuration.from_credentials
|
14
|
+
end
|
15
|
+
|
16
|
+
def call(context)
|
17
|
+
controller = context.controller
|
18
|
+
request = controller.request
|
19
|
+
|
20
|
+
# Handle webhook verification
|
21
|
+
if request.get? && request.params["hub.mode"] == "subscribe"
|
22
|
+
return handle_verification(context)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Handle webhook messages
|
26
|
+
if request.post?
|
27
|
+
return handle_webhook(context)
|
28
|
+
end
|
29
|
+
|
30
|
+
controller.head :bad_request
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def handle_verification(context)
|
36
|
+
controller = context.controller
|
37
|
+
params = controller.request.params
|
38
|
+
|
39
|
+
verify_token = @config.verify_token
|
40
|
+
|
41
|
+
if params["hub.verify_token"] == verify_token
|
42
|
+
controller.render plain: params["hub.challenge"]
|
43
|
+
else
|
44
|
+
controller.head :forbidden
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def handle_webhook(context)
|
49
|
+
controller = context.controller
|
50
|
+
body = JSON.parse(controller.request.body.read)
|
51
|
+
|
52
|
+
# Extract message data from WhatsApp webhook
|
53
|
+
entry = body.dig("entry", 0)
|
54
|
+
return controller.head :ok unless entry
|
55
|
+
|
56
|
+
changes = entry.dig("changes", 0)
|
57
|
+
return controller.head :ok unless changes
|
58
|
+
|
59
|
+
value = changes["value"]
|
60
|
+
return controller.head :ok unless value
|
61
|
+
|
62
|
+
# Handle incoming messages
|
63
|
+
if value["messages"]&.any?
|
64
|
+
message = value["messages"].first
|
65
|
+
contact = value["contacts"]&.first
|
66
|
+
|
67
|
+
context["request.id"] = message["from"]
|
68
|
+
context["request.gateway"] = :whatsapp_cloud_api
|
69
|
+
context["request.message_id"] = message["id"]
|
70
|
+
context["request.msisdn"] = Phonelib.parse(message["from"]).e164
|
71
|
+
context["request.contact_name"] = contact&.dig("profile", "name")
|
72
|
+
context["request.timestamp"] = message["timestamp"]
|
73
|
+
|
74
|
+
# Extract message content based on type
|
75
|
+
case message["type"]
|
76
|
+
when "text"
|
77
|
+
context.input = message.dig("text", "body")
|
78
|
+
when "interactive"
|
79
|
+
# Handle button/list replies
|
80
|
+
if message.dig("interactive", "type") == "button_reply"
|
81
|
+
context.input = message.dig("interactive", "button_reply", "id")
|
82
|
+
elsif message.dig("interactive", "type") == "list_reply"
|
83
|
+
context.input = message.dig("interactive", "list_reply", "id")
|
84
|
+
end
|
85
|
+
when "location"
|
86
|
+
context["request.location"] = {
|
87
|
+
latitude: message.dig("location", "latitude"),
|
88
|
+
longitude: message.dig("location", "longitude"),
|
89
|
+
name: message.dig("location", "name"),
|
90
|
+
address: message.dig("location", "address")
|
91
|
+
}
|
92
|
+
# Set input so screen can proceed
|
93
|
+
context.input = "$location$"
|
94
|
+
when "image", "document", "audio", "video"
|
95
|
+
context["request.media"] = {
|
96
|
+
type: message["type"],
|
97
|
+
id: message.dig(message["type"], "id"),
|
98
|
+
mime_type: message.dig(message["type"], "mime_type"),
|
99
|
+
caption: message.dig(message["type"], "caption")
|
100
|
+
}
|
101
|
+
# Set input so screen can proceed
|
102
|
+
context.input = "$media$"
|
103
|
+
end
|
104
|
+
|
105
|
+
response = @app.call(context)
|
106
|
+
send_whatsapp_message(context, response)
|
107
|
+
end
|
108
|
+
|
109
|
+
# Handle message status updates
|
110
|
+
if value["statuses"]&.any?
|
111
|
+
# Log status updates but don't process them
|
112
|
+
Rails.logger.info "WhatsApp status update: #{value["statuses"]}"
|
113
|
+
end
|
114
|
+
|
115
|
+
controller.head :ok
|
116
|
+
end
|
117
|
+
|
118
|
+
def send_whatsapp_message(context, response)
|
119
|
+
return unless response
|
120
|
+
|
121
|
+
phone_number_id = @config.phone_number_id
|
122
|
+
access_token = @config.access_token
|
123
|
+
to = context["request.msisdn"]
|
124
|
+
|
125
|
+
message_data = build_message_payload(response, to)
|
126
|
+
|
127
|
+
uri = URI("#{WHATSAPP_API_URL}/#{phone_number_id}/messages")
|
128
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
129
|
+
http.use_ssl = true
|
130
|
+
|
131
|
+
request = Net::HTTP::Post.new(uri)
|
132
|
+
request["Authorization"] = "Bearer #{access_token}"
|
133
|
+
request["Content-Type"] = "application/json"
|
134
|
+
request.body = message_data.to_json
|
135
|
+
|
136
|
+
response = http.request(request)
|
137
|
+
|
138
|
+
unless response.is_a?(Net::HTTPSuccess)
|
139
|
+
Rails.logger.error "WhatsApp API error: #{response.body}"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def build_message_payload(response, to)
|
144
|
+
type, content, options = response
|
145
|
+
|
146
|
+
case type
|
147
|
+
when :text
|
148
|
+
{
|
149
|
+
messaging_product: "whatsapp",
|
150
|
+
to: to,
|
151
|
+
type: "text",
|
152
|
+
text: { body: content }
|
153
|
+
}
|
154
|
+
when :interactive_buttons
|
155
|
+
{
|
156
|
+
messaging_product: "whatsapp",
|
157
|
+
to: to,
|
158
|
+
type: "interactive",
|
159
|
+
interactive: {
|
160
|
+
type: "button",
|
161
|
+
body: { text: content },
|
162
|
+
action: {
|
163
|
+
buttons: options[:buttons].map.with_index do |button, index|
|
164
|
+
{
|
165
|
+
type: "reply",
|
166
|
+
reply: {
|
167
|
+
id: button[:id] || index.to_s,
|
168
|
+
title: button[:title]
|
169
|
+
}
|
170
|
+
}
|
171
|
+
end
|
172
|
+
}
|
173
|
+
}
|
174
|
+
}
|
175
|
+
when :interactive_list
|
176
|
+
{
|
177
|
+
messaging_product: "whatsapp",
|
178
|
+
to: to,
|
179
|
+
type: "interactive",
|
180
|
+
interactive: {
|
181
|
+
type: "list",
|
182
|
+
body: { text: content },
|
183
|
+
action: {
|
184
|
+
button: options[:button_text] || "Choose",
|
185
|
+
sections: options[:sections]
|
186
|
+
}
|
187
|
+
}
|
188
|
+
}
|
189
|
+
when :template
|
190
|
+
{
|
191
|
+
messaging_product: "whatsapp",
|
192
|
+
to: to,
|
193
|
+
type: "template",
|
194
|
+
template: {
|
195
|
+
name: options[:template_name],
|
196
|
+
language: { code: options[:language] || "en_US" },
|
197
|
+
components: options[:components] || []
|
198
|
+
}
|
199
|
+
}
|
200
|
+
else
|
201
|
+
# Default to text message
|
202
|
+
{
|
203
|
+
messaging_product: "whatsapp",
|
204
|
+
to: to,
|
205
|
+
type: "text",
|
206
|
+
text: { body: content.to_s }
|
207
|
+
}
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module FlowChat
|
2
|
+
module Whatsapp
|
3
|
+
module Middleware
|
4
|
+
class Executor
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(context)
|
10
|
+
whatsapp_app = build_whatsapp_app context
|
11
|
+
flow = context.flow.new whatsapp_app
|
12
|
+
flow.send context["flow.action"]
|
13
|
+
rescue FlowChat::Interrupt::Prompt => e
|
14
|
+
# Return the interrupt data for WhatsApp message formatting
|
15
|
+
e.prompt
|
16
|
+
rescue FlowChat::Interrupt::Terminate => e
|
17
|
+
# Clean up session and return terminal message
|
18
|
+
context.session.destroy
|
19
|
+
e.prompt
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def build_whatsapp_app(context)
|
25
|
+
FlowChat::Whatsapp::App.new(context)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module FlowChat
|
2
|
+
module Whatsapp
|
3
|
+
class Processor < FlowChat::BaseProcessor
|
4
|
+
def use_whatsapp_config(config)
|
5
|
+
@whatsapp_config = config
|
6
|
+
self
|
7
|
+
end
|
8
|
+
|
9
|
+
def use_gateway(gateway_class)
|
10
|
+
if gateway_class == FlowChat::Whatsapp::Gateway::CloudApi
|
11
|
+
@gateway = @whatsapp_config ? gateway_class.new(nil, @whatsapp_config) : gateway_class.new(nil)
|
12
|
+
else
|
13
|
+
@gateway = gateway_class.new(nil)
|
14
|
+
end
|
15
|
+
self
|
16
|
+
end
|
17
|
+
|
18
|
+
protected
|
19
|
+
|
20
|
+
def middleware_name
|
21
|
+
"whatsapp.middleware"
|
22
|
+
end
|
23
|
+
|
24
|
+
def build_middleware_stack
|
25
|
+
create_middleware_stack("whatsapp")
|
26
|
+
end
|
27
|
+
|
28
|
+
def configure_middleware_stack(builder)
|
29
|
+
builder.use gateway
|
30
|
+
builder.use FlowChat::Session::Middleware
|
31
|
+
builder.use middleware
|
32
|
+
builder.use FlowChat::Whatsapp::Middleware::Executor
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,206 @@
|
|
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)
|
11
|
+
if input.present?
|
12
|
+
processed_input = process_input(input, transform, validate, convert)
|
13
|
+
return processed_input unless processed_input.nil?
|
14
|
+
end
|
15
|
+
|
16
|
+
# Send message and wait for response
|
17
|
+
raise FlowChat::Interrupt::Prompt.new([:text, message, {}])
|
18
|
+
end
|
19
|
+
|
20
|
+
def select(message, choices, transform: nil, validate: nil, convert: nil)
|
21
|
+
if input.present?
|
22
|
+
processed_input = process_selection(input, choices, transform, validate, convert)
|
23
|
+
return processed_input unless processed_input.nil?
|
24
|
+
end
|
25
|
+
|
26
|
+
# Validate choices
|
27
|
+
validate_choices(choices)
|
28
|
+
|
29
|
+
# Determine the best way to present choices
|
30
|
+
if choices.is_a?(Array)
|
31
|
+
# Convert array to hash with index-based keys
|
32
|
+
choice_hash = choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
|
33
|
+
present_choices_as_list(message, choice_hash)
|
34
|
+
elsif choices.is_a?(Hash)
|
35
|
+
if choices.length <= 3
|
36
|
+
# Use buttons for 3 or fewer choices
|
37
|
+
present_choices_as_buttons(message, choices)
|
38
|
+
else
|
39
|
+
# Use list for more than 3 choices
|
40
|
+
present_choices_as_list(message, choices)
|
41
|
+
end
|
42
|
+
else
|
43
|
+
raise ArgumentError, "choices must be an Array or Hash"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def yes?(message, transform: nil, validate: nil, convert: nil)
|
48
|
+
if input.present?
|
49
|
+
processed_input = process_boolean(input, transform, validate, convert)
|
50
|
+
return processed_input unless processed_input.nil?
|
51
|
+
end
|
52
|
+
|
53
|
+
buttons = [
|
54
|
+
{ id: "yes", title: "Yes" },
|
55
|
+
{ id: "no", title: "No" }
|
56
|
+
]
|
57
|
+
|
58
|
+
raise FlowChat::Interrupt::Prompt.new([:interactive_buttons, message, { buttons: buttons }])
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def process_input(input, transform, validate, convert)
|
64
|
+
# Apply transformation
|
65
|
+
transformed_input = transform ? transform.call(input) : input
|
66
|
+
|
67
|
+
# Apply conversion first, then validation
|
68
|
+
converted_input = convert ? convert.call(transformed_input) : transformed_input
|
69
|
+
|
70
|
+
# Apply validation on converted value
|
71
|
+
if validate
|
72
|
+
error_message = validate.call(converted_input)
|
73
|
+
if error_message
|
74
|
+
raise FlowChat::Interrupt::Prompt.new([:text, error_message, {}])
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
converted_input
|
79
|
+
end
|
80
|
+
|
81
|
+
def process_selection(input, choices, transform, validate, convert)
|
82
|
+
choice_hash = choices.is_a?(Array) ?
|
83
|
+
choices.each_with_index.to_h { |choice, index| [index.to_s, choice] } :
|
84
|
+
choices
|
85
|
+
|
86
|
+
# Check if input matches a valid choice
|
87
|
+
if choice_hash.key?(input)
|
88
|
+
selected_value = choice_hash[input]
|
89
|
+
process_input(selected_value, transform, validate, convert)
|
90
|
+
elsif choice_hash.value?(input)
|
91
|
+
# Input matches a choice value directly
|
92
|
+
process_input(input, transform, validate, convert)
|
93
|
+
else
|
94
|
+
# Invalid choice
|
95
|
+
choice_list = choice_hash.map { |key, value| "#{key}: #{value}" }.join("\n")
|
96
|
+
error_message = "Invalid choice. Please select one of:\n#{choice_list}"
|
97
|
+
raise FlowChat::Interrupt::Prompt.new([:text, error_message, {}])
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def process_boolean(input, transform, validate, convert)
|
102
|
+
boolean_value = case input.to_s.downcase
|
103
|
+
when "yes", "y", "1", "true"
|
104
|
+
true
|
105
|
+
when "no", "n", "0", "false"
|
106
|
+
false
|
107
|
+
else
|
108
|
+
nil
|
109
|
+
end
|
110
|
+
|
111
|
+
if boolean_value.nil?
|
112
|
+
raise FlowChat::Interrupt::Prompt.new([:text, "Please answer with Yes or No.", {}])
|
113
|
+
end
|
114
|
+
|
115
|
+
process_input(boolean_value, transform, validate, convert)
|
116
|
+
end
|
117
|
+
|
118
|
+
def present_choices_as_buttons(message, choices)
|
119
|
+
buttons = choices.map do |key, value|
|
120
|
+
{
|
121
|
+
id: key.to_s,
|
122
|
+
title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
|
123
|
+
}
|
124
|
+
end
|
125
|
+
|
126
|
+
raise FlowChat::Interrupt::Prompt.new([:interactive_buttons, message, { buttons: buttons }])
|
127
|
+
end
|
128
|
+
|
129
|
+
def present_choices_as_list(message, choices)
|
130
|
+
items = choices.map do |key, value|
|
131
|
+
original_text = value.to_s
|
132
|
+
truncated_title = truncate_text(original_text, 24)
|
133
|
+
|
134
|
+
# If title was truncated, put full text in description (up to 72 chars)
|
135
|
+
description = if original_text.length > 24
|
136
|
+
truncate_text(original_text, 72)
|
137
|
+
else
|
138
|
+
nil
|
139
|
+
end
|
140
|
+
|
141
|
+
{
|
142
|
+
id: key.to_s,
|
143
|
+
title: truncated_title,
|
144
|
+
description: description
|
145
|
+
}.compact
|
146
|
+
end
|
147
|
+
|
148
|
+
# If 10 or fewer items, use single section
|
149
|
+
if items.length <= 10
|
150
|
+
sections = [
|
151
|
+
{
|
152
|
+
title: "Options",
|
153
|
+
rows: items
|
154
|
+
}
|
155
|
+
]
|
156
|
+
else
|
157
|
+
# Paginate into multiple sections (max 10 items per section)
|
158
|
+
sections = items.each_slice(10).with_index.map do |section_items, index|
|
159
|
+
start_num = (index * 10) + 1
|
160
|
+
end_num = start_num + section_items.length - 1
|
161
|
+
|
162
|
+
{
|
163
|
+
title: "#{start_num}-#{end_num}",
|
164
|
+
rows: section_items
|
165
|
+
}
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
raise FlowChat::Interrupt::Prompt.new([:interactive_list, message, { sections: sections }])
|
170
|
+
end
|
171
|
+
|
172
|
+
def validate_choices(choices)
|
173
|
+
# Check for empty choices
|
174
|
+
if choices.nil? || choices.empty?
|
175
|
+
raise ArgumentError, "choices cannot be empty"
|
176
|
+
end
|
177
|
+
|
178
|
+
choice_count = choices.is_a?(Array) ? choices.length : choices.length
|
179
|
+
|
180
|
+
# WhatsApp supports max 100 total items across all sections
|
181
|
+
if choice_count > 100
|
182
|
+
raise ArgumentError, "WhatsApp supports maximum 100 choice options, got #{choice_count}"
|
183
|
+
end
|
184
|
+
|
185
|
+
# Validate individual choice values
|
186
|
+
choices_to_validate = choices.is_a?(Array) ? choices : choices.values
|
187
|
+
|
188
|
+
choices_to_validate.each_with_index do |choice, index|
|
189
|
+
if choice.nil? || choice.to_s.strip.empty?
|
190
|
+
raise ArgumentError, "choice at index #{index} cannot be empty"
|
191
|
+
end
|
192
|
+
|
193
|
+
choice_text = choice.to_s
|
194
|
+
if choice_text.length > 100
|
195
|
+
raise ArgumentError, "choice '#{choice_text[0..20]}...' is too long (#{choice_text.length} chars). Maximum is 100 characters"
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def truncate_text(text, length)
|
201
|
+
return text if text.length <= length
|
202
|
+
text[0, length - 3] + "..."
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|