telegem 3.5.0 → 3.6.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,11 +1,112 @@
1
- require 'async/http'
2
- require 'json'
1
+ require "async/http"
2
+ require "json"
3
+ require "logger"
4
+ require "securerandom"
5
+ require "stringio"
6
+ require "tempfile"
3
7
 
4
8
  module Telegem
5
9
  module API
10
+ class MultipartForm
11
+ CRLF = "\r\n"
12
+
13
+ attr_reader :content_type
14
+
15
+ def initialize
16
+ @boundary = "----telegem#{SecureRandom.hex(16)}"
17
+ @content_type = "multipart/form-data; boundary=#{@boundary}"
18
+ @parts = []
19
+ end
20
+
21
+ def add(name, value)
22
+ if file?(value)
23
+ append_file(name, value)
24
+ else
25
+ append_field(name, value.to_s)
26
+ end
27
+ end
28
+
29
+ def body
30
+ String.new.tap do |buffer|
31
+ @parts.each do |part|
32
+ buffer << "--#{@boundary}#{CRLF}"
33
+ buffer << part
34
+ buffer << CRLF
35
+ end
36
+
37
+ buffer << "--#{@boundary}--#{CRLF}"
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def append_field(name, value)
44
+ @parts << <<~PART.gsub("\n", CRLF)
45
+ Content-Disposition: form-data; name="#{name}"
46
+
47
+ #{value}
48
+ PART
49
+ end
50
+
51
+ def append_file(name, value)
52
+ io =
53
+ case value
54
+ when String
55
+ File.open(value, "rb")
56
+ else
57
+ value
58
+ end
59
+
60
+ filename =
61
+ if io.respond_to?(:path) && io.path
62
+ File.basename(io.path)
63
+ else
64
+ "upload.bin"
65
+ end
66
+
67
+ mime =
68
+ case File.extname(filename).downcase
69
+ when ".jpg", ".jpeg"
70
+ "image/jpeg"
71
+ when ".png"
72
+ "image/png"
73
+ when ".gif"
74
+ "image/gif"
75
+ when ".webp"
76
+ "image/webp"
77
+ when ".mp4"
78
+ "video/mp4"
79
+ when ".mp3"
80
+ "audio/mpeg"
81
+ when ".ogg"
82
+ "audio/ogg"
83
+ when ".pdf"
84
+ "application/pdf"
85
+ else
86
+ "application/octet-stream"
87
+ end
88
+
89
+ @parts << String.new.tap do |part|
90
+ part << %(Content-Disposition: form-data; name="#{name}"; filename="#{filename}") << CRLF
91
+ part << "Content-Type: #{mime}" << CRLF
92
+ part << CRLF
93
+ part << io.read
94
+ end
95
+
96
+ io.close if value.is_a?(String)
97
+ end
98
+
99
+ def file?(obj)
100
+ obj.is_a?(File) ||
101
+ obj.is_a?(Tempfile) ||
102
+ obj.is_a?(StringIO) ||
103
+ (obj.is_a?(String) && File.file?(obj))
104
+ end
105
+ end
106
+
6
107
  class Client
7
- BASE_URL = 'https://api.telegram.org'
8
-
108
+ BASE_URL = "https://api.telegram.org"
109
+
9
110
  attr_reader :token, :logger
10
111
 
11
112
  def initialize(token, **options)
@@ -13,20 +114,21 @@ module Telegem
13
114
  @logger = options[:logger] || Logger.new($stdout)
14
115
  @timeout = options[:timeout] || 30
15
116
  @retries = options[:retries] || 3
16
- @retry_delay = options[:retry_delay] || 1 # seconds
17
-
117
+ @retry_delay = options[:retry_delay] || 1
118
+
18
119
  @endpoint = Async::HTTP::Endpoint.parse(BASE_URL, timeout: @timeout)
19
120
  @client = Async::HTTP::Client.new(@endpoint)
20
121
  end
21
-
122
+
22
123
  def call(method, params = {})
23
124
  with_retry do
24
125
  make_request(method, params)
25
126
  end
26
127
  end
27
-
128
+
28
129
  def call!(method, params = {}, &callback)
29
130
  return unless callback
131
+
30
132
  begin
31
133
  result = call(method, params)
32
134
  callback.call(result, nil)
@@ -34,38 +136,45 @@ module Telegem
34
136
  callback.call(nil, error)
35
137
  end
36
138
  end
37
-
139
+
38
140
  def upload(method, params)
39
141
  with_retry do
40
142
  url = "/bot#{@token}/#{method}"
41
-
42
- body = Async::HTTP::Body::Multipart.new
43
-
143
+
144
+ form = MultipartForm.new
145
+
44
146
  params.each do |key, value|
