mtproto 0.0.19 → 0.0.20

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b96860172330495c1d12e06b8de8861ac33c97a6882a955fc77fe3e85e4268e4
4
- data.tar.gz: 95c1faa866ea16ad23ba0cc06849adb6684f2267a53f7f018dc2ade8ad8decb4
3
+ metadata.gz: 9c0529af4d0277b14c02e0a618a865e67234aaf956873d749579f0aae5d2b4c8
4
+ data.tar.gz: c519588e92e960402dfe515e9b202e8f9c83a05ca6a21952a05d339b031c8878
5
5
  SHA512:
6
- metadata.gz: 6566d9f51ccf60e24a99ec3410309088813a26dd42fa353699390c2714427e688e43882130e950cfe24268f586ed920ac4b6e7473cb183e660f8be3d4a249191
7
- data.tar.gz: 36016eb3d8176d4dc806fa8057835d0b9947ef5ad77acc9e32add81a39a6bb2a2a8a367b5a5329ece094299cfdf2188350658c1b69a42c23d0d34cf576ac6281
6
+ metadata.gz: 782793b348cdd81fd8bfb43ceedd459bef20c0c2033c5b36068a392db9b3cc796273c0debfe9ddf0a1c2965b6b7aebe4a591f8b6afc35fed180e116c40d24a34
7
+ data.tar.gz: e2221dcca9e4e15f92b88140ef5eeb523e838a1a88687497e87a0241b2c196f2a6c70b841fbfecca36fccbb2ac6f68fa2be912dbe6cdc94f2c32a7175cec9cc5
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../tl/objects/get_bot_callback_answer'
4
+ require_relative '../../tl/objects/bot_callback_answer'
5
+
6
+ module MTProto
7
+ class Client
8
+ class API
9
+ # messages.getBotCallbackAnswer — press the inline callback button carrying
10
+ # `data` on message `msg_id` in `peer`. The server delivers an
11
+ # updateBotCallbackQuery to the bot and blocks until the bot answers (or the
12
+ # ~5s timeout), then returns the bot's botCallbackAnswer. This is the client
13
+ # half of "bots manage bots": the caller drives another bot's inline keyboard.
14
+ def get_bot_callback_answer(peer:, msg_id:, data: nil)
15
+ rpc_call(
16
+ TL::GetBotCallbackAnswer.new(peer: peer, msg_id: msg_id, data: data),
17
+ TL::BotCallbackAnswer
18
+ ).body
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../tl/objects/get_channel_difference'
4
+ require_relative '../../tl/objects/channel_difference'
5
+
6
+ module MTProto
7
+ class Client
8
+ class API
9
+ # updates.getChannelDifference — pull a channel/supergroup's backlog since
10
+ # `pts` (seed it from the dialog's pts). Returns a parsed ChannelDifference.
11
+ # `force` re-fetches even without a pending gap.
12
+ def get_channel_difference(channel:, pts:, limit: 100, force: false)
13
+ rpc_call(
14
+ TL::GetChannelDifference.new(
15
+ channel: channel, pts: pts, limit: limit, force: force
16
+ ),
17
+ TL::ChannelDifference
18
+ ).body
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../tl/objects/import_bot_authorization'
4
+ require_relative '../../tl/objects/authorization'
5
+
6
+ module MTProto
7
+ class Client
8
+ class API
9
+ def import_bot_authorization(bot_auth_token)
10
+ result = rpc_call(
11
+ TL::ImportBotAuthorization.new(
12
+ api_id: @client.api_id,
13
+ api_hash: @client.api_hash,
14
+ bot_auth_token: bot_auth_token
15
+ ),
16
+ TL::Authorization
17
+ ).body
18
+
19
+ if result.authorization? && result.user_id
20
+ @client.update_user(user_id: result.user_id, access_hash: result.access_hash)
21
+ end
22
+
23
+ result
24
+ end
25
+ end
26
+ end
27
+ end
@@ -2,13 +2,28 @@
2
2
 
