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
|
@@ -19,10 +19,13 @@ 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
|
-
|
|
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
|
+
type, content, _options = response
|
|
26
29
|
|
|
27
30
|
result = instrument(Events::MESSAGE_SENT, {
|
|
28
31
|
to: to,
|
|
@@ -50,7 +53,7 @@ module FlowChat
|
|
|
50
53
|
# @return [Hash] API response or nil on error
|
|
51
54
|
def send_text(to, text)
|
|
52
55
|
FlowChat.logger.debug { "WhatsApp::Client: Sending text message to #{to}" }
|
|
53
|
-
send_message(to,
|
|
56
|
+
send_message(to, text)
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
# Send interactive buttons
|
|
@@ -60,7 +63,8 @@ module FlowChat
|
|
|
60
63
|
# @return [Hash] API response or nil on error
|
|
61
64
|
def send_buttons(to, text, buttons)
|
|
62
65
|
FlowChat.logger.debug { "WhatsApp::Client: Sending interactive buttons to #{to} with #{buttons.size} buttons" }
|
|
63
|
-
|
|
66
|
+
choices = buttons.each_with_object({}) { |button, hash| hash[button[:id]] = button[:title] }
|
|
67
|
+
send_message(to, text, choices: choices)
|
|
64
68
|
end
|
|
65
69
|
|
|
66
70
|
# Send interactive list
|
|
@@ -72,7 +76,9 @@ module FlowChat
|
|
|
72
76
|
def send_list(to, text, sections, button_text = "Choose")
|
|
73
77
|
total_items = sections.sum { |section| section[:rows]&.size || 0 }
|
|
74
78
|
FlowChat.logger.debug { "WhatsApp::Client: Sending interactive list to #{to} with #{sections.size} sections, #{total_items} total items" }
|
|
75
|
-
|
|
79
|
+
choices = {}
|
|
80
|
+
sections.each { |section| section[:rows]&.each { |row| choices[row[:id]] = row[:title] } }
|
|
81
|
+
send_message(to, text, choices: choices)
|
|
76
82
|
end
|
|
77
83
|
|
|
78
84
|
# Send a template message
|
|
@@ -83,11 +89,13 @@ module FlowChat
|
|
|
83
89
|
# @return [Hash] API response or nil on error
|
|
84
90
|
def send_template(to, template_name, components = [], language = "en_US")
|
|
85
91
|
FlowChat.logger.debug { "WhatsApp::Client: Sending template '#{template_name}' to #{to} in #{language}" }
|
|
86
|
-
|
|
92
|
+
media = {
|
|
93
|
+
type: :template,
|
|
87
94
|
template_name: template_name,
|
|
88
95
|
components: components,
|
|
89
96
|
language: language
|
|
90
|
-
}
|
|
97
|
+
}
|
|
98
|
+
send_message(to, "", media: media)
|
|
91
99
|
end
|
|
92
100
|
|
|
93
101
|
# Send image message
|
|
@@ -98,7 +106,12 @@ module FlowChat
|
|
|
98
106
|
# @return [Hash] API response
|
|
99
107
|
def send_image(to, image_url_or_id, caption = nil, mime_type = nil)
|
|
100
108
|
FlowChat.logger.debug { "WhatsApp::Client: Sending image to #{to} - #{url?(image_url_or_id) ? "URL" : "Media ID"}" }
|
|
101
|
-
|
|
109
|
+
media = if url?(image_url_or_id)
|
|
110
|
+
{type: :image, url: image_url_or_id}
|
|
111
|
+
else
|
|
112
|
+
{type: :image, id: image_url_or_id}
|
|
113
|
+
end
|
|
114
|
+
send_message(to, caption, media: media)
|
|
102
115
|
end
|
|
103
116
|
|
|
104
117
|
# Send document message
|
|
@@ -145,6 +158,41 @@ module FlowChat
|
|
|
145
158
|
send_media_message(to, :sticker, sticker_url_or_id, mime_type: mime_type)
|
|
146
159
|
end
|
|
147
160
|
|
|
161
|
+
# Mark an inbound message as read, optionally showing a typing indicator.
|
|
162
|
+
#
|
|
163
|
+
# WhatsApp Cloud API ties the typing indicator to the mark-as-read call:
|
|
164
|
+
# passing `typing: true` adds a `typing_indicator` field to the same
|
|
165
|
+
# request. The indicator auto-dismisses on the next outbound message or
|
|
166
|
+
# after ~25 seconds; there is no separate "stop typing" call.
|
|
167
|
+
#
|
|
168
|
+
# @param message_id [String] the inbound message id (wamid.*) to mark as read
|
|
169
|
+
# @param typing [Boolean] when true, also show a typing indicator
|
|
170
|
+
# @return [Hash, nil] parsed API response, or nil on failure
|
|
171
|
+
def mark_as_read(message_id, typing: false)
|
|
172
|
+
payload = {
|
|
173
|
+
messaging_product: "whatsapp",
|
|
174
|
+
status: "read",
|
|
175
|
+
message_id: message_id
|
|
176
|
+
}
|
|
177
|
+
payload[:typing_indicator] = {type: "text"} if typing
|
|
178
|
+
|
|
179
|
+
send_read_receipt_payload(payload, message_id)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Show a typing indicator in response to an inbound message.
|
|
183
|
+
#
|
|
184
|
+
# Convenience wrapper around `mark_as_read(message_id, typing: true)`.
|
|
185
|
+
# Note that WhatsApp ties the typing indicator to read-receipt delivery,
|
|
186
|
+
# so calling this also marks the message as read. There is no stop-typing
|
|
187
|
+
# call — the indicator auto-dismisses on the next outbound message or
|
|
188
|
+
# after ~25 seconds.
|
|
189
|
+
#
|
|
190
|
+
# @param message_id [String] the inbound message id (wamid.*)
|
|
191
|
+
# @return [Hash, nil] parsed API response, or nil on failure
|
|
192
|
+
def indicate_typing(message_id)
|
|
193
|
+
mark_as_read(message_id, typing: true)
|
|
194
|
+
end
|
|
195
|
+
|
|
148
196
|
# Upload media file and return media ID
|
|
149
197
|
# @param file_path_or_io [String, IO] File path or IO object
|
|
150
198
|
# @param mime_type [String] MIME type of the file (required)
|
|
@@ -379,7 +427,7 @@ module FlowChat
|
|
|
379
427
|
data = JSON.parse(response.body)
|
|
380
428
|
data["url"]
|
|
381
429
|
else
|
|
382
|
-
|
|
430
|
+
FlowChat.logger.error { "WhatsApp::Client: Media API error: #{response.body}" }
|
|
383
431
|
nil
|
|
384
432
|
end
|
|
385
433
|
end
|
|
@@ -403,7 +451,7 @@ module FlowChat
|
|
|
403
451
|
if response.is_a?(Net::HTTPSuccess)
|
|
404
452
|
response.body
|
|
405
453
|
else
|
|
406
|
-
|
|
454
|
+
FlowChat.logger.error { "WhatsApp::Client: Media download error: #{response.body}" }
|
|
407
455
|
nil
|
|
408
456
|
end
|
|
409
457
|
end
|
|
@@ -420,7 +468,7 @@ module FlowChat
|
|
|
420
468
|
response = http.head(uri.path)
|
|
421
469
|
response["content-type"]
|
|
422
470
|
rescue => e
|
|
423
|
-
|
|
471
|
+
FlowChat.logger.warn { "WhatsApp::Client: Could not detect MIME type for #{url}: #{e.message}" }
|
|
424
472
|
nil
|
|
425
473
|
end
|
|
426
474
|
|
|
@@ -489,6 +537,13 @@ module FlowChat
|
|
|
489
537
|
result
|
|
490
538
|
else
|
|
491
539
|
FlowChat.logger.error { "WhatsApp::Client: API request failed - #{response.code}: #{response.body}" }
|
|
540
|
+
report_api_error(
|
|
541
|
+
"WhatsApp API request failed",
|
|
542
|
+
response_code: response.code,
|
|
543
|
+
response_body: response.body,
|
|
544
|
+
recipient: to,
|
|
545
|
+
message_type: message_type
|
|
546
|
+
)
|
|
492
547
|
nil
|
|
493
548
|
end
|
|
494
549
|
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
@@ -497,9 +552,85 @@ module FlowChat
|
|
|
497
552
|
raise network_error
|
|
498
553
|
rescue => error
|
|
499
554
|
FlowChat.logger.error { "WhatsApp::Client: API request exception: #{error.class.name}: #{error.message}" }
|
|
555
|
+
report_api_error(
|
|
556
|
+
"WhatsApp API request exception: #{error.class.name}",
|
|
557
|
+
error: error,
|
|
558
|
+
recipient: to,
|
|
559
|
+
message_type: message_type
|
|
560
|
+
)
|
|
500
561
|
nil
|
|
501
562
|
end
|
|
502
563
|
|
|
564
|
+
def send_read_receipt_payload(payload, message_id)
|
|
565
|
+
FlowChat.logger.debug { "WhatsApp::Client: Marking message #{message_id} as read (typing=#{payload.key?(:typing_indicator)})" }
|
|
566
|
+
|
|
567
|
+
uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
|
|
568
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
569
|
+
http.use_ssl = true
|
|
570
|
+
|
|
571
|
+
request = Net::HTTP::Post.new(uri)
|
|
572
|
+
request["Authorization"] = "Bearer #{@config.access_token}"
|
|
573
|
+
request["Content-Type"] = "application/json"
|
|
574
|
+
request.body = payload.to_json
|
|
575
|
+
|
|
576
|
+
response = http.request(request)
|
|
577
|
+
|
|
578
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
579
|
+
JSON.parse(response.body)
|
|
580
|
+
else
|
|
581
|
+
FlowChat.logger.error { "WhatsApp::Client: mark_as_read failed - #{response.code}: #{response.body}" }
|
|
582
|
+
report_api_error(
|
|
583
|
+
"WhatsApp mark_as_read failed",
|
|
584
|
+
response_code: response.code,
|
|
585
|
+
response_body: response.body,
|
|
586
|
+
message_type: "read_receipt"
|
|
587
|
+
)
|
|
588
|
+
nil
|
|
589
|
+
end
|
|
590
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
591
|
+
FlowChat.logger.error { "WhatsApp::Client: Network timeout marking read: #{network_error.class.name}: #{network_error.message}" }
|
|
592
|
+
raise network_error
|
|
593
|
+
rescue => error
|
|
594
|
+
FlowChat.logger.error { "WhatsApp::Client: mark_as_read exception: #{error.class.name}: #{error.message}" }
|
|
595
|
+
report_api_error(
|
|
596
|
+
"WhatsApp mark_as_read exception: #{error.class.name}",
|
|
597
|
+
error: error,
|
|
598
|
+
message_type: "read_receipt"
|
|
599
|
+
)
|
|
600
|
+
nil
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def report_api_error(message, response_code: nil, response_body: nil, error: nil, recipient: nil, message_type: nil)
|
|
604
|
+
error_details = parse_error_response(response_body)
|
|
605
|
+
|
|
606
|
+
FlowChat::Instrumentation.report_api_error(
|
|
607
|
+
message,
|
|
608
|
+
error: error,
|
|
609
|
+
platform: :whatsapp,
|
|
610
|
+
phone_number_id: @config.phone_number_id,
|
|
611
|
+
recipient: recipient,
|
|
612
|
+
message_type: message_type,
|
|
613
|
+
response_code: response_code,
|
|
614
|
+
**error_details
|
|
615
|
+
)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def parse_error_response(response_body)
|
|
619
|
+
return {} unless response_body
|
|
620
|
+
|
|
621
|
+
parsed = JSON.parse(response_body)
|
|
622
|
+
return {} unless parsed.is_a?(Hash) && parsed["error"]
|
|
623
|
+
|
|
624
|
+
{
|
|
625
|
+
error_type: parsed.dig("error", "type"),
|
|
626
|
+
error_code: parsed.dig("error", "code"),
|
|
627
|
+
error_subcode: parsed.dig("error", "error_subcode"),
|
|
628
|
+
error_message: parsed.dig("error", "message")
|
|
629
|
+
}.compact
|
|
630
|
+
rescue JSON::ParserError
|
|
631
|
+
{}
|
|
632
|
+
end
|
|
633
|
+
|
|
503
634
|
def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
|
|
504
635
|
media_object = if url?(url_or_id)
|
|
505
636
|
{link: url_or_id}
|
|
@@ -29,7 +29,7 @@ module FlowChat
|
|
|
29
29
|
|
|
30
30
|
config = new(nil)
|
|
31
31
|
|
|
32
|
-
if defined?(Rails) && Rails.application.credentials
|
|
32
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.whatsapp
|
|
33
33
|
FlowChat.logger.debug { "WhatsApp::Configuration: Loading from Rails credentials" }
|
|
34
34
|
credentials = Rails.application.credentials.whatsapp
|
|
35
35
|
config.access_token = credentials[:access_token]
|