45
- if file_object?(value)
46
- body.add(key.to_s, value, filename: File.basename(value))
47
- else
48
- body.add(key.to_s, value.to_s)
49
- end
147
+ form.add(key.to_s, value)
50
148
  end
51
-
52
- response = @client.post(url, {}, body)
149
+
150
+ body = form.body
151
+
152
+ response = @client.post(
153
+ url,
154
+ {
155
+ "content-type" => form.content_type,
156
+ "content-length" => body.bytesize.to_s
157
+ },
158
+ body
159
+ )
160
+
53
161
  handle_response(response)
54
162
  end
55
163
  end
56
-
164
+
57
165
  def download(file_id, destination_path = nil)
58
166
  with_retry do
59
- file_info = call('getFile', file_id: file_id)
60
- return nil unless file_info && file_info['file_path']
61
-
62
- file_path = file_info['file_path']
167
+ file_info = call("getFile", file_id: file_id)
168
+ return nil unless file_info && file_info["file_path"]
169
+
170
+ file_path = file_info["file_path"]
63
171
  download_url = "/file/bot#{@token}/#{file_path}"
64
-
172
+
65
173
  response = @client.get(download_url)
66
-
174
+
67
175
  if response.status == 200
68
176
  content = response.read
177
+
69
178
  if destination_path
70
179
  File.binwrite(destination_path, content)
71
180
  destination_path
@@ -77,78 +186,90 @@ module Telegem
77
186
  end
78
187
  end
79
188
  end
80
-
189
+
81
190
  def get_updates(offset: nil, timeout: 30, limit: 100, allowed_updates: nil)
82
- params = { timeout: timeout, limit: limit }
191
+ params = {
192
+ timeout: timeout,
193
+ limit: limit
194
+ }
195
+
83
196
  params[:offset] = offset if offset
84
197
  params[:allowed_updates] = allowed_updates if allowed_updates
85
- call('getUpdates', params)
198
+
199
+ call("getUpdates", params)
86
200
  end
87
-
201
+
88
202
  def close
89
203
  @client.close
90
204
  end
91
-
205
+
92
206
  private
93
-
94
- def with_retry(&block)
207
+
208
+ def with_retry
95
209
  retries = 0
210
+
96
211
  begin
97
- block.call
212
+ yield
98
213
  rescue NetworkError, Async::TimeoutError => e
99
214
  retries += 1
215
+
100
216
  if retries <= @retries
101
217
  @logger.warn("API request failed: #{e.message}. Retry #{retries}/#{@retries}") if @logger
102
- sleep @retry_delay * retries # exponential backoff
218
+ sleep(@retry_delay * retries)
103
219
  retry
104
220
  else
105
221
  raise
106
222
  end
107
- rescue APIError => e
108
- # Don't retry API errors (bad request, unauthorized, etc.)
223
+ rescue APIError
109
224
  raise
110
225
  end
111
226
  end
112
-
227
+
113
228
  def make_request(method, params)
114
229
  url = "/bot#{@token}/#{method}"
115
- @logger.debug("Api call #{method}") if @logger
116
-
230
+
231
+ @logger.debug("API call #{method}") if @logger
232
+
117
233
  response = @client.post(
118
234
  url,
119
- { 'content-type' => 'application/json' },
235
+ {
236
+ "content-type" => "application/json"
237
+ },
120
238
  JSON.dump(params.compact)
121
239
  )
122
-
240
+
123
241
  handle_response(response)
124
242
  end
125
-
243
+
126
244
  def handle_response(response)
127
245
  json = JSON.parse(response.read)
128
-
129
- if json && json['ok']
130
- json['result']
246
+
247
+ if json["ok"]
248
+ json["result"]
131
249
  else
132
- error_msg = json ? json['description'] : "HTTP #{response.status} - Empty response"
250
+ error_msg = json["description"] || "HTTP #{response.status}"
133
251
  raise APIError.new(error_msg, response.status)
134
252
  end
135
253
  end
136
-
254
+
137
255
  def file_object?(obj)
138
- obj.is_a?(File) || obj.is_a?(StringIO) || obj.is_a?(Tempfile) ||
139
- (obj.is_a?(String) && File.exist?(obj))
256
+ obj.is_a?(File) ||
257
+ obj.is_a?(StringIO) ||
258
+ obj.is_a?(Tempfile) ||
259
+ (obj.is_a?(String) && File.file?(obj))
140
260
  end
141
261
  end
142
-
262
+
143
263
  class APIError < StandardError
144
264
  attr_reader :code
145
-
265
+
146
266
  def initialize(message, code = nil)
147
267
  super(message)
148
268
  @code = code
149
269
  end
150
270
  end
151
-
152
- class NetworkError < APIError; end
271
+
272
+ class NetworkError < APIError
273
+ end
153
274
  end
