flow_chat 0.6.1 → 0.7.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/.github/workflows/ci.yml +44 -0
- data/.gitignore +2 -1
- data/README.md +84 -1229
- data/docs/configuration.md +337 -0
- data/docs/flows.md +320 -0
- data/docs/images/simulator.png +0 -0
- data/docs/instrumentation.md +216 -0
- data/docs/media.md +153 -0
- data/docs/testing.md +475 -0
- data/docs/ussd-setup.md +306 -0
- data/docs/whatsapp-setup.md +162 -0
- data/examples/multi_tenant_whatsapp_controller.rb +9 -37
- data/examples/simulator_controller.rb +9 -18
- data/examples/ussd_controller.rb +32 -38
- data/examples/whatsapp_controller.rb +32 -125
- data/examples/whatsapp_media_examples.rb +68 -336
- data/examples/whatsapp_message_job.rb +5 -3
- data/flow_chat.gemspec +6 -2
- data/lib/flow_chat/base_processor.rb +48 -2
- data/lib/flow_chat/config.rb +5 -0
- data/lib/flow_chat/context.rb +13 -1
- data/lib/flow_chat/instrumentation/log_subscriber.rb +176 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +197 -0
- data/lib/flow_chat/instrumentation/setup.rb +155 -0
- data/lib/flow_chat/instrumentation.rb +70 -0
- data/lib/flow_chat/prompt.rb +20 -20
- data/lib/flow_chat/session/cache_session_store.rb +73 -7
- data/lib/flow_chat/session/middleware.rb +37 -4
- data/lib/flow_chat/session/rails_session_store.rb +36 -1
- data/lib/flow_chat/simulator/controller.rb +6 -6
- data/lib/flow_chat/ussd/gateway/nalo.rb +30 -0
- data/lib/flow_chat/ussd/gateway/nsano.rb +33 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +109 -0
- data/lib/flow_chat/ussd/middleware/executor.rb +24 -2
- data/lib/flow_chat/ussd/middleware/pagination.rb +87 -7
- data/lib/flow_chat/ussd/processor.rb +14 -0
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +99 -12
- data/lib/flow_chat/whatsapp/configuration.rb +35 -4
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +120 -34
- data/lib/flow_chat/whatsapp/middleware/executor.rb +24 -2
- data/lib/flow_chat/whatsapp/processor.rb +8 -0
- data/lib/flow_chat/whatsapp/renderer.rb +4 -9
- data/lib/flow_chat.rb +23 -0
- metadata +22 -11
- data/.travis.yml +0 -6
- data/app/controllers/demo_controller.rb +0 -101
- data/app/flow_chat/demo_restaurant_flow.rb +0 -889
- data/config/routes_demo.rb +0 -59
- data/examples/initializer.rb +0 -86
- data/examples/media_prompts_examples.rb +0 -27
- data/images/ussd_simulator.png +0 -0
@@ -2,23 +2,44 @@ module FlowChat
|
|
2
2
|
module Ussd
|
3
3
|
module Middleware
|
4
4
|
class Pagination
|
5
|
+
include FlowChat::Instrumentation
|
6
|
+
|
7
|
+
attr_reader :context
|
8
|
+
|
5
9
|
def initialize(app)
|
6
10
|
@app = app
|
11
|
+
FlowChat.logger.debug { "Ussd::Pagination: Initialized USSD pagination middleware" }
|
7
12
|
end
|
8
13
|
|
9
14
|
def call(context)
|
10
15
|
@context = context
|
11
16
|
@session = context.session
|
12
17
|
|
18
|
+
session_id = context["session.id"]
|
19
|
+
FlowChat.logger.debug { "Ussd::Pagination: Processing request for session #{session_id}" }
|
20
|
+
|
13
21
|
if intercept?
|
22
|
+
FlowChat.logger.info { "Ussd::Pagination: Intercepting request for pagination handling - session #{session_id}" }
|
14
23
|
type, prompt = handle_intercepted_request
|
15
24
|
[type, prompt, []]
|
16
25
|
else
|
26
|
+
# Clear pagination state for new flows
|
27
|
+
if pagination_state.present?
|
28
|
+
FlowChat.logger.debug { "Ussd::Pagination: Clearing pagination state for new flow - session #{session_id}" }
|
29
|
+
end
|
17
30
|
@session.delete "ussd.pagination"
|
31
|
+
|
18
32
|
type, prompt, choices, media = @app.call(context)
|
19
33
|
|
20
34
|
prompt = FlowChat::Ussd::Renderer.new(prompt, choices: choices, media: media).render
|
21
|
-
|
35
|
+
|
36
|
+
if prompt.present?
|
37
|
+
original_length = prompt.length
|
38
|
+
type, prompt = maybe_paginate(type, prompt)
|
39
|
+
if prompt.length != original_length
|
40
|
+
FlowChat.logger.info { "Ussd::Pagination: Content paginated - original: #{original_length} chars, paginated: #{prompt.length} chars" }
|
41
|
+
end
|
42
|
+
end
|
22
43
|
|
23
44
|
[type, prompt, []]
|
24
45
|
end
|
@@ -27,53 +48,91 @@ module FlowChat
|
|
27
48
|
private
|
28
49
|
|
29
50
|
def intercept?
|
30
|
-
pagination_state.present? &&
|
51
|
+
should_intercept = pagination_state.present? &&
|
31
52
|
(pagination_state["type"].to_sym == :terminal ||
|
32
53
|
([FlowChat::Config.ussd.pagination_next_option, FlowChat::Config.ussd.pagination_back_option].include? @context.input))
|
54
|
+
|
55
|
+
if should_intercept
|
56
|
+
FlowChat.logger.debug { "Ussd::Pagination: Intercepting - input: #{@context.input}, pagination type: #{pagination_state["type"]}" }
|
57
|
+
end
|
58
|
+
|
59
|
+
should_intercept
|
33
60
|
end
|
34
61
|
|
35
62
|
def handle_intercepted_request
|
36
|
-
FlowChat
|
63
|
+
FlowChat.logger.info { "Ussd::Pagination: Handling paginated request" }
|
37
64
|
start, finish, has_more = calculate_offsets
|
38
65
|
type = (pagination_state["type"].to_sym == :terminal && !has_more) ? :terminal : :prompt
|
39
66
|
prompt = pagination_state["prompt"][start..finish] + build_pagination_options(type, has_more)
|
40
67
|
set_pagination_state(current_page, start, finish)
|
41
68
|
|
69
|
+
# Instrument pagination navigation
|
70
|
+
instrument(Events::PAGINATION_TRIGGERED, {
|
71
|
+
session_id: @context["session.id"],
|
72
|
+
current_page: current_page,
|
73
|
+
total_pages: calculate_total_pages,
|
74
|
+
content_length: pagination_state["prompt"].length,
|
75
|
+
page_limit: FlowChat::Config.ussd.pagination_page_size,
|
76
|
+
navigation_action: (@context.input == FlowChat::Config.ussd.pagination_next_option) ? "next" : "back"
|
77
|
+
})
|
78
|
+
|
79
|
+
FlowChat.logger.debug { "Ussd::Pagination: Serving page content - start: #{start}, finish: #{finish}, has_more: #{has_more}, type: #{type}" }
|
42
80
|
[type, prompt]
|
43
81
|
end
|
44
82
|
|
45
83
|
def maybe_paginate(type, prompt)
|
46
84
|
if prompt.length > FlowChat::Config.ussd.pagination_page_size
|
47
85
|
original_prompt = prompt
|
48
|
-
FlowChat
|
86
|
+
FlowChat.logger.info { "Ussd::Pagination: Content exceeds page size (#{prompt.length} > #{FlowChat::Config.ussd.pagination_page_size}), initiating pagination" }
|
87
|
+
|
49
88
|
slice_end = single_option_slice_size
|
50
89
|
# Ensure we do not cut words and options off in the middle.
|
51
90
|
current_pagebreak = original_prompt[slice_end + 1].blank? ? slice_end : original_prompt[0..slice_end].rindex("\n") || original_prompt[0..slice_end].rindex(" ") || slice_end
|
91
|
+
|
92
|
+
FlowChat.logger.debug { "Ussd::Pagination: First page break at position #{current_pagebreak}" }
|
93
|
+
|
52
94
|
set_pagination_state(1, 0, current_pagebreak, original_prompt, type)
|
53
95
|
prompt = original_prompt[0..current_pagebreak] + "\n\n" + next_option
|
54
96
|
type = :prompt
|
97
|
+
|
98
|
+
# Instrument initial pagination setup
|
99
|
+
total_pages = calculate_total_pages(original_prompt)
|
100
|
+
instrument(Events::PAGINATION_TRIGGERED, {
|
101
|
+
session_id: @context["session.id"],
|
102
|
+
current_page: 1,
|
103
|
+
total_pages: total_pages,
|
104
|
+
content_length: original_prompt.length,
|
105
|
+
page_limit: FlowChat::Config.ussd.pagination_page_size,
|
106
|
+
navigation_action: "initial"
|
107
|
+
})
|
108
|
+
|
109
|
+
FlowChat.logger.debug { "Ussd::Pagination: First page prepared with #{prompt.length} characters" }
|
55
110
|
end
|
56
111
|
[type, prompt]
|
57
112
|
end
|
58
113
|
|
59
114
|
def calculate_offsets
|
60
115
|
page = current_page
|
116
|
+
|
117
|
+
FlowChat.logger.debug { "Ussd::Pagination: Calculating offsets for page #{page}" }
|
118
|
+
|
61
119
|
offset = pagination_state["offsets"][page.to_s]
|
62
120
|
if offset.present?
|
63
|
-
FlowChat
|
121
|
+
FlowChat.logger.debug { "Ussd::Pagination: Using cached offset for page #{page}" }
|
64
122
|
start = offset["start"]
|
65
123
|
finish = offset["finish"]
|
66
124
|
has_more = pagination_state["prompt"].length > finish
|
67
125
|
else
|
68
|
-
FlowChat
|
126
|
+
FlowChat.logger.debug { "Ussd::Pagination: Computing new offset for page #{page}" }
|
69
127
|
# We are guaranteed a previous offset because it was set in maybe_paginate
|
70
128
|
previous_page = page - 1
|
71
129
|
previous_offset = pagination_state["offsets"][previous_page.to_s]
|
72
130
|
start = previous_offset["finish"] + 1
|
73
131
|
has_more, len = (pagination_state["prompt"].length > start + single_option_slice_size) ? [true, dual_options_slice_size] : [false, single_option_slice_size]
|
74
132
|
finish = start + len
|
133
|
+
|
75
134
|
if start > pagination_state["prompt"].length
|
76
|
-
FlowChat
|
135
|
+
FlowChat.logger.warn { "Ussd::Pagination: No content for page #{page}, reverting to page #{page - 1}" }
|
77
136
|
page -= 1
|
78
137
|
has_more = false
|
79
138
|
start = previous_offset["start"]
|
@@ -90,19 +149,26 @@ module FlowChat
|
|
90
149
|
# We're in the middle of a word, find the last word boundary
|
91
150
|
boundary_pos = slice_text.rindex("\n") || slice_text.rindex(" ")
|
92
151
|
if boundary_pos
|
152
|
+
old_finish = finish
|
93
153
|
finish = start + boundary_pos
|
154
|
+
FlowChat.logger.debug { "Ussd::Pagination: Adjusted finish for word boundary - #{old_finish} -> #{finish}" }
|
94
155
|
end
|
95
156
|
# If no boundary found, we'll have to break mid-word (fallback)
|
96
157
|
end
|
97
158
|
end
|
98
159
|
end
|
99
160
|
end
|
161
|
+
|
162
|
+
FlowChat.logger.debug { "Ussd::Pagination: Page #{page} offsets - start: #{start}, finish: #{finish}, has_more: #{has_more}" }
|
100
163
|
[start, finish, has_more]
|
101
164
|
end
|
102
165
|
|
103
166
|
def build_pagination_options(type, has_more)
|
104
167
|
options_str = ""
|
105
168
|
has_less = current_page > 1
|
169
|
+
|
170
|
+
FlowChat.logger.debug { "Ussd::Pagination: Building pagination options - type: #{type}, has_more: #{has_more}, has_less: #{has_less}" }
|
171
|
+
|
106
172
|
if type.to_sym == :prompt
|
107
173
|
options_str += "\n\n"
|
108
174
|
next_opt = has_more ? next_option : ""
|
@@ -126,6 +192,7 @@ module FlowChat
|
|
126
192
|
# We accomodate the 2 newlines and the longest of the options
|
127
193
|
# We subtract an additional 1 to normalize it for slicing
|
128
194
|
@single_option_slice_size = FlowChat::Config.ussd.pagination_page_size - 2 - [next_option.length, back_option.length].max - 1
|
195
|
+
FlowChat.logger.debug { "Ussd::Pagination: Calculated single option slice size: #{@single_option_slice_size}" }
|
129
196
|
end
|
130
197
|
@single_option_slice_size
|
131
198
|
end
|
@@ -135,6 +202,7 @@ module FlowChat
|
|
135
202
|
# To display both back and next options
|
136
203
|
# We accomodate the 3 newlines and both of the options
|
137
204
|
@dual_options_slice_size = FlowChat::Config.ussd.pagination_page_size - 3 - [next_option.length, back_option.length].sum - 1
|
205
|
+
FlowChat.logger.debug { "Ussd::Pagination: Calculated dual options slice size: #{@dual_options_slice_size}" }
|
138
206
|
end
|
139
207
|
@dual_options_slice_size
|
140
208
|
end
|
@@ -143,8 +211,10 @@ module FlowChat
|
|
143
211
|
page = pagination_state["page"]
|
144
212
|
if @context.input == FlowChat::Config.ussd.pagination_back_option
|
145
213
|
page -= 1
|
214
|
+
FlowChat.logger.debug { "Ussd::Pagination: Moving to previous page: #{page}" }
|
146
215
|
elsif @context.input == FlowChat::Config.ussd.pagination_next_option
|
147
216
|
page += 1
|
217
|
+
FlowChat.logger.debug { "Ussd::Pagination: Moving to next page: #{page}" }
|
148
218
|
end
|
149
219
|
[page, 1].max
|
150
220
|
end
|
@@ -165,8 +235,18 @@ module FlowChat
|
|
165
235
|
"prompt" => prompt,
|
166
236
|
"type" => type.to_s
|
167
237
|
}
|
238
|
+
|
239
|
+
FlowChat.logger.debug { "Ussd::Pagination: Saving pagination state - page: #{page}, total_content: #{prompt&.length || 0} chars" }
|
168
240
|
@session.set "ussd.pagination", new_state
|
169
241
|
end
|
242
|
+
|
243
|
+
def calculate_total_pages(content = nil)
|
244
|
+
content ||= pagination_state["prompt"]
|
245
|
+
return 1 unless content&.length&.> FlowChat::Config.ussd.pagination_page_size
|
246
|
+
|
247
|
+
# Rough estimation - actual pages may vary due to word boundaries
|
248
|
+
(content.length.to_f / single_option_slice_size).ceil
|
249
|
+
end
|
170
250
|
end
|
171
251
|
end
|
172
252
|
end
|
@@ -2,6 +2,7 @@ module FlowChat
|
|
2
2
|
module Ussd
|
3
3
|
class Processor < FlowChat::BaseProcessor
|
4
4
|
def use_resumable_sessions
|
5
|
+
FlowChat.logger.debug { "Ussd::Processor: Enabling resumable sessions middleware" }
|
5
6
|
middleware.insert_before 0, FlowChat::Ussd::Middleware::ResumableSession
|
6
7
|
self
|
7
8
|
end
|
@@ -13,14 +14,27 @@ module FlowChat
|
|
13
14
|
end
|
14
15
|
|
15
16
|
def build_middleware_stack
|
17
|
+
FlowChat.logger.debug { "Ussd::Processor: Building USSD middleware stack" }
|
16
18
|
create_middleware_stack("ussd")
|
17
19
|
end
|
18
20
|
|
19
21
|
def configure_middleware_stack(builder)
|
22
|
+
FlowChat.logger.debug { "Ussd::Processor: Configuring USSD middleware stack" }
|
23
|
+
|
20
24
|
builder.use FlowChat::Session::Middleware
|
25
|
+
FlowChat.logger.debug { "Ussd::Processor: Added Session::Middleware" }
|
26
|
+
|
21
27
|
builder.use FlowChat::Ussd::Middleware::Pagination
|
28
|
+
FlowChat.logger.debug { "Ussd::Processor: Added Ussd::Middleware::Pagination" }
|
29
|
+
|
22
30
|
builder.use middleware
|
31
|
+
FlowChat.logger.debug { "Ussd::Processor: Added custom middleware" }
|
32
|
+
|
33
|
+
builder.use FlowChat::Ussd::Middleware::ChoiceMapper
|
34
|
+
FlowChat.logger.debug { "Ussd::Processor: Added Ussd::Middleware::ChoiceMapper" }
|
35
|
+
|
23
36
|
builder.use FlowChat::Ussd::Middleware::Executor
|
37
|
+
FlowChat.logger.debug { "Ussd::Processor: Added Ussd::Middleware::Executor" }
|
24
38
|
end
|
25
39
|
end
|
26
40
|
end
|
data/lib/flow_chat/version.rb
CHANGED
@@ -7,8 +7,12 @@ require "securerandom"
|
|
7
7
|
module FlowChat
|
8
8
|
module Whatsapp
|
9
9
|
class Client
|
10
|
+
include FlowChat::Instrumentation
|
11
|
+
|
10
12
|
def initialize(config)
|
11
13
|
@config = config
|
14
|
+
FlowChat.logger.info { "WhatsApp::Client: Initialized WhatsApp client for phone_number_id: #{@config.phone_number_id}" }
|
15
|
+
FlowChat.logger.debug { "WhatsApp::Client: API base URL: #{FlowChat::Config.whatsapp.api_base_url}" }
|
12
16
|
end
|
13
17
|
|
14
18
|
# Send a message to a WhatsApp number
|
@@ -16,8 +20,28 @@ module FlowChat
|
|
16
20
|
# @param response [Array] FlowChat response array [type, content, options]
|
17
21
|
# @return [Hash] API response or nil on error
|
18
22
|
def send_message(to, response)
|
19
|
-
|
20
|
-
|
23
|
+
type, content, _ = response
|
24
|
+
FlowChat.logger.info { "WhatsApp::Client: Sending #{type} message to #{to}" }
|
25
|
+
FlowChat.logger.debug { "WhatsApp::Client: Message content: '#{content.to_s.truncate(100)}'" }
|
26
|
+
|
27
|
+
result = instrument(Events::MESSAGE_SENT, {
|
28
|
+
to: to,
|
29
|
+
message_type: type.to_s,
|
30
|
+
content_length: content.to_s.length,
|
31
|
+
platform: :whatsapp
|
32
|
+
}) do
|
33
|
+
message_data = build_message_payload(response, to)
|
34
|
+
send_message_payload(message_data)
|
35
|
+
end
|
36
|
+
|
37
|
+
if result
|
38
|
+
message_id = result.dig("messages", 0, "id")
|
39
|
+
FlowChat.logger.debug { "WhatsApp::Client: Message sent successfully to #{to}, message_id: #{message_id}" }
|
40
|
+
else
|
41
|
+
FlowChat.logger.error { "WhatsApp::Client: Failed to send message to #{to}" }
|
42
|
+
end
|
43
|
+
|
44
|
+
result
|
21
45
|
end
|
22
46
|
|
23
47
|
# Send a text message
|
@@ -25,6 +49,7 @@ module FlowChat
|
|
25
49
|
# @param text [String] Message text
|
26
50
|
# @return [Hash] API response or nil on error
|
27
51
|
def send_text(to, text)
|
52
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending text message to #{to}" }
|
28
53
|
send_message(to, [:text, text, {}])
|
29
54
|
end
|
30
55
|
|
@@ -34,6 +59,7 @@ module FlowChat
|
|
34
59
|
# @param buttons [Array] Array of button hashes with :id and :title
|
35
60
|
# @return [Hash] API response or nil on error
|
36
61
|
def send_buttons(to, text, buttons)
|
62
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending interactive buttons to #{to} with #{buttons.size} buttons" }
|
37
63
|
send_message(to, [:interactive_buttons, text, {buttons: buttons}])
|
38
64
|
end
|
39
65
|
|
@@ -44,6 +70,8 @@ module FlowChat
|
|
44
70
|
# @param button_text [String] Button text (default: "Choose")
|
45
71
|
# @return [Hash] API response or nil on error
|
46
72
|
def send_list(to, text, sections, button_text = "Choose")
|
73
|
+
total_items = sections.sum { |section| section[:rows]&.size || 0 }
|
74
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending interactive list to #{to} with #{sections.size} sections, #{total_items} total items" }
|
47
75
|
send_message(to, [:interactive_list, text, {sections: sections, button_text: button_text}])
|
48
76
|
end
|
49
77
|
|
@@ -54,6 +82,7 @@ module FlowChat
|
|
54
82
|
# @param language [String] Language code (default: "en_US")
|
55
83
|
# @return [Hash] API response or nil on error
|
56
84
|
def send_template(to, template_name, components = [], language = "en_US")
|
85
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending template '#{template_name}' to #{to} in #{language}" }
|
57
86
|
send_message(to, [:template, "", {
|
58
87
|
template_name: template_name,
|
59
88
|
components: components,
|
@@ -68,6 +97,7 @@ module FlowChat
|
|
68
97
|
# @param mime_type [String] Optional MIME type for URLs (e.g., 'image/jpeg')
|
69
98
|
# @return [Hash] API response
|
70
99
|
def send_image(to, image_url_or_id, caption = nil, mime_type = nil)
|
100
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending image to #{to} - #{url?(image_url_or_id) ? "URL" : "Media ID"}" }
|
71
101
|
send_media_message(to, :image, image_url_or_id, caption: caption, mime_type: mime_type)
|
72
102
|
end
|
73
103
|
|
@@ -80,6 +110,7 @@ module FlowChat
|
|
80
110
|
# @return [Hash] API response
|
81
111
|
def send_document(to, document_url_or_id, caption = nil, filename = nil, mime_type = nil)
|
82
112
|
filename ||= extract_filename_from_url(document_url_or_id) if url?(document_url_or_id)
|
113
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending document to #{to} - filename: #{filename}" }
|
83
114
|
send_media_message(to, :document, document_url_or_id, caption: caption, filename: filename, mime_type: mime_type)
|
84
115
|
end
|
85
116
|
|
@@ -90,6 +121,7 @@ module FlowChat
|
|
90
121
|
# @param mime_type [String] Optional MIME type for URLs (e.g., 'video/mp4')
|
91
122
|
# @return [Hash] API response
|
92
123
|
def send_video(to, video_url_or_id, caption = nil, mime_type = nil)
|
124
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending video to #{to}" }
|
93
125
|
send_media_message(to, :video, video_url_or_id, caption: caption, mime_type: mime_type)
|
94
126
|
end
|
95
127
|
|
@@ -99,6 +131,7 @@ module FlowChat
|
|
99
131
|
# @param mime_type [String] Optional MIME type for URLs (e.g., 'audio/mpeg')
|
100
132
|
# @return [Hash] API response
|
101
133
|
def send_audio(to, audio_url_or_id, mime_type = nil)
|
134
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending audio to #{to}" }
|
102
135
|
send_media_message(to, :audio, audio_url_or_id, mime_type: mime_type)
|
103
136
|
end
|
104
137
|
|
@@ -108,6 +141,7 @@ module FlowChat
|
|
108
141
|
# @param mime_type [String] Optional MIME type for URLs (e.g., 'image/webp')
|
109
142
|
# @return [Hash] API response
|
110
143
|
def send_sticker(to, sticker_url_or_id, mime_type = nil)
|
144
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending sticker to #{to}" }
|
111
145
|
send_media_message(to, :sticker, sticker_url_or_id, mime_type: mime_type)
|
112
146
|
end
|
113
147
|
|
@@ -118,17 +152,23 @@ module FlowChat
|
|
118
152
|
# @return [String] Media ID
|
119
153
|
# @raise [StandardError] If upload fails
|
120
154
|
def upload_media(file_path_or_io, mime_type, filename = nil)
|
155
|
+
FlowChat.logger.info { "WhatsApp::Client: Uploading media file - type: #{mime_type}, filename: #{filename}" }
|
156
|
+
|
121
157
|
raise ArgumentError, "mime_type is required" if mime_type.nil? || mime_type.empty?
|
122
158
|
|
159
|
+
file_size = nil
|
123
160
|
if file_path_or_io.is_a?(String)
|
124
161
|
# File path
|
125
162
|
raise ArgumentError, "File not found: #{file_path_or_io}" unless File.exist?(file_path_or_io)
|
126
163
|
filename ||= File.basename(file_path_or_io)
|
164
|
+
file_size = File.size(file_path_or_io)
|
165
|
+
FlowChat.logger.debug { "WhatsApp::Client: Uploading file from path: #{file_path_or_io} (#{file_size} bytes)" }
|
127
166
|
file = File.open(file_path_or_io, "rb")
|
128
167
|
else
|
129
168
|
# IO object
|
130
169
|
file = file_path_or_io
|
131
170
|
filename ||= "upload"
|
171
|
+
FlowChat.logger.debug { "WhatsApp::Client: Uploading file from IO object" }
|
132
172
|
end
|
133
173
|
|
134
174
|
# Upload directly via HTTP
|
@@ -136,6 +176,8 @@ module FlowChat
|
|
136
176
|
http = Net::HTTP.new(uri.host, uri.port)
|
137
177
|
http.use_ssl = true
|
138
178
|
|
179
|
+
FlowChat.logger.debug { "WhatsApp::Client: Uploading to #{uri}" }
|
180
|
+
|
139
181
|
# Prepare multipart form data
|
140
182
|
boundary = "----WebKitFormBoundary#{SecureRandom.hex(16)}"
|
141
183
|
|
@@ -165,15 +207,45 @@ module FlowChat
|
|
165
207
|
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
166
208
|
request.body = body
|
167
209
|
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
210
|
+
result = instrument(Events::MEDIA_UPLOAD, {
|
211
|
+
filename: filename,
|
212
|
+
mime_type: mime_type,
|
213
|
+
size: file_size,
|
214
|
+
platform: :whatsapp
|
215
|
+
}) do
|
216
|
+
response = http.request(request)
|
217
|
+
|
218
|
+
if response.is_a?(Net::HTTPSuccess)
|
219
|
+
data = JSON.parse(response.body)
|
220
|
+
media_id = data["id"]
|
221
|
+
if media_id
|
222
|
+
FlowChat.logger.info { "WhatsApp::Client: Media upload successful - media_id: #{media_id}" }
|
223
|
+
{success: true, media_id: media_id}
|
224
|
+
else
|
225
|
+
FlowChat.logger.error { "WhatsApp::Client: Media upload failed - no media_id in response: #{data}" }
|
226
|
+
raise StandardError, "Failed to upload media: #{data}"
|
227
|
+
end
|
228
|
+
else
|
229
|
+
FlowChat.logger.error { "WhatsApp::Client: Media upload error - #{response.code}: #{response.body}" }
|
230
|
+
raise StandardError, "Media upload failed: #{response.body}"
|
231
|
+
end
|
176
232
|
end
|
233
|
+
|
234
|
+
result[:media_id]
|
235
|
+
rescue => error
|
236
|
+
FlowChat.logger.error { "WhatsApp::Client: Media upload exception: #{error.class.name}: #{error.message}" }
|
237
|
+
|
238
|
+
# Instrument the error
|
239
|
+
instrument(Events::MEDIA_UPLOAD, {
|
240
|
+
filename: filename,
|
241
|
+
mime_type: mime_type,
|
242
|
+
size: file_size,
|
243
|
+
success: false,
|
244
|
+
error: error.message,
|
245
|
+
platform: :whatsapp
|
246
|
+
})
|
247
|
+
|
248
|
+
raise
|
177
249
|
ensure
|
178
250
|
file&.close if file_path_or_io.is_a?(String)
|
179
251
|
end
|
@@ -394,6 +466,11 @@ module FlowChat
|
|
394
466
|
# @param message_data [Hash] Message payload
|
395
467
|
# @return [Hash] API response or nil on error
|
396
468
|
def send_message_payload(message_data)
|
469
|
+
to = message_data[:to]
|
470
|
+
message_type = message_data[:type]
|
471
|
+
|
472
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending API request to #{to} - type: #{message_type}" }
|
473
|
+
|
397
474
|
uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
|
398
475
|
http = Net::HTTP.new(uri.host, uri.port)
|
399
476
|
http.use_ssl = true
|
@@ -403,14 +480,24 @@ module FlowChat
|
|
403
480
|
request["Content-Type"] = "application/json"
|
404
481
|
request.body = message_data.to_json
|
405
482
|
|
483
|
+
FlowChat.logger.debug { "WhatsApp::Client: Making HTTP request to WhatsApp API" }
|
406
484
|
response = http.request(request)
|
407
485
|
|
408
486
|
if response.is_a?(Net::HTTPSuccess)
|
409
|
-
JSON.parse(response.body)
|
487
|
+
result = JSON.parse(response.body)
|
488
|
+
FlowChat.logger.debug { "WhatsApp::Client: API request successful - response: #{result}" }
|
489
|
+
result
|
410
490
|
else
|
411
|
-
|
491
|
+
FlowChat.logger.error { "WhatsApp::Client: API request failed - #{response.code}: #{response.body}" }
|
412
492
|
nil
|
413
493
|
end
|
494
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
495
|
+
# Let network timeouts bubble up for proper error handling
|
496
|
+
FlowChat.logger.error { "WhatsApp::Client: Network timeout: #{network_error.class.name}: #{network_error.message}" }
|
497
|
+
raise network_error
|
498
|
+
rescue => error
|
499
|
+
FlowChat.logger.error { "WhatsApp::Client: API request exception: #{error.class.name}: #{error.message}" }
|
500
|
+
nil
|
414
501
|
end
|
415
502
|
|
416
503
|
def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
|
@@ -18,14 +18,19 @@ module FlowChat
|
|
18
18
|
@business_account_id = nil
|
19
19
|
@skip_signature_validation = false
|
20
20
|
|
21
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
|
22
|
+
|
21
23
|
register_as(name) if name.present?
|
22
24
|
end
|
23
25
|
|
24
26
|
# Load configuration from Rails credentials or environment variables
|
25
27
|
def self.from_credentials
|
28
|
+
FlowChat.logger.info { "WhatsApp::Configuration: Loading configuration from credentials/environment" }
|
29
|
+
|
26
30
|
config = new(nil)
|
27
31
|
|
28
32
|
if defined?(Rails) && Rails.application.credentials.whatsapp
|
33
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Loading from Rails credentials" }
|
29
34
|
credentials = Rails.application.credentials.whatsapp
|
30
35
|
config.access_token = credentials[:access_token]
|
31
36
|
config.phone_number_id = credentials[:phone_number_id]
|
@@ -35,6 +40,7 @@ module FlowChat
|
|
35
40
|
config.business_account_id = credentials[:business_account_id]
|
36
41
|
config.skip_signature_validation = credentials[:skip_signature_validation] || false
|
37
42
|
else
|
43
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Loading from environment variables" }
|
38
44
|
# Fallback to environment variables
|
39
45
|
config.access_token = ENV["WHATSAPP_ACCESS_TOKEN"]
|
40
46
|
config.phone_number_id = ENV["WHATSAPP_PHONE_NUMBER_ID"]
|
@@ -45,43 +51,68 @@ module FlowChat
|
|
45
51
|
config.skip_signature_validation = ENV["WHATSAPP_SKIP_SIGNATURE_VALIDATION"] == "true"
|
46
52
|
end
|
47
53
|
|
54
|
+
if config.valid?
|
55
|
+
FlowChat.logger.info { "WhatsApp::Configuration: Configuration loaded successfully - phone_number_id: #{config.phone_number_id}" }
|
56
|
+
else
|
57
|
+
FlowChat.logger.warn { "WhatsApp::Configuration: Incomplete configuration loaded - missing required fields" }
|
58
|
+
end
|
59
|
+
|
48
60
|
config
|
49
61
|
end
|
50
62
|
|
51
63
|
# Register a named configuration
|
52
64
|
def self.register(name, config)
|
65
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration '#{name}'" }
|
53
66
|
@@configurations[name.to_sym] = config
|
54
67
|
end
|
55
68
|
|
56
69
|
# Get a named configuration
|
57
70
|
def self.get(name)
|
58
|
-
@@configurations[name.to_sym]
|
71
|
+
config = @@configurations[name.to_sym]
|
72
|
+
if config
|
73
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Retrieved configuration '#{name}'" }
|
74
|
+
config
|
75
|
+
else
|
76
|
+
FlowChat.logger.error { "WhatsApp::Configuration: Configuration '#{name}' not found" }
|
77
|
+
raise ArgumentError, "WhatsApp configuration '#{name}' not found"
|
78
|
+
end
|
59
79
|
end
|
60
80
|
|
61
81
|
# Check if a named configuration exists
|
62
82
|
def self.exists?(name)
|
63
|
-
@@configurations.key?(name.to_sym)
|
83
|
+
exists = @@configurations.key?(name.to_sym)
|
84
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Configuration '#{name}' exists: #{exists}" }
|
85
|
+
exists
|
64
86
|
end
|
65
87
|
|
66
88
|
# Get all configuration names
|
67
89
|
def self.configuration_names
|
68
|
-
@@configurations.keys
|
90
|
+
names = @@configurations.keys
|
91
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Available configurations: #{names}" }
|
92
|
+
names
|
69
93
|
end
|
70
94
|
|
71
95
|
# Clear all registered configurations (useful for testing)
|
72
96
|
def self.clear_all!
|
97
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Clearing all registered configurations" }
|
73
98
|
@@configurations.clear
|
74
99
|
end
|
75
100
|
|
76
101
|
# Register this configuration with a name
|
77
102
|
def register_as(name)
|
103
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration as '#{name}'" }
|
78
104
|
@name = name.to_sym
|
79
105
|
self.class.register(@name, self)
|
80
106
|
self
|
81
107
|
end
|
82
108
|
|
83
109
|
def valid?
|
84
|
-
|
110
|
+
is_valid = access_token && !access_token.to_s.empty? &&
|
111
|
+
phone_number_id && !phone_number_id.to_s.empty? &&
|
112
|
+
verify_token && !verify_token.to_s.empty?
|
113
|
+
|
114
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Configuration valid: #{is_valid}" }
|
115
|
+
is_valid
|
85
116
|
end
|
86
117
|
|
87
118
|
# API endpoints
|