3
3
  require_relative '../../tl/objects/send_message'
4
4
  require_relative '../../tl/objects/update_short_sent_message'
5
+ require_relative '../../markdown'
5
6
 
6
7
  module MTProto
7
8
  class Client
8
9
  class API
9
- def send_message(peer:, message:, random_id: nil)
10
- rpc_call(TL::SendMessage.new(peer: peer, message: message, random_id: random_id),
11
- TL::UpdateShortSentMessage).body
10
+ # Send a text message. Formatting can be supplied three ways:
11
+ # - `entities:` explicit formatting spans;
12
+ # - `parse_mode: :markdown` — parse Markdown out of `message` on the client
13
+ # (the plain text + derived entities are sent);
14
+ # - `rich_markdown:` — Telegram's server-side "Rich Formatting": the raw
15
+ # Markdown string is sent as inputRichMessageMarkdown and the SERVER formats
16
+ # it (`message`/`entities` are ignored). Note: server-gated (premium).
17
+ # A `reply_to` (with optional `quote_text`/`quote_offset`) replies to a message,
18
+ # optionally pinning the quoted fragment.
19
+ def send_message(peer:, message: '', random_id: nil, reply_to: nil, entities: nil,
20
+ parse_mode: nil, rich_markdown: nil, quote_text: nil, quote_offset: nil)
21
+ message, entities = Markdown.parse(message) if parse_mode && entities.nil? && rich_markdown.nil?
22
+ rpc_call(
23
+ TL::SendMessage.new(peer: peer, message: message, random_id: random_id, reply_to: reply_to,
24
+ entities: entities, rich_markdown: rich_markdown, quote_text: quote_text, quote_offset: quote_offset),
25
+ TL::UpdateShortSentMessage
26
+ ).body
12
27
  end
13
28
  end
14
29
  end
@@ -4,6 +4,7 @@ require_relative 'api/send_code'
4
4
  require_relative 'api/sign_in'
5
5
  require_relative 'api/export_authorization'
6
6
  require_relative 'api/import_authorization'
7
+ require_relative 'api/import_bot_authorization'
7
8
  require_relative 'api/export_login_token'
8
9
  require_relative 'api/import_login_token'
9
10
  require_relative 'api/check_password'
@@ -12,6 +13,8 @@ require_relative 'api/get_dialogs'
12
13
  require_relative 'api/get_history'
13
14
  require_relative 'api/get_updates_state'
14
15
  require_relative 'api/get_updates_difference'
16
+ require_relative 'api/get_channel_difference'
17
+ require_relative 'api/get_bot_callback_answer'
15
18
  require_relative 'api/send_message'
16
19
  require_relative 'api/get_contacts'
17
20
 
@@ -25,15 +28,24 @@ module MTProto
25
28
  private
26
29
 
27
30
  def rpc_call(request, response_class)
28
- response = @client.rpc.call(request, response_class)
29
- response.wait!(@client.timeout)
30
- response
31
- rescue RpcError => e
32
- raise unless e.flood_wait?
33
-
34
- warn "[MTProto] FLOOD_WAIT: retrying in #{e.flood_wait_seconds}s..."
35
- sleep(e.flood_wait_seconds)
36
- retry
31
+ reinited = false
32
+ begin
33
+ response = @client.rpc.call(request, response_class)
34
+ response.wait!(@client.timeout)
35
+ response
36
+ rescue RpcError => e
37
+ if e.flood_wait?
38
+ warn "[MTProto] FLOOD_WAIT: retrying in #{e.flood_wait_seconds}s..."
39
+ sleep(e.flood_wait_seconds)
40
+ retry
41
+ elsif e.error_message == 'CONNECTION_NOT_INITED' && !reinited
42
+ reinited = true
43
+ @client.reinit_connection!
44
+ retry
45
+ else
46
+ raise
47
+ end
48
+ end
37
49
  end
38
50
  end
39
51
  end
