telegem 1.0.6 → 2.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/docs/Api.md +146 -354
- data/docs/Cookbook(copy_paste).md +644 -0
- data/docs/Getting_started.md +348 -0
- data/docs/webhook_setup.md +199 -0
- data/lib/api/client.rb +123 -49
- data/lib/api/types.rb +283 -67
- data/lib/core/bot.rb +91 -56
- data/lib/core/context.rb +96 -110
- data/lib/markup/.gitkeep +0 -0
- data/lib/markup/keyboard.rb +53 -38
- data/lib/telegem.rb +15 -5
- data/lib/webhook/server.rb +246 -112
- metadata +108 -17
- data/docs/Cookbook.md +0 -407
- data/docs/SETTING_WEBHOOK.md +0 -367
- data/docs/UNDERSTANDING-WEBHOOK-n-POLLING.md +0 -241
data/lib/api/client.rb
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
# lib/api/client.rb -
|
|
1
|
+
# lib/api/client.rb - V2.0.0 (ASYNC-FIRST, NON-BLOCKING)
|
|
2
2
|
require 'httpx'
|
|
3
|
+
require 'json'
|
|
3
4
|
|
|
4
5
|
module Telegem
|
|
5
6
|
module API
|
|
6
7
|
class Client
|
|
7
8
|
BASE_URL = 'https://api.telegram.org'
|
|
8
9
|
|
|
9
|
-
attr_reader :token, :logger, :http
|
|
10
|
+
attr_reader :token, :logger, :http, :connection_pool
|
|
10
11
|
|
|
11
|
-
def initialize(token, logger: nil, timeout: 30)
|
|
12
|
+
def initialize(token, logger: nil, timeout: 30, pool_size: 10)
|
|
12
13
|
@token = token
|
|
13
14
|
@logger = logger || Logger.new($stdout)
|
|
14
15
|
|
|
15
|
-
# HTTPX with
|
|
16
|
+
# HTTPX with proper async configuration
|
|
16
17
|
@http = HTTPX.plugin(:persistent)
|
|
17
|
-
.plugin(:retries, max_retries: 3)
|
|
18
18
|
.with(
|
|
19
19
|
timeout: {
|
|
20
20
|
connect_timeout: 10,
|
|
@@ -24,39 +24,69 @@ module Telegem
|
|
|
24
24
|
},
|
|
25
25
|
headers: {
|
|
26
26
|
'Content-Type' => 'application/json',
|
|
27
|
-
'User-Agent' => "Telegem/#{Telegem::VERSION}"
|
|
28
|
-
}
|
|
27
|
+
'User-Agent' => "Telegem/#{Telegem::VERSION} (Ruby #{RUBY_VERSION}; #{RUBY_PLATFORM})"
|
|
28
|
+
},
|
|
29
|
+
max_requests: pool_size # Connection pool
|
|
29
30
|
)
|
|
31
|
+
|
|
32
|
+
# Add retry plugin if available
|
|
33
|
+
if HTTPX.plugins.key?(:retries)
|
|
34
|
+
@http = @http.plugin(:retries, max_retries: 3, retry_on: [500, 502, 503, 504])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Ensure proper cleanup
|
|
38
|
+
ObjectSpace.define_finalizer(self, proc { close })
|
|
30
39
|
end
|
|
31
40
|
|
|
32
|
-
#
|
|
41
|
+
# PRIMARY API: Async call - returns HTTPX request (promise-like)
|
|
33
42
|
def call(method, params = {})
|
|
34
43
|
url = "#{BASE_URL}/bot#{@token}/#{method}"
|
|
35
44
|
|
|
36
|
-
@logger.debug("API
|
|
45
|
+
@logger.debug("🚀 Async API: #{method}") if @logger
|
|
37
46
|
|
|
38
|
-
#
|
|
47
|
+
# Pure async - returns immediately
|
|
39
48
|
@http.post(url, json: params.compact)
|
|
40
|
-
.then(&method(:
|
|
41
|
-
.on_error(&method(:
|
|
49
|
+
.then(&method(:handle_response_async))
|
|
50
|
+
.on_error(&method(:handle_error_async))
|
|
42
51
|
end
|
|
43
52
|
|
|
44
|
-
#
|
|
45
|
-
def
|
|
46
|
-
|
|
53
|
+
# SYNC WRAPPER: For when you absolutely need blocking
|
|
54
|
+
def call!(method, params = {}, timeout: nil)
|
|
55
|
+
timeout ||= @http.options.timeout[:read_timeout]
|
|
47
56
|
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
# Start async request
|
|
58
|
+
request = call(method, params)
|
|
59
|
+
|
|
60
|
+
# Wait with timeout
|
|
61
|
+
begin
|
|
62
|
+
wait_result = request.wait(timeout)
|
|
63
|
+
|
|
64
|
+
if request.error
|
|
65
|
+
# Error already logged by handle_error_async
|
|
66
|
+
raise APIError, request.error.message
|
|
67
|
+
elsif !wait_result
|
|
68
|
+
raise NetworkError, "Request timeout after #{timeout}s"
|
|
54
69
|
end
|
|
70
|
+
|
|
71
|
+
# Return the actual result (not the request object)
|
|
72
|
+
request.instance_variable_get(:@result) || request.response
|
|
73
|
+
rescue Timeout::Error
|
|
74
|
+
raise NetworkError, "Request timeout after #{timeout}s"
|
|
55
75
|
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# File upload - also async
|
|
79
|
+
def upload(method, params)
|
|
80
|
+
url = "#{BASE_URL}/bot#{@token}/#{method}"
|
|
81
|
+
|
|
82
|
+
# Build multipart form
|
|
83
|
+
form = build_multipart_form(params)
|
|
84
|
+
|
|
85
|
+
@logger.debug("📤 Async Upload: #{method}") if @logger
|
|
56
86
|
|
|
57
87
|
@http.post(url, form: form)
|
|
58
|
-
.then(&method(:
|
|
59
|
-
.on_error(&method(:
|
|
88
|
+
.then(&method(:handle_response_async))
|
|
89
|
+
.on_error(&method(:handle_error_async))
|
|
60
90
|
end
|
|
61
91
|
|
|
62
92
|
# Convenience method for getUpdates with proper async handling
|
|
@@ -73,55 +103,90 @@ module Telegem
|
|
|
73
103
|
@http.close
|
|
74
104
|
end
|
|
75
105
|
|
|
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
106
|
private
|
|
86
107
|
|
|
87
|
-
|
|
108
|
+
# Async response handler - stores result on request object
|
|
109
|
+
def handle_response_async(response)
|
|
88
110
|
response.raise_for_status unless response.status == 200
|
|
89
111
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
112
|
+
case response.status
|
|
113
|
+
when 429 # Rate limit
|
|
114
|
+
retry_after = response.headers['retry-after']&.to_i || 1
|
|
115
|
+
raise RateLimitError.new("Rate limited", retry_after)
|
|
116
|
+
when 200
|
|
117
|
+
begin
|
|
118
|
+
json = response.json
|
|
119
|
+
rescue JSON::ParserError => e
|
|
120
|
+
raise APIError, "Invalid JSON: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
unless json
|
|
124
|
+
raise APIError, "Empty response"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if json['ok']
|
|
128
|
+
# Store result on request for sync access
|
|
129
|
+
response.request.instance_variable_set(:@result, json['result'])
|
|
130
|
+
json['result']
|
|
131
|
+
else
|
|
132
|
+
raise APIError.new(json['description'], json['error_code'])
|
|
133
|
+
end
|
|
97
134
|
else
|
|
98
|
-
|
|
135
|
+
response.raise_for_status
|
|
99
136
|
end
|
|
100
137
|
end
|
|
101
138
|
|
|
102
|
-
|
|
139
|
+
# Async error handler
|
|
140
|
+
def handle_error_async(error)
|
|
103
141
|
case error
|
|
104
142
|
when HTTPX::TimeoutError
|
|
105
|
-
@logger.error("
|
|
106
|
-
raise NetworkError, "
|
|
143
|
+
@logger.error("⏰ Timeout: #{error.message}") if @logger
|
|
144
|
+
raise NetworkError, "Timeout: #{error.message}"
|
|
107
145
|
when HTTPX::ConnectionError
|
|
108
|
-
@logger.error("Connection
|
|
146
|
+
@logger.error("🔌 Connection: #{error.message}") if @logger
|
|
109
147
|
raise NetworkError, "Connection failed: #{error.message}"
|
|
110
148
|
when HTTPX::HTTPError
|
|
111
|
-
@logger.error("HTTP
|
|
149
|
+
@logger.error("🌐 HTTP #{error.response.status}: #{error.message}") if @logger
|
|
112
150
|
raise APIError, "HTTP #{error.response.status}: #{error.message}"
|
|
151
|
+
when RateLimitError
|
|
152
|
+
@logger.error("🚦 Rate limit: retry after #{error.retry_after}s") if @logger
|
|
153
|
+
raise error # Re-raise for user handling
|
|
113
154
|
else
|
|
114
|
-
@logger.error("Unexpected
|
|
155
|
+
@logger.error("💥 Unexpected: #{error.class}: #{error.message}") if @logger
|
|
115
156
|
raise APIError, error.message
|
|
116
157
|
end
|
|
117
158
|
end
|
|
118
159
|
|
|
160
|
+
def build_multipart_form(params)
|
|
161
|
+
params.map do |key, value|
|
|
162
|
+
if file_object?(value)
|
|
163
|
+
[key.to_s, HTTPX::FormData::File.new(value)]
|
|
164
|
+
else
|
|
165
|
+
[key.to_s, value.to_s]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
119
170
|
def file_object?(obj)
|
|
120
|
-
|
|
121
|
-
|
|
171
|
+
case obj
|
|
172
|
+
when File, StringIO, Tempfile
|
|
173
|
+
true
|
|
174
|
+
when Pathname
|
|
175
|
+
obj.exist? && obj.readable?
|
|
176
|
+
when String
|
|
177
|
+
# Check if it's a local file (not URL)
|
|
178
|
+
if obj.start_with?('http://', 'https://', 'ftp://')
|
|
179
|
+
false # Telegram handles URLs directly
|
|
180
|
+
else
|
|
181
|
+
File.exist?(obj) && File.readable?(obj)
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
false
|
|
185
|
+
end
|
|
122
186
|
end
|
|
123
187
|
end
|
|
124
188
|
|
|
189
|
+
# Custom Errors
|
|
125
190
|
class APIError < StandardError
|
|
126
191
|
attr_reader :code
|
|
127
192
|
|
|
@@ -132,5 +197,14 @@ module Telegem
|
|
|
132
197
|
end
|
|
133
198
|
|
|
134
199
|
class NetworkError < APIError; end
|
|
200
|
+
|
|
201
|
+
class RateLimitError < APIError
|
|
202
|
+
attr_reader :retry_after
|
|
203
|
+
|
|
204
|
+
def initialize(message, retry_after = 1)
|
|
205
|
+
super(message)
|
|
206
|
+
@retry_after = retry_after
|
|
207
|
+
end
|
|
208
|
+
end
|
|
135
209
|
end
|
|
136
210
|
end
|