telegem 0.2.5 → 1.0.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.
data/lib/api/client.rb CHANGED
@@ -1,163 +1,136 @@
1
- require 'async'
2
- require 'async/http'
3
- require 'json'
4
- require 'logger'
5
- require 'mime/types'
6
- require 'securerandom'
7
- require 'ostruct'
1
+ # lib/api/client.rb - CORRECTED VERSION
2
+ require 'httpx'
8
3
 
9
4
  module Telegem
10
5
  module API
11
6
  class Client
12
7
  BASE_URL = 'https://api.telegram.org'
8
+
9
+ attr_reader :token, :logger, :http
13
10
 
14
- attr_reader :token, :logger
15
-
16
- def initialize(token, endpoint: nil, logger: nil)
11
+ def initialize(token, logger: nil, timeout: 30)
17
12
  @token = token
18
13
  @logger = logger || Logger.new($stdout)
19
- @endpoint = endpoint || Async::HTTP::Endpoint.parse("#{BASE_URL}/bot#{token}")
20
- @client = nil
21
- @semaphore = Async::Semaphore.new(30)
14
+
15
+ # HTTPX with persistent connections and proper async support
16
+ @http = HTTPX.plugin(:persistent)
17
+ .plugin(:retries, max_retries: 3, retry_on: [Timeout::Error, HTTPX::Error])
18
+ .with(
19
+ timeout: {
20
+ connect_timeout: 10,
21
+ write_timeout: 10,
22
+ read_timeout: timeout,
23
+ keep_alive_timeout: 15
24
+ },
25
+ headers: {
26
+ 'Content-Type' => 'application/json',
27
+ 'User-Agent' => "Telegem/#{Telegem::VERSION}"
28
+ }
29
+ )
22
30
  end
23
31
 
32
+ # Main API call - returns HTTPX request (promise-like object)
24
33
  def call(method, params = {})
25
- Async do |task|
26
- @semaphore.async do
27
- make_request(method, clean_params(params))
28
- end
29
- end
34
+ url = "#{BASE_URL}/bot#{@token}/#{method}"
35
+
36
+ @logger.debug("API Call: #{method}") if @logger
37
+
38
+ # Return the async request object directly
39
+ @http.post(url, json: params.compact)
40
+ .then(&method(:handle_response))
41
+ .on_error(&method(:handle_error))
30
42
  end
31
43
 
44
+ # File upload method
32
45
  def upload(method, params)
33
- Async do |task|
34
- @semaphore.async do
35
- make_multipart_request(method, params)
46
+ url = "#{BASE_URL}/bot#{@token}/#{method}"
47
+
48
+ # Convert params to multipart form data
49
+ form = params.map do |key, value|
50
+ if file_object?(value)
51
+ [key.to_s, HTTPX::FormData::File.new(value)]
52
+ else
53
+ [key.to_s, value.to_s]
36
54
  end
37
55
  end
56
+
57
+ @http.post(url, form: form)
58
+ .then(&method(:handle_response))
59
+ .on_error(&method(:handle_error))
38
60
  end
39
61
 
40
- def get_updates(offset: nil, timeout: 30, limit: 100)
62
+ # Convenience method for getUpdates with proper async handling
63
+ def get_updates(offset: nil, timeout: 30, limit: 100, allowed_updates: nil)
41
64
  params = { timeout: timeout, limit: limit }
42
65
  params[:offset] = offset if offset
66
+ params[:allowed_updates] = allowed_updates if allowed_updates
67
+
43
68
  call('getUpdates', params)
44
69
  end
45
70
 
71
+ # Close connections gracefully
46
72
  def close
