telegem 0.2.0 → 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,156 +1,136 @@
1
- module Telegem
2
- module API
3
- class Client
4
- BASE_URL = 'https://api.telegram.org'
5
-
6
- attr_reader :token, :logger
7
-
8
- def initialize(token, endpoint: nil, logger: nil)
9
- @token = token
10
- @logger = logger || Logger.new($stdout)
11
- @endpoint = endpoint || Async::HTTP::Endpoint.parse("#{BASE_URL}/bot#{token}")
12
- @client = nil
13
- @semaphore = Async::Semaphore.new(30)
14
- end
15
-
16
- def call(method, params = {})
17
- Async do |task|
18
- @semaphore.async do
19
- make_request(method, clean_params(params))
20
- end
21
- end
22
- end
23
-
24
- def upload(method, params)
25
- Async do |task|
26
- @semaphore.async do
27
- make_multipart_request(method, params)
28
- end
29
- end
30
- end
31
-
32
- def get_updates(offset: nil, timeout: 30, limit: 100)
33
- params = { timeout: timeout, limit: limit }
34
- params[:offset] = offset if offset
35
- call('getUpdates', params)
36
- end
37
-
38
- def close
39
- @client&.close
40
- end
41
-
42
- private
43
-
44
- def make_request(method, params)
45
- with_client do |client|
46
- headers = { 'content-type' => 'application/json' }
47
- body = params.to_json
48
-
49
- response = client.post("/bot#{@token}/#{method}", headers, body)
50
- handle_response(response)
51
- end
52
- end
53
-
54
- def make_multipart_request(method, params)
55
- with_client do |client|
56
- form = build_multipart(params)
57
- headers = form.headers
58
-
59
- response = client.post("/bot#{@token}/#{method}", headers, form.body)
60
- handle_response(response)
61
- end
62
- end
63
-
64
- def with_client(&block)
65
- @client ||= Async::HTTP::Client.new(@endpoint)
66
- yield @client
67
- end
68
-
69
- def clean_params(params)
70
- params.reject { |_, v| v.nil? }
71
- end
72
-
73
- def build_multipart(params)
74
- # Build multipart form data for file uploads
75
- boundary = SecureRandom.hex(16)
76
- parts = []
77
-
78
- params.each do |key, value|
79
- if file?(value)
80
- parts << part_from_file(key, value, boundary)
81
- else
82
- parts << part_from_field(key, value, boundary)
83
- end
84
- end
85
-
86
- parts << "--#{boundary}--\r\n"
87
-
88
- body = parts.join
89
- headers = {
90
- 'content-type' => "multipart/form-data; boundary=#{boundary}",
91
- 'content-length' => body.bytesize.to_s
92
- }
93
-
94
- OpenStruct.new(headers: headers, body: body)
95
- end
96
-
97
- def file?(value)
98
- value.is_a?(File) ||
99
- value.is_a?(StringIO) ||
100
- (value.is_a?(String) && File.exist?(value))
101
- end
102
-
103
- def part_from_file(key, file, boundary)
104
- filename = File.basename(file.path) if file.respond_to?(:path)
105
- filename ||= "file"
106
-
107
- mime_type = MIME::Types.type_for(filename).first || 'application/octet-stream'
108
-
109
- content = if file.is_a?(String)
110
- File.read(file)
111
- else
112
- file.read
113
- end
114
-
115
- <<~PART
116
- --#{boundary}\r
117
- Content-Disposition: form-data; name="#{key}"; filename="#{filename}"\r
118
- Content-Type: #{mime_type}\r
119
- \r
120
- #{content}\r
121
- PART
122
- end
123
-
124
- def part_from_field(key, value, boundary)
125
- <<~PART
126
- --#{boundary}\r
127
- Content-Disposition: form-data; name="#{key}"\r
128
- \r
129
- #{value}\r
130
- PART
131
- end
132
-
133
- def handle_response(response)
134
- body = response.read
135
- json = JSON.parse(body)
136
-
137
- if json['ok']
138
- json['result']
139
- else
140
- raise APIError.new(json['description'], json['error_code'])
141
- end
142
- rescue JSON::ParserError
143
- raise APIError, "Invalid JSON response: #{body[0..100]}"
144
- end
145
- end
146
-
147
- class APIError < StandardError
148
- attr_reader :code
149
-
150
- def initialize(message, code = nil)
151
- super(message)
152
- @code = code
153
- end
1
+ # lib/api/client.rb - CORRECTED VERSION
2
+ require 'httpx'
3
+
4
+ module Telegem
5
+ module API
6
+ class Client
7
+ BASE_URL = 'https://api.telegram.org'
8
+
9
+ attr_reader :token, :logger, :http
10
+
11
+ def initialize(token, logger: nil, timeout: 30)
12
+ @token = token
13
+ @logger = logger || Logger.new($stdout)
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
+ )
30
+ end
31
+
32
+ # Main API call - returns HTTPX request (promise-like object)
33
+ def call(method, params = {})
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))
42
+ end
43
+
44
+ # File upload method
45
+ def upload(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]
154
54
  end
155
55
  end
