flow_chat 0.8.2 → 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 +11 -11
- 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/ussd_controller.rb +9 -9
- data/examples/whatsapp_controller.rb +2 -2
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/{base_app.rb → app.rb} +16 -9
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/config.rb +10 -30
- 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/gateway/simple.rb +81 -33
- data/lib/flow_chat/http/renderer.rb +3 -3
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +23 -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 +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/renderers/markdown_support.rb +58 -0
- data/lib/flow_chat/session/middleware.rb +25 -9
- 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 +24 -4
- 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 +132 -96
- 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 +84 -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_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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
module Telegram
|
|
5
|
+
class ConfigurationError < StandardError; end
|
|
6
|
+
|
|
7
|
+
module Gateway
|
|
8
|
+
class BotApi
|
|
9
|
+
include FlowChat::Instrumentation
|
|
10
|
+
include FlowChat::GatewayAsyncSupport
|
|
11
|
+
|
|
12
|
+
attr_reader :client, :context
|
|
13
|
+
|
|
14
|
+
def initialize(app, config = nil)
|
|
15
|
+
@app = app
|
|
16
|
+
@config = config || FlowChat::Telegram::Configuration.from_credentials
|
|
17
|
+
@client = FlowChat::Telegram::Client.new(@config)
|
|
18
|
+
|
|
19
|
+
FlowChat.logger.info { "BotApi: Initialized Telegram Bot API gateway" }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(context)
|
|
23
|
+
@context = context
|
|
24
|
+
@controller = context.controller
|
|
25
|
+
request = @controller.request
|
|
26
|
+
|
|
27
|
+
FlowChat.logger.debug {
|
|
28
|
+
"BotApi: Processing #{begin
|
|
29
|
+
request.request_method
|
|
30
|
+
rescue
|
|
31
|
+
"POST"
|
|
32
|
+
end} request"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Handle POST webhooks only
|
|
36
|
+
if request.post?
|
|
37
|
+
return handle_webhook(context)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@controller.head :bad_request
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Platform-specific middleware configuration
|
|
44
|
+
def self.configure_middleware_stack(builder, custom_middleware)
|
|
45
|
+
FlowChat.logger.debug { "BotApi: Configuring Telegram middleware stack" }
|
|
46
|
+
builder.use custom_middleware
|
|
47
|
+
builder.use FlowChat::Telegram::Middleware::ChoiceMapper
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def handle_webhook(context)
|
|
53
|
+
begin
|
|
54
|
+
parse_request_body(@controller.request)
|
|
55
|
+
FlowChat.logger.debug { "BotApi: Successfully parsed webhook request body" }
|
|
56
|
+
rescue JSON::ParserError => e
|
|
57
|
+
FlowChat.logger.error { "BotApi: Failed to parse webhook body: #{e.message}" }
|
|
58
|
+
return @controller.head :bad_request
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Validate webhook signature (skip in background)
|
|
62
|
+
# Return 200 OK even for invalid signatures to prevent Telegram from retrying
|
|
63
|
+
unless in_background? || valid_webhook_signature?(@controller.request)
|
|
64
|
+
FlowChat.logger.warn { "BotApi: Invalid webhook signature - dropping request" }
|
|
65
|
+
return @controller.head :ok
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Process update
|
|
69
|
+
if @body["message"]
|
|
70
|
+
process_message(@body["message"], context)
|
|
71
|
+
elsif @body["callback_query"]
|
|
72
|
+
process_callback_query(@body["callback_query"], context)
|
|
73
|
+
else
|
|
74
|
+
FlowChat.logger.debug { "BotApi: No message or callback_query in update - returning OK" }
|
|
75
|
+
return @controller.head :ok
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Routing: async or inline
|
|
79
|
+
if should_enqueue_async?
|
|
80
|
+
enqueue_async_job
|
|
81
|
+
return @controller.head :ok
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
handle_message_inline(context, @controller)
|
|
85
|
+
@controller.head :ok
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def process_message(message, context)
|
|
89
|
+
chat = message["chat"]
|
|
90
|
+
from = message["from"]
|
|
91
|
+
|
|
92
|
+
context["request.id"] = chat["id"].to_s
|
|
93
|
+
context["request.user_id"] = from["id"].to_s
|
|
94
|
+
context["request.user_name"] = [from["first_name"], from["last_name"]].compact.join(" ")
|
|
95
|
+
context["request.username"] = from["username"]
|
|
96
|
+
context["request.gateway"] = :telegram_bot_api
|
|
97
|
+
context["request.platform"] = :telegram
|
|
98
|
+
context["request.message_id"] = message["message_id"].to_s
|
|
99
|
+
context["request.timestamp"] = Time.at(message["date"]).iso8601
|
|
100
|
+
context["request.body"] = @body
|
|
101
|
+
|
|
102
|
+
context["telegram.client"] = @client
|
|
103
|
+
context["telegram.chat_type"] = chat["type"]
|
|
104
|
+
|
|
105
|
+
extract_message_content!(message, context)
|
|
106
|
+
|
|
107
|
+
if context.input.present?
|
|
108
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
|
109
|
+
from: from["id"].to_s,
|
|
110
|
+
message: context.input,
|
|
111
|
+
message_type: detect_message_type(message),
|
|
112
|
+
chat_type: chat["type"]
|
|
113
|
+
})
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def process_callback_query(callback_query, context)
|
|
118
|
+
from = callback_query["from"]
|
|
119
|
+
message = callback_query["message"]
|
|
120
|
+
chat = message["chat"]
|
|
121
|
+
|
|
122
|
+
context["request.id"] = chat["id"].to_s
|
|
123
|
+
context["request.user_id"] = from["id"].to_s
|
|
124
|
+
context["request.user_name"] = [from["first_name"], from["last_name"]].compact.join(" ")
|
|
125
|
+
context["request.username"] = from["username"]
|
|
126
|
+
context["request.gateway"] = :telegram_bot_api
|
|
127
|
+
context["request.platform"] = :telegram
|
|
128
|
+
context["request.message_id"] = message["message_id"].to_s
|
|
129
|
+
context["request.timestamp"] = Time.current.iso8601
|
|
130
|
+
context["request.body"] = @body
|
|
131
|
+
|
|
132
|
+
context["telegram.client"] = @client
|
|
133
|
+
context["telegram.callback_query_id"] = callback_query["id"]
|
|
134
|
+
context["telegram.original_message_id"] = message["message_id"]
|
|
135
|
+
context["telegram.chat_type"] = chat["type"]
|
|
136
|
+
|
|
137
|
+
# Input is the callback_data
|
|
138
|
+
context.input = callback_query["data"]
|
|
139
|
+
|
|
140
|
+
# Auto-answer callback query to remove loading indicator
|
|
141
|
+
@client.answer_callback_query(callback_query["id"])
|
|
142
|
+
|
|
143
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
|
144
|
+
from: from["id"].to_s,
|
|
145
|
+
message: context.input,
|
|
146
|
+
message_type: "callback_query"
|
|
147
|
+
})
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def extract_message_content!(message, context)
|
|
151
|
+
if message["text"]
|
|
152
|
+
context.input = message["text"]
|
|
153
|
+
elsif message["location"]
|
|
154
|
+
context["request.location"] = {
|
|
155
|
+
"latitude" => message["location"]["latitude"],
|
|
156
|
+
"longitude" => message["location"]["longitude"]
|
|
157
|
+
}
|
|
158
|
+
context.input = FlowChat::Input::LOCATION
|
|
159
|
+
elsif message["photo"]
|
|
160
|
+
# Photos come as array, take highest resolution (last)
|
|
161
|
+
photo = message["photo"].last
|
|
162
|
+
context["request.media"] = {
|
|
163
|
+
type: :photo,
|
|
164
|
+
file_id: photo["file_id"],
|
|
165
|
+
file_unique_id: photo["file_unique_id"],
|
|
166
|
+
width: photo["width"],
|
|
167
|
+
height: photo["height"]
|
|
168
|
+
}
|
|
169
|
+
context.input = FlowChat::Input::MEDIA
|
|
170
|
+
elsif message["video"]
|
|
171
|
+
video = message["video"]
|
|
172
|
+
context["request.media"] = {
|
|
173
|
+
type: :video,
|
|
174
|
+
file_id: video["file_id"],
|
|
175
|
+
file_unique_id: video["file_unique_id"],
|
|
176
|
+
width: video["width"],
|
|
177
|
+
height: video["height"],
|
|
178
|
+
duration: video["duration"],
|
|
179
|
+
mime_type: video["mime_type"]
|
|
180
|
+
}
|
|
181
|
+
context.input = FlowChat::Input::MEDIA
|
|
182
|
+
elsif message["audio"]
|
|
183
|
+
audio = message["audio"]
|
|
184
|
+
context["request.media"] = {
|
|
185
|
+
type: :audio,
|
|
186
|
+
file_id: audio["file_id"],
|
|
187
|
+
file_unique_id: audio["file_unique_id"],
|
|
188
|
+
duration: audio["duration"],
|
|
189
|
+
mime_type: audio["mime_type"],
|
|
190
|
+
title: audio["title"],
|
|
191
|
+
performer: audio["performer"]
|
|
192
|
+
}
|
|
193
|
+
context.input = FlowChat::Input::MEDIA
|
|
194
|
+
elsif message["document"]
|
|
195
|
+
doc = message["document"]
|
|
196
|
+
context["request.media"] = {
|
|
197
|
+
type: :document,
|
|
198
|
+
file_id: doc["file_id"],
|
|
199
|
+
file_unique_id: doc["file_unique_id"],
|
|
200
|
+
file_name: doc["file_name"],
|
|
201
|
+
mime_type: doc["mime_type"]
|
|
202
|
+
}
|
|
203
|
+
context.input = FlowChat::Input::MEDIA
|
|
204
|
+
elsif message["voice"]
|
|
205
|
+
voice = message["voice"]
|
|
206
|
+
context["request.media"] = {
|
|
207
|
+
type: :voice,
|
|
208
|
+
file_id: voice["file_id"],
|
|
209
|
+
file_unique_id: voice["file_unique_id"],
|
|
210
|
+
duration: voice["duration"],
|
|
211
|
+
mime_type: voice["mime_type"]
|
|
212
|
+
}
|
|
213
|
+
context.input = FlowChat::Input::MEDIA
|
|
214
|
+
elsif message["sticker"]
|
|
215
|
+
sticker = message["sticker"]
|
|
216
|
+
context["request.media"] = {
|
|
217
|
+
type: :sticker,
|
|
218
|
+
file_id: sticker["file_id"],
|
|
219
|
+
file_unique_id: sticker["file_unique_id"],
|
|
220
|
+
width: sticker["width"],
|
|
221
|
+
height: sticker["height"],
|
|
222
|
+
is_animated: sticker["is_animated"],
|
|
223
|
+
is_video: sticker["is_video"],
|
|
224
|
+
emoji: sticker["emoji"],
|
|
225
|
+
set_name: sticker["set_name"]
|
|
226
|
+
}
|
|
227
|
+
context.input = FlowChat::Input::MEDIA
|
|
228
|
+
elsif message["contact"]
|
|
229
|
+
context["request.contact"] = {
|
|
230
|
+
phone_number: message["contact"]["phone_number"],
|
|
231
|
+
first_name: message["contact"]["first_name"],
|
|
232
|
+
last_name: message["contact"]["last_name"],
|
|
233
|
+
user_id: message["contact"]["user_id"]
|
|
234
|
+
}
|
|
235
|
+
context.input = FlowChat::Input::CONTACT
|
|
236
|
+
else
|
|
237
|
+
context.input = ""
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def detect_message_type(message)
|
|
242
|
+
return "text" if message["text"]
|
|
243
|
+
return "location" if message["location"]
|
|
244
|
+
return "photo" if message["photo"]
|
|
245
|
+
return "video" if message["video"]
|
|
246
|
+
return "audio" if message["audio"]
|
|
247
|
+
return "document" if message["document"]
|
|
248
|
+
return "voice" if message["voice"]
|
|
249
|
+
return "sticker" if message["sticker"]
|
|
250
|
+
return "contact" if message["contact"]
|
|
251
|
+
"unknown"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def valid_webhook_signature?(request)
|
|
255
|
+
return true if @config.skip_signature_validation
|
|
256
|
+
return true unless @config.secret_token
|
|
257
|
+
|
|
258
|
+
provided_token = request.headers["X-Telegram-Bot-Api-Secret-Token"]
|
|
259
|
+
return false unless provided_token
|
|
260
|
+
|
|
261
|
+
secure_compare(@config.secret_token, provided_token.to_s)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def secure_compare(a, b)
|
|
265
|
+
return false unless a.bytesize == b.bytesize
|
|
266
|
+
l = a.unpack("C*")
|
|
267
|
+
res = 0
|
|
268
|
+
b.each_byte { |byte| res |= byte ^ l.shift }
|
|
269
|
+
res == 0
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def handle_message_inline(context, controller)
|
|
273
|
+
response = @app.call(context)
|
|
274
|
+
return unless response
|
|
275
|
+
|
|
276
|
+
_type, prompt, choices, media = response
|
|
277
|
+
@client.send_message(context["request.id"], prompt, choices: choices, media: media)
|
|
278
|
+
|
|
279
|
+
instrument(Events::MESSAGE_SENT, {
|
|
280
|
+
to: context["request.id"],
|
|
281
|
+
message: prompt,
|
|
282
|
+
gateway: :telegram_bot_api,
|
|
283
|
+
platform: :telegram
|
|
284
|
+
})
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def parse_request_body(request)
|
|
288
|
+
return @body if @body
|
|
289
|
+
|
|
290
|
+
if request.body.nil?
|
|
291
|
+
@body = {}
|
|
292
|
+
else
|
|
293
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
294
|
+
@body = JSON.parse(request.body.read)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Telegram
|
|
3
|
+
module Middleware
|
|
4
|
+
class ChoiceMapper
|
|
5
|
+
def initialize(app)
|
|
6
|
+
@app = app
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(context)
|
|
10
|
+
input = context.input
|
|
11
|
+
|
|
12
|
+
# Check if input matches a stored choice key (for validation/logging)
|
|
13
|
+
if context.session && input.present?
|
|
14
|
+
choices = context.session.get("telegram_choices")
|
|
15
|
+
if choices&.key?(input)
|
|
16
|
+
FlowChat.logger.debug { "ChoiceMapper: Input '#{input}' is a valid choice key" }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
response = @app.call(context)
|
|
21
|
+
|
|
22
|
+
# Store choices in session for validation
|
|
23
|
+
if response
|
|
24
|
+
_, _, choices, _ = response
|
|
25
|
+
if choices.is_a?(Hash)
|
|
26
|
+
context.session&.set("telegram_choices", choices)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
response
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "flow_chat/renderers/markdown_support"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
module Telegram
|
|
5
|
+
class Renderer
|
|
6
|
+
include FlowChat::Renderers::MarkdownSupport
|
|
7
|
+
|
|
8
|
+
attr_reader :message, :choices, :media
|
|
9
|
+
|
|
10
|
+
def initialize(message, choices: nil, media: nil)
|
|
11
|
+
@message = message
|
|
12
|
+
@choices = choices
|
|
13
|
+
@media = media
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render
|
|
17
|
+
if media && choices
|
|
18
|
+
build_media_with_keyboard
|
|
19
|
+
elsif media
|
|
20
|
+
build_media_message
|
|
21
|
+
elsif choices
|
|
22
|
+
build_keyboard_message
|
|
23
|
+
else
|
|
24
|
+
build_text_message
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_text_message
|
|
31
|
+
[:text, to_html(message), {}]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_keyboard_message
|
|
35
|
+
validate_choices!
|
|
36
|
+
keyboard = build_inline_keyboard(choices)
|
|
37
|
+
[:inline_keyboard, to_html(message), {keyboard: keyboard}]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_media_message
|
|
41
|
+
media_type = media[:type] || :photo
|
|
42
|
+
url = media[:url] || media[:file_id]
|
|
43
|
+
|
|
44
|
+
case media_type.to_sym
|
|
45
|
+
when :photo
|
|
46
|
+
[:photo, to_html(message), {url: url}]
|
|
47
|
+
when :document
|
|
48
|
+
[:document, to_html(message), {url: url, filename: media[:filename]}]
|
|
49
|
+
when :video
|
|
50
|
+
[:video, to_html(message), {url: url}]
|
|
51
|
+
when :audio
|
|
52
|
+
[:audio, to_html(message), {url: url}]
|
|
53
|
+
when :voice
|
|
54
|
+
[:voice, to_html(message), {url: url}]
|
|
55
|
+
else
|
|
56
|
+
# Fallback to text for unsupported types
|
|
57
|
+
[:text, to_html(message), {}]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_media_with_keyboard
|
|
62
|
+
validate_choices!
|
|
63
|
+
keyboard = build_inline_keyboard(choices)
|
|
64
|
+
media_type = media[:type] || :photo
|
|
65
|
+
url = media[:url] || media[:file_id]
|
|
66
|
+
|
|
67
|
+
[:photo_with_keyboard, to_html(message), {
|
|
68
|
+
url: url,
|
|
69
|
+
keyboard: keyboard,
|
|
70
|
+
media_type: media_type.to_sym
|
|
71
|
+
}]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_inline_keyboard(choice_hash)
|
|
75
|
+
buttons = choice_hash.map do |key, value|
|
|
76
|
+
{
|
|
77
|
+
text: truncate_text(value.to_s, 64),
|
|
78
|
+
callback_data: key.to_s[0, 64]
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Layout: 2 buttons per row for <=4 choices, 1 per row for >4
|
|
83
|
+
if buttons.length <= 4
|
|
84
|
+
buttons.each_slice(2).to_a
|
|
85
|
+
else
|
|
86
|
+
buttons.zip
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_choices!
|
|
91
|
+
unless choices.is_a?(Hash)
|
|
92
|
+
raise ArgumentError, "choices must be a Hash"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def truncate_text(text, length)
|
|
97
|
+
return text if text.length <= length
|
|
98
|
+
text[0, length - 3] + "..."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# MarkdownSupport overrides for Telegram-specific behavior
|
|
102
|
+
|
|
103
|
+
def allowed_tags
|
|
104
|
+
# Tags supported by Telegram Bot API HTML mode
|
|
105
|
+
# Note: p and br are allowed through sanitization but converted to newlines in post_process_html
|
|
106
|
+
%w[b strong i em u s strike del a code pre blockquote p br]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def allowed_attributes
|
|
110
|
+
%w[href]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def post_process_html(html)
|
|
114
|
+
# Convert <p> tags to text with double newlines (Telegram doesn't support <p>)
|
|
115
|
+
result = html.gsub(%r{<p>(.*?)</p>}m, '\1' + "\n\n")
|
|
116
|
+
|
|
117
|
+
# Convert <br> and <br/> to newlines (Telegram doesn't support <br>)
|
|
118
|
+
result = result.gsub(/<br\s*\/?>/, "\n")
|
|
119
|
+
|
|
120
|
+
# Clean up excessive newlines
|
|
121
|
+
result.gsub(/\n{3,}/, "\n\n").strip
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -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)
|
|
@@ -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