telegem 3.4.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
@@ -91,7 +88,8 @@ module Telegem
91
88
  can_join_groups can_read_all_group_messages
92
89
  supports_inline_queries language_code
93
90
  is_premium added_to_attachment_menu
94
- can_connect_to_business can_manage_bots].freeze
91
+ can_connect_to_business can_manage_bots
92
+ supports_guest_queries].freeze
95
93
 
96
94
  def initialize(data)
97
95
  super(data)
@@ -191,7 +189,9 @@ module Telegem
191
189
  forward_from_message_id forward_signature
192
190
  forward_sender_name forward_date reply_to_message
193
191
  media_group_id author_signature
194
- has_protected_content managed_bot_created managed_bot].freeze
192
+ has_protected_content managed_bot_created managed_bot
193
+ guest_bot_caller_user guest_bot_caller_chat
194
+ guest_query_id live_photo].freeze
195
195
 
196
196
  def initialize(data)
197
197
  super(data)
@@ -301,6 +301,10 @@ module Telegem
301
301
  wrap('voice', Voice)
302
302
  wrap('video_note', VideoNote)
303
303
  wrap('sticker', Sticker)
304
+ wrap('live_photo', LivePhoto)
305
+
306
+ wrap('guest_bot_caller_user', User)
307
+ wrap('guest_bot_caller_chat', Chat)
304
308
 
305
309
  wrap('invoice', Invoice)
306
310
  wrap('successful_payment', SuccessfulPayment)
@@ -409,7 +413,8 @@ module Telegem
409
413
  edited_channel_post inline_query chosen_inline_result
410
414
  callback_query shipping_query pre_checkout_query
411
415
  poll poll_answer my_chat_member chat_member
412
- chat_join_request managed_bot_created managed_bot].freeze
416
+ chat_join_request managed_bot_created managed_bot
417
+ guest_message].freeze
413
418
 
414
419
  def initialize(data)
415
420
  super(data)
@@ -438,6 +443,7 @@ module Telegem
438
443
  return :chat_join_request if chat_join_request
439
444
  return :managed_bot_created if managed_bot_created
440
445
  return :managed_bot if managed_bot
446
+ return :guest_message if guest_message
441
447
  :unknown
442
448
  end
443
449
 
@@ -463,6 +469,8 @@ module Telegem
463
469
  chat_join_request.from
464
470
  when :managed_bot_created, :managed_bot
465
471
  managed_bot_created&.from || managed_bot&.from
472
+ when :guest_message
473
+ guest_message.from
466
474
  else
467
475
  nil
468
476
  end
@@ -497,6 +505,8 @@ module Telegem
497
505
  # Bot API 9.6 - Managed Bot Support
498
506
  wrap('managed_bot_created', ManagedBotCreated)
499
507
  wrap('managed_bot', ManagedBotUpdated)
508
+ # Bot API 10.0 - Guest Message Support
509
+ wrap('guest_message', SentGuestMessage)
500
510
  end
501
511
  end
502
512
 
@@ -546,6 +556,8 @@ module Telegem
546
556
  # Bot API 9.6 enhancements
547
557
  wrap('added_by_user', User)
548
558
  wrap('added_by_chat', Chat)
559
+ # Bot API 10.0 - media support
560
+ wrap('media', InputPollOptionMedia)
549
561
  end
550
562
  end
551
563
 
@@ -556,10 +568,19 @@ module Telegem
556
568
  super(data)
557
569
  wrap_array('options', PollOption)
558
570
  wrap_array('explanation_entities', MessageEntity)
571
+ # Bot API 10.0 - poll media support
572
+ wrap('media', PollMedia)
573
+ wrap('explanation_media', PollMedia)
559
574
  end
560
575
  end
561
576
 
562
- class ChatPermissions < BaseType; end
577
+ class ChatPermissions < BaseType
578
+ def initialize(data)
579
+ super(data)
580
+ # Bot API 10.0 - reaction support
581
+ define_accessor(:can_react_to_messages)
582
+ end
583
+ end
563
584
  class ChatPhoto < BaseType; end
564
585
  class ChatInviteLink < BaseType; end
565
586
 
@@ -568,7 +589,13 @@ module Telegem
568
589
  class ChatMemberOwner < ChatMember; end
569
590
  class ChatMemberAdministrator < ChatMember; end
570
591
  class ChatMemberMember < ChatMember; end
571
- class ChatMemberRestricted < ChatMember; end
592
+ class ChatMemberRestricted < ChatMember
593
+ def initialize(data)
594
+ super(data)
595
+ # Bot API 10.0 - reaction support
596
+ define_accessor(:can_react_to_messages)
597
+ end
598
+ end
572
599
  class ChatMemberLeft < ChatMember; end
573
600
  class ChatMemberBanned < ChatMember; end
574
601
 
@@ -699,5 +726,29 @@ module Telegem
699
726
  class ManagedBotCreated < BaseType; end
700
727
  class ManagedBotDeleted < BaseType; end
701
728
  class ManagedBotUpdated < BaseType; end
729
+
730
+ # Bot API 10.0 - Guest Mode Support
731
+ class SentGuestMessage < BaseType; end
732
+
733
+ # Bot API 10.0 - Poll Media Support
734
+ class PollMedia < BaseType; end
735
+ class InputPollMedia < BaseType; end
736
+ class InputPollOptionMedia < BaseType; end
737
+
738
+ # Bot API 10.0 - Live Photo Support
739
+ class LivePhoto < BaseType
740
+ def initialize(data)
741
+ super(data)
742
+ wrap('photo', PhotoSize)
743
+ wrap('video', Video)
744
+ end
745
+ end
746
+
747
+ class InputMediaLivePhoto < BaseType; end
748
+ class PaidMediaLivePhoto < BaseType; end
749
+ class InputPaidMediaLivePhoto < BaseType; end
750
+
751
+ # Bot API 10.0 - Business Access Settings
752
+ class BotAccessSettings < BaseType; end
702
753
  end
703
754
  end
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
@@ -63,7 +63,6 @@ module Telegem
63
63
  end
64
64
 
65
65
  def increment_counters(ctx)
66
- now = Time.now.to_i
67
66
  if @options[:global]
68
67
  key = "global"
69
68
  @counters[:global].increment(key, 1, ttl: @options[:global][:per])
@@ -80,8 +79,12 @@ module Telegem
80
79
  end
81
80
  end
82
81
  def rate_limit_response(ctx)
83
- ctx.reply("⏳ Please wait a moment before sending another request.") rescue nil
82
+ begin
83
+ ctx.reply("⏳ Please wait a moment before sending another request.")
84
+ rescue StandardError => e
85
+ ctx.logger&.warn("Failed to send rate limit response: #{e.class}: #{e.message}")
86
+ end
84
87
  nil
85
88
  end
86
89
  end
87
- end
90
+ end
data/lib/telegem.rb CHANGED
@@ -3,7 +3,7 @@ require 'logger'
3
3
  require 'json'
4
4
 
5
5
  module Telegem
6
- VERSION = "3.4.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