@@ -36,8 +36,14 @@ module MTProto
36
36
  new_server_salt = response_body[20, 8].unpack1('Q<')
37
37
 
38
38
  client.update_server_salt(new_server_salt)
39
+ resend(bad_msg_id)
40
+ end
39
41
 
40
- response = @pending_requests.delete(bad_msg_id)
42
+ # Re-send an already-sent request (still tracked in @pending_requests) under
43
+ # a fresh msg_id, keeping the same Response so its caller's wait! resolves on
44
+ # the new reply. Used after bad_server_salt / bad_msg_notification.
45
+ def resend(msg_id)
46
+ response = @pending_requests.delete(msg_id)
41
47
  return unless response
42
48
 
43
49
  new_msg_id = send_encrypted(response.body_bytes)
@@ -45,6 +51,12 @@ module MTProto
45
51
  @pending_requests[new_msg_id] = response
46
52
  end
47
53
 
54
+ # Fail a pending request with an error so its caller stops waiting.
55
+ def fail_request(msg_id, error)
56
+ response = @pending_requests.delete(msg_id)
57
+ response&.signal_error(error)
58
+ end
59
+
48
60
  def handle_rpc_result(response_body)
49
61
  req_msg_id = response_body[4, 8].unpack1('Q<')
50
62
  result = response_body[12..]
@@ -5,6 +5,7 @@ require 'digest'
5
5
  require 'base64'
6
6
  require 'async'
7
7
  require 'async/condition'
8
+ require_relative 'tl/constructors'
8
9
  require_relative 'transport/tcp_connection'
9
10
  require_relative 'transport/connection'
10
11
  require_relative 'transport/abridged_packet_codec'
@@ -88,6 +89,11 @@ module MTProto
88
89
  ACK_INTERVAL = 5
89
90
  PING_INTERVAL = 30
90
91
 
92
+ # Reconnect backoff: wait RECONNECT_BACKOFF_BASE seconds after the first failed
93
+ # attempt, doubling each retry, capped at RECONNECT_BACKOFF_MAX.
94
+ RECONNECT_BACKOFF_BASE = 1
95
+ RECONNECT_BACKOFF_MAX = 30
96
+
91
97
  def on_update(&block)
92
98
  @on_update_callbacks << block
93
99
  end
@@ -119,23 +125,7 @@ module MTProto
119
125
 
120
126
  @running = true
121
127
  @ack_ids = []
122
- @receiver_task = Async do
123
- @connection.receive do |packet, error|
124
- if error
125
- warn "[MTProto] Packet read error: #{error.message}"
126
- next
127
- end
128
-
129
- decrypted = EncryptedMessage.decrypt(
130
- auth_key: @auth_key,
131
- encrypted_message_data: packet.data.pack('C*'),
132
- sender: :server
133
- )
134
-
135
- collect_ack(decrypted[:msg_id], decrypted[:seq_no], decrypted[:body])
136
- process_message(decrypted[:body])
137
- end
138
- end
128
+ @receiver_task = Async { receiver_loop }
139
129
  @keepalive_task = Async { keepalive_loop }
140
130
  end
141
131
 
@@ -205,6 +195,14 @@ module MTProto
205
195
  response.body
206
196
  end
207
197
 
198
+ # Force a fresh initConnection on the current connection. Used to recover
199
+ # when the server reports CONNECTION_NOT_INITED (it lost our connection
200
+ # state): reset the guard and run init again.
201
+ def reinit_connection!
202
+ @connection_initialized = false
203
+ init_connection!
204
+ end
205
+
208
206
  def save_auth_data
209
207
  raise 'Cannot save auth_data: auth_key not set' unless @auth_key
210
208
 
@@ -245,22 +243,89 @@ module MTProto
245
243
 
246
244
  private
247
245
 
248
- # Periodically acknowledge received messages and ping, so the server doesn't
249
- # drop a long-lived connection. Ends when the connection dies (the wrapper
250
- # reconnects) or when the task is stopped on disconnect!.
246
+ # Read and dispatch incoming messages for as long as the client runs. On a
247
+ # transport drop, reconnect and resume; the post-reconnect re-init + replay run
248
+ # in a child task so this loop can read the responses those calls wait on.
249
+ def receiver_loop
250
+ while @running
251
+ begin
252
+ @connection.receive { |packet, error| handle_incoming(packet, error) }
253
+ rescue Transport::ConnectionError => e
254
+ break unless @running
255
+
256
+ reconnect_after(e)
257
+ end
258
+ end
259
+ end
260
+
261
+ def handle_incoming(packet, error)
262
+ if error
263
+ warn "[MTProto] Packet read error: #{error.message}"
264
+ return
265
+ end
266
+
267
+ decrypted = EncryptedMessage.decrypt(
268
+ auth_key: @auth_key,
269
+ encrypted_message_data: packet.data.pack('C*'),
270
+ sender: :server
271
+ )
272
+
273
+ collect_ack(decrypted[:msg_id], decrypted[:seq_no], decrypted[:body])
274
+ process_message(decrypted[:body], decrypted[:msg_id])
275
+ end
276
+
277
+ # Re-establish the transport and re-run initConnection. The re-init runs in a
278
+ # child task because it waits on a response that only this (resumed) receiver
279
+ # loop can read.
280
+ def reconnect_after(error)
281
+ warn "[MTProto] connection lost (#{error.message}); reconnecting"
282
+ # Fail in-flight requests instead of resending them across the drop: a
283
+ # resend could double-execute a side-effecting request, so callers retry at
284
+ # a higher level. Matches mtproto-rust's reconnect behaviour.
285
+ rpc.signal_all_error(error)
286
+ @connection_initialized = false
287
+ reestablish_connection
288
+ return unless @running
289
+
290
+ Async { init_connection! }
291
+ end
292
+
293
+ # Reconnect the transport, retrying with exponential backoff until it succeeds
294
+ # or the client is shut down.
295
+ def reestablish_connection
296
+ attempt = 0
297
+ while @running
298
+ @connection.disconnect!
299
+ begin
300
+ @connection.connect!
301
+ return
302
+ rescue Transport::ConnectionError, SystemCallError => e
303
+ attempt += 1
304
+ warn "[MTProto] reconnect attempt #{attempt} failed: #{e.message}"
305
+ sleep [RECONNECT_BACKOFF_BASE * (2**(attempt - 1)), RECONNECT_BACKOFF_MAX].min
306
+ end
307
+ end
308
+ end
309
+
310
+ # Periodically acknowledge received messages and ping so the server keeps the
311
+ # connection. Survives transport drops (a failed tick is skipped; the receiver
312
+ # loop reconnects) and ends when the client is shut down.
251
313
  def keepalive_loop
252
314
  elapsed = 0
253
- loop do
315
+ while @running
254
316
  sleep ACK_INTERVAL
255
- flush_acks
256
317
  elapsed += ACK_INTERVAL
257
- next if elapsed < PING_INTERVAL
258
318
 
259
- elapsed = 0
260
- rpc.send_ping
319
+ begin
320
+ flush_acks
321
+ next if elapsed < PING_INTERVAL
322
+
323
+ elapsed = 0
324
+ rpc.send_ping
325
+ rescue Transport::ConnectionError
326
+ nil # connection down/reconnecting — skip this tick
327
+ end
261
328
  end
262
- rescue StandardError
263
- nil
264
329
  end
265
330
 
266
331
  # Queue msg_ids of received content-related server messages for msgs_ack.
@@ -285,20 +350,50 @@ module MTProto
285
350
  rpc.send_ack(ids)
286
351
  end
287
352
 
288
- def process_message(response_body)
353
+ def process_message(response_body, server_msg_id)
289
354
  constructor = response_body[0, 4].unpack1('L<')
290
355
 
291
356
  case constructor
292
- when TL::Constructors::BAD_SERVER_SALT
293
- rpc.handle_bad_server_salt(response_body, self)
294
- when TL::Constructors::NEW_SESSION_CREATED
295
- handle_new_session(response_body)
296
357
  when TL::Constructors::MSG_CONTAINER
297
358
  handle_container(response_body)
298
359
  when TL::Constructors::RPC_RESULT
299
360
  rpc.handle_rpc_result(response_body)
361
+ when TL::Constructors::BAD_SERVER_SALT
362
+ rpc.handle_bad_server_salt(response_body, self)
363
+ when TL::Constructors::BAD_MSG_NOTIFICATION
364
+ handle_bad_msg_notification(response_body, server_msg_id)
365
+ when TL::Constructors::NEW_SESSION_CREATED
366
+ handle_new_session(response_body)
367
+ when *TL::Constructors::SERVICE_MESSAGES
368
+ nil # transport/service messages — no app-level action
369
+ else
370
+ dispatch_update(constructor, response_body)
371
+ end
372
+ end
373
+
374
+ def dispatch_update(constructor, response_body)
375
+ unless TL::Constructors::UPDATE_CONSTRUCTORS.include?(constructor)
376
+ warn "[MTProto] Ignoring unknown server message 0x#{constructor.to_s(16).rjust(8, '0')}"
377
+ return
378
+ end
379
+
380
+ @on_update_callbacks.each { |cb| cb.call(constructor, response_body) }
381
+ end
382
+
383
+ # bad_msg_notification#a7eff811 bad_msg_id:long bad_msg_seqno:int error_code:int.
384
+ # Codes 16/17 mean our clock is off (msg_id too low/high): resync time_offset
385
+ # from the server msg_id and resend the rejected request. Other codes can't be
386
+ # auto-recovered here, so fail the request loudly instead of leaving it to hang.
387
+ def handle_bad_msg_notification(response_body, server_msg_id)
388
+ bad_msg_id = response_body[4, 8].unpack1('Q<')
389
+ error_code = response_body[16, 4].unpack1('l<')
390
+
391
+ if [16, 17].include?(error_code)
392
+ @time_offset = (server_msg_id >> 32) - Time.now.to_i
393
+ rpc.resend(bad_msg_id)
300
394
  else
301
- @on_update_callbacks.each { |cb| cb.call(constructor, response_body) }
395
+ warn "[MTProto] bad_msg_notification code=#{error_code} for msg #{bad_msg_id}"
396
+ rpc.fail_request(bad_msg_id, RpcError.new(0, "BAD_MSG_NOTIFICATION_#{error_code}"))
302
397
  end
303
398
  end
304
399
 
@@ -311,7 +406,7 @@ module MTProto
311
406
  container = TL::MsgContainer.deserialize(response_body)
312
407
 
313
408
  container.messages.each do |msg|
314
- process_message(msg[:body])
409
+ process_message(msg[:body], msg[:msg_id])
315
410
  end
316
411
  end
317
412
  end
data/lib/mtproto/dc.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MTProto
4
+ # Datacenter IP addresses by number. An auth key is bound to the DC that
5
+ # issued it, so a saved session must reconnect to that same DC — resolve the
6
+ # host from auth_data[:dc_number] rather than assuming a single endpoint.
7
+ module DC
8
+ PRODUCTION = {
9
+ 1 => '149.154.175.53',
10
+ 2 => '149.154.167.51',
11
+ 3 => '149.154.175.100',
12
+ 4 => '149.154.167.91',
13
+ 5 => '91.108.56.130'
14
+ }.freeze
15
+
16
+ TEST = {
17
+ 1 => '149.154.175.40',
18
+ 2 => '149.154.167.40',
19
+ 3 => '149.154.175.117'
20
+ }.freeze
21
+
22
+ DEFAULT_PORT = 443
23
+
24
+ module_function
25
+
26
+ def address(dc_number, test:)
27
+ map = test ? TEST : PRODUCTION
28
+ { host: map[dc_number] || map.fetch(2), port: DEFAULT_PORT }
29
+ end
30
+ end
31
+ end