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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/release.yml +56 -0
  5. data/.standard.yml +4 -0
  6. data/CHANGELOG.md +22 -0
  7. data/CLAUDE.md +327 -0
  8. data/CONTRIBUTING.md +134 -0
  9. data/Gemfile +1 -0
  10. data/README.md +290 -105
  11. data/Rakefile +5 -1
  12. data/SECURITY.md +42 -349
  13. data/docs/architecture.md +510 -0
  14. data/docs/async-background-processing.md +298 -0
  15. data/docs/configuration.md +556 -226
  16. data/docs/factory-pattern.md +355 -0
  17. data/docs/gateway-context-variables.md +171 -0
  18. data/docs/gateway-development.md +723 -0
  19. data/docs/getting-started.md +429 -0
  20. data/docs/instrumentation.md +264 -153
  21. data/docs/platforms/telegram.md +1013 -0
  22. data/docs/platforms/ussd.md +693 -0
  23. data/docs/platforms/whatsapp.md +1395 -0
  24. data/docs/testing.md +243 -365
  25. data/examples/custom_session_id_example.rb +119 -0
  26. data/examples/http_controller.rb +11 -11
  27. data/examples/intercom_configuration_example.rb +118 -0
  28. data/examples/intercom_controller.rb +194 -0
  29. data/examples/multi_tenant_whatsapp_controller.rb +4 -4
  30. data/examples/ussd_controller.rb +9 -9
  31. data/examples/whatsapp_controller.rb +2 -2
  32. data/flow_chat.gemspec +4 -0
  33. data/lib/flow_chat/{base_app.rb → app.rb} +16 -9
  34. data/lib/flow_chat/async_job.rb +176 -0
  35. data/lib/flow_chat/config.rb +10 -30
  36. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  37. data/lib/flow_chat/factory.rb +94 -0
  38. data/lib/flow_chat/gateway_async_support.rb +106 -0
  39. data/lib/flow_chat/generic_async_job.rb +30 -0
  40. data/lib/flow_chat/http/gateway/simple.rb +81 -33
  41. data/lib/flow_chat/http/renderer.rb +3 -3
  42. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  43. data/lib/flow_chat/instrumentation.rb +23 -0
  44. data/lib/flow_chat/intercom/client.rb +155 -0
  45. data/lib/flow_chat/intercom/configuration.rb +149 -0
  46. data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
  47. data/lib/flow_chat/intercom/renderer.rb +71 -0
  48. data/lib/flow_chat/phone_number_util.rb +37 -35
  49. data/lib/flow_chat/processor.rb +188 -0
  50. data/lib/flow_chat/renderers/markdown_support.rb +58 -0
  51. data/lib/flow_chat/session/middleware.rb +25 -9
  52. data/lib/flow_chat/telegram/client.rb +240 -0
  53. data/lib/flow_chat/telegram/configuration.rb +118 -0
  54. data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
  55. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
  56. data/lib/flow_chat/telegram/renderer.rb +125 -0
  57. data/lib/flow_chat/telegram.rb +7 -0
  58. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  59. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  60. data/lib/flow_chat/ussd/renderer.rb +1 -1
  61. data/lib/flow_chat/version.rb +1 -1
  62. data/lib/flow_chat/whatsapp/client.rb +144 -13
  63. data/lib/flow_chat/whatsapp/configuration.rb +1 -1
  64. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +132 -96
  65. data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
  66. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
  67. data/lib/flow_chat/whatsapp/renderer.rb +145 -11
  68. data/lib/flow_chat.rb +11 -1
  69. data/lib/tasks/release.rake +165 -0
  70. metadata +84 -25
  71. data/docs/flows.md +0 -320
  72. data/docs/http-gateway-protocol.md +0 -432
  73. data/docs/images/simulator.png +0 -0
  74. data/docs/media.md +0 -153
  75. data/docs/sessions.md +0 -433
  76. data/docs/ussd-setup.md +0 -322
  77. data/docs/whatsapp-setup.md +0 -162
  78. data/examples/whatsapp_message_job.rb +0 -113
  79. data/lib/flow_chat/base_processor.rb +0 -146
  80. data/lib/flow_chat/http/app.rb +0 -6
  81. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  82. data/lib/flow_chat/http/processor.rb +0 -33
  83. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  84. data/lib/flow_chat/ussd/app.rb +0 -6
  85. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  86. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  87. data/lib/flow_chat/ussd/processor.rb +0 -39
  88. data/lib/flow_chat/whatsapp/app.rb +0 -29
  89. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  90. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  91. 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 = request.host rescue nil
125
- path = request.path rescue nil
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(/^\//, '') if path.present? && path != '/'
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('/').gsub(/[^a-zA-Z0-9._-]/, '_')
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