156
- end
56
+
57
+ @http.post(url, form: form)
58
+ .then(&method(:handle_response))
59
+ .on_error(&method(:handle_error))
60
+ end
61
+
62
+ # Convenience method for getUpdates with proper async handling
63
+ def get_updates(offset: nil, timeout: 30, limit: 100, allowed_updates: nil)
64
+ params = { timeout: timeout, limit: limit }
65
+ params[:offset] = offset if offset
66
+ params[:allowed_updates] = allowed_updates if allowed_updates
67
+
68
+ call('getUpdates', params)
69
+ end
70
+
71
+ # Close connections gracefully
72
+ def close
73
+ @http.close
74
+ end
75
+
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)
83
+ end
84
+
85
+ private
86
+
87
+ def handle_response(response)
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
+
95
+ if json['ok']
96
+ json['result']
97
+ else
98
+ raise APIError.new(json['description'], json['error_code'])
99
+ end
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))
122
+ end
123
+ end
124
+
125
+ class APIError < StandardError
126
+ attr_reader :code
127
+
128
+ def initialize(message, code = nil)
129
+ super(message)
130
+ @code = code
131
+ end
132
+ end
133
+
134
+ class NetworkError < APIError; end
135
+ end
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,42 +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 = updates_task.wait
75
-
76
- updates.each do |update|
77
- parent.async do |child|
78
- @semaphore.async do
79
- process_update(update)
80
- end
81
- end
82
- end
83
-
84
- offset = updates.last&.update_id.to_i + 1 if updates.any?
85
- end
86
- rescue => e
87
- handle_error(e)
88
- raise
89
- ensure
90
- @running = false
91
- 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
92
77
  end
93
78
 
94
- @polling_task
79
+ self
95
80
  end
96
81
 
97
82
  def webhook(app = nil, &block)
98
- require_relative '../../webhook/server'
99
-
83
+ require 'telegem/webhook/server'
84
+
100
85
  if block_given?
101
86
  Webhook::Server.new(self, &block)
102
87
  elsif app
@@ -107,42 +92,30 @@ module Telegem
107
92
  end
108
93
 
109
94
  def set_webhook(url, **options)
110
- Async do
111
- params = { url: url }.merge(options)
112
- await @api.call('setWebhook', params)
113
- end
95
+ @api.call('setWebhook', { url: url }.merge(options))
114
96
  end
115
97
 
116
98
  def delete_webhook
117
- Async do
118
- await @api.call('deleteWebhook', {})
119
- end
99
+ @api.call('deleteWebhook', {})
120
100
  end
121
101
 
122
102
  def get_webhook_info
123
- Async do
124
- await @api.call('getWebhookInfo', {})
125
- end
103
+ @api.call('getWebhookInfo', {})
126
104
  end
127
105
 
128
106
  def process(update_data)
129
- Async do
130
- update = Types::Update.new(update_data)
131
- await process_update(update)
132
- end
107
+ update = Types::Update.new(update_data)
108
+ process_update(update)
133
109
  end
134
110
 
135
111
  def shutdown
136
- @logger.info "Shutting down..."
137
-
138
- if @polling_task && @polling_task.running?
139
- @polling_task.stop
140
- @polling_task = nil
141
- end
112
+ @logger.info "🛑 Shutting down..."
142
113
 
143
- @api.close
144
114
  @running = false
145
- @logger.info "Bot stopped"
115
+ @polling_thread&.join(5) if @polling_thread&.alive?
116
+ @api.close
117
+
118
+ @logger.info "✅ Bot stopped"
146
119
  end
147
120
 
148
121
  def running?
@@ -151,32 +124,74 @@ module Telegem
151
124
 
152
125
  private
153
126
 
154
- def fetch_updates(offset, timeout: 30, limit: 100, allowed_updates: nil)
155
- Async do
156
- params = { timeout: timeout, limit: limit }
157
- params[:offset] = offset if offset
158
- params[:allowed_updates] = allowed_updates if allowed_updates
159
-
160
- updates = await @api.get_updates(**params)
161
- updates.map { |data| Types::Update.new(data) }
162
- rescue API::APIError => e
163
- @logger.error "Failed to fetch updates: #{e.message}"
164
- []
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
165
148
  end
166
149
  end
167
150
 
168
- def process_update(update)
169
- Async do
170
- 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
171
161
 
172
- begin
173
- await run_middleware_chain(ctx) do |context|
174
- 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}")
175
175
  end
176
- rescue => e
177
- await handle_error(e, ctx)
178
176
  end
179
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
180
195
  end
181
196
 
182
197
  def run_middleware_chain(ctx, &final)
@@ -204,16 +219,13 @@ module Telegem
204
219
  end
205
220
 
206
221
  def dispatch_to_handlers(ctx)
207
- Async do
208
- update_type = detect_update_type(ctx.update)
209
- handlers = @handlers[update_type] || []
210
-
211
- handlers.each do |handler|
212
- if matches_filters?(ctx, handler[:filters])
213
- result = handler[:handler].call(ctx)
214
- result = await(result) if result.is_a?(Async::Task)
215
- break
216
- 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
217
229
  end
218
230
  end
219
231
  end
@@ -241,13 +253,7 @@ module Telegem
241
253
  when :command
242
254
  matches_command_filter(ctx, value)
243
255
  else
244
- if value.respond_to?(:call)
245
- result = value.call(ctx)
246
- result = await(result) if result.is_a?(Async::Task)
247
- result
248
- else
249
- ctx.update.send(key) == value
250
- end
256
+ ctx.update.send(key) == value
251
257
  end
252
258
  end
253
259
  end
@@ -274,11 +280,11 @@ module Telegem
274
280
 
275
281
  def handle_error(error, ctx = nil)
276
282
  if @error_handler
277
- result = @error_handler.call(error, ctx)
278
- await(result) if result.is_a?(Async::Task)
283
+ @error_handler.call(error, ctx)
279
284
  else
280
- @logger.error("Unhandled error: #{error.class}: #{error.message}")
281
- @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
282
288
  end
283
289
  end
284
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