mtproto 0.0.32 → 0.0.34

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/docs/test_architecture_level1.md +23 -107
  3. data/lib/mtproto/auth_key_generator.rb +3 -1
  4. data/lib/mtproto/client/api/resolve_username.rb +18 -0
  5. data/lib/mtproto/client/api/send_message.rb +5 -2
  6. data/lib/mtproto/client/api/set_typing.rb +4 -4
  7. data/lib/mtproto/client/api/stream_draft.rb +13 -0
  8. data/lib/mtproto/client/api.rb +2 -0
  9. data/lib/mtproto/crypto/dh_key_exchange.rb +2 -1
  10. data/lib/mtproto/draft_stream.rb +52 -0
  11. data/lib/mtproto/tl/constructors.rb +1 -0
  12. data/lib/mtproto/tl/objects/authorization.rb +4 -3
  13. data/lib/mtproto/tl/objects/channel_difference.rb +13 -32
  14. data/lib/mtproto/tl/objects/contacts.rb +7 -89
  15. data/lib/mtproto/tl/objects/dialogs.rb +14 -352
  16. data/lib/mtproto/tl/objects/edit_message.rb +1 -1
  17. data/lib/mtproto/tl/objects/media_parts.rb +68 -0
  18. data/lib/mtproto/tl/objects/message.rb +14 -0
  19. data/lib/mtproto/tl/objects/message_service.rb +40 -0
  20. data/lib/mtproto/tl/objects/messages.rb +7 -32
  21. data/lib/mtproto/tl/objects/reply_keyboard_force_reply.rb +46 -0
  22. data/lib/mtproto/tl/objects/resolved_peer.rb +29 -0
  23. data/lib/mtproto/tl/objects/send_message.rb +1 -1
  24. data/lib/mtproto/tl/objects/send_message_text_draft_action.rb +52 -0
  25. data/lib/mtproto/tl/objects/send_multi_media.rb +52 -0
  26. data/lib/mtproto/tl/objects/update.rb +3 -35
  27. data/lib/mtproto/tl/objects/updates.rb +4 -6
  28. data/lib/mtproto/tl/objects/updates_difference.rb +9 -29
  29. data/lib/mtproto/tl/objects/upload_media.rb +26 -0
  30. data/lib/mtproto/tl/peers.rb +236 -0
  31. data/lib/mtproto/tl/reader.rb +29 -2
  32. data/lib/mtproto/updates/event_builder.rb +20 -32
  33. data/lib/mtproto/updates/parser.rb +9 -8
  34. data/lib/mtproto/updates/state_machine.rb +7 -7
  35. data/lib/mtproto/version.rb +1 -1
  36. data/lib/mtproto.rb +1 -0
  37. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f20076eea4c2e79508740e13b6f8405437f47ce5ea04f7bffcb55eef0134026
4
- data.tar.gz: 3e0ad205973f1f54e8ba60b1b97c4cac1c4a67ea5e59d2b7c797950f943953e0
3
+ metadata.gz: 8e9121e732982986db9b71a12288993dfb18888e3fe42e2e2b3bb4db55ca494a
4
+ data.tar.gz: 98b72d7cc330c681c6552a9e3f5b1081e7d614283e685c3f6be70837dae0390b
5
5
  SHA512:
6
- metadata.gz: 815dbe344577826f6ef0e6d4fac25c0a56eb58f8372f5612c3c77c8f71b9b2684624f3152b3ba4c7f62046dcb239b1bff6ee95b0af98ab249f79a30928abb6cb
7
- data.tar.gz: daaf36af66d6cff257881b8ebb3b19ab6c7b6c4bdaa8d7b9ef9f277a7da53539525039380714555948f80112fd46815985c324cbbd6c3075bef8738cf1ef8296
6
+ metadata.gz: 9a6fe6fc4c86420ee93a35f44f21fc39778b28fbc7a4c92e2294545dde285de72683db01a282e0517e5b8f4172af211465d17d7141185637d90fccba67b12b7b
7
+ data.tar.gz: 276c70eaffb140cd6b8d5f34582f9009c205d00e46afbefdd5f6050c67e79600c1afd6c5238f38d7cd8121fe0fb611b09a00b5237017bbcb69bc9df2d3e6cbd8
@@ -22,11 +22,13 @@ once.
22
22
 