154
- end
275
+ end
data/lib/api/types.rb CHANGED
@@ -53,10 +53,7 @@ module Telegem
53
53
  elsif @_raw_data.key?(camel_key)
54
54
  define_singleton_method(name) { @_raw_data[camel_key] }
55
55
  else
56
- define_singleton_method(name) do
57
- raise NoMethodError,
58
- "undefined method `#{name}' for #{self.class} with keys: #{@_raw_data.keys}"
59
- end
56
+ define_singleton_method(name) { nil }
60
57
  end
61
58
 
62
59
  @_accessors_defined[name] = true
data/lib/core/context.rb CHANGED
@@ -124,6 +124,62 @@ module Telegem
124
124
  params = { chat_id: chat.id, text: text }.merge(options)
125
125
  @bot.api.call('sendMessage', params)
126
126
  end
127
+
128
+ def reply_rich(rich_message, **options)
129
+ return nil unless chat
130
+
131
+ params = { chat_id: chat.id, rich_message: rich_message }.merge(options)
132
+ @bot.api.call('sendRichMessage', params)
133
+ end
134
+
135
+ # Draft streaming support (Bot API 10.1)
136
+ def start_draft(initial_text = "", **options)
137
+ return nil unless chat
138
+
139
+ draft_id = "draft_#{chat.id}_#{Time.now.to_i}_#{rand(1000)}"
140
+ session[:telegem_draft_id] = draft_id
141
+
142
+ rich_message = { blocks: [{ type: "paragraph", content: initial_text }] }
143
+ params = { chat_id: chat.id, draft_id: draft_id, rich_message: rich_message }.merge(options)
144
+ @bot.api.call('sendRichMessageDraft', params)
145
+
146
+ draft_id
147
+ end
148
+
149
+ def append_to_draft(draft_id = nil, content, **options)
150
+ return nil unless chat
151
+
152
+ draft_id ||= session[:telegem_draft_id]
153
+ return nil unless draft_id
154
+
155
+ rich_message = { blocks: [{ type: "paragraph", content: content }] }
156
+ params = { chat_id: chat.id, draft_id: draft_id, rich_message: rich_message }.merge(options)
157
+ @bot.api.call('sendRichMessageDraft', params)
158
+ end
159
+
160
+ def publish_draft(draft_id = nil, **options)
161
+ return nil unless chat
162
+
163
+ draft_id ||= session[:telegem_draft_id]
164
+ return nil unless draft_id
165
+
166
+ params = { chat_id: chat.id, draft_id: draft_id }.merge(options)
167
+ result = @bot.api.call('publishRichMessageDraft', params)
168
+ session.delete(:telegem_draft_id)
169
+ result
170
+ end
171
+
172
+ def cancel_draft(draft_id = nil, **options)
173
+ return nil unless chat
174
+
175
+ draft_id ||= session[:telegem_draft_id]
176
+ return nil unless draft_id
177
+
178
+ params = { chat_id: chat.id, draft_id: draft_id }.merge(options)
179
+ result = @bot.api.call('cancelRichMessageDraft', params)
180
+ session.delete(:telegem_draft_id)
181
+ result
182
+ end
127
183
 
128
184
  def edit_message_text(text, **options)
129
185
  return nil unless message && chat
data/lib/telegem.rb CHANGED
@@ -3,7 +3,7 @@ require 'logger'
3
3
  require 'json'
4
4
 
5
5
  module Telegem
6
- VERSION = "3.5.0"
6
+ VERSION = "3.6.0"
7
7
  end
8
8
 
9
9
  #
@@ -104,28 +104,38 @@ module Telegem
104
104
  end
105
105
 
106
106
  def handle_request(request)
107
- case request.path
108
- when @secret_token, "/#{@secret_token}"
109
- handle_webhook_request(request)
110
- when '/health', '/healthz'
111
- health_endpoint(request)
112
- else
113
- [404, {}, ["Not Found"]]
107
+ begin
108
+ case request.path
109
+ when @secret_token, "/#{@secret_token}"
110
+ handle_webhook_request(request)
111
+ when '/health', '/healthz'
112
+ health_endpoint(request)
113
+ else
114
+ [404, {}, ["Not Found"]]
115
+ end
116
+ rescue => e
117
+ @logger.error("Request handler error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}")
118
+ [500, {}, ["Internal Server Error"]]
114
119
  end
115
120
  end
116
121
 
117
122
  def handle_webhook_request(request)
118
123
  return [405, {}, ["Method Not Allowed"]] unless request.post?
124
+
119
125
  received = request.headers['X-Telegram-Bot-Api-Secret-Token'] ||
