flow_chat 0.8.2 → 0.10.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/pages.yml +43 -0
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +189 -133
- data/Rakefile +17 -2
- data/SECURITY.md +42 -349
- data/docs/architecture.md +83 -0
- data/docs/async-background-processing.md +64 -0
- data/docs/configuration.md +110 -287
- data/docs/factory-pattern.md +58 -0
- data/docs/gateway-context-variables.md +168 -0
- data/docs/gateway-development.md +159 -0
- data/docs/getting-started.md +90 -0
- data/docs/instrumentation.md +95 -175
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +109 -0
- data/docs/platforms/ussd.md +78 -0
- data/docs/platforms/whatsapp.md +147 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +33 -426
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +22 -20
- data/examples/intercom_configuration_example.rb +113 -0
- data/examples/intercom_controller.rb +182 -0
- data/examples/multi_tenant_whatsapp_controller.rb +63 -168
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +88 -160
- data/examples/whatsapp_controller.rb +18 -17
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/app.rb +211 -0
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +126 -23
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
- 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/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +104 -36
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/http/renderer.rb +3 -3
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +182 -0
- data/lib/flow_chat/intercom/client.rb +161 -0
- data/lib/flow_chat/intercom/configuration.rb +102 -0
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +123 -0
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/phone_number_util.rb +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +167 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +36 -11
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +283 -0
- data/lib/flow_chat/telegram/configuration.rb +78 -0
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
- data/lib/flow_chat/telegram/renderer.rb +133 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- 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 +158 -20
- data/lib/flow_chat/whatsapp/configuration.rb +13 -52
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
- data/lib/flow_chat/whatsapp/renderer.rb +259 -64
- data/lib/flow_chat.rb +1 -1
- data/lib/tasks/release.rake +165 -0
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +132 -25
- data/docs/flows.md +0 -320
- data/docs/http-gateway-protocol.md +0 -432
- 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_app.rb +0 -86
- data/lib/flow_chat/base_processor.rb +0 -146
- data/lib/flow_chat/http/app.rb +0 -6
- data/lib/flow_chat/http/middleware/executor.rb +0 -24
- data/lib/flow_chat/http/processor.rb +0 -33
- 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 -96
- 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
|
@@ -3,6 +3,7 @@ module FlowChat
|
|
|
3
3
|
module Gateway
|
|
4
4
|
class Nalo
|
|
5
5
|
include FlowChat::Instrumentation
|
|
6
|
+
include FlowChat::GatewayAsyncSupport
|
|
6
7
|
|
|
7
8
|
attr_reader :context
|
|
8
9
|
|
|
@@ -10,9 +11,15 @@ module FlowChat
|
|
|
10
11
|
@app = app
|
|
11
12
|
end
|
|
12
13
|
|
|
14
|
+
# USSD does not support async processing due to synchronous protocol requirements
|
|
15
|
+
def async_supported?
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
13
19
|
def call(context)
|
|
14
20
|
@context = context
|
|
15
|
-
|
|
21
|
+
@controller = context.controller
|
|
22
|
+
params = @controller.request.params
|
|
16
23
|
|
|
17
24
|
context["request.id"] = params["USERID"]
|
|
18
25
|
context["request.msisdn"] = FlowChat::PhoneNumberUtil.to_e164(params["MSISDN"])
|
|
@@ -21,9 +28,9 @@ module FlowChat
|
|
|
21
28
|
context["request.timestamp"] = Time.current.iso8601
|
|
22
29
|
context["request.gateway"] = :nalo
|
|
23
30
|
context["request.platform"] = :ussd
|
|
24
|
-
context["request.
|
|
31
|
+
context["request.body"] = (params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h).transform_keys(&:to_s)
|
|
25
32
|
# context["request.type"] = params["MSGTYPE"] ? :initial : :response
|
|
26
|
-
context.input = params["USERDATA"].presence
|
|
33
|
+
context.input = params["USERDATA"].presence || ""
|
|
27
34
|
|
|
28
35
|
# Instrument message received when user provides input using new scalable approach
|
|
29
36
|
if context.input.present?
|
|
@@ -49,7 +56,7 @@ module FlowChat
|
|
|
49
56
|
timestamp: context["request.timestamp"]
|
|
50
57
|
})
|
|
51
58
|
|
|
52
|
-
|
|
59
|
+
@controller.render json: {
|
|
53
60
|
USERID: params["USERID"],
|
|
54
61
|
MSISDN: params["MSISDN"],
|
|
55
62
|
MSG: render_prompt(prompt, choices, media),
|
|
@@ -57,6 +64,19 @@ module FlowChat
|
|
|
57
64
|
}
|
|
58
65
|
end
|
|
59
66
|
|
|
67
|
+
def self.configure_middleware_stack(builder, custom_middleware)
|
|
68
|
+
FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Configuring middleware stack" }
|
|
69
|
+
|
|
70
|
+
builder.use FlowChat::Ussd::Middleware::Pagination
|
|
71
|
+
FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Added Ussd::Middleware::Pagination" }
|
|
72
|
+
|
|
73
|
+
builder.use custom_middleware
|
|
74
|
+
FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Added custom middleware" }
|
|
75
|
+
|
|
76
|
+
builder.use FlowChat::Ussd::Middleware::ChoiceMapper
|
|
77
|
+
FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Added Ussd::Middleware::ChoiceMapper" }
|
|
78
|
+
end
|
|
79
|
+
|
|
60
80
|
private
|
|
61
81
|
|
|
62
82
|
def render_prompt(prompt, choices, media)
|
|
@@ -57,6 +57,16 @@ module FlowChat
|
|
|
57
57
|
@context.input = original_choice
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# USSD is deliberately exempt from the fold every other choice mapper
|
|
61
|
+
# now takes. A numeric keypad can only ever send a position, and
|
|
62
|
+
# positions are unique by construction, so the relation this resolves
|
|
63
|
+
# on is already injective - there is no equivalence under which two
|
|
64
|
+
# choices could collapse into one another.
|
|
65
|
+
#
|
|
66
|
+
# Every other mapper has to be *given* that guarantee, by asking
|
|
67
|
+
# FlowChat::ChoiceTitles to number a set whose titles collide under
|
|
68
|
+
# its own resolver's fold. Nothing here needs to, and adding aliasing
|
|
69
|
+
# to this mapper would introduce the very ambiguity it does not have.
|
|
60
70
|
def create_numbered_mapping(choices)
|
|
61
71
|
# Choices are always a hash after normalize_choices
|
|
62
72
|
numbered_choices = {}
|
|
@@ -21,7 +21,6 @@ module FlowChat
|
|
|
21
21
|
if intercept?
|
|
22
22
|
FlowChat.logger.info { "Ussd::Pagination: Intercepting request for pagination handling - session #{session_id}" }
|
|
23
23
|
type, prompt = handle_intercepted_request
|
|
24
|
-
[type, prompt, []]
|
|
25
24
|
else
|
|
26
25
|
# Clear pagination state for new flows
|
|
27
26
|
if pagination_state.present?
|
|
@@ -41,8 +40,8 @@ module FlowChat
|
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
42
|
|
|
44
|
-
[type, prompt, []]
|
|
45
43
|
end
|
|
44
|
+
[type, prompt, []]
|
|
46
45
|
end
|
|
47
46
|
|
|
48
47
|
private
|
|
@@ -61,15 +60,17 @@ module FlowChat
|
|
|
61
60
|
|
|
62
61
|
def handle_intercepted_request
|
|
63
62
|
FlowChat.logger.info { "Ussd::Pagination: Handling paginated request" }
|
|
64
|
-
|
|
63
|
+
# Cache current_page to avoid multiple calculations
|
|
64
|
+
page = current_page
|
|
65
|
+
start, finish, has_more = calculate_offsets_for_page(page)
|
|
65
66
|
type = (pagination_state["type"].to_sym == :terminal && !has_more) ? :terminal : :prompt
|
|
66
67
|
prompt = pagination_state["prompt"][start..finish] + build_pagination_options(type, has_more)
|
|
67
|
-
set_pagination_state(
|
|
68
|
+
set_pagination_state(page, start, finish)
|
|
68
69
|
|
|
69
70
|
# Instrument pagination navigation
|
|
70
71
|
instrument(Events::PAGINATION_TRIGGERED, {
|
|
71
72
|
session_id: @context["session.id"],
|
|
72
|
-
current_page:
|
|
73
|
+
current_page: page,
|
|
73
74
|
total_pages: calculate_total_pages,
|
|
74
75
|
content_length: pagination_state["prompt"].length,
|
|
75
76
|
page_limit: FlowChat::Config.ussd.pagination_page_size,
|
|
@@ -113,7 +114,10 @@ module FlowChat
|
|
|
113
114
|
|
|
114
115
|
def calculate_offsets
|
|
115
116
|
page = current_page
|
|
117
|
+
calculate_offsets_for_page(page)
|
|
118
|
+
end
|
|
116
119
|
|
|
120
|
+
def calculate_offsets_for_page(page)
|
|
117
121
|
FlowChat.logger.debug { "Ussd::Pagination: Calculating offsets for page #{page}" }
|
|
118
122
|
|
|
119
123
|
offset = pagination_state["offsets"][page.to_s]
|
data/lib/flow_chat/version.rb
CHANGED
|
@@ -19,17 +19,27 @@ module FlowChat
|
|
|
19
19
|
# @param to [String] Phone number in E.164 format
|
|
20
20
|
# @param response [Array] FlowChat response array [type, content, options]
|
|
21
21
|
# @return [Hash] API response or nil on error
|
|
22
|
-
def send_message(to,
|
|
23
|
-
|
|
24
|
-
FlowChat.logger.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
22
|
+
def send_message(to, prompt, choices: nil, media: nil)
|
|
23
|
+
FlowChat.logger.info { "WhatsApp::Client: Sending message to #{to}" }
|
|
24
|
+
FlowChat.logger.debug { "WhatsApp::Client: Message content: '#{prompt.to_s.truncate(100)}'" }
|
|
25
|
+
|
|
26
|
+
# Use renderer to convert to structured response
|
|
27
|
+
response = FlowChat::Whatsapp::Renderer.new(prompt, choices: choices, media: media).render
|
|
28
|
+
_, _, options = response
|
|
29
|
+
|
|
30
|
+
# MESSAGE_SENT is instrumented by the gateway, not here. This wrapped
|
|
31
|
+
# the send in its own instrument block, and ActiveSupport::Notifications
|
|
32
|
+
# publishes a block event once the block returns whatever it returned -
|
|
33
|
+
# so the event fired even when the send had failed and this method was
|
|
34
|
+
# about to answer nil, and fired a second time when the gateway
|
|
35
|
+
# instrumented the same send. Every subscriber counted a successful
|
|
36
|
+
# send twice and a failed one once.
|
|
37
|
+
result = begin
|
|
38
|
+
# Above the button cap there is no interactive surface that can
|
|
39
|
+
# carry media (see the renderer), so it rides in options[:media]
|
|
40
|
+
# and goes out as its own message ahead of the choice message.
|
|
41
|
+
send_message_payload(build_message_payload(options[:media], to)) if options[:media]
|
|
42
|
+
|
|
33
43
|
message_data = build_message_payload(response, to)
|
|
34
44
|
send_message_payload(message_data)
|
|
35
45
|
end
|
|
@@ -50,7 +60,7 @@ module FlowChat
|
|
|
50
60
|
# @return [Hash] API response or nil on error
|
|
51
61
|
def send_text(to, text)
|
|
52
62
|
FlowChat.logger.debug { "WhatsApp::Client: Sending text message to #{to}" }
|
|
53
|
-
send_message(to,
|
|
63
|
+
send_message(to, text)
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
# Send interactive buttons
|
|
@@ -60,7 +70,8 @@ module FlowChat
|
|
|
60
70
|
# @return [Hash] API response or nil on error
|
|
61
71
|
def send_buttons(to, text, buttons)
|
|
62
72
|
FlowChat.logger.debug { "WhatsApp::Client: Sending interactive buttons to #{to} with #{buttons.size} buttons" }
|
|
63
|
-
|
|
73
|
+
choices = buttons.each_with_object({}) { |button, hash| hash[button[:id]] = button[:title] }
|
|
74
|
+
send_message(to, text, choices: choices)
|
|
64
75
|
end
|
|
65
76
|
|
|
66
77
|
# Send interactive list
|
|
@@ -72,7 +83,9 @@ module FlowChat
|
|
|
72
83
|
def send_list(to, text, sections, button_text = "Choose")
|
|
73
84
|
total_items = sections.sum { |section| section[:rows]&.size || 0 }
|
|
74
85
|
FlowChat.logger.debug { "WhatsApp::Client: Sending interactive list to #{to} with #{sections.size} sections, #{total_items} total items" }
|
|
75
|
-
|
|
86
|
+
choices = {}
|
|
87
|
+
sections.each { |section| section[:rows]&.each { |row| choices[row[:id]] = row[:title] } }
|
|
88
|
+
send_message(to, text, choices: choices)
|
|
76
89
|
end
|
|
77
90
|
|
|
78
91
|
# Send a template message
|
|
@@ -83,11 +96,13 @@ module FlowChat
|
|
|
83
96
|
# @return [Hash] API response or nil on error
|
|
84
97
|
def send_template(to, template_name, components = [], language = "en_US")
|
|
85
98
|
FlowChat.logger.debug { "WhatsApp::Client: Sending template '#{template_name}' to #{to} in #{language}" }
|
|
86
|
-
|
|
99
|
+
media = {
|
|
100
|
+
type: :template,
|
|
87
101
|
template_name: template_name,
|
|
88
102
|
components: components,
|
|
89
103
|
language: language
|
|
90
|
-
}
|
|
104
|
+
}
|
|
105
|
+
send_message(to, "", media: media)
|
|
91
106
|
end
|
|
92
107
|
|
|
93
108
|
# Send image message
|
|
@@ -98,7 +113,12 @@ module FlowChat
|
|
|
98
113
|
# @return [Hash] API response
|
|
99
114
|
def send_image(to, image_url_or_id, caption = nil, mime_type = nil)
|
|
100
115
|
FlowChat.logger.debug { "WhatsApp::Client: Sending image to #{to} - #{url?(image_url_or_id) ? "URL" : "Media ID"}" }
|
|
101
|
-
|
|
116
|
+
media = if url?(image_url_or_id)
|
|
117
|
+
{type: :image, url: image_url_or_id}
|
|
118
|
+
else
|
|
119
|
+
{type: :image, id: image_url_or_id}
|
|
120
|
+
end
|
|
121
|
+
send_message(to, caption, media: media)
|
|
102
122
|
end
|
|
103
123
|
|
|
104
124
|
# Send document message
|
|
@@ -145,6 +165,41 @@ module FlowChat
|
|
|
145
165
|
send_media_message(to, :sticker, sticker_url_or_id, mime_type: mime_type)
|
|
146
166
|
end
|
|
147
167
|
|
|
168
|
+
# Mark an inbound message as read, optionally showing a typing indicator.
|
|
169
|
+
#
|
|
170
|
+
# WhatsApp Cloud API ties the typing indicator to the mark-as-read call:
|
|
171
|
+
# passing `typing: true` adds a `typing_indicator` field to the same
|
|
172
|
+
# request. The indicator auto-dismisses on the next outbound message or
|
|
173
|
+
# after ~25 seconds; there is no separate "stop typing" call.
|
|
174
|
+
#
|
|
175
|
+
# @param message_id [String] the inbound message id (wamid.*) to mark as read
|
|
176
|
+
# @param typing [Boolean] when true, also show a typing indicator
|
|
177
|
+
# @return [Hash, nil] parsed API response, or nil on failure
|
|
178
|
+
def mark_as_read(message_id, typing: false)
|
|
179
|
+
payload = {
|
|
180
|
+
messaging_product: "whatsapp",
|
|
181
|
+
status: "read",
|
|
182
|
+
message_id: message_id
|
|
183
|
+
}
|
|
184
|
+
payload[:typing_indicator] = {type: "text"} if typing
|
|
185
|
+
|
|
186
|
+
send_read_receipt_payload(payload, message_id)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Show a typing indicator in response to an inbound message.
|
|
190
|
+
#
|
|
191
|
+
# Convenience wrapper around `mark_as_read(message_id, typing: true)`.
|
|
192
|
+
# Note that WhatsApp ties the typing indicator to read-receipt delivery,
|
|
193
|
+
# so calling this also marks the message as read. There is no stop-typing
|
|
194
|
+
# call — the indicator auto-dismisses on the next outbound message or
|
|
195
|
+
# after ~25 seconds.
|
|
196
|
+
#
|
|
197
|
+
# @param message_id [String] the inbound message id (wamid.*)
|
|
198
|
+
# @return [Hash, nil] parsed API response, or nil on failure
|
|
199
|
+
def indicate_typing(message_id)
|
|
200
|
+
mark_as_read(message_id, typing: true)
|
|
201
|
+
end
|
|
202
|
+
|
|
148
203
|
# Upload media file and return media ID
|
|
149
204
|
# @param file_path_or_io [String, IO] File path or IO object
|
|
150
205
|
# @param mime_type [String] MIME type of the file (required)
|
|
@@ -379,7 +434,7 @@ module FlowChat
|
|
|
379
434
|
data = JSON.parse(response.body)
|
|
380
435
|
data["url"]
|
|
381
436
|
else
|
|
382
|
-
|
|
437
|
+
FlowChat.logger.error { "WhatsApp::Client: Media API error: #{response.body}" }
|
|
383
438
|
nil
|
|
384
439
|
end
|
|
385
440
|
end
|
|
@@ -403,7 +458,7 @@ module FlowChat
|
|
|
403
458
|
if response.is_a?(Net::HTTPSuccess)
|
|
404
459
|
response.body
|
|
405
460
|
else
|
|
406
|
-
|
|
461
|
+
FlowChat.logger.error { "WhatsApp::Client: Media download error: #{response.body}" }
|
|
407
462
|
nil
|
|
408
463
|
end
|
|
409
464
|
end
|
|
@@ -420,7 +475,7 @@ module FlowChat
|
|
|
420
475
|
response = http.head(uri.path)
|
|
421
476
|
response["content-type"]
|
|
422
477
|
rescue => e
|
|
423
|
-
|
|
478
|
+
FlowChat.logger.warn { "WhatsApp::Client: Could not detect MIME type for #{url}: #{e.message}" }
|
|
424
479
|
nil
|
|
425
480
|
end
|
|
426
481
|
|
|
@@ -489,6 +544,13 @@ module FlowChat
|
|
|
489
544
|
result
|
|
490
545
|
else
|
|
491
546
|
FlowChat.logger.error { "WhatsApp::Client: API request failed - #{response.code}: #{response.body}" }
|
|
547
|
+
report_api_error(
|
|
548
|
+
"WhatsApp API request failed",
|
|
549
|
+
response_code: response.code,
|
|
550
|
+
response_body: response.body,
|
|
551
|
+
recipient: to,
|
|
552
|
+
message_type: message_type
|
|
553
|
+
)
|
|
492
554
|
nil
|
|
493
555
|
end
|
|
494
556
|
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
@@ -497,9 +559,85 @@ module FlowChat
|
|
|
497
559
|
raise network_error
|
|
498
560
|
rescue => error
|
|
499
561
|
FlowChat.logger.error { "WhatsApp::Client: API request exception: #{error.class.name}: #{error.message}" }
|
|
562
|
+
report_api_error(
|
|
563
|
+
"WhatsApp API request exception: #{error.class.name}",
|
|
564
|
+
error: error,
|
|
565
|
+
recipient: to,
|
|
566
|
+
message_type: message_type
|
|
567
|
+
)
|
|
500
568
|
nil
|
|
501
569
|
end
|
|
502
570
|
|
|
571
|
+
def send_read_receipt_payload(payload, message_id)
|
|
572
|
+
FlowChat.logger.debug { "WhatsApp::Client: Marking message #{message_id} as read (typing=#{payload.key?(:typing_indicator)})" }
|
|
573
|
+
|
|
574
|
+
uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
|
|
575
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
576
|
+
http.use_ssl = true
|
|
577
|
+
|
|
578
|
+
request = Net::HTTP::Post.new(uri)
|
|
579
|
+
request["Authorization"] = "Bearer #{@config.access_token}"
|
|
580
|
+
request["Content-Type"] = "application/json"
|
|
581
|
+
request.body = payload.to_json
|
|
582
|
+
|
|
583
|
+
response = http.request(request)
|
|
584
|
+
|
|
585
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
586
|
+
JSON.parse(response.body)
|
|
587
|
+
else
|
|
588
|
+
FlowChat.logger.error { "WhatsApp::Client: mark_as_read failed - #{response.code}: #{response.body}" }
|
|
589
|
+
report_api_error(
|
|
590
|
+
"WhatsApp mark_as_read failed",
|
|
591
|
+
response_code: response.code,
|
|
592
|
+
response_body: response.body,
|
|
593
|
+
message_type: "read_receipt"
|
|
594
|
+
)
|
|
595
|
+
nil
|
|
596
|
+
end
|
|
597
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
598
|
+
FlowChat.logger.error { "WhatsApp::Client: Network timeout marking read: #{network_error.class.name}: #{network_error.message}" }
|
|
599
|
+
raise network_error
|
|
600
|
+
rescue => error
|
|
601
|
+
FlowChat.logger.error { "WhatsApp::Client: mark_as_read exception: #{error.class.name}: #{error.message}" }
|
|
602
|
+
report_api_error(
|
|
603
|
+
"WhatsApp mark_as_read exception: #{error.class.name}",
|
|
604
|
+
error: error,
|
|
605
|
+
message_type: "read_receipt"
|
|
606
|
+
)
|
|
607
|
+
nil
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def report_api_error(message, response_code: nil, response_body: nil, error: nil, recipient: nil, message_type: nil)
|
|
611
|
+
error_details = parse_error_response(response_body)
|
|
612
|
+
|
|
613
|
+
FlowChat::Instrumentation.report_api_error(
|
|
614
|
+
message,
|
|
615
|
+
error: error,
|
|
616
|
+
platform: :whatsapp,
|
|
617
|
+
phone_number_id: @config.phone_number_id,
|
|
618
|
+
recipient: recipient,
|
|
619
|
+
message_type: message_type,
|
|
620
|
+
response_code: response_code,
|
|
621
|
+
**error_details
|
|
622
|
+
)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def parse_error_response(response_body)
|
|
626
|
+
return {} unless response_body
|
|
627
|
+
|
|
628
|
+
parsed = JSON.parse(response_body)
|
|
629
|
+
return {} unless parsed.is_a?(Hash) && parsed["error"]
|
|
630
|
+
|
|
631
|
+
{
|
|
632
|
+
error_type: parsed.dig("error", "type"),
|
|
633
|
+
error_code: parsed.dig("error", "code"),
|
|
634
|
+
error_subcode: parsed.dig("error", "error_subcode"),
|
|
635
|
+
error_message: parsed.dig("error", "message")
|
|
636
|
+
}.compact
|
|
637
|
+
rescue JSON::ParserError
|
|
638
|
+
{}
|
|
639
|
+
end
|
|
640
|
+
|
|
503
641
|
def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
|
|
504
642
|
media_object = if url?(url_or_id)
|
|
505
643
|
{link: url_or_id}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
module FlowChat
|
|
2
2
|
module Whatsapp
|
|
3
3
|
class Configuration
|
|
4
|
+
include FlowChat::NamedConfiguration
|
|
5
|
+
|
|
6
|
+
# "Whatsapp" is the constant, "WhatsApp" is the product.
|
|
7
|
+
def self.configuration_label
|
|
8
|
+
"WhatsApp"
|
|
9
|
+
end
|
|
10
|
+
|
|
4
11
|
attr_accessor :access_token, :phone_number_id, :verify_token, :app_id, :app_secret,
|
|
5
12
|
:webhook_verify_token, :business_account_id, :name, :skip_signature_validation
|
|
6
13
|
|
|
7
|
-
# Class-level storage for named configurations
|
|
8
|
-
@@configurations = {}
|
|
9
|
-
|
|
10
14
|
def initialize(name)
|
|
11
15
|
@name = name
|
|
12
16
|
@access_token = nil
|
|
@@ -29,7 +33,7 @@ module FlowChat
|
|
|
29
33
|
|
|
30
34
|
config = new(nil)
|
|
31
35
|
|
|
32
|
-
if defined?(Rails) && Rails.application.credentials
|
|
36
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.whatsapp
|
|
33
37
|
FlowChat.logger.debug { "WhatsApp::Configuration: Loading from Rails credentials" }
|
|
34
38
|
credentials = Rails.application.credentials.whatsapp
|
|
35
39
|
config.access_token = credentials[:access_token]
|
|
@@ -60,56 +64,13 @@ module FlowChat
|
|
|
60
64
|
config
|
|
61
65
|
end
|
|
62
66
|
|
|
63
|
-
# Register a named configuration
|
|
64
|
-
def self.register(name, config)
|
|
65
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration '#{name}'" }
|
|
66
|
-
@@configurations[name.to_sym] = config
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Get a named configuration
|
|
70
|
-
def self.get(name)
|
|
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
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# Check if a named configuration exists
|
|
82
|
-
def self.exists?(name)
|
|
83
|
-
exists = @@configurations.key?(name.to_sym)
|
|
84
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Configuration '#{name}' exists: #{exists}" }
|
|
85
|
-
exists
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Get all configuration names
|
|
89
|
-
def self.configuration_names
|
|
90
|
-
names = @@configurations.keys
|
|
91
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Available configurations: #{names}" }
|
|
92
|
-
names
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Clear all registered configurations (useful for testing)
|
|
96
|
-
def self.clear_all!
|
|
97
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Clearing all registered configurations" }
|
|
98
|
-
@@configurations.clear
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Register this configuration with a name
|
|
102
|
-
def register_as(name)
|
|
103
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration as '#{name}'" }
|
|
104
|
-
@name = name.to_sym
|
|
105
|
-
self.class.register(@name, self)
|
|
106
|
-
self
|
|
107
|
-
end
|
|
108
|
-
|
|
109
67
|
def valid?
|
|
110
|
-
|
|
68
|
+
# Wrapped so a predicate answers true or false rather than nil, which
|
|
69
|
+
# the bare && chain returns for a missing first field. Intercom and
|
|
70
|
+
# Telegram already do this and pin it in their tests.
|
|
71
|
+
is_valid = !!(access_token && !access_token.to_s.empty? &&
|
|
111
72
|
phone_number_id && !phone_number_id.to_s.empty? &&
|
|
112
|
-
verify_token && !verify_token.to_s.empty?
|
|
73
|
+
verify_token && !verify_token.to_s.empty?)
|
|
113
74
|
|
|
114
75
|
FlowChat.logger.debug { "WhatsApp::Configuration: Configuration valid: #{is_valid}" }
|
|
115
76
|
is_valid
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Whatsapp
|
|
3
|
+
# Its own file so Zeitwerk can resolve it by name. Declared inside
|
|
4
|
+
# gateway/cloud_api.rb, which Zeitwerk maps to a different constant, it existed only
|
|
5
|
+
# once that file had loaded for some other reason, so an application
|
|
6
|
+
# rescuing it, or a test naming it first, got an uninitialized constant.
|
|
7
|
+
class ConfigurationError < StandardError; end
|
|
8
|
+
end
|
|
9
|
+
end
|