flow_chat 0.3.0 → 0.4.1
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/Gemfile +1 -0
- data/README.md +642 -86
- data/examples/initializer.rb +31 -0
- data/examples/media_prompts_examples.rb +28 -0
- data/examples/multi_tenant_whatsapp_controller.rb +244 -0
- data/examples/ussd_controller.rb +264 -0
- data/examples/whatsapp_controller.rb +140 -0
- data/examples/whatsapp_media_examples.rb +406 -0
- data/examples/whatsapp_message_job.rb +111 -0
- data/lib/flow_chat/base_processor.rb +67 -0
- data/lib/flow_chat/config.rb +36 -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/simulator/controller.rb +78 -0
- data/lib/flow_chat/simulator/views/simulator.html.erb +1707 -0
- 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 +14 -42
- data/lib/flow_chat/ussd/prompt.rb +39 -5
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/app.rb +64 -0
- data/lib/flow_chat/whatsapp/client.rb +439 -0
- data/lib/flow_chat/whatsapp/configuration.rb +113 -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 +26 -0
- data/lib/flow_chat/whatsapp/prompt.rb +251 -0
- data/lib/flow_chat/whatsapp/send_job_support.rb +79 -0
- data/lib/flow_chat/whatsapp/template_manager.rb +162 -0
- data/lib/flow_chat.rb +1 -0
- metadata +21 -3
- data/lib/flow_chat/ussd/simulator/controller.rb +0 -51
- data/lib/flow_chat/ussd/simulator/views/simulator.html.erb +0 -239
@@ -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
|
+
@client = FlowChat::Whatsapp::Client.new(@config)
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(context)
|
18
|
+
controller = context.controller
|
19
|
+
request = controller.request
|
20
|
+
|
21
|
+
# Handle webhook verification
|
22
|
+
if request.get? && request.params["hub.mode"] == "subscribe"
|
23
|
+
return handle_verification(context)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Handle webhook messages
|
27
|
+
if request.post?
|
28
|
+
return handle_webhook(context)
|
29
|
+
end
|
30
|
+
|
31
|
+
controller.head :bad_request
|
32
|
+
end
|
33
|
+
|
34
|
+
# Expose client for out-of-band messaging
|
35
|
+
def client
|
36
|
+
@client
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def determine_message_handler(context)
|
42
|
+
# Check for simulator parameter in request (highest priority)
|
43
|
+
if context["simulator_mode"] || context.controller.request.params["simulator_mode"]
|
44
|
+
return :simulator
|
45
|
+
end
|
46
|
+
|
47
|
+
# Use global WhatsApp configuration
|
48
|
+
FlowChat::Config.whatsapp.message_handling_mode
|
49
|
+
end
|
50
|
+
|
51
|
+
def handle_verification(context)
|
52
|
+
controller = context.controller
|
53
|
+
params = controller.request.params
|
54
|
+
|
55
|
+
verify_token = @config.verify_token
|
56
|
+
|
57
|
+
if params["hub.verify_token"] == verify_token
|
58
|
+
controller.render plain: params["hub.challenge"]
|
59
|
+
else
|
60
|
+
controller.head :forbidden
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def handle_webhook(context)
|
65
|
+
controller = context.controller
|
66
|
+
body = JSON.parse(controller.request.body.read)
|
67
|
+
|
68
|
+
# Check for simulator mode parameter in request
|
69
|
+
if body.dig("simulator_mode") || controller.request.params["simulator_mode"]
|
70
|
+
context["simulator_mode"] = true
|
71
|
+
end
|
72
|
+
|
73
|
+
# Extract message data from WhatsApp webhook
|
74
|
+
entry = body.dig("entry", 0)
|
75
|
+
return controller.head :ok unless entry
|
76
|
+
|
77
|
+
changes = entry.dig("changes", 0)
|
78
|
+
return controller.head :ok unless changes
|
79
|
+
|
80
|
+
value = changes["value"]
|
81
|
+
return controller.head :ok unless value
|
82
|
+
|
83
|
+
# Handle incoming messages
|
84
|
+
if value["messages"]&.any?
|
85
|
+
message = value["messages"].first
|
86
|
+
contact = value["contacts"]&.first
|
87
|
+
|
88
|
+
context["request.id"] = message["from"]
|
89
|
+
context["request.gateway"] = :whatsapp_cloud_api
|
90
|
+
context["request.message_id"] = message["id"]
|
91
|
+
context["request.msisdn"] = Phonelib.parse(message["from"]).e164
|
92
|
+
context["request.contact_name"] = contact&.dig("profile", "name")
|
93
|
+
context["request.timestamp"] = message["timestamp"]
|
94
|
+
|
95
|
+
# Extract message content based on type
|
96
|
+
extract_message_content(message, context)
|
97
|
+
|
98
|
+
# Determine message handling mode
|
99
|
+
handler_mode = determine_message_handler(context)
|
100
|
+
|
101
|
+
# Process the message based on handling mode
|
102
|
+
case handler_mode
|
103
|
+
when :inline
|
104
|
+
handle_message_inline(context, controller)
|
105
|
+
when :background
|
106
|
+
handle_message_background(context, controller)
|
107
|
+
when :simulator
|
108
|
+
# Return early from simulator mode to preserve the JSON response
|
109
|
+
return handle_message_simulator(context, controller)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Handle message status updates
|
114
|
+
if value["statuses"]&.any?
|
115
|
+
Rails.logger.info "WhatsApp status update: #{value["statuses"]}"
|
116
|
+
end
|
117
|
+
|
118
|
+
controller.head :ok
|
119
|
+
end
|
120
|
+
|
121
|
+
def extract_message_content(message, context)
|
122
|
+
case message["type"]
|
123
|
+
when "text"
|
124
|
+
context.input = message.dig("text", "body")
|
125
|
+
when "interactive"
|
126
|
+
# Handle button/list replies
|
127
|
+
if message.dig("interactive", "type") == "button_reply"
|
128
|
+
context.input = message.dig("interactive", "button_reply", "id")
|
129
|
+
elsif message.dig("interactive", "type") == "list_reply"
|
130
|
+
context.input = message.dig("interactive", "list_reply", "id")
|
131
|
+
end
|
132
|
+
when "location"
|
133
|
+
context["request.location"] = {
|
134
|
+
latitude: message.dig("location", "latitude"),
|
135
|
+
longitude: message.dig("location", "longitude"),
|
136
|
+
name: message.dig("location", "name"),
|
137
|
+
address: message.dig("location", "address")
|
138
|
+
}
|
139
|
+
context.input = "$location$"
|
140
|
+
when "image", "document", "audio", "video"
|
141
|
+
context["request.media"] = {
|
142
|
+
type: message["type"],
|
143
|
+
id: message.dig(message["type"], "id"),
|
144
|
+
mime_type: message.dig(message["type"], "mime_type"),
|
145
|
+
caption: message.dig(message["type"], "caption")
|
146
|
+
}
|
147
|
+
context.input = "$media$"
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def handle_message_inline(context, controller)
|
152
|
+
response = @app.call(context)
|
153
|
+
if response
|
154
|
+
result = @client.send_message(context["request.msisdn"], response)
|
155
|
+
context["whatsapp.message_result"] = result
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def handle_message_background(context, controller)
|
160
|
+
# Process the flow synchronously (maintaining controller context)
|
161
|
+
response = @app.call(context)
|
162
|
+
|
163
|
+
if response
|
164
|
+
# Queue only the response delivery asynchronously
|
165
|
+
send_data = {
|
166
|
+
msisdn: context["request.msisdn"],
|
167
|
+
response: response,
|
168
|
+
config_name: @config.name
|
169
|
+
}
|
170
|
+
|
171
|
+
# Get job class from configuration
|
172
|
+
job_class_name = FlowChat::Config.whatsapp.background_job_class
|
173
|
+
|
174
|
+
# Enqueue background job for sending only
|
175
|
+
begin
|
176
|
+
job_class = job_class_name.constantize
|
177
|
+
job_class.perform_later(send_data)
|
178
|
+
rescue NameError
|
179
|
+
# Fallback to inline sending if no job system
|
180
|
+
Rails.logger.warn "Background mode requested but no #{job_class_name} found. Falling back to inline sending."
|
181
|
+
result = @client.send_message(context["request.msisdn"], response)
|
182
|
+
context["whatsapp.message_result"] = result
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def handle_message_simulator(context, controller)
|
188
|
+
response = @app.call(context)
|
189
|
+
|
190
|
+
if response
|
191
|
+
# For simulator mode, return the response data in the HTTP response
|
192
|
+
# instead of actually sending via WhatsApp API
|
193
|
+
message_payload = @client.build_message_payload(response, context["request.msisdn"])
|
194
|
+
|
195
|
+
simulator_response = {
|
196
|
+
mode: "simulator",
|
197
|
+
webhook_processed: true,
|
198
|
+
would_send: message_payload,
|
199
|
+
message_info: {
|
200
|
+
to: context["request.msisdn"],
|
201
|
+
contact_name: context["request.contact_name"],
|
202
|
+
timestamp: Time.now.iso8601
|
203
|
+
}
|
204
|
+
}
|
205
|
+
|
206
|
+
controller.render json: simulator_response
|
207
|
+
return
|
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,26 @@
|
|
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
|
+
protected
|
10
|
+
|
11
|
+
def middleware_name
|
12
|
+
"whatsapp.middleware"
|
13
|
+
end
|
14
|
+
|
15
|
+
def build_middleware_stack
|
16
|
+
create_middleware_stack("whatsapp")
|
17
|
+
end
|
18
|
+
|
19
|
+
def configure_middleware_stack(builder)
|
20
|
+
builder.use FlowChat::Session::Middleware
|
21
|
+
builder.use middleware
|
22
|
+
builder.use FlowChat::Whatsapp::Middleware::Executor
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,251 @@
|
|
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, media: nil)
|
11
|
+
if input.present?
|
12
|
+
begin
|
13
|
+
processed_input = process_input(input, transform, validate, convert)
|
14
|
+
return processed_input unless processed_input.nil?
|
15
|
+
rescue FlowChat::Interrupt::Prompt => validation_error
|
16
|
+
# If validation failed, include the error message with the original prompt
|
17
|
+
error_message = validation_error.prompt[1]
|
18
|
+
combined_message = "#{message}\n\n#{error_message}"
|
19
|
+
raise FlowChat::Interrupt::Prompt.new([:text, combined_message, {}])
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Send message and wait for response, optionally with media
|
24
|
+
if media
|
25
|
+
raise FlowChat::Interrupt::Prompt.new(build_media_prompt(message, media))
|
26
|
+
else
|
27
|
+
raise FlowChat::Interrupt::Prompt.new([:text, message, {}])
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def say(message, media: nil)
|
32
|
+
if media
|
33
|
+
raise FlowChat::Interrupt::Terminate.new(build_media_prompt(message, media))
|
34
|
+
else
|
35
|
+
raise FlowChat::Interrupt::Terminate.new([:text, message, {}])
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def select(message, choices, transform: nil, validate: nil, convert: nil)
|
40
|
+
if input.present?
|
41
|
+
processed_input = process_selection(input, choices, transform, validate, convert)
|
42
|
+
return processed_input unless processed_input.nil?
|
43
|
+
end
|
44
|
+
|
45
|
+
# Validate choices
|
46
|
+
validate_choices(choices)
|
47
|
+
|
48
|
+
# Standard selection without media support
|
49
|
+
interactive_prompt = build_selection_prompt(message, choices)
|
50
|
+
raise FlowChat::Interrupt::Prompt.new(interactive_prompt)
|
51
|
+
end
|
52
|
+
|
53
|
+
def yes?(message, transform: nil, validate: nil, convert: nil)
|
54
|
+
if input.present?
|
55
|
+
processed_input = process_boolean(input, transform, validate, convert)
|
56
|
+
return processed_input unless processed_input.nil?
|
57
|
+
end
|
58
|
+
|
59
|
+
buttons = [
|
60
|
+
{ id: "yes", title: "Yes" },
|
61
|
+
{ id: "no", title: "No" }
|
62
|
+
]
|
63
|
+
raise FlowChat::Interrupt::Prompt.new([:interactive_buttons, message, { buttons: buttons }])
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def build_media_prompt(message, media)
|
69
|
+
media_type = media[:type] || :image
|
70
|
+
url = media[:url] || media[:path]
|
71
|
+
filename = media[:filename]
|
72
|
+
|
73
|
+
case media_type.to_sym
|
74
|
+
when :image
|
75
|
+
[:media_image, "", { url: url, caption: message }]
|
76
|
+
when :document
|
77
|
+
[:media_document, "", { url: url, caption: message, filename: filename }]
|
78
|
+
when :audio
|
79
|
+
[:media_audio, "", { url: url, caption: message }]
|
80
|
+
when :video
|
81
|
+
[:media_video, "", { url: url, caption: message }]
|
82
|
+
when :sticker
|
83
|
+
[:media_sticker, "", { url: url }] # Stickers don't support captions
|
84
|
+
else
|
85
|
+
raise ArgumentError, "Unsupported media type: #{media_type}"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def build_selection_prompt(message, choices)
|
90
|
+
# Determine the best way to present choices
|
91
|
+
if choices.is_a?(Array)
|
92
|
+
# Convert array to hash with index-based keys
|
93
|
+
choice_hash = choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
|
94
|
+
build_list_prompt(message, choice_hash)
|
95
|
+
elsif choices.is_a?(Hash)
|
96
|
+
if choices.length <= 3
|
97
|
+
# Use buttons for 3 or fewer choices
|
98
|
+
build_buttons_prompt(message, choices)
|
99
|
+
else
|
100
|
+
# Use list for more than 3 choices
|
101
|
+
build_list_prompt(message, choices)
|
102
|
+
end
|
103
|
+
else
|
104
|
+
raise ArgumentError, "choices must be an Array or Hash"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def build_buttons_prompt(message, choices)
|
109
|
+
buttons = choices.map do |key, value|
|
110
|
+
{
|
111
|
+
id: key.to_s,
|
112
|
+
title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
|
113
|
+
}
|
114
|
+
end
|
115
|
+
|
116
|
+
[:interactive_buttons, message, { buttons: buttons }]
|
117
|
+
end
|
118
|
+
|
119
|
+
def build_list_prompt(message, choices)
|
120
|
+
items = choices.map do |key, value|
|
121
|
+
original_text = value.to_s
|
122
|
+
truncated_title = truncate_text(original_text, 24)
|
123
|
+
|
124
|
+
# If title was truncated, put full text in description (up to 72 chars)
|
125
|
+
description = if original_text.length > 24
|
126
|
+
truncate_text(original_text, 72)
|
127
|
+
else
|
128
|
+
nil
|
129
|
+
end
|
130
|
+
|
131
|
+
{
|
132
|
+
id: key.to_s,
|
133
|
+
title: truncated_title,
|
134
|
+
description: description
|
135
|
+
}.compact
|
136
|
+
end
|
137
|
+
|
138
|
+
# If 10 or fewer items, use single section
|
139
|
+
if items.length <= 10
|
140
|
+
sections = [
|
141
|
+
{
|
142
|
+
title: "Options",
|
143
|
+
rows: items
|
144
|
+
}
|
145
|
+
]
|
146
|
+
else
|
147
|
+
# Paginate into multiple sections (max 10 items per section)
|
148
|
+
sections = items.each_slice(10).with_index.map do |section_items, index|
|
149
|
+
start_num = (index * 10) + 1
|
150
|
+
end_num = start_num + section_items.length - 1
|
151
|
+
|
152
|
+
{
|
153
|
+
title: "#{start_num}-#{end_num}",
|
154
|
+
rows: section_items
|
155
|
+
}
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
[:interactive_list, message, { sections: sections }]
|
160
|
+
end
|
161
|
+
|
162
|
+
def process_input(input, transform, validate, convert)
|
163
|
+
# Apply transformation
|
164
|
+
transformed_input = transform ? transform.call(input) : input
|
165
|
+
|
166
|
+
# Apply conversion first, then validation
|
167
|
+
converted_input = convert ? convert.call(transformed_input) : transformed_input
|
168
|
+
|
169
|
+
# Apply validation on converted value
|
170
|
+
if validate
|
171
|
+
error_message = validate.call(converted_input)
|
172
|
+
if error_message
|
173
|
+
raise FlowChat::Interrupt::Prompt.new([:text, error_message, {}])
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
converted_input
|
178
|
+
end
|
179
|
+
|
180
|
+
def process_selection(input, choices, transform, validate, convert)
|
181
|
+
choice_hash = choices.is_a?(Array) ?
|
182
|
+
choices.each_with_index.to_h { |choice, index| [index.to_s, choice] } :
|
183
|
+
choices
|
184
|
+
|
185
|
+
# Check if input matches a valid choice
|
186
|
+
if choice_hash.key?(input)
|
187
|
+
selected_value = choice_hash[input]
|
188
|
+
process_input(selected_value, transform, validate, convert)
|
189
|
+
elsif choice_hash.value?(input)
|
190
|
+
# Input matches a choice value directly
|
191
|
+
process_input(input, transform, validate, convert)
|
192
|
+
else
|
193
|
+
# Invalid choice
|
194
|
+
choice_list = choice_hash.map { |key, value| "#{key}: #{value}" }.join("\n")
|
195
|
+
error_message = "Invalid choice. Please select one of:\n#{choice_list}"
|
196
|
+
raise FlowChat::Interrupt::Prompt.new([:text, error_message, {}])
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def process_boolean(input, transform, validate, convert)
|
201
|
+
boolean_value = case input.to_s.downcase
|
202
|
+
when "yes", "y", "1", "true"
|
203
|
+
true
|
204
|
+
when "no", "n", "0", "false"
|
205
|
+
false
|
206
|
+
else
|
207
|
+
nil
|
208
|
+
end
|
209
|
+
|
210
|
+
if boolean_value.nil?
|
211
|
+
raise FlowChat::Interrupt::Prompt.new([:text, "Please answer with Yes or No.", {}])
|
212
|
+
end
|
213
|
+
|
214
|
+
process_input(boolean_value, transform, validate, convert)
|
215
|
+
end
|
216
|
+
|
217
|
+
def validate_choices(choices)
|
218
|
+
# Check for empty choices
|
219
|
+
if choices.nil? || choices.empty?
|
220
|
+
raise ArgumentError, "choices cannot be empty"
|
221
|
+
end
|
222
|
+
|
223
|
+
choice_count = choices.is_a?(Array) ? choices.length : choices.length
|
224
|
+
|
225
|
+
# WhatsApp supports max 100 total items across all sections
|
226
|
+
if choice_count > 100
|
227
|
+
raise ArgumentError, "WhatsApp supports maximum 100 choice options, got #{choice_count}"
|
228
|
+
end
|
229
|
+
|
230
|
+
# Validate individual choice values
|
231
|
+
choices_to_validate = choices.is_a?(Array) ? choices : choices.values
|
232
|
+
|
233
|
+
choices_to_validate.each_with_index do |choice, index|
|
234
|
+
if choice.nil? || choice.to_s.strip.empty?
|
235
|
+
raise ArgumentError, "choice at index #{index} cannot be empty"
|
236
|
+
end
|
237
|
+
|
238
|
+
choice_text = choice.to_s
|
239
|
+
if choice_text.length > 100
|
240
|
+
raise ArgumentError, "choice '#{choice_text[0..20]}...' is too long (#{choice_text.length} chars). Maximum is 100 characters"
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
def truncate_text(text, length)
|
246
|
+
return text if text.length <= length
|
247
|
+
text[0, length - 3] + "..."
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module FlowChat
|
2
|
+
module Whatsapp
|
3
|
+
# Module to be included in background jobs for WhatsApp response delivery
|
4
|
+
# Only handles sending responses, not processing flows
|
5
|
+
module SendJobSupport
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
# Set up job configuration
|
10
|
+
queue_as :default
|
11
|
+
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
12
|
+
end
|
13
|
+
|
14
|
+
# Main job execution method for sending responses
|
15
|
+
def perform_whatsapp_send(send_data)
|
16
|
+
config = resolve_whatsapp_config(send_data)
|
17
|
+
client = FlowChat::Whatsapp::Client.new(config)
|
18
|
+
|
19
|
+
result = client.send_message(send_data[:msisdn], send_data[:response])
|
20
|
+
|
21
|
+
if result
|
22
|
+
Rails.logger.info "WhatsApp message sent successfully: #{result['messages']&.first&.dig('id')}"
|
23
|
+
on_whatsapp_send_success(send_data, result)
|
24
|
+
else
|
25
|
+
Rails.logger.error "Failed to send WhatsApp message to #{send_data[:msisdn]}"
|
26
|
+
raise "WhatsApp API call failed"
|
27
|
+
end
|
28
|
+
rescue => e
|
29
|
+
on_whatsapp_send_error(e, send_data)
|
30
|
+
handle_whatsapp_send_error(e, send_data, config)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
# Resolve WhatsApp configuration by name or fallback
|
36
|
+
def resolve_whatsapp_config(send_data)
|
37
|
+
# Try to resolve by name first (preferred method)
|
38
|
+
if send_data[:config_name] && FlowChat::Whatsapp::Configuration.exists?(send_data[:config_name])
|
39
|
+
return FlowChat::Whatsapp::Configuration.get(send_data[:config_name])
|
40
|
+
end
|
41
|
+
|
42
|
+
# Final fallback to default configuration
|
43
|
+
FlowChat::Whatsapp::Configuration.from_credentials
|
44
|
+
end
|
45
|
+
|
46
|
+
# Handle errors with user notification
|
47
|
+
def handle_whatsapp_send_error(error, send_data, config = nil)
|
48
|
+
Rails.logger.error "WhatsApp send job error: #{error.message}"
|
49
|
+
Rails.logger.error error.backtrace&.join("\n") if error.backtrace
|
50
|
+
|
51
|
+
# Try to send error message to user if we have config
|
52
|
+
if config
|
53
|
+
begin
|
54
|
+
client = FlowChat::Whatsapp::Client.new(config)
|
55
|
+
client.send_text(
|
56
|
+
send_data[:msisdn],
|
57
|
+
"⚠️ We're experiencing technical difficulties. Please try again in a few minutes."
|
58
|
+
)
|
59
|
+
rescue => send_error
|
60
|
+
Rails.logger.error "Failed to send error message: #{send_error.message}"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Re-raise for job retry logic
|
65
|
+
raise error
|
66
|
+
end
|
67
|
+
|
68
|
+
# Override this method in your job for custom behavior
|
69
|
+
def on_whatsapp_send_success(send_data, result)
|
70
|
+
# Optional callback for successful message sending
|
71
|
+
end
|
72
|
+
|
73
|
+
# Override this method in your job for custom error handling
|
74
|
+
def on_whatsapp_send_error(error, send_data)
|
75
|
+
# Optional callback for error handling
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|