120
- request.headers['x-telegram-bot-api-secret-token']
121
- return [403, {}, ["Forbidden"]] unless received == @secret_token
126
+ request.headers['x-telegram-bot-api-secret-token']
127
+ return [403, {}, ["Forbidden"]] unless received == @secret_token
122
128
 
123
129
  begin
124
130
  body = request.body.read
125
- update_data = JSON.parse(body)
126
- process_webhook_update(update_data)
131
+ update_data = json_to_symbols(JSON.parse(body))
132
+ process_webhook_update(update_data)
127
133
  [200, {}, ["OK"]]
128
- rescue
134
+ rescue JSON::ParserError => e
135
+ @logger.error("JSON parse error: #{e}")
136
+ [400, {}, ["Bad Request"]]
137
+ rescue => e
138
+ @logger.error("Webhook handler error: #{e.class} - #{e.message}")
129
139
  [500, {}, ["Internal Server Error"]]
130
140
  end
131
141
  end
@@ -137,11 +147,12 @@ module Telegem
137
147
  end
138
148
 
139
149
  def health_endpoint(request)
140
- [200, { 'Content-Type' => 'application/json' }, [{
150
+ body = {
141
151
  status: 'ok',
142
152
  mode: @ssl_mode.to_s,
143
153
  ssl: @ssl_mode != :none
144
- }.to_json]]
154
+ }.to_json
155
+ [200, { 'Content-Type' => 'application/json' }, [body]]
145
156
  end
146
157
 
147
158
  def stop
@@ -183,6 +194,17 @@ module Telegem
183
194
  def running?
184
195
  @running
185
196
  end
197
+
198
+ def json_to_symbols(obj)
199
+ case obj
200
+ when Hash
201
+ obj.transform_keys { |k| k.to_sym }.transform_values { |v| json_to_symbols(v) }
202
+ when Array
203
+ obj.map { |v| json_to_symbols(v) }
204
+ else
205
+ obj
206
+ end
207
+ end
186
208
  end
187
209
  end
188
210
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: telegem
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.5.0
4
+ version: 3.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zendrx
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-13 00:00:00.000000000 Z
11
+ date: 2026-06-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: securerandom
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: 0.92.1
47
+ version: '0.8'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: 0.92.1
54
+ version: '0.8'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: pdf-reader
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -108,6 +108,20 @@ dependencies:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
110
  version: '1.50'
111
+ - !ruby/object:Gem::Dependency
112
+ name: logger
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.6'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.6'
111
125
  description: |
112
126
  Telegem is a modern Telegram Bot Framework for Ruby inspired by Telegraf.js.
113
127
  Built with async-first design using async-http, featuring scenes, middleware,
@@ -119,14 +133,14 @@ executables:
119
133
  extensions: []
120
134
  extra_rdoc_files: []
121
135
  files:
136
+ - ".anv"
122
137
  - ".rubocop.yml"
123
138
  - CHANGELOG.md
124
- - CODE_OF_CONDUCT.md
125
139
  - Gemfile
126
- - Gemfile.lock
127
140
  - LICENSE
128
141
  - README.md
129
142
  - Starts_HallofFame.md
143
+ - _config.yml
130
144
  - assets/.gitkeep
131
145
  - assets/logo.png
132
146
  - bin/.gitkeep
@@ -186,7 +200,7 @@ metadata:
186
200
  documentation_uri: https://github.com/slick-lab/telegem/tree/main/docs
187
201
  rubygems_mfa_required: 'false'
188
202
  post_install_message: |
189
- Thanks for installing Telegem 3.5.0!
203
+ Thanks for installing Telegem 3.6.0!
190
204
 
191
205
  Documentation: https://github.com/slick-lab/telegem
192
206
 
@@ -203,7 +217,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
203
217
  requirements:
204
218
  - - ">="
205
219
  - !ruby/object:Gem::Version
206
- version: 3.1.0
220
+ version: 3.2.0
207
221
  required_rubygems_version: !ruby/object:Gem::Requirement
208
222
  requirements:
209
223
  - - ">="
data/CODE_OF_CONDUCT.md DELETED
@@ -1,13 +0,0 @@
1
- # Contributor Covenant Code of Conduct
2
-
3
- ## Our Pledge
4
- We pledge to make participation in our project a harassment-free experience for everyone...
5
-
6
- ## Our Standards
7
- Examples of behavior that contributes to a positive environment...
8
-
9
- ## Enforcement
10
- Instances of abusive, harassing, or otherwise unacceptable behavior may be reported...
11
-
12
- ## Attribution
13
- This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org)...
data/Gemfile.lock DELETED
@@ -1,11 +0,0 @@
1
- GEM
2
- remote: https://rubygems.org/
3
- specs:
4
-
5
- PLATFORMS
6
- x86_64-linux
7
-
8
- DEPENDENCIES
9
-
10
- BUNDLED WITH
11
- 2.4.10