23
23
  ## How It Works
24
24
 
25
- These are semi-manual tests. Each test case:
25
+ Each test case is a script that drives the whole scenario itself:
26
26
 
27
- 1. Connects to Telegram with a pre-authenticated session
28
- 2. Tells the user what to do (e.g., "Send a text message to this account")
29
- 3. Waits until the corresponding data arrives from Telegram
27
+ 1. Connects to Telegram with pre-authenticated sessions — one per side the
28
+ scenario needs (a user, a bot, a second user)
29
+ 2. Performs the user-facing actions from those sessions, so no operator has to
30
+ do anything by hand
31
+ 3. Waits out the traffic the actions produce
30
32
  4. Saves the raw decrypted message bodies as `.bin` files and a `captured.json`
31
33
  listing the TL constructors received
32
34
 
@@ -112,11 +114,11 @@ No metadata file — the folder + filename are the catalogue.
112
114
 
113
115
  ## File Structure
114
116
 
115
- Each scenario is an executable script (not an RSpec spec). The flow is
116
- interactive the script prints instructions, waits on STDIN, then captures
117
- the resulting Telegram traffic. RSpec's declarative `describe`/`it` shape
118
- doesn't fit that, so these scripts live under `spec/level1/` alongside the
119
- rspec tree but are invoked directly, not through `rake spec`.
117
+ Each scenario is an executable script (not an RSpec spec). It talks to live
118
+ Telegram, needs credentials, and narrates what it observes as it goes —
119
+ RSpec's declarative `describe`/`it` shape fits none of that, so these scripts
120
+ live under `spec/level1/` alongside the rspec tree, are invoked directly, and
121
+ never run as part of `rake spec`.
120
122
 
121
123
  ```
122
124
  spec/
@@ -131,107 +133,21 @@ spec/
131
133
  constructors/ # Deduplicated constructor variant library
132
134
  ```
133
135
 
134
- ## Running with xp
135
-
136
- These scripts are interactive — they print a prompt, block on STDIN, run
137
- until the captured traffic arrives, then exit. When the operator is a
138
- Claude Code bot driving the repo from short-lived Bash invocations, a
139
- plain shell can't keep the script alive across calls: each tool call is
140
- a fresh process, so the script would exit (or be killed) the moment the
141
- shell returns.
142
-
143
- The way around it is [xp](https://github.com/alev-pro/xp) — a small
144
- process stdin/stdout multiplexer. A long-lived xp daemon owns the
145
- script under a named topic; separate client invocations send to its
146
- stdin and read from its stdout. The script keeps running between
147
- calls.
148
-
149
- The recipe below assumes xp is built at
150
- `~/devel/alev-pro/xp/target/release/xp`. Adjust the path if it lives
151
- elsewhere.
152
-
153
- ### 1. Start the xp daemon
154
-
155
- ```sh
156
- nohup ~/devel/alev-pro/xp/target/release/xp --run-daemon \
157
- >/tmp/xp-daemon.log 2>&1 &
158
- echo $! > /tmp/xp-daemon.pid
159
- ```
160
-
161
- The daemon binds a Unix socket at `/tmp/xp-<uid>.sock`. The PID file
162
- is just a convention so teardown can `kill` by PID — `pkill -f` on
163
- the daemon command line is dangerous because it can also match the
164
- caller's own wrapper shell.
165
-
166
- ### 2. Spawn the Level 1 script under a topic
136
+ ## Running a scenario
167
137
 
168
138
  ```sh
169
- ~/devel/alev-pro/xp/target/release/xp -t l1 -- \
170
- bash -lc 'cd ~/devel/alev-pro/mtproto-ruby \
171
- && bundle exec spec/level1/receive_private_text_message'
139
+ cd ~/devel/alev-pro/mtproto-ruby
140
+ bundle exec spec/level1/<scenario>
172
141
  ```
173
142
 
174
- `l1` is just a chosen topic name. Any string works.
175
-
176
- ### 3. Read the script's stdout
177
-
178
- ```sh
179
- ~/devel/alev-pro/xp/target/release/xp -t l1 --read-stdout --cursor c1
180
- ```
181
-
182
- `--cursor c1` keeps a stable read position across separate xp client
183
- invocations. Without `--cursor`, xp keys the cursor on the caller's
184
- posix session id (`getsid(0)`), and every fresh Bash tool call has a
185
- new sid — meaning every read replays the buffer from byte 0.
186
-
187
- Expected initial output:
188
-
189
- ```
190
- Connecting...
191
- Connected as: <first> <last>
192
- Send a text message to this account in a private chat.
193
- Press Enter when done.
194
- ```
143
+ The script runs to completion on its own and exits; nothing has to be typed at
144
+ it. It reads its credentials from the environment (`.env` is loaded): the test
145
+ DC address, key and number, the API id and hash, and one or more
146
+ pre-authenticated sessions. Each script names the variables it needs.
195
147
 
196
- ### 4. Trigger the scenario
197
-
198
- Perform the user-facing action the script asks for (in this case,
199
- send a private text message to the test-DC account from another
200
- Telegram client).
201
-
202
- ### 5. Unblock the script
203
-
204
- ```sh
205
- echo "" | ~/devel/alev-pro/xp/target/release/xp -t l1 --put-to-stdin
206
- ```
207
-
208
- The script then prints the captured constructor list and writes the
209
- fixtures + `captured.json` to `spec/fixtures/level1/test_cases/<scenario>/`.
210
-
211
- ### 6. Tear down
212
-
213
- ```sh
214
- kill "$(cat /tmp/xp-daemon.pid)"
215
- rm -f /tmp/xp-<uid>.sock /tmp/xp-daemon.pid
216
- ```
217
-
218
- ### STDOUT buffering caveat
219
-
220
- Ruby's `STDOUT` defaults to full buffering when stdout is not a tty,
221
- and xp pipes the child's stdout through a plain pipe rather than a
222
- pty. Without an explicit flush the script's `puts` calls would stay
223
- in-process until exit, and step 3 would read nothing. Each Level 1
224
- script must therefore set `STDOUT.sync = true` near the top, before
225
- any output.
226
-
227
- ### `bash -lc` noise
228
-
229
- When `bash -i` runs under xp without a pty, it complains:
230
-
231
- ```
232
- bash: cannot set terminal process group (...): Inappropriate ioctl for device
233
- bash: no job control in this shell
234
- ```
148
+ ### STDOUT buffering
235
149
 
236
- That noise lands on stderr, which the daemon inherits and writes into
237
- `/tmp/xp-daemon.log`. The script's own stdout stays clean.
150
+ Ruby's `STDOUT` defaults to full buffering when stdout is not a tty, so a script
151
+ whose output is piped or captured would hold everything in-process until it
152
+ exits. Each Level 1 script therefore sets `STDOUT.sync = true` near the top,
153
+ before any output.
@@ -193,7 +193,9 @@ module MTProto
193
193
  Digest::SHA1.digest(new_nonce + "\x03".b + auth_key_aux_hash)[-16..]
194
194
  end
195
195
 
196
- raise 'new_nonce_hash mismatch!' unless dh_gen_response.new_nonce_hash == expected_new_nonce_hash
196
+ unless dh_gen_response.new_nonce_hash == expected_new_nonce_hash
197
+ raise "new_nonce_hash mismatch! (dh_gen #{dh_gen_response.status}, auth_key #{auth_key.bytesize} bytes)"
198
+ end
197
199
 
198
200
  case dh_gen_response.status
199
201
  when :retry
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../tl/objects/resolve_username'
4
+ require_relative '../../tl/objects/resolved_peer'
5
+
6
+ module MTProto
7
+ class Client
8
+ class API
9
+ def resolve_username(username:)
10
+ rpc_call(TL::ResolveUsername.new(username: username), TL::ResolvedPeer).body
11
+ rescue RpcError => e
12
+ raise unless e.error_message == 'USERNAME_NOT_OCCUPIED'
13
+
14
+ nil
15
+ end
16
+ end
17
+ end
18
+ end
@@ -16,14 +16,17 @@ module MTProto
16
16
  # it (`message`/`entities` are ignored). Note: server-gated (premium).
17
17
  # A `reply_to` (with optional `quote_text`/`quote_offset`) replies to a message,
18
18
  # optionally pinning the quoted fragment.
19
+ # `silent:` delivers without the recipient's notification sound — for a message that
20
+ # is not news to them: a transcript of what they just said, a repaint of something
21
+ # already on screen.
19
22
  def send_message(peer:, message: '', random_id: nil, reply_to: nil, entities: nil,
20
23
  parse_mode: nil, rich_markdown: nil, quote_text: nil, quote_offset: nil, no_webpage: false,
21
- reply_markup: nil)
24
+ reply_markup: nil, silent: false)
22
25
  message, entities = Markdown.parse(message) if parse_mode && entities.nil? && rich_markdown.nil?
23
26
  rpc_call(
24
27
  TL::SendMessage.new(peer: peer, message: message, random_id: random_id, reply_to: reply_to,
25
28
  entities: entities, rich_markdown: rich_markdown, quote_text: quote_text, quote_offset: quote_offset,
26
- no_webpage: no_webpage, reply_markup: reply_markup),
29
+ no_webpage: no_webpage, reply_markup: reply_markup, silent: silent),
27
30
  TL::UpdateShortSentMessage
28
31
  ).body
29
32
  end
@@ -8,11 +8,11 @@ module MTProto
8
8
  class Client
9
9
  class API
10
10
  # messages.setTyping — show a chat action in `peer`. `action` is a
11
- # SendMessageAction type symbol (default :typing) or a prebuilt object (pass
12
- # an object to carry upload progress). `top_msg_id` targets a forum topic /
13
- # thread. Returns the raw Bool reply, normally ignored.
11
+ # SendMessageAction type symbol (default :typing), or any object that
12
+ # serializes itself. `top_msg_id` targets a forum topic / thread. Returns
13
+ # the raw Bool reply, normally ignored.
14
14
  def set_typing(peer:, action: :typing, top_msg_id: nil)
15
- action = TL::SendMessageAction.new(action) unless action.is_a?(TL::SendMessageAction)
15
+ action = TL::SendMessageAction.new(action) if action.is_a?(Symbol)
16
16
  rpc_call(
17
17
  TL::SetTyping.new(peer: peer, action: action, top_msg_id: top_msg_id),
18
18
  TL::RawResponse
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../draft_stream'
4
+
5
+ module MTProto
6
+ class Client
7
+ class API
8
+ def stream_draft(peer:, random_id: nil, top_msg_id: nil)
9
+ DraftStream.new(api: self, peer: peer, random_id: random_id, top_msg_id: top_msg_id)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -17,7 +17,9 @@ require_relative 'api/get_channel_difference'
17
17
  require_relative 'api/get_bot_callback_answer'
18
18
  require_relative 'api/send_message'
19
19
  require_relative 'api/set_typing'
20
+ require_relative 'api/stream_draft'
20
21
  require_relative 'api/get_contacts'
22
+ require_relative 'api/resolve_username'
21
23
  require_relative 'api/export_bot_token'
22
24
  require_relative 'api/pin_message'
23
25
  require_relative 'api/unpin_all_messages'
@@ -29,7 +29,8 @@ module MTProto
29
29
  dh_prime = OpenSSL::BN.new(dh_prime_bytes, 2)
30
30
 
31
31
  auth_key_bn = g_a.mod_exp(b, dh_prime)
32
- auth_key_bn.to_s(2)
32
+ # BN#to_s(2) drops leading zero bytes, and roughly one shared secret in 256 has one.
33
+ auth_key_bn.to_s(2).rjust(dh_prime_bytes.bytesize, "\x00".b)
33
34
  end
34
35
 
35
36
  def generate_random_2048_bit_number
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'tl/objects/send_message_text_draft_action'
5
+
6
+ module MTProto
7
+ # Telegram caps the underlying call at 20 per 5s and 40 per 30s per peer, and
8
+ # breaching that returns FLOOD_WAIT and blocks what follows. A frame with no room
9
+ # left is therefore dropped rather than queued — the next frame carries newer text
10
+ # anyway.
11
+ class DraftStream
12
+ WINDOWS = [[5.0, 20], [30.0, 40]].freeze
13
+
14
+ attr_reader :random_id
15
+
16
+ def initialize(api:, peer:, random_id: nil, top_msg_id: nil, clock: nil)
17
+ @api = api
18
+ @peer = peer
19
+ @random_id = random_id || SecureRandom.random_number(2**63)
20
+ @top_msg_id = top_msg_id
21
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
22
+ @sent_at = []
23
+ end
24
+
25
+ def update(text, entities: [])
26
+ now = @clock.call
27
+ return false unless room?(now)
28
+
29
+ @api.set_typing(
30
+ peer: @peer, top_msg_id: @top_msg_id,
31
+ action: TL::SendMessageTextDraftAction.new(random_id: @random_id, text: text, entities: entities)
32
+ )
33
+ @sent_at << now
34
+ true
35
+ rescue RpcError
36
+ false
37
+ end
38
+
39
+ # Send the finished answer as an ordinary message, which also clears the draft.
40
+ def finish(text, **)
41
+ @api.send_message(peer: @peer, message: text, **)
42
+ end
43
+
44
+ private
45
+
46
+ def room?(now)
47
+ longest = WINDOWS.map(&:first).max
48
+ @sent_at.reject! { |at| now - at >= longest }
49
+ WINDOWS.all? { |span, cap| @sent_at.count { |at| now - at < span } < cap }
50
+ end
51
+ end
52
+ end
@@ -89,6 +89,7 @@ module MTProto
89
89
  CONTACTS_CONTACTS = 0xeae87e42
90
90
  CONTACTS_CONTACTS_NOT_MODIFIED = 0xb74ba9d2
91
91
  CONTACT = 0x145ade0b
92
+ CONTACTS_RESOLVED_PEER = 0x7f077ad9
92
93
 
93
94
  # Messages
94
95
  MESSAGES_GET_DIALOGS = 0xa0f4cb4f
@@ -27,13 +27,14 @@ module MTProto
27
27
  flags = data[offset, 4].unpack1('L<')
28
28
  offset += 4
29
29
 
30
- offset += 4 if flags.anybits?(1 << 0) # tmp_sessions
31
30
  offset += 4 if flags.anybits?(1 << 1) # otherwise_relogin_days
31
+ offset += 4 if flags.anybits?(1 << 0) # tmp_sessions
32
32
 
33
33
  offset = skip_tl_bytes(data, offset) if flags.anybits?(1 << 2) # future_auth_token
34
34
 
35
- # user#20b1422 flags:# flags2:# id:long access_hash:flags.0?long ...
36
- offset += 4 # user constructor
35
+ user_constructor = data[offset, 4].unpack1('L<')
36
+ offset += 4
37
+ raise UnexpectedConstructorError, user_constructor unless user_constructor == Constructors::USER
37
38
 
38
39
  user_flags = data[offset, 4].unpack1('L<')
39
40
  offset += 4
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../schema'
3
+ require_relative '../reader'
4
+ require_relative '../peers'
4
5
  require_relative 'message'
5
- require_relative 'dialogs'
6
6
 
7
7
  module MTProto
8
8
  module TL
@@ -18,8 +18,6 @@ module MTProto
18
18
  class ChannelDifference
19
19
  attr_reader :type, :final, :pts, :timeout, :new_messages, :chats, :users
20
20
 
21
- SCHEMA_PATH = File.expand_path('../../../../data/tl-schema.json', __dir__)
22
-
23
21
  # Channel messages ride in new_messages, but message-bearing updates can also
24
22
  # appear in other_updates — mine those too, like UpdatesDifference.
25
23
  UPDATE_NEW_MESSAGE = 0x1f2b0afd
@@ -35,10 +33,6 @@ module MTProto
35
33
  @users = users
36
34
  end
37
35
 
38
- def self.schema
39
- @schema ||= Schema.new(SCHEMA_PATH)
40
- end
41
-
42
36
  def self.deserialize(data)
43
37
  constructor = data[0, 4].unpack1('L<')
44
38
 
@@ -77,10 +71,11 @@ module MTProto
77
71
  offset += 4
78
72
  end
79
73
 
80
- messages, offset = parse_messages_vector(data, offset)
74
+ raw_messages, offset = Message.vector_at(data, offset)
75
+ messages = raw_messages.map(&:to_h)
81
76
  other_messages, offset = parse_other_updates(data, offset)
82
- chats, offset = Dialogs.chats_at(data, offset)
83
- users, = Dialogs.users_at(data, offset)
77
+ chats, offset = Peers.chats_at(data, offset)
78
+ users, = Peers.users_at(data, offset)
84
79
 
85
80
  new(
86
81
  type: :difference,
@@ -107,10 +102,11 @@ module MTProto
107
102
  offset += 4
108
103
  end
109
104
 
110
- offset = schema.skip(data, offset) # dialog:Dialog
111
- messages, offset = parse_messages_vector(data, offset)
112
- chats, offset = Dialogs.chats_at(data, offset)
113
- users, = Dialogs.users_at(data, offset)
105
+ offset = Reader.schema.skip(data, offset) # dialog:Dialog
106
+ raw_messages, offset = Message.vector_at(data, offset)
107
+ messages = raw_messages.map(&:to_h)
108
+ chats, offset = Peers.chats_at(data, offset)
109
+ users, = Peers.users_at(data, offset)
114
110
 
115
111
  new(
116
112
  type: :too_long,
@@ -122,24 +118,9 @@ module MTProto
122
118
  )
123
119
  end
124
120
 
125
- def parse_messages_vector(data, offset)
126
- offset += 4 # vector constructor
127
- count = data[offset, 4].unpack1('L<')
128
- offset += 4
129
-
130
- messages = []
131
- count.times do
132
- constructor = data[offset, 4].unpack1('L<')
133
- msg = Message.deserialize(data, offset, constructor)
134
- messages << msg.to_h if msg
135
- offset = schema.skip(data, offset)
136
- end
137
- [messages, offset]
138
- end
139
-
140
121
  # other_updates:Vector<Update> — mine message-bearing updates
141
122
  # (updateNewMessage/updateNewChannelMessage: the ctor, then an inner Message,
142
- # then pts/pts_count), and advance the rest via schema.
123
+ # then pts/pts_count), and advance the rest via Reader.schema.
143
124
  def parse_other_updates(data, offset)
144
125
  offset += 4 # vector constructor
145
126
  count = data[offset, 4].unpack1('L<')
@@ -153,7 +134,7 @@ module MTProto
153
134
  msg = Message.deserialize(data, inner_off, data[inner_off, 4].unpack1('L<'))
154
135
  messages << msg.to_h if msg
155
136
  end
156
- offset = schema.skip(data, offset)
137
+ offset = Reader.schema.skip(data, offset)
157
138
  end
158
139
  [messages, offset]
159
140
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../schema'
3
+ require_relative '../reader'
4
+ require_relative '../peers'
4
5
 
5
6
  module MTProto
6
7
  module TL
@@ -8,8 +9,6 @@ module MTProto
8
9
  Contact = Struct.new(:user_id, :mutual, keyword_init: true)
9
10
  User = Struct.new(:id, :first_name, :last_name, :access_hash, keyword_init: true)
10
11
 
11
- SCHEMA_PATH = File.expand_path('../../../../data/tl-schema.json', __dir__)
12
-
13
12
  attr_reader :contacts, :saved_count, :users, :not_modified
14
13
 
15
14
  def initialize(contacts: [], saved_count: 0, users: [], not_modified: false)
@@ -23,10 +22,6 @@ module MTProto
23
22
  @not_modified
24
23
  end
25
24
 
26
- def self.schema
27
- @schema ||= Schema.new(SCHEMA_PATH)
28
- end
29
-
30
25
  def self.deserialize(data)
31
26
  constructor = data[0, 4].unpack1('L<')
32
27
  return new(not_modified: true) if constructor == Constructors::CONTACTS_CONTACTS_NOT_MODIFIED
@@ -41,9 +36,6 @@ module MTProto
41
36
  new(contacts: contacts, saved_count: saved_count, users: users)
42
37
  end
43
38
 
44
- USER_EMPTY = 0xd3bc4b7a
45
- private_constant :USER_EMPTY
46
-
47
39
  class << self
48
40
  private
49
41
 
@@ -68,87 +60,13 @@ module MTProto
68
60
  [contacts, offset]
69
61
  end
70
62
 
71
- # User parsing mirrors TL::Dialogs.parse_user (user#31774388). Kept private here
72
- # so this class can be used without instantiating Dialogs.
73
63
  def parse_users_vector(data, offset)
74
- offset += 4 # vector tag
75
- count = data[offset, 4].unpack1('L<')
76
- offset += 4
77
-
78
- users = []
79
- count.times do
80
- user, offset = parse_user(data, offset)
81
- users << user if user
82
- end
83
- [users, offset]
84
- end
85
-
86
- def parse_user(data, offset)
87
- constructor = data[offset, 4].unpack1('L<')
88
- offset += 4
89
-
90
- if constructor == USER_EMPTY
91
- offset += 8
92
- return [nil, offset]
64
+ records, offset = Peers.users_at(data, offset)
65
+ mapped = records.map do |user|
66
+ User.new(id: user[:id], first_name: user[:first_name],
67
+ last_name: user[:last_name], access_hash: user[:access_hash])
93
68
  end
94
-
95
- raise "Unknown User constructor: 0x#{constructor.to_s(16)}" unless constructor == Constructors::USER
96
-
97
- flags = data[offset, 4].unpack1('L<')
98
- offset += 4
99
- flags2 = data[offset, 4].unpack1('L<')
100
- offset += 4
101
- id = data[offset, 8].unpack1('Q<')
102
- offset += 8
103
- access_hash = nil
104
- if flags.anybits?(1 << 0)
105
- access_hash = data[offset, 8].unpack1('Q<')
106
- offset += 8
107
- end
108
-
109
- first_name = nil
110
- first_name, offset = read_tl_string(data, offset) if flags.anybits?(1 << 1)
111
- last_name = nil
112
- last_name, offset = read_tl_string(data, offset) if flags.anybits?(1 << 2)
113
-
114
- offset = skip_tl_string(data, offset) if flags.anybits?(1 << 3) # username
115
- offset = skip_tl_string(data, offset) if flags.anybits?(1 << 4) # phone
116
- offset = schema.skip(data, offset) if flags.anybits?(1 << 5) # photo
117
- offset = schema.skip(data, offset) if flags.anybits?(1 << 6) # status
118
- offset += 4 if flags.anybits?(1 << 14) # bot_info_version
119
- offset = schema.skip_vector(data, offset) { |d, o| schema.skip(d, o) } if flags.anybits?(1 << 18)
120
- offset = skip_tl_string(data, offset) if flags.anybits?(1 << 19) # bot_inline_placeholder
121
- offset = skip_tl_string(data, offset) if flags.anybits?(1 << 22) # lang_code
122
- offset = schema.skip(data, offset) if flags.anybits?(1 << 30) # emoji_status
123
- offset = schema.skip_vector(data, offset) { |d, o| schema.skip(d, o) } if flags2.anybits?(1 << 0)
124
- offset += 4 if flags2.anybits?(1 << 5) # stories_max_id
125
- offset = schema.skip(data, offset) if flags2.anybits?(1 << 8) # color
126
- offset = schema.skip(data, offset) if flags2.anybits?(1 << 9) # profile_color
127
- offset += 4 if flags2.anybits?(1 << 12) # bot_active_users
128
- offset += 8 if flags2.anybits?(1 << 14) # bot_verification_icon
129
- offset += 8 if flags2.anybits?(1 << 15) # send_paid_messages_stars
130
-
131
- [User.new(id: id, first_name: first_name, last_name: last_name, access_hash: access_hash), offset]
132
- end
133
-
134
- def read_tl_string(data, offset)
135
- first_byte = data.getbyte(offset)
136
- if first_byte == 254
137
- len = data.getbyte(offset + 1) |
138
- (data.getbyte(offset + 2) << 8) |
139
- (data.getbyte(offset + 3) << 16)
140
- total = 4 + len
141
- else
142
- len = first_byte
143
- total = 1 + len
144
- end
145
- padding = (4 - (total % 4)) % 4
146
- [data[offset + (total - len), len].force_encoding('UTF-8'), offset + total + padding]
147
- end
148
-
149
- def skip_tl_string(data, offset)
150
- _, new_offset = read_tl_string(data, offset)
151
- new_offset
69
+ [mapped, offset]
152
70
  end
153
71
  end
154
72
  end