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.
- checksums.yaml +4 -4
- data/.replit +13 -0
- data/Contributing.md +553 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +11 -0
- data/LICENSE +21 -0
- data/Readme.md +353 -0
- data/Test-Projects/.gitkeep +0 -0
- data/Test-Projects/bot_test1.rb +75 -0
- data/Test-Projects/pizza_test_bot_guide.md +163 -0
- data/docs/.gitkeep +0 -0
- data/docs/Api.md +419 -0
- data/docs/Cookbook.md +407 -0
- data/docs/How_to_use.md +571 -0
- data/docs/QuickStart.md +258 -0
- data/docs/Usage.md +717 -0
- data/lib/api/client.rb +134 -154
- data/lib/core/bot.rb +102 -96
- data/lib/core/composer.rb +36 -18
- data/lib/core/context.rb +180 -177
- data/lib/core/scene.rb +81 -71
- data/lib/session/memory_store.rb +1 -1
- data/lib/session/middleware.rb +20 -36
- data/lib/telegem.rb +57 -54
- data/lib/webhook/.gitkeep +0 -0
- data/lib/webhook/server.rb +193 -0
- metadata +38 -35
- data/telegem.gemspec +0 -43
- data/webhook/server.rb +0 -86
data/lib/api/client.rb
CHANGED
|
@@ -1,156 +1,136 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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(:
|
|
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
|
-
@
|
|
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
|
|
63
|
+
return if @running
|
|
65
64
|
|
|
66
|
-
@
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
79
|
+
self
|
|
95
80
|
end
|
|
96
81
|
|
|
97
82
|
def webhook(app = nil, &block)
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
await @api.call('deleteWebhook', {})
|
|
119
|
-
end
|
|
99
|
+
@api.call('deleteWebhook', {})
|
|
120
100
|
end
|
|
121
101
|
|
|
122
102
|
def get_webhook_info
|
|
123
|
-
|
|
124
|
-
await @api.call('getWebhookInfo', {})
|
|
125
|
-
end
|
|
103
|
+
@api.call('getWebhookInfo', {})
|
|
126
104
|
end
|
|
127
105
|
|
|
128
106
|
def process(update_data)
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
@
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|