47
- @client&.close
48
- end
49
-
50
- private
51
-
52
- def make_request(method, params)
53
- with_client do |client|
54
- headers = { 'content-type' => 'application/json' }
55
- body = params.to_json
56
-
57
- response = client.post("/bot#{@token}/#{method}", headers, body)
58
- handle_response(response)
59
- end
60
- end
61
-
62
- def make_multipart_request(method, params)
63
- with_client do |client|
64
- form = build_multipart(params)
65
- headers = form.headers
66
-
67
- response = client.post("/bot#{@token}/#{method}", headers, form.body)
68
- handle_response(response)
69
- end
70
- end
71
-
72
- def with_client(&block)
73
- @client ||= Async::HTTP::Client.new(@endpoint)
74
- yield @client
75
- end
76
-
77
- def clean_params(params)
78
- params.reject { |_, v| v.nil? }
79
- end
80
-
81
- def build_multipart(params)
82
- boundary = SecureRandom.hex(16)
83
- parts = []
84
-
85
- params.each do |key, value|
86
- if file?(value)
87
- parts << part_from_file(key, value, boundary)
88
- else
89
- parts << part_from_field(key, value, boundary)
90
- end
91
- end
92
-
93
- parts << "--#{boundary}--\r\n"
94
-
95
- body = parts.join
96
- headers = {
97
- 'content-type' => "multipart/form-data; boundary=#{boundary}",
98
- 'content-length' => body.bytesize.to_s
99
- }
100
-
101
- OpenStruct.new(headers: headers, body: body)
73
+ @http.close
102
74
  end
103
75
 
104
- def file?(value)
105
- value.is_a?(File) ||
106
- value.is_a?(StringIO) ||
107
- (value.is_a?(String) && File.exist?(value))
76
+ # Synchronous version (for convenience in non-async contexts)
77
+ def call!(method, params = {})
78
+ request = call(method, params)
79
+ request.wait # Wait for completion
80
+ handle_response(request)
81
+ rescue => e
82
+ handle_error(e, request)
108
83
  end
109
84
 
110
- def part_from_file(key, file, boundary)
111
- filename = File.basename(file.path) if file.respond_to?(:path)
112
- filename ||= "file"
113
-
114
- mime_type = MIME::Types.type_for(filename).first || 'application/octet-stream'
115
-
116
- content = if file.is_a?(String)
117
- File.read(file)
118
- else
119
- file.read
120
- end
121
-
122
- <<~PART
123
- --#{boundary}\r
124
- Content-Disposition: form-data; name="#{key}"; filename="#{filename}"\r
125
- Content-Type: #{mime_type}\r
126
- \r
127
- #{content}\r
128
- PART
129
- end
130
-
131
- def part_from_field(key, value, boundary)
132
- <<~PART
133
- --#{boundary}\r
134
- Content-Disposition: form-data; name="#{key}"\r
135
- \r
136
- #{value}\r
137
- PART
138
- end
85
+ private
139
86
 
140
87
  def handle_response(response)
141
- body = response.read
142
- json = JSON.parse(body)
143
-
88
+ response.raise_for_status unless response.status == 200
89
+
90
+ json = response.json
91
+ unless json
92
+ raise APIError, "Empty or invalid JSON response"
93
+ end
94
+
144
95
  if json['ok']
145
96
  json['result']
146
97
  else
147
98
  raise APIError.new(json['description'], json['error_code'])
148
99
  end
149
- rescue JSON::ParserError
150
- raise APIError, "Invalid JSON response: #{body[0..100]}"
100
+ end
101
+
102
+ def handle_error(error, request = nil)
103
+ case error
104
+ when HTTPX::TimeoutError
105
+ @logger.error("Telegram API timeout: #{error.message}") if @logger
106
+ raise NetworkError, "Request timeout: #{error.message}"
107
+ when HTTPX::ConnectionError
108
+ @logger.error("Connection error: #{error.message}") if @logger
109
+ raise NetworkError, "Connection failed: #{error.message}"
110
+ when HTTPX::HTTPError
111
+ @logger.error("HTTP error #{error.response.status}: #{error.message}") if @logger
112
+ raise APIError, "HTTP #{error.response.status}: #{error.message}"
113
+ else
114
+ @logger.error("Unexpected error: #{error.class}: #{error.message}") if @logger
115
+ raise APIError, error.message
116
+ end
117
+ end
118
+
119
+ def file_object?(obj)
120
+ obj.is_a?(File) || obj.is_a?(StringIO) || obj.is_a?(Tempfile) ||
121
+ (obj.is_a?(String) && File.exist?(obj))
151
122
  end
152
123
  end
153
124
 
154
125
  class APIError < StandardError
155
126
  attr_reader :code
156
-
127
+
157
128
  def initialize(message, code = nil)
158
129
  super(message)
159
130
  @code = code
160
131
  end
161
132
  end
133
+
134
+ class NetworkError < APIError; end
162
135
  end
163
136
  end
data/lib/core/bot.rb CHANGED
@@ -5,7 +5,7 @@ module Telegem
5
5
 
6
6
  def initialize(token, **options)
7
7
  @token = token
8
- @api = API::Client.new(token, **options.slice(:endpoint, :logger))
8
+ @api = API::Client.new(token, **options.slice(:logger, :timeout))
9
9
  @handlers = {
10
10
  message: [],
11
11
  callback_query: [],
@@ -20,10 +20,9 @@ module Telegem
20
20
  @logger = options[:logger] || Logger.new($stdout)
21
21
  @error_handler = nil
22
22
  @session_store = options[:session_store] || Session::MemoryStore.new
23
- @concurrency = options[:concurrency] || 10
24
- @semaphore = Async::Semaphore.new(@concurrency)
25
- @polling_task = nil
23
+ @polling_thread = nil
26
24
  @running = false
25
+ @offset = nil
27
26
  end
28
27
 
29
28
  def command(name, **options, &block)
@@ -61,43 +60,28 @@ module Telegem
61
60
  end
62
61
 
63
62
  def start_polling(**options)
64
- return @polling_task if @running
63
+ return if @running
65
64
 
66
- @polling_task = Async do |parent|
67
- @running = true
68
- @logger.info "Starting async polling..."
69
- offset = nil
70
-
71
- begin
72
- loop do
73
- updates_task = fetch_updates(offset, **options)
74
- updates_data = updates_task.wait
75
- updates = updates_data.map { |data| Types::Update.new(data) }
76
-
77
- updates.each do |update|
78
- parent.async do |child|
79
- @semaphore.async do
80
- process_update(update)
81
- end
82
- end
83
- end
84
-
85
- offset = updates.last&.update_id.to_i + 1 if updates.any?
86
- end
87
- rescue => e
88
- handle_error(e)
89
- raise
90
- ensure
91
- @running = false
92
- end
65
+ @running = true
66
+ @polling_options = {
67
+ timeout: 30,
68
+ limit: 100,
69
+ allowed_updates: nil
70
+ }.merge(options)
71
+
72
+ @offset = nil
73
+
74
+ @polling_thread = Thread.new do
75
+ @logger.info "🤖 Starting Telegem bot (HTTPX async)..."
76
+ poll_loop
93
77
  end
94
78
 
95
- @polling_task
79
+ self
96
80
  end
97
81
 
98
82
  def webhook(app = nil, &block)
99
- require_relative '../../webhook/server'
100
-
83
+ require 'telegem/webhook/server'
84
+
101
85
  if block_given?
102
86
  Webhook::Server.new(self, &block)
103
87
  elsif app
@@ -108,42 +92,30 @@ module Telegem
108
92
  end
109
93
 
110
94
  def set_webhook(url, **options)
111
- Async do
112
- params = { url: url }.merge(options)
113
- await @api.call('setWebhook', params)
114
- end
95
+ @api.call('setWebhook', { url: url }.merge(options))
115
96
  end
116
97
 
117
98
  def delete_webhook
118
- Async do
119
- await @api.call('deleteWebhook', {})
120
- end
99
+ @api.call('deleteWebhook', {})
121
100
  end
122
101
 
123
102
  def get_webhook_info
124
- Async do
125
- await @api.call('getWebhookInfo', {})
126
- end
103
+ @api.call('getWebhookInfo', {})
127
104
  end
128
105
 
129
106
  def process(update_data)
130
- Async do
131
- update = Types::Update.new(update_data)
132
- await process_update(update)
133
- end
107
+ update = Types::Update.new(update_data)
108
+ process_update(update)
134
109
  end
135
110
 
136
111
  def shutdown
137
- @logger.info "Shutting down..."
112
+ @logger.info "🛑 Shutting down..."
138
113
 
139
- if @polling_task && @polling_task.running?
140
- @polling_task.stop
141
- @polling_task = nil
142
- end
143
-
144
- @api.close
145
114
  @running = false
146
- @logger.info "Bot stopped"
115
+ @polling_thread&.join(5) if @polling_thread&.alive?
116
+ @api.close
117
+
118
+ @logger.info "✅ Bot stopped"
147
119
  end
148
120
 
149
121
  def running?
@@ -152,26 +124,74 @@ module Telegem
152
124
 
153
125
  private
154
126
 
155
- def fetch_updates(offset, timeout: 30, limit: 100, allowed_updates: nil)
156
- params = { timeout: timeout, limit: limit }
157
- params[:offset] = offset if offset
158
- params[:allowed_updates] = allowed_updates if allowed_updates
159
-
160
- @api.get_updates(**params)
127
+ def poll_loop
128
+ while @running
129
+ begin
130
+ # Get updates - returns HTTPX request immediately
131
+ updates_request = fetch_updates
132
+
133
+ # Wait for this poll to complete (with timeout)
134
+ response = updates_request.wait(@polling_options[:timeout] + 5)
135
+
136
+ if response && response.status == 200 && response.json
137
+ handle_updates_response(response.json)
138
+ end
139
+
140
+ # Small delay to prevent tight loop on errors
141
+ sleep 0.1 unless @offset.nil?
142
+
143
+ rescue => e
144
+ handle_error(e)
145
+ # Longer delay on error
146
+ sleep 5
147
+ end
148
+ end
161
149
  end
162
150
 
163
- def process_update(update)
164
- Async do
165
- ctx = Context.new(update, self)
151
+ def fetch_updates
152
+ params = {
153
+ timeout: @polling_options[:timeout],
154
+ limit: @polling_options[:limit]
155
+ }
156
+ params[:offset] = @offset if @offset
157
+ params[:allowed_updates] = @polling_options[:allowed_updates] if @polling_options[:allowed_updates]
158
+
159
+ @api.call('getUpdates', params)
160
+ end
166
161
 
167
- begin
168
- await run_middleware_chain(ctx) do |context|
169
- await dispatch_to_handlers(context)
162
+ def handle_updates_response(api_response)
163
+ return unless api_response['ok']
164
+
165
+ updates_data = api_response['result'] || []
166
+
167
+ # Process each update in its own thread for concurrency
168
+ updates_data.each do |update_data|
169
+ Thread.new do
170
+ begin
171
+ update = Types::Update.new(update_data)
172
+ process_update(update)
173
+ rescue => e
174
+ @logger.error("Error in update thread: #{e.message}")
170
175
  end
171
- rescue => e
172
- await handle_error(e, ctx)
173
176
  end
174
177
  end
178
+
179
+ # Update offset for next poll
180
+ if updates_data.any?
181
+ @offset = updates_data.last['update_id'] + 1
182
+ end
183
+ end
184
+
185
+ def process_update(update)
186
+ ctx = Context.new(update, self)
187
+
188
+ begin
189
+ run_middleware_chain(ctx) do |context|
190
+ dispatch_to_handlers(context)
191
+ end
192
+ rescue => e
193
+ handle_error(e, ctx)
194
+ end
175
195
  end
176
196
 
177
197
  def run_middleware_chain(ctx, &final)
@@ -199,16 +219,13 @@ module Telegem
199
219
  end
200
220
 
201
221
  def dispatch_to_handlers(ctx)
202
- Async do
203
- update_type = detect_update_type(ctx.update)
204
- handlers = @handlers[update_type] || []
205
-
206
- handlers.each do |handler|
207
- if matches_filters?(ctx, handler[:filters])
208
- result = handler[:handler].call(ctx)
209
- result = await(result) if result.is_a?(Async::Task)
210
- break
211
- end
222
+ update_type = detect_update_type(ctx.update)
223
+ handlers = @handlers[update_type] || []
224
+
225
+ handlers.each do |handler|
226
+ if matches_filters?(ctx, handler[:filters])
227
+ handler[:handler].call(ctx)
228
+ break # First matching handler wins
212
229
  end
213
230
  end
214
231
  end
@@ -236,13 +253,7 @@ module Telegem
236
253
  when :command
237
254
  matches_command_filter(ctx, value)
238
255
  else
239
- if value.respond_to?(:call)
240
- result = value.call(ctx)
241
- result = await(result) if result.is_a?(Async::Task)
242
- result
243
- else
244
- ctx.update.send(key) == value
245
- end
256
+ ctx.update.send(key) == value
246
257
  end
247
258
  end
248
259
  end
@@ -269,11 +280,11 @@ module Telegem
269
280
 
270
281
  def handle_error(error, ctx = nil)
271
282
  if @error_handler
272
- result = @error_handler.call(error, ctx)
273
- await(result) if result.is_a?(Async::Task)
283
+ @error_handler.call(error, ctx)
274
284
  else
275
- @logger.error("Unhandled error: #{error.class}: #{error.message}")
276
- @logger.error(error.backtrace.join("\n")) if error.backtrace
285
+ @logger.error("Unhandled error: #{error.class}: #{error.message}")
286
+ @logger.error("Context: #{ctx.raw_update}") if ctx
287
+ @logger.error(error.backtrace&.join("\n")) if error.backtrace
277
288
  end
278
289
  end
279
290
  end
data/lib/core/composer.rb CHANGED
@@ -1,3 +1,4 @@
1
+ # lib/core/composer.rb - HTTPX VERSION (NO ASYNC GEM)
1
2
  module Telegem
2
3
  module Core
3
4
  class Composer
@@ -13,24 +14,14 @@ module Telegem
13
14
  def call(ctx, &final)
14
15
  return final.call(ctx) if @middleware.empty?
15
16
 
16
- # Build async-aware chain
17
+ # Build the middleware chain
17
18
  chain = final
18
-
19
+
20
+ # Reverse the middleware so last added runs last in chain
19
21
  @middleware.reverse_each do |middleware|
20
- chain = ->(context) do
21
- if middleware.respond_to?(:call)
22
- result = middleware.call(context, chain)
23
- result.is_a?(Async::Task) ? result : Async::Task.new(result)
24
- elsif middleware.is_a?(Class)
25
- instance = middleware.new
26
- result = instance.call(context, chain)
27
- result.is_a?(Async::Task) ? result : Async::Task.new(result)
28
- else
29
- raise "Invalid middleware: #{middleware.class}"
30
- end
31
- end
22
+ chain = create_middleware_wrapper(middleware, chain)
32
23
  end
33
-
24
+
34
25
  # Execute the chain
35
26
  chain.call(ctx)
36
27
  end
@@ -38,6 +29,33 @@ module Telegem
38
29
  def empty?
39
30
  @middleware.empty?
40
31
  end
41
- end
42
- end
43
- end
32
+
33
+ private
34
+
35
+ def create_middleware_wrapper(middleware, next_middleware)
36
+ ->(context) do
37
+ middleware_instance = instantiate_middleware(middleware)
38
+
39
+ if middleware_instance.respond_to?(:call)
40
+ # Call the middleware with next in chain
41
+ middleware_instance.call(context, next_middleware)
42
+ else
43
+ raise "Invalid middleware: #{middleware.class}"
44
+ end
45
+ end
46
+ end
47
+
48
+ def instantiate_middleware(middleware)
49
+ case middleware
50
+ when Class
51
+ middleware.new
52
+ when Proc, Method
53
+ middleware
54
+ else
55
+ # Assume it's already a middleware instance
56
+ middleware
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end