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,188 @@
|
|
|
1
|
+
require "middleware"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
class Processor
|
|
5
|
+
include FlowChat::Instrumentation
|
|
6
|
+
|
|
7
|
+
attr_reader :custom_middleware_builder, :context, :async_job_class, :async_job_params
|
|
8
|
+
|
|
9
|
+
def initialize(controller, enable_simulator: nil)
|
|
10
|
+
FlowChat.logger.debug { "Processor: Initializing processor for controller #{controller.class.name}" }
|
|
11
|
+
|
|
12
|
+
@context = FlowChat::Context.new
|
|
13
|
+
@context["controller"] = controller
|
|
14
|
+
@context["enable_simulator"] = enable_simulator.nil? ? (defined?(Rails) && Rails.env.local?) : enable_simulator
|
|
15
|
+
@custom_middleware_builder = ::Middleware::Builder.new(name: "processor.custom_middleware_builder")
|
|
16
|
+
@session_options = FlowChat::Config.session
|
|
17
|
+
@async_job_class = nil
|
|
18
|
+
@async_job_params = {}
|
|
19
|
+
|
|
20
|
+
FlowChat.logger.debug { "Processor: Simulator mode #{@context["enable_simulator"] ? "enabled" : "disabled"}" }
|
|
21
|
+
|
|
22
|
+
yield self if block_given?
|
|
23
|
+
|
|
24
|
+
FlowChat.logger.debug { "Processor: Initialized #{self.class.name} successfully" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def use_gateway(gateway_class, *args)
|
|
28
|
+
FlowChat.logger.debug { "Processor: Configuring gateway #{gateway_class.name} with args: #{args.inspect}" }
|
|
29
|
+
@gateway_class = gateway_class
|
|
30
|
+
@gateway_args = args
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def use_session_store(session_store)
|
|
35
|
+
raise "Session store must be a class" unless session_store.is_a?(Class)
|
|
36
|
+
FlowChat.logger.debug { "Processor: Configuring session store #{session_store.name}" }
|
|
37
|
+
@context["session.store"] = session_store
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def use_session_config(boundaries: nil, hash_identifiers: nil, identifier: nil, &block)
|
|
42
|
+
if block_given?
|
|
43
|
+
FlowChat.logger.debug { "Processor: Configuring session config with custom proc" }
|
|
44
|
+
@session_options = @session_options.dup
|
|
45
|
+
@session_options.session_id_proc = block
|
|
46
|
+
else
|
|
47
|
+
FlowChat.logger.debug { "Processor: Configuring session config: boundaries=#{boundaries.inspect}, hash_identifiers=#{hash_identifiers}, identifier=#{identifier}" }
|
|
48
|
+
|
|
49
|
+
# Update the session options directly
|
|
50
|
+
@session_options = @session_options.dup
|
|
51
|
+
@session_options.boundaries = Array(boundaries) unless boundaries.nil?
|
|
52
|
+
@session_options.hash_identifiers = hash_identifiers unless hash_identifiers.nil?
|
|
53
|
+
@session_options.identifier = identifier unless identifier.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def use_middleware(middleware)
|
|
60
|
+
if block_given?
|
|
61
|
+
yield custom_middleware_builder
|
|
62
|
+
return self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
raise "Middleware must be a class" unless middleware.is_a?(Class)
|
|
66
|
+
FlowChat.logger.debug { "Processor: Adding custom middleware: #{middleware.name}" }
|
|
67
|
+
custom_middleware_builder.use middleware
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def use_cross_platform_sessions
|
|
72
|
+
FlowChat.logger.debug { "Processor: Enabling cross-platform sessions via session configuration" }
|
|
73
|
+
use_session_config(
|
|
74
|
+
boundaries: [:flow]
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def use_url_isolation
|
|
79
|
+
FlowChat.logger.debug { "Processor: Enabling URL-based session isolation" }
|
|
80
|
+
current_boundaries = @session_options.boundaries.dup
|
|
81
|
+
current_boundaries << :url unless current_boundaries.include?(:url)
|
|
82
|
+
use_session_config(boundaries: current_boundaries)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def use_durable_sessions(cross_gateway: false)
|
|
86
|
+
FlowChat.logger.debug { "Processor: Enabling durable sessions via session configuration" }
|
|
87
|
+
use_session_config(
|
|
88
|
+
identifier: :user_id
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def use_async(job_class = nil, **job_params)
|
|
93
|
+
# If no job class provided, use GenericAsyncJob with factory param
|
|
94
|
+
if job_class.nil?
|
|
95
|
+
unless job_params.key?(:factory)
|
|
96
|
+
raise ArgumentError, "When use_async is called without a job class, factory: parameter is required"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
FlowChat.logger.debug { "Processor: Configuring async processing with GenericAsyncJob for factory '#{job_params[:factory]}'" }
|
|
100
|
+
@async_job_class = FlowChat::GenericAsyncJob
|
|
101
|
+
else
|
|
102
|
+
FlowChat.logger.debug { "Processor: Configuring async processing with job class #{job_class.name} and params: #{job_params.inspect}" }
|
|
103
|
+
@async_job_class = job_class
|
|
104
|
+
end
|
|
105
|
+
@async_job_params = job_params
|
|
106
|
+
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def async_enabled?
|
|
111
|
+
!@async_job_class.nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def run(flow_class, action, **options)
|
|
115
|
+
# Instrument flow execution (this will log via LogSubscriber)
|
|
116
|
+
instrument(Events::FLOW_EXECUTION_START, {
|
|
117
|
+
flow_name: flow_class.name.underscore,
|
|
118
|
+
action: action.to_s,
|
|
119
|
+
processor_type: self.class.name
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
@context["processor"] = self
|
|
123
|
+
@context["flow.name"] = flow_class.name.underscore
|
|
124
|
+
@context["flow.class"] = flow_class
|
|
125
|
+
@context["flow.action"] = action
|
|
126
|
+
@context["flow.options"] = options
|
|
127
|
+
|
|
128
|
+
FlowChat.logger.debug { "Processor: Context prepared for flow #{flow_class.name}" }
|
|
129
|
+
|
|
130
|
+
stack = create_middleware_stack
|
|
131
|
+
yield stack if block_given?
|
|
132
|
+
|
|
133
|
+
FlowChat.logger.debug { "Processor: Executing middleware stack for #{flow_class.name}##{action}" }
|
|
134
|
+
|
|
135
|
+
# Instrument flow execution with timing (this will log completion via LogSubscriber)
|
|
136
|
+
instrument(Events::FLOW_EXECUTION_END, {
|
|
137
|
+
flow_name: flow_class.name.underscore,
|
|
138
|
+
action: action.to_s
|
|
139
|
+
}) do
|
|
140
|
+
stack.call(@context)
|
|
141
|
+
end
|
|
142
|
+
rescue => error
|
|
143
|
+
FlowChat.logger.error { "Processor: Flow execution failed - #{flow_class.name}##{action}, Error: #{error.class.name}: #{error.message}" }
|
|
144
|
+
FlowChat.logger.debug { "Processor: Stack trace: #{error.backtrace.join("\n")}" }
|
|
145
|
+
|
|
146
|
+
# Instrument flow execution error (this will log error via LogSubscriber)
|
|
147
|
+
instrument(Events::FLOW_EXECUTION_ERROR, {
|
|
148
|
+
flow_name: flow_class.name.underscore,
|
|
149
|
+
action: action.to_s,
|
|
150
|
+
error_class: error.class.name,
|
|
151
|
+
error_message: error.message,
|
|
152
|
+
backtrace: error.backtrace&.first(10)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
raise
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
protected
|
|
159
|
+
|
|
160
|
+
# Helper method for building stacks
|
|
161
|
+
def create_middleware_stack
|
|
162
|
+
raise ArgumentError, "Gateway is required. Call use_gateway(gateway_class, *args) before running." unless @gateway_class
|
|
163
|
+
|
|
164
|
+
middleware_stack = ::Middleware::Builder.new(name: @gateway_class.name) do |b|
|
|
165
|
+
# Gateway always comes first
|
|
166
|
+
b.use @gateway_class, *@gateway_args
|
|
167
|
+
# Session middleware next. We need to setup our session identifiers
|
|
168
|
+
b.use FlowChat::Session::Middleware, @session_options
|
|
169
|
+
|
|
170
|
+
if @gateway_class.respond_to?(:configure_middleware_stack)
|
|
171
|
+
FlowChat.logger.debug { "Processor: Using platform specific middleware configuration" }
|
|
172
|
+
@gateway_class.configure_middleware_stack(b, custom_middleware_builder)
|
|
173
|
+
else
|
|
174
|
+
b.use custom_middleware_builder
|
|
175
|
+
FlowChat.logger.debug { "Processor: Added custom middleware" }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Executor always goes last.
|
|
179
|
+
# Nothing can execute after it.
|
|
180
|
+
b.use FlowChat::Executor
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
middleware_stack.inject_logger(FlowChat.logger) if FlowChat::Config.inject_middleware_logger
|
|
184
|
+
|
|
185
|
+
middleware_stack
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "kramdown"
|
|
2
|
+
require "rails-html-sanitizer"
|
|
3
|
+
|
|
4
|
+
module FlowChat
|
|
5
|
+
module Renderers
|
|
6
|
+
module MarkdownSupport
|
|
7
|
+
def to_html(text)
|
|
8
|
+
return "" if text.nil?
|
|
9
|
+
|
|
10
|
+
html = Kramdown::Document.new(text.to_s, **kramdown_options).to_html.strip
|
|
11
|
+
sanitize_html(html)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def sanitize_html(html)
|
|
15
|
+
sanitized = self.class.sanitizer.sanitize(
|
|
16
|
+
html,
|
|
17
|
+
tags: allowed_tags,
|
|
18
|
+
attributes: allowed_attributes
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
post_process_html(sanitized)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.included(base)
|
|
25
|
+
base.extend(ClassMethods)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module ClassMethods
|
|
29
|
+
def sanitizer
|
|
30
|
+
@sanitizer ||= Rails::Html::SafeListSanitizer.new
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Override in subclasses to customize Kramdown options
|
|
37
|
+
# Default uses straight quotes (ASCII 39/34) instead of curly smart quotes
|
|
38
|
+
def kramdown_options
|
|
39
|
+
{smart_quotes: [39, 39, 34, 34]}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Override in subclasses to specify allowed HTML tags
|
|
43
|
+
def allowed_tags
|
|
44
|
+
%w[b strong i em a code pre]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Override in subclasses to specify allowed HTML attributes
|
|
48
|
+
def allowed_attributes
|
|
49
|
+
%w[href]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Override in subclasses to post-process sanitized HTML
|
|
53
|
+
def post_process_html(html)
|
|
54
|
+
html
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
1
3
|
module FlowChat
|
|
2
4
|
module Session
|
|
3
5
|
class Middleware
|
|
@@ -52,6 +54,14 @@ module FlowChat
|
|
|
52
54
|
return session_id
|
|
53
55
|
end
|
|
54
56
|
|
|
57
|
+
# Check for custom session ID proc
|
|
58
|
+
if @session_options.session_id_proc
|
|
59
|
+
FlowChat.logger.debug { "Session::Middleware: Using custom session ID proc" }
|
|
60
|
+
session_id = @session_options.session_id_proc.call(context)
|
|
61
|
+
FlowChat.logger.debug { "Session::Middleware: Generated custom session ID: #{session_id}" }
|
|
62
|
+
return session_id
|
|
63
|
+
end
|
|
64
|
+
|
|
55
65
|
FlowChat.logger.debug { "Session::Middleware: Building session ID for platform=#{platform}, gateway=#{gateway}, flow=#{flow_name}" }
|
|
56
66
|
|
|
57
67
|
# Get identifier based on configuration
|
|
@@ -65,7 +75,7 @@ module FlowChat
|
|
|
65
75
|
|
|
66
76
|
def get_session_identifier(context)
|
|
67
77
|
identifier_type = @session_options.identifier || platform_default_identifier(context)
|
|
68
|
-
|
|
78
|
+
|
|
69
79
|
case identifier_type
|
|
70
80
|
when :request_id
|
|
71
81
|
context["request.id"]
|
|
@@ -82,7 +92,7 @@ module FlowChat
|
|
|
82
92
|
|
|
83
93
|
def platform_default_identifier(context)
|
|
84
94
|
platform = context["request.platform"]
|
|
85
|
-
|
|
95
|
+
|
|
86
96
|
case platform
|
|
87
97
|
when :whatsapp
|
|
88
98
|
:msisdn
|
|
@@ -121,19 +131,26 @@ module FlowChat
|
|
|
121
131
|
return nil unless request
|
|
122
132
|
|
|
123
133
|
# Extract host and path for URL boundary
|
|
124
|
-
host =
|
|
125
|
-
|
|
134
|
+
host = begin
|
|
135
|
+
request.host
|
|
136
|
+
rescue
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
path = begin
|
|
140
|
+
request.path
|
|
141
|
+
rescue
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
126
144
|
|
|
127
|
-
# Create a normalized URL identifier: host + path
|
|
145
|
+
# Create a normalized URL identifier: host + path
|
|
128
146
|
# e.g., "example.com/api/v1/ussd" or "tenant1.example.com/ussd"
|
|
129
147
|
url_parts = []
|
|
130
148
|
url_parts << host if host.present?
|
|
131
|
-
url_parts << path.sub(/^\//,
|
|
149
|
+
url_parts << path.sub(/^\//, "") if path.present? && path != "/"
|
|
132
150
|
|
|
133
151
|
# For long URLs, use first part + hash suffix instead of full hash
|
|
134
|
-
url_identifier = url_parts.join(
|
|
152
|
+
url_identifier = url_parts.join("/").gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
135
153
|
if url_identifier.length > 50
|
|
136
|
-
require 'digest'
|
|
137
154
|
# Take first 41 chars + hash suffix to keep it manageable but recognizable
|
|
138
155
|
first_part = url_identifier[0, 41]
|
|
139
156
|
hash_suffix = Digest::SHA256.hexdigest(url_identifier)[0, 8]
|
|
@@ -145,7 +162,6 @@ module FlowChat
|
|
|
145
162
|
|
|
146
163
|
def hash_identifier(identifier)
|
|
147
164
|
# Use SHA256 but only take first 8 characters for reasonable session IDs
|
|
148
|
-
require 'digest'
|
|
149
165
|
Digest::SHA256.hexdigest(identifier.to_s)[0, 8]
|
|
150
166
|
end
|
|
151
167
|
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module FlowChat
|
|
6
|
+
module Telegram
|
|
7
|
+
class Client
|
|
8
|
+
include FlowChat::Instrumentation
|
|
9
|
+
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
FlowChat.logger.info { "Telegram::Client: Initialized Telegram client" }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Main send_message method matching FlowChat pattern
|
|
16
|
+
def send_message(chat_id, prompt, choices: nil, media: nil)
|
|
17
|
+
FlowChat.logger.info { "Telegram::Client: Sending message to chat #{chat_id}" }
|
|
18
|
+
|
|
19
|
+
response = FlowChat::Telegram::Renderer.new(prompt, choices: choices, media: media).render
|
|
20
|
+
type, content, options = response
|
|
21
|
+
|
|
22
|
+
case type
|
|
23
|
+
when :text
|
|
24
|
+
send_text(chat_id, content)
|
|
25
|
+
when :inline_keyboard
|
|
26
|
+
send_text_with_keyboard(chat_id, content, options[:keyboard])
|
|
27
|
+
when :photo
|
|
28
|
+
send_photo(chat_id, options[:url], caption: content)
|
|
29
|
+
when :photo_with_keyboard
|
|
30
|
+
send_photo_with_keyboard(chat_id, options[:url], caption: content, keyboard: options[:keyboard])
|
|
31
|
+
when :document
|
|
32
|
+
send_document(chat_id, options[:url], caption: content)
|
|
33
|
+
when :video
|
|
34
|
+
send_video(chat_id, options[:url], caption: content)
|
|
35
|
+
when :audio
|
|
36
|
+
send_audio(chat_id, options[:url], caption: content)
|
|
37
|
+
when :voice
|
|
38
|
+
send_voice(chat_id, options[:url])
|
|
39
|
+
else
|
|
40
|
+
send_text(chat_id, content.to_s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def send_text(chat_id, text, parse_mode: "HTML")
|
|
45
|
+
api_request("sendMessage", {
|
|
46
|
+
chat_id: chat_id,
|
|
47
|
+
text: text,
|
|
48
|
+
parse_mode: parse_mode
|
|
49
|
+
}.compact)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def send_text_with_keyboard(chat_id, text, keyboard, parse_mode: "HTML")
|
|
53
|
+
api_request("sendMessage", {
|
|
54
|
+
chat_id: chat_id,
|
|
55
|
+
text: text,
|
|
56
|
+
parse_mode: parse_mode,
|
|
57
|
+
reply_markup: {inline_keyboard: keyboard}
|
|
58
|
+
}.compact)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def send_photo(chat_id, photo_url_or_id, caption: nil)
|
|
62
|
+
api_request("sendPhoto", {
|
|
63
|
+
chat_id: chat_id,
|
|
64
|
+
photo: photo_url_or_id,
|
|
65
|
+
caption: caption
|
|
66
|
+
}.compact)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def send_photo_with_keyboard(chat_id, photo_url_or_id, caption: nil, keyboard: nil)
|
|
70
|
+
api_request("sendPhoto", {
|
|
71
|
+
chat_id: chat_id,
|
|
72
|
+
photo: photo_url_or_id,
|
|
73
|
+
caption: caption,
|
|
74
|
+
reply_markup: keyboard ? {inline_keyboard: keyboard} : nil
|
|
75
|
+
}.compact)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def send_document(chat_id, document_url_or_id, caption: nil)
|
|
79
|
+
api_request("sendDocument", {
|
|
80
|
+
chat_id: chat_id,
|
|
81
|
+
document: document_url_or_id,
|
|
82
|
+
caption: caption
|
|
83
|
+
}.compact)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def send_video(chat_id, video_url_or_id, caption: nil)
|
|
87
|
+
api_request("sendVideo", {
|
|
88
|
+
chat_id: chat_id,
|
|
89
|
+
video: video_url_or_id,
|
|
90
|
+
caption: caption
|
|
91
|
+
}.compact)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def send_audio(chat_id, audio_url_or_id, caption: nil)
|
|
95
|
+
api_request("sendAudio", {
|
|
96
|
+
chat_id: chat_id,
|
|
97
|
+
audio: audio_url_or_id,
|
|
98
|
+
caption: caption
|
|
99
|
+
}.compact)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def send_voice(chat_id, voice_url_or_id)
|
|
103
|
+
api_request("sendVoice", {
|
|
104
|
+
chat_id: chat_id,
|
|
105
|
+
voice: voice_url_or_id
|
|
106
|
+
})
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def answer_callback_query(callback_query_id, text: nil, show_alert: false)
|
|
110
|
+
api_request("answerCallbackQuery", {
|
|
111
|
+
callback_query_id: callback_query_id,
|
|
112
|
+
text: text,
|
|
113
|
+
show_alert: show_alert
|
|
114
|
+
}.compact)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def edit_message_text(chat_id, message_id, text, keyboard: nil, parse_mode: "HTML")
|
|
118
|
+
api_request("editMessageText", {
|
|
119
|
+
chat_id: chat_id,
|
|
120
|
+
message_id: message_id,
|
|
121
|
+
text: text,
|
|
122
|
+
parse_mode: parse_mode,
|
|
123
|
+
reply_markup: keyboard ? {inline_keyboard: keyboard} : nil
|
|
124
|
+
}.compact)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def delete_message(chat_id, message_id)
|
|
128
|
+
api_request("deleteMessage", {
|
|
129
|
+
chat_id: chat_id,
|
|
130
|
+
message_id: message_id
|
|
131
|
+
})
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Send a chat action (e.g. typing indicator) to a Telegram chat.
|
|
135
|
+
#
|
|
136
|
+
# The action lasts ~5 seconds or until the next outbound message.
|
|
137
|
+
# Valid actions per Telegram Bot API: "typing", "upload_photo",
|
|
138
|
+
# "record_video", "upload_video", "record_voice", "upload_voice",
|
|
139
|
+
# "upload_document", "choose_sticker", "find_location",
|
|
140
|
+
# "record_video_note", "upload_video_note".
|
|
141
|
+
#
|
|
142
|
+
# @param chat_id [Integer, String] the target chat id
|
|
143
|
+
# @param action [String] the chat action to broadcast (default: "typing")
|
|
144
|
+
# @return [Hash] parsed Telegram API response
|
|
145
|
+
def send_chat_action(chat_id, action: "typing")
|
|
146
|
+
api_request("sendChatAction", chat_id: chat_id, action: action)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Show a typing indicator in a Telegram chat.
|
|
150
|
+
#
|
|
151
|
+
# Convenience wrapper around `send_chat_action(chat_id, action: "typing")`.
|
|
152
|
+
# The indicator lasts ~5 seconds or until the next outbound message;
|
|
153
|
+
# there is no stop-typing call.
|
|
154
|
+
#
|
|
155
|
+
# @param chat_id [Integer, String] the target chat id
|
|
156
|
+
# @return [Hash] parsed Telegram API response
|
|
157
|
+
def indicate_typing(chat_id)
|
|
158
|
+
send_chat_action(chat_id, action: "typing")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Webhook management
|
|
162
|
+
def set_webhook(url, secret_token: nil, allowed_updates: nil)
|
|
163
|
+
api_request("setWebhook", {
|
|
164
|
+
url: url,
|
|
165
|
+
secret_token: secret_token,
|
|
166
|
+
allowed_updates: allowed_updates || ["message", "callback_query"]
|
|
167
|
+
}.compact)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def delete_webhook
|
|
171
|
+
api_request("deleteWebhook")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def get_webhook_info
|
|
175
|
+
api_request("getWebhookInfo")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def get_me
|
|
179
|
+
api_request("getMe")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def api_request(method, params = {})
|
|
185
|
+
uri = URI("#{@config.api_base_url}/#{method}")
|
|
186
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
187
|
+
http.use_ssl = true
|
|
188
|
+
|
|
189
|
+
request = Net::HTTP::Post.new(uri)
|
|
190
|
+
request["Content-Type"] = "application/json"
|
|
191
|
+
request.body = params.to_json
|
|
192
|
+
|
|
193
|
+
FlowChat.logger.debug { "Telegram::Client: API request to #{method}" }
|
|
194
|
+
|
|
195
|
+
response = http.request(request)
|
|
196
|
+
result = JSON.parse(response.body)
|
|
197
|
+
|
|
198
|
+
if result["ok"]
|
|
199
|
+
FlowChat.logger.debug { "Telegram::Client: API request successful" }
|
|
200
|
+
else
|
|
201
|
+
FlowChat.logger.error { "Telegram::Client: API error - #{result["description"]}" }
|
|
202
|
+
report_api_error(
|
|
203
|
+
"Telegram API error: #{result["description"]}",
|
|
204
|
+
api_method: method,
|
|
205
|
+
error_code: result["error_code"],
|
|
206
|
+
error_description: result["description"],
|
|
207
|
+
chat_id: params[:chat_id]
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
result
|
|
212
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
213
|
+
FlowChat.logger.error { "Telegram::Client: Network timeout: #{network_error.class.name}: #{network_error.message}" }
|
|
214
|
+
raise network_error
|
|
215
|
+
rescue => error
|
|
216
|
+
FlowChat.logger.error { "Telegram::Client: API request exception: #{error.class.name}: #{error.message}" }
|
|
217
|
+
report_api_error(
|
|
218
|
+
"Telegram API request exception: #{error.class.name}",
|
|
219
|
+
api_method: method,
|
|
220
|
+
error: error,
|
|
221
|
+
chat_id: params[:chat_id]
|
|
222
|
+
)
|
|
223
|
+
{"ok" => false, "description" => error.message}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def report_api_error(message, api_method: nil, error_code: nil, error_description: nil, error: nil, chat_id: nil)
|
|
227
|
+
FlowChat::Instrumentation.report_api_error(
|
|
228
|
+
message,
|
|
229
|
+
error: error,
|
|
230
|
+
platform: :telegram,
|
|
231
|
+
bot_id: @config.bot_id,
|
|
232
|
+
api_method: api_method,
|
|
233
|
+
error_code: error_code,
|
|
234
|
+
error_description: error_description,
|
|
235
|
+
chat_id: chat_id
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Telegram
|
|
3
|
+
class Configuration
|
|
4
|
+
attr_accessor :bot_token, :secret_token, :name, :skip_signature_validation
|
|
5
|
+
|
|
6
|
+
@@configurations = {}
|
|
7
|
+
|
|
8
|
+
def initialize(name)
|
|
9
|
+
@name = name
|
|
10
|
+
@bot_token = nil
|
|
11
|
+
@secret_token = nil
|
|
12
|
+
@skip_signature_validation = false
|
|
13
|
+
|
|
14
|
+
FlowChat.logger.debug { "Telegram::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
|
|
15
|
+
|
|
16
|
+
register_as(name) if name.present?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.from_credentials
|
|
20
|
+
FlowChat.logger.info { "Telegram::Configuration: Loading configuration from credentials/environment" }
|
|
21
|
+
|
|
22
|
+
config = new(nil)
|
|
23
|
+
|
|
24
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application.credentials.telegram
|
|
25
|
+
FlowChat.logger.debug { "Telegram::Configuration: Loading from Rails credentials" }
|
|
26
|
+
credentials = Rails.application.credentials.telegram
|
|
27
|
+
config.bot_token = credentials[:bot_token]
|
|
28
|
+
config.secret_token = credentials[:secret_token]
|
|
29
|
+
config.skip_signature_validation = credentials[:skip_signature_validation] || false
|
|
30
|
+
else
|
|
31
|
+
FlowChat.logger.debug { "Telegram::Configuration: Loading from environment variables" }
|
|
32
|
+
config.bot_token = ENV["TELEGRAM_BOT_TOKEN"]
|
|
33
|
+
config.secret_token = ENV["TELEGRAM_SECRET_TOKEN"]
|
|
34
|
+
config.skip_signature_validation = ENV["TELEGRAM_SKIP_SIGNATURE_VALIDATION"] == "true"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if config.valid?
|
|
38
|
+
FlowChat.logger.info { "Telegram::Configuration: Configuration loaded successfully" }
|
|
39
|
+
else
|
|
40
|
+
FlowChat.logger.warn { "Telegram::Configuration: Incomplete configuration loaded - missing required fields" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
config
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.register(name, config)
|
|
47
|
+
FlowChat.logger.debug { "Telegram::Configuration: Registering configuration '#{name}'" }
|
|
48
|
+
@@configurations[name.to_sym] = config
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.get(name)
|
|
52
|
+
config = @@configurations[name.to_sym]
|
|
53
|
+
if config
|
|
54
|
+
FlowChat.logger.debug { "Telegram::Configuration: Retrieved configuration '#{name}'" }
|
|
55
|
+
config
|
|
56
|
+
else
|
|
57
|
+
FlowChat.logger.error { "Telegram::Configuration: Configuration '#{name}' not found" }
|
|
58
|
+
raise ArgumentError, "Telegram configuration '#{name}' not found"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.exists?(name)
|
|
63
|
+
exists = @@configurations.key?(name.to_sym)
|
|
64
|
+
FlowChat.logger.debug { "Telegram::Configuration: Configuration '#{name}' exists: #{exists}" }
|
|
65
|
+
exists
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.configuration_names
|
|
69
|
+
names = @@configurations.keys
|
|
70
|
+
FlowChat.logger.debug { "Telegram::Configuration: Available configurations: #{names}" }
|
|
71
|
+
names
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.clear_all!
|
|
75
|
+
FlowChat.logger.debug { "Telegram::Configuration: Clearing all registered configurations" }
|
|
76
|
+
@@configurations.clear
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def register_as(name)
|
|
80
|
+
FlowChat.logger.debug { "Telegram::Configuration: Registering configuration as '#{name}'" }
|
|
81
|
+
@name = name.to_sym
|
|
82
|
+
self.class.register(@name, self)
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def valid?
|
|
87
|
+
is_valid = !!(bot_token && !bot_token.to_s.empty?)
|
|
88
|
+
FlowChat.logger.debug { "Telegram::Configuration: Configuration valid: #{is_valid}" }
|
|
89
|
+
is_valid
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def api_base_url
|
|
93
|
+
return nil unless bot_token
|
|
94
|
+
"https://api.telegram.org/bot#{bot_token}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def bot_id
|
|
98
|
+
bot_token&.split(":")&.first
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def send_message_url
|
|
102
|
+
"#{api_base_url}/sendMessage"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def set_webhook_url
|
|
106
|
+
"#{api_base_url}/setWebhook"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def get_webhook_info_url
|
|
110
|
+
"#{api_base_url}/getWebhookInfo"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def delete_webhook_url
|
|
114
|
+
"#{api_base_url}/deleteWebhook"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|