flow_chat 0.8.1 → 0.9.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/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +290 -105
- data/Rakefile +5 -1
- data/SECURITY.md +42 -349
- data/docs/architecture.md +510 -0
- data/docs/async-background-processing.md +298 -0
- data/docs/configuration.md +556 -226
- data/docs/factory-pattern.md +355 -0
- data/docs/gateway-context-variables.md +171 -0
- data/docs/gateway-development.md +723 -0
- data/docs/getting-started.md +429 -0
- data/docs/instrumentation.md +264 -153
- data/docs/platforms/telegram.md +1013 -0
- data/docs/platforms/ussd.md +693 -0
- data/docs/platforms/whatsapp.md +1395 -0
- data/docs/testing.md +243 -365
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +154 -0
- data/examples/intercom_configuration_example.rb +118 -0
- data/examples/intercom_controller.rb +194 -0
- data/examples/multi_tenant_whatsapp_controller.rb +4 -4
- data/examples/simulator_controller.rb +21 -1
- data/examples/ussd_controller.rb +10 -10
- data/examples/whatsapp_controller.rb +2 -2
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/{base_app.rb → app.rb} +28 -9
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/config.rb +23 -28
- data/lib/flow_chat/{base_executor.rb → executor.rb} +7 -12
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/gateway/simple.rb +125 -0
- data/lib/flow_chat/http/renderer.rb +41 -0
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +25 -0
- data/lib/flow_chat/intercom/client.rb +155 -0
- data/lib/flow_chat/intercom/configuration.rb +149 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
- data/lib/flow_chat/intercom/renderer.rb +71 -0
- data/lib/flow_chat/phone_number_util.rb +49 -0
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/renderers/markdown_support.rb +58 -0
- data/lib/flow_chat/session/cache_session_store.rb +1 -17
- data/lib/flow_chat/session/middleware.rb +43 -26
- data/lib/flow_chat/simulator/controller.rb +17 -5
- data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
- data/lib/flow_chat/telegram/client.rb +240 -0
- data/lib/flow_chat/telegram/configuration.rb +118 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
- data/lib/flow_chat/telegram/renderer.rb +125 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +27 -11
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +144 -13
- data/lib/flow_chat/whatsapp/configuration.rb +1 -1
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +144 -106
- data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
- data/lib/flow_chat/whatsapp/renderer.rb +145 -11
- data/lib/flow_chat.rb +11 -1
- data/lib/tasks/release.rake +165 -0
- metadata +88 -21
- data/docs/flows.md +0 -320
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_processor.rb +0 -145
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -98
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
require "net/http"
|
|
2
2
|
require "json"
|
|
3
|
-
require "phonelib"
|
|
4
3
|
require "openssl"
|
|
5
4
|
|
|
6
5
|
module FlowChat
|
|
@@ -11,6 +10,7 @@ module FlowChat
|
|
|
11
10
|
module Gateway
|
|
12
11
|
class CloudApi
|
|
13
12
|
include FlowChat::Instrumentation
|
|
13
|
+
include FlowChat::GatewayAsyncSupport
|
|
14
14
|
|
|
15
15
|
attr_reader :context
|
|
16
16
|
|
|
@@ -25,48 +25,59 @@ module FlowChat
|
|
|
25
25
|
|
|
26
26
|
def call(context)
|
|
27
27
|
@context = context
|
|
28
|
-
controller = context.controller
|
|
29
|
-
request = controller.request
|
|
28
|
+
@controller = context.controller
|
|
29
|
+
request = @controller.request
|
|
30
30
|
|
|
31
31
|
FlowChat.logger.debug { "CloudApi: Processing #{request.request_method} request to #{request.path}" }
|
|
32
32
|
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
# Skip webhook-specific handling in background mode
|
|
34
|
+
unless in_background?
|
|
35
|
+
# Handle webhook verification
|
|
36
|
+
if request.get? && request.params["hub.mode"] == "subscribe"
|
|
37
|
+
FlowChat.logger.info { "CloudApi: Handling webhook verification request" }
|
|
38
|
+
return handle_verification(context)
|
|
39
|
+
end
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
# Handle webhook messages
|
|
40
43
|
if request.post?
|
|
41
|
-
FlowChat.logger.info { "CloudApi: Handling webhook message" }
|
|
44
|
+
FlowChat.logger.info { "CloudApi: Handling webhook message (background: #{in_background?})" }
|
|
42
45
|
return handle_webhook(context)
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
FlowChat.logger.warn { "CloudApi: Invalid request method or parameters - returning bad request" }
|
|
46
|
-
controller.head :bad_request
|
|
49
|
+
@controller.head :bad_request
|
|
47
50
|
end
|
|
48
51
|
|
|
49
52
|
# Expose client for out-of-band messaging
|
|
50
53
|
attr_reader :client
|
|
51
54
|
|
|
55
|
+
# Configure WhatsApp-specific middleware stack
|
|
56
|
+
def self.configure_middleware_stack(builder, custom_middleware)
|
|
57
|
+
FlowChat.logger.debug { "CloudApi: Configuring WhatsApp middleware stack" }
|
|
58
|
+
|
|
59
|
+
builder.use custom_middleware
|
|
60
|
+
FlowChat.logger.debug { "CloudApi: Added custom middleware" }
|
|
61
|
+
|
|
62
|
+
builder.use FlowChat::Whatsapp::Middleware::ChoiceMapper
|
|
63
|
+
FlowChat.logger.debug { "CloudApi: Added Whatsapp::Middleware::ChoiceMapper" }
|
|
64
|
+
end
|
|
65
|
+
|
|
52
66
|
private
|
|
53
67
|
|
|
54
68
|
def determine_message_handler(context)
|
|
55
|
-
#
|
|
69
|
+
# Use simulator mode if enabled, otherwise always use inline
|
|
56
70
|
if context["simulator_mode"]
|
|
57
71
|
FlowChat.logger.debug { "CloudApi: Using simulator message handler" }
|
|
58
|
-
|
|
72
|
+
:simulator
|
|
73
|
+
else
|
|
74
|
+
FlowChat.logger.debug { "CloudApi: Using inline message handler" }
|
|
75
|
+
:inline
|
|
59
76
|
end
|
|
60
|
-
|
|
61
|
-
# Use global WhatsApp configuration
|
|
62
|
-
mode = FlowChat::Config.whatsapp.message_handling_mode
|
|
63
|
-
FlowChat.logger.debug { "CloudApi: Using #{mode} message handling mode" }
|
|
64
|
-
mode
|
|
65
77
|
end
|
|
66
78
|
|
|
67
79
|
def handle_verification(context)
|
|
68
|
-
|
|
69
|
-
params = controller.request.params
|
|
80
|
+
params = @controller.request.params
|
|
70
81
|
|
|
71
82
|
verify_token = @config.verify_token
|
|
72
83
|
provided_token = params["hub.verify_token"]
|
|
@@ -81,7 +92,7 @@ module FlowChat
|
|
|
81
92
|
platform: :whatsapp
|
|
82
93
|
})
|
|
83
94
|
|
|
84
|
-
controller.render plain: challenge
|
|
95
|
+
@controller.render plain: challenge
|
|
85
96
|
else
|
|
86
97
|
# Use instrumentation for webhook verification failure
|
|
87
98
|
instrument(Events::WEBHOOK_FAILED, {
|
|
@@ -89,20 +100,18 @@ module FlowChat
|
|
|
89
100
|
platform: :whatsapp
|
|
90
101
|
})
|
|
91
102
|
|
|
92
|
-
controller.head :forbidden
|
|
103
|
+
@controller.head :forbidden
|
|
93
104
|
end
|
|
94
105
|
end
|
|
95
106
|
|
|
96
107
|
def handle_webhook(context)
|
|
97
|
-
controller = context.controller
|
|
98
|
-
|
|
99
108
|
# Parse body
|
|
100
109
|
begin
|
|
101
|
-
parse_request_body(controller.request)
|
|
110
|
+
parse_request_body(@controller.request)
|
|
102
111
|
FlowChat.logger.debug { "CloudApi: Successfully parsed webhook request body" }
|
|
103
112
|
rescue JSON::ParserError => e
|
|
104
113
|
FlowChat.logger.error { "CloudApi: Failed to parse webhook body: #{e.message}" }
|
|
105
|
-
return controller.head :bad_request
|
|
114
|
+
return @controller.head :bad_request
|
|
106
115
|
end
|
|
107
116
|
|
|
108
117
|
# Check for simulator mode parameter in request (before validation)
|
|
@@ -113,10 +122,11 @@ module FlowChat
|
|
|
113
122
|
context["simulator_mode"] = true
|
|
114
123
|
end
|
|
115
124
|
|
|
116
|
-
# Validate webhook signature for security (skip for simulator mode)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
# Validate webhook signature for security (skip for simulator mode and background)
|
|
126
|
+
# Return 200 OK even for invalid signatures to prevent WhatsApp from retrying
|
|
127
|
+
unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
|
|
128
|
+
FlowChat.logger.warn { "CloudApi: Invalid webhook signature - dropping request" }
|
|
129
|
+
return @controller.head :ok
|
|
120
130
|
end
|
|
121
131
|
|
|
122
132
|
FlowChat.logger.debug { "CloudApi: Webhook signature validation passed" }
|
|
@@ -125,19 +135,19 @@ module FlowChat
|
|
|
125
135
|
entry = @body.dig("entry", 0)
|
|
126
136
|
unless entry
|
|
127
137
|
FlowChat.logger.debug { "CloudApi: No entry found in webhook body - returning OK" }
|
|
128
|
-
return controller.head :ok
|
|
138
|
+
return @controller.head :ok
|
|
129
139
|
end
|
|
130
140
|
|
|
131
141
|
changes = entry.dig("changes", 0)
|
|
132
142
|
unless changes
|
|
133
143
|
FlowChat.logger.debug { "CloudApi: No changes found in webhook entry - returning OK" }
|
|
134
|
-
return controller.head :ok
|
|
144
|
+
return @controller.head :ok
|
|
135
145
|
end
|
|
136
146
|
|
|
137
147
|
value = changes["value"]
|
|
138
148
|
unless value
|
|
139
149
|
FlowChat.logger.debug { "CloudApi: No value found in webhook changes - returning OK" }
|
|
140
|
-
return controller.head :ok
|
|
150
|
+
return @controller.head :ok
|
|
141
151
|
end
|
|
142
152
|
|
|
143
153
|
# Handle incoming messages
|
|
@@ -145,44 +155,65 @@ module FlowChat
|
|
|
145
155
|
message = value["messages"].first
|
|
146
156
|
contact = value["contacts"]&.first
|
|
147
157
|
|
|
148
|
-
phone_number = message["from"]
|
|
158
|
+
phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
|
|
149
159
|
message_id = message["id"]
|
|
150
160
|
contact_name = contact&.dig("profile", "name")
|
|
161
|
+
business_phone_number = value.dig("metadata", "display_phone_number")
|
|
162
|
+
business_phone_number_id = value.dig("metadata", "phone_number_id")
|
|
151
163
|
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
message_id: message_id,
|
|
158
|
-
platform: :whatsapp
|
|
159
|
-
})
|
|
164
|
+
# Validate that webhook is for our configured phone number
|
|
165
|
+
if business_phone_number_id != @config.phone_number_id
|
|
166
|
+
FlowChat.logger.warn { "CloudApi: Webhook received for phone_number_id '#{business_phone_number_id}' but configured for '#{@config.phone_number_id}' - rejecting" }
|
|
167
|
+
return @controller.head :forbidden
|
|
168
|
+
end
|
|
160
169
|
|
|
161
170
|
context["request.id"] = phone_number
|
|
171
|
+
context["request.user_id"] = phone_number
|
|
172
|
+
context["request.user_name"] = contact_name if contact_name
|
|
173
|
+
context["request.msisdn"] = phone_number
|
|
162
174
|
context["request.gateway"] = :whatsapp_cloud_api
|
|
163
175
|
context["request.platform"] = :whatsapp
|
|
164
176
|
context["request.message_id"] = message_id
|
|
165
|
-
context["request.
|
|
166
|
-
context["request.
|
|
167
|
-
|
|
177
|
+
context["request.timestamp"] = Time.current.iso8601
|
|
178
|
+
context["request.body"] = @body
|
|
179
|
+
|
|
180
|
+
context["whatsapp.business.phone_number"] = FlowChat::PhoneNumberUtil.to_e164(business_phone_number)
|
|
181
|
+
context["whatsapp.business.phone_number_id"] = business_phone_number_id
|
|
182
|
+
context["whatsapp.client"] = @client
|
|
168
183
|
|
|
169
184
|
# Extract message content based on type
|
|
170
|
-
extract_message_content(message, context)
|
|
185
|
+
extract_message_content!(message, context)
|
|
186
|
+
|
|
187
|
+
if context.input.present?
|
|
188
|
+
# Use instrumentation for message received
|
|
189
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
|
190
|
+
from: phone_number,
|
|
191
|
+
message: context.input,
|
|
192
|
+
message_type: message["type"],
|
|
193
|
+
message_id: message_id
|
|
194
|
+
})
|
|
195
|
+
end
|
|
171
196
|
|
|
172
197
|
FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
|
|
173
198
|
|
|
174
|
-
# Determine
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
#
|
|
185
|
-
|
|
199
|
+
# Determine routing: async enqueue, background execute, or inline
|
|
200
|
+
if should_enqueue_async?
|
|
201
|
+
# Webhook with async enabled → enqueue job and return immediately
|
|
202
|
+
enqueue_async_job
|
|
203
|
+
return @controller.head :ok
|
|
204
|
+
else
|
|
205
|
+
# Background OR inline → process message
|
|
206
|
+
# Determine message handling mode (simulator vs inline)
|
|
207
|
+
handler_mode = determine_message_handler(context)
|
|
208
|
+
|
|
209
|
+
# Process the message based on handling mode
|
|
210
|
+
case handler_mode
|
|
211
|
+
when :inline
|
|
212
|
+
handle_message_inline(context, @controller)
|
|
213
|
+
when :simulator
|
|
214
|
+
# Return early from simulator mode to preserve the JSON response
|
|
215
|
+
return handle_message_simulator(context, @controller)
|
|
216
|
+
end
|
|
186
217
|
end
|
|
187
218
|
end
|
|
188
219
|
|
|
@@ -193,7 +224,7 @@ module FlowChat
|
|
|
193
224
|
FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
|
|
194
225
|
end
|
|
195
226
|
|
|
196
|
-
controller.head :ok
|
|
227
|
+
@controller.head :ok
|
|
197
228
|
end
|
|
198
229
|
|
|
199
230
|
# Validate webhook signature to ensure request comes from WhatsApp
|
|
@@ -260,14 +291,14 @@ module FlowChat
|
|
|
260
291
|
res == 0
|
|
261
292
|
end
|
|
262
293
|
|
|
263
|
-
def extract_message_content(message, context)
|
|
294
|
+
def extract_message_content!(message, context)
|
|
264
295
|
message_type = message["type"]
|
|
265
296
|
FlowChat.logger.debug { "CloudApi: Extracting content from #{message_type} message" }
|
|
266
297
|
|
|
267
298
|
case message_type
|
|
268
299
|
when "text"
|
|
269
300
|
content = message.dig("text", "body")
|
|
270
|
-
context.input = content
|
|
301
|
+
context.input = content.presence || ""
|
|
271
302
|
FlowChat.logger.debug { "CloudApi: Text message content: '#{content}'" }
|
|
272
303
|
when "interactive"
|
|
273
304
|
# Handle button/list replies
|
|
@@ -288,57 +319,57 @@ module FlowChat
|
|
|
288
319
|
address: message.dig("location", "address")
|
|
289
320
|
}
|
|
290
321
|
context["request.location"] = location
|
|
291
|
-
context.input =
|
|
322
|
+
context.input = FlowChat::Input::LOCATION
|
|
292
323
|
FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
|
|
293
|
-
when "image", "document", "audio", "video"
|
|
324
|
+
when "image", "document", "audio", "video", "sticker"
|
|
325
|
+
media_data = message[message["type"]]
|
|
294
326
|
context["request.media"] = {
|
|
295
|
-
type: message["type"],
|
|
296
|
-
id:
|
|
297
|
-
mime_type:
|
|
298
|
-
caption:
|
|
327
|
+
type: message["type"].to_sym,
|
|
328
|
+
id: media_data["id"],
|
|
329
|
+
mime_type: media_data["mime_type"],
|
|
330
|
+
caption: media_data["caption"],
|
|
331
|
+
filename: media_data["filename"],
|
|
332
|
+
sha256: media_data["sha256"],
|
|
333
|
+
animated: media_data["animated"]
|
|
299
334
|
}
|
|
300
|
-
context.input =
|
|
335
|
+
context.input = FlowChat::Input::MEDIA
|
|
336
|
+
FlowChat.logger.debug { "CloudApi: Media received - Type: #{message["type"]}, ID: #{media_data["id"]}" }
|
|
337
|
+
when "contacts"
|
|
338
|
+
# WhatsApp sends contacts as an array, take the first one
|
|
339
|
+
contact_data = message.dig("contacts", 0)
|
|
340
|
+
if contact_data
|
|
341
|
+
phones = contact_data.dig("phones") || []
|
|
342
|
+
context["request.contact"] = {
|
|
343
|
+
name: contact_data.dig("name", "formatted_name"),
|
|
344
|
+
first_name: contact_data.dig("name", "first_name"),
|
|
345
|
+
last_name: contact_data.dig("name", "last_name"),
|
|
346
|
+
phones: phones.map { |p| p["phone"] },
|
|
347
|
+
phone_number: phones.first&.dig("phone")
|
|
348
|
+
}
|
|
349
|
+
context.input = FlowChat::Input::CONTACT
|
|
350
|
+
FlowChat.logger.debug { "CloudApi: Contact received - Name: #{context["request.contact"][:name]}" }
|
|
351
|
+
end
|
|
301
352
|
end
|
|
302
353
|
end
|
|
303
354
|
|
|
304
355
|
def handle_message_inline(context, controller)
|
|
305
356
|
response = @app.call(context)
|
|
306
357
|
if response
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
result = @client.send_message(context["request.msisdn"], rendered_message)
|
|
358
|
+
type, prompt, choices, media = response
|
|
359
|
+
result = @client.send_message(context["request.msisdn"], prompt, choices: choices, media: media)
|
|
310
360
|
context["whatsapp.message_result"] = result
|
|
311
|
-
end
|
|
312
|
-
end
|
|
313
361
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
response: rendered_message,
|
|
326
|
-
config_name: @config.name
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
# Get job class from configuration
|
|
330
|
-
job_class_name = FlowChat::Config.whatsapp.background_job_class
|
|
331
|
-
|
|
332
|
-
# Enqueue background job for sending only
|
|
333
|
-
begin
|
|
334
|
-
job_class = job_class_name.constantize
|
|
335
|
-
job_class.perform_later(send_data)
|
|
336
|
-
rescue NameError
|
|
337
|
-
# Fallback to inline sending if no job system
|
|
338
|
-
Rails.logger.warn "Background mode requested but no #{job_class_name} found. Falling back to inline sending."
|
|
339
|
-
result = @client.send_message(context["request.msisdn"], rendered_message)
|
|
340
|
-
context["whatsapp.message_result"] = result
|
|
341
|
-
end
|
|
362
|
+
# Instrument message sent
|
|
363
|
+
instrument(Events::MESSAGE_SENT, {
|
|
364
|
+
to: context["request.msisdn"],
|
|
365
|
+
session_id: context["request.id"],
|
|
366
|
+
message: prompt,
|
|
367
|
+
message_type: (type == :prompt) ? "prompt" : "terminal",
|
|
368
|
+
gateway: :whatsapp_cloud_api,
|
|
369
|
+
platform: :whatsapp,
|
|
370
|
+
content_length: prompt.to_s.length,
|
|
371
|
+
timestamp: context["request.timestamp"]
|
|
372
|
+
})
|
|
342
373
|
end
|
|
343
374
|
end
|
|
344
375
|
|
|
@@ -346,12 +377,12 @@ module FlowChat
|
|
|
346
377
|
response = @app.call(context)
|
|
347
378
|
|
|
348
379
|
if response
|
|
349
|
-
|
|
350
|
-
|
|
380
|
+
_, prompt, choices, media = response
|
|
381
|
+
response_data = render_response(prompt, choices, media)
|
|
351
382
|
|
|
352
383
|
# For simulator mode, return the response data in the HTTP response
|
|
353
384
|
# instead of actually sending via WhatsApp API
|
|
354
|
-
message_payload = @client.build_message_payload(
|
|
385
|
+
message_payload = @client.build_message_payload(response_data, context["request.msisdn"])
|
|
355
386
|
|
|
356
387
|
simulator_response = {
|
|
357
388
|
mode: "simulator",
|
|
@@ -359,7 +390,7 @@ module FlowChat
|
|
|
359
390
|
would_send: message_payload,
|
|
360
391
|
message_info: {
|
|
361
392
|
to: context["request.msisdn"],
|
|
362
|
-
contact_name: context["request.
|
|
393
|
+
contact_name: context["request.user_name"],
|
|
363
394
|
timestamp: Time.now.iso8601
|
|
364
395
|
}
|
|
365
396
|
}
|
|
@@ -382,8 +413,7 @@ module FlowChat
|
|
|
382
413
|
return false unless simulator_secret && !simulator_secret.empty?
|
|
383
414
|
|
|
384
415
|
# Check for simulator cookie
|
|
385
|
-
|
|
386
|
-
simulator_cookie = request.cookies["flowchat_simulator"]
|
|
416
|
+
simulator_cookie = @controller.request.cookies["flowchat_simulator"]
|
|
387
417
|
return false unless simulator_cookie
|
|
388
418
|
|
|
389
419
|
# Verify the cookie is a valid HMAC signature
|
|
@@ -410,7 +440,15 @@ module FlowChat
|
|
|
410
440
|
end
|
|
411
441
|
|
|
412
442
|
def parse_request_body(request)
|
|
413
|
-
@body
|
|
443
|
+
return @body if @body
|
|
444
|
+
|
|
445
|
+
if request.body.nil?
|
|
446
|
+
FlowChat.logger.debug { "CloudApi: Request body is nil, returning empty hash" }
|
|
447
|
+
@body = {}
|
|
448
|
+
else
|
|
449
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
450
|
+
@body = JSON.parse(request.body.read)
|
|
451
|
+
end
|
|
414
452
|
end
|
|
415
453
|
|
|
416
454
|
def render_response(prompt, choices, media)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
module Whatsapp
|
|
5
|
+
# Generates WhatsApp-safe IDs from choice labels
|
|
6
|
+
#
|
|
7
|
+
# WhatsApp Cloud API requires button and list item IDs to be:
|
|
8
|
+
# - Maximum 256 characters
|
|
9
|
+
# - Unique within the message
|
|
10
|
+
# - Non-empty strings
|
|
11
|
+
#
|
|
12
|
+
# This generator:
|
|
13
|
+
# - Sanitizes labels to alphanumeric + underscore/hyphen
|
|
14
|
+
# - Truncates to fit within 256-char limit
|
|
15
|
+
# - Appends hash suffix for duplicate labels (similar to Rails index naming)
|
|
16
|
+
# - Ensures readability while maintaining uniqueness
|
|
17
|
+
#
|
|
18
|
+
# @example Basic usage
|
|
19
|
+
# generator = IdGenerator.new
|
|
20
|
+
# id = generator.generate_id("Create Account") # => "create_account"
|
|
21
|
+
#
|
|
22
|
+
# @example Handling duplicates
|
|
23
|
+
# generator = IdGenerator.new
|
|
24
|
+
# id1 = generator.generate_id("Accept") # => "accept"
|
|
25
|
+
# id2 = generator.generate_id("Accept") # => "accept_a1b2c3"
|
|
26
|
+
#
|
|
27
|
+
# @example Special characters
|
|
28
|
+
# generator = IdGenerator.new
|
|
29
|
+
# id = generator.generate_id("Yes! 👍 (recommended)") # => "yes_recommended"
|
|
30
|
+
#
|
|
31
|
+
class IdGenerator
|
|
32
|
+
MAX_ID_LENGTH = 256
|
|
33
|
+
HASH_SUFFIX_LENGTH = 3
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@generated_ids = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Generate a WhatsApp-safe ID from a label
|
|
40
|
+
#
|
|
41
|
+
# @param label [String] The choice label to convert
|
|
42
|
+
# @return [String] A sanitized, unique ID
|
|
43
|
+
def generate_id(label)
|
|
44
|
+
# Normalize the label
|
|
45
|
+
normalized = normalize_label(label)
|
|
46
|
+
|
|
47
|
+
# If normalized label is empty, use a fallback
|
|
48
|
+
normalized = "choice" if normalized.empty?
|
|
49
|
+
|
|
50
|
+
# Truncate to limit first
|
|
51
|
+
truncated = truncate_to_limit(normalized)
|
|
52
|
+
|
|
53
|
+
# Check if we need a hash suffix for uniqueness
|
|
54
|
+
final_id = if @generated_ids.include?(truncated)
|
|
55
|
+
add_hash_suffix(truncated, label)
|
|
56
|
+
else
|
|
57
|
+
truncated
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Track this ID
|
|
61
|
+
@generated_ids << final_id
|
|
62
|
+
|
|
63
|
+
final_id
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Reset the generator state (useful for testing or starting a new message)
|
|
67
|
+
def reset
|
|
68
|
+
@generated_ids = []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get all generated IDs (useful for debugging)
|
|
72
|
+
def generated_ids
|
|
73
|
+
@generated_ids.dup
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Normalize a label into a WhatsApp-safe identifier
|
|
79
|
+
# Keeps readability by preserving spaces and basic punctuation
|
|
80
|
+
#
|
|
81
|
+
# @param label [String] The original label
|
|
82
|
+
# @return [String] Normalized identifier
|
|
83
|
+
def normalize_label(label)
|
|
84
|
+
label
|
|
85
|
+
.to_s
|
|
86
|
+
.gsub(/[^\w\s\-']/, "") # remove special chars (keep word chars, spaces, hyphens, apostrophes)
|
|
87
|
+
.gsub(/\s+/, " ") # collapse multiple spaces to single space (after removing chars)
|
|
88
|
+
.strip # trim leading/trailing whitespace
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Add a hash suffix to make the ID unique
|
|
92
|
+
#
|
|
93
|
+
# The hash is generated from the original label to ensure
|
|
94
|
+
# the same label always produces the same hash.
|
|
95
|
+
#
|
|
96
|
+
# @param base_id [String] The base identifier
|
|
97
|
+
# @param original_label [String] The original label for hash generation
|
|
98
|
+
# @return [String] ID with hash suffix
|
|
99
|
+
def add_hash_suffix(base_id, original_label)
|
|
100
|
+
# Generate a short hash from the original label + timestamp for uniqueness
|
|
101
|
+
# We use the current generated_ids count to ensure different duplicates get different hashes
|
|
102
|
+
hash_input = "#{original_label}_#{@generated_ids.count { |id| id.start_with?(base_id) }}"
|
|
103
|
+
hash = Digest::SHA256.hexdigest(hash_input)[0...HASH_SUFFIX_LENGTH]
|
|
104
|
+
|
|
105
|
+
# Calculate max base length to fit: base + "_" + hash
|
|
106
|
+
max_base_length = MAX_ID_LENGTH - HASH_SUFFIX_LENGTH - 1
|
|
107
|
+
|
|
108
|
+
# Truncate base if needed
|
|
109
|
+
truncated_base = base_id[0...max_base_length]
|
|
110
|
+
|
|
111
|
+
"#{truncated_base} #{hash}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Truncate ID to maximum allowed length
|
|
115
|
+
#
|
|
116
|
+
# @param id [String] The ID to truncate
|
|
117
|
+
# @return [String] Truncated ID
|
|
118
|
+
def truncate_to_limit(id)
|
|
119
|
+
return id if id.length <= MAX_ID_LENGTH
|
|
120
|
+
id[0...MAX_ID_LENGTH]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|