mtproto 0.0.14 → 0.0.17
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/data/tl-schema.json +47158 -42684
- data/lib/mtproto/auth_key_generator.rb +2 -2
- data/lib/mtproto/client/api/export_authorization.rb +17 -0
- data/lib/mtproto/client/api/import_authorization.rb +23 -0
- data/lib/mtproto/client/api.rb +2 -0
- data/lib/mtproto/client/rpc.rb +33 -0
- data/lib/mtproto/client.rb +54 -1
- data/lib/mtproto/file_downloader.rb +122 -0
- data/lib/mtproto/tl/constructor_names.rb +324 -109
- data/lib/mtproto/tl/constructors.rb +16 -8
- data/lib/mtproto/tl/objects/bot_command_scope.rb +81 -0
- data/lib/mtproto/tl/objects/channels_join_channel.rb +1 -1
- data/lib/mtproto/tl/objects/channels_update_username.rb +42 -0
- data/lib/mtproto/tl/objects/contacts.rb +1 -1
- data/lib/mtproto/tl/objects/create_bot.rb +54 -0
- data/lib/mtproto/tl/objects/dialogs.rb +25 -10
- data/lib/mtproto/tl/objects/edit_access_settings.rb +46 -0
- data/lib/mtproto/tl/objects/export_authorization.rb +17 -0
- data/lib/mtproto/tl/objects/export_bot_token.rb +32 -0
- data/lib/mtproto/tl/objects/exported_authorization.rb +32 -0
- data/lib/mtproto/tl/objects/exported_bot_token.rb +27 -0
- data/lib/mtproto/tl/objects/forward_messages.rb +12 -3
- data/lib/mtproto/tl/objects/get_access_settings.rb +28 -0
- data/lib/mtproto/tl/objects/get_bot_callback_answer.rb +67 -0
- data/lib/mtproto/tl/objects/get_bot_commands.rb +46 -0
- data/lib/mtproto/tl/objects/get_file.rb +10 -2
- data/lib/mtproto/tl/objects/import_authorization.rb +38 -0
- data/lib/mtproto/tl/objects/input_keyboard_button_request_peer.rb +54 -0
- data/lib/mtproto/tl/objects/keyboard_button_callback.rb +50 -0
- data/lib/mtproto/tl/objects/message.rb +97 -21
- data/lib/mtproto/tl/objects/messages.rb +7 -74
- data/lib/mtproto/tl/objects/messages_get_messages.rb +26 -0
- data/lib/mtproto/tl/objects/reply_inline_markup.rb +30 -0
- data/lib/mtproto/tl/objects/reply_keyboard_markup.rb +35 -0
- data/lib/mtproto/tl/objects/request_peer_type_create_bot.rb +47 -0
- data/lib/mtproto/tl/objects/reset_bot_commands.rb +45 -0
- data/lib/mtproto/tl/objects/send_bot_requested_peer.rb +51 -0
- data/lib/mtproto/tl/objects/send_media.rb +87 -4
- data/lib/mtproto/tl/objects/send_message.rb +7 -2
- data/lib/mtproto/tl/objects/send_message_action.rb +48 -0
- data/lib/mtproto/tl/objects/set_bot_callback_answer.rb +54 -0
- data/lib/mtproto/tl/objects/set_bot_commands.rb +53 -0
- data/lib/mtproto/tl/objects/set_bot_guest_chat_result.rb +84 -0
- data/lib/mtproto/tl/objects/set_bot_info.rb +64 -0
- data/lib/mtproto/tl/objects/set_typing.rb +49 -0
- data/lib/mtproto/tl/objects/update_status.rb +24 -0
- data/lib/mtproto/tl/objects/updates.rb +117 -0
- data/lib/mtproto/tl/objects/updates_difference.rb +45 -69
- data/lib/mtproto/tl/objects/upload_profile_photo.rb +65 -0
- data/lib/mtproto/tl/reader.rb +188 -0
- data/lib/mtproto/transport/abridged_packet_codec.rb +5 -1
- data/lib/mtproto/unencrypted_message.rb +39 -0
- data/lib/mtproto/version.rb +1 -1
- data/lib/mtproto.rb +4 -1
- data/scripts/gen_constructor_names.rb +72 -0
- data/scripts/tl_to_json.rb +72 -0
- data/scripts/verify_ids.rb +33 -0
- metadata +38 -2
- data/lib/mtproto/message/message.rb +0 -85
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../reader'
|
|
4
|
+
require_relative 'message'
|
|
5
|
+
require_relative 'dialogs'
|
|
6
|
+
|
|
7
|
+
module MTProto
|
|
8
|
+
module TL
|
|
9
|
+
# The live `updates`#74ae4240 / `updatesCombined`#725b04c3 push container a bot
|
|
10
|
+
# receives in real time (as opposed to the `updates.getDifference` reply
|
|
11
|
+
# UpdatesDifference handles). Mines the message-bearing updates into structured
|
|
12
|
+
# messages — including any media — and returns the senders in users[] for the
|
|
13
|
+
# peer/access_hash cache. Each update is advanced via Schema#skip, so an update
|
|
14
|
+
# type we don't model never drops the ones we do.
|
|
15
|
+
class Updates
|
|
16
|
+
attr_reader :messages, :users, :chats, :callback_queries, :guest_queries
|
|
17
|
+
|
|
18
|
+
UPDATE_NEW_MESSAGE = 0x1f2b0afd
|
|
19
|
+
UPDATE_NEW_CHANNEL_MESSAGE = 0x62ba04d9
|
|
20
|
+
UPDATE_BOT_CALLBACK_QUERY = 0xb9cfc48d
|
|
21
|
+
UPDATE_BOT_GUEST_CHAT_QUERY = 0xcdd4093d
|
|
22
|
+
|
|
23
|
+
def initialize(messages:, users:, chats:, callback_queries: [], guest_queries: [])
|
|
24
|
+
@messages = messages
|
|
25
|
+
@users = users
|
|
26
|
+
@chats = chats
|
|
27
|
+
@callback_queries = callback_queries
|
|
28
|
+
@guest_queries = guest_queries
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# updates: updates:Vector<Update> users:Vector<User> chats:Vector<Chat> ...
|
|
32
|
+
# (updatesCombined adds a seq_start before seq; both share this prefix).
|
|
33
|
+
def self.deserialize(data)
|
|
34
|
+
constructor = data[0, 4].unpack1('L<')
|
|
35
|
+
unless [Constructors::UPDATES, Constructors::UPDATES_COMBINED].include?(constructor)
|
|
36
|
+
return new(messages: [], users: [], chats: [], callback_queries: [], guest_queries: [])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
messages, callback_queries, guest_queries, offset = parse_updates_vector(data, 4)
|
|
40
|
+
users, offset = Dialogs.users_at(data, offset)
|
|
41
|
+
chats, = Dialogs.chats_at(data, offset)
|
|
42
|
+
new(messages: messages, users: users, chats: chats,
|
|
43
|
+
callback_queries: callback_queries, guest_queries: guest_queries)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.parse_updates_vector(data, offset)
|
|
47
|
+
offset += 4 # vector constructor
|
|
48
|
+
count = data[offset, 4].unpack1('L<')
|
|
49
|
+
offset += 4
|
|
50
|
+
|
|
51
|
+
messages = []
|
|
52
|
+
callback_queries = []
|
|
53
|
+
guest_queries = []
|
|
54
|
+
count.times do
|
|
55
|
+
ctor = data[offset, 4].unpack1('L<')
|
|
56
|
+
if [UPDATE_NEW_MESSAGE, UPDATE_NEW_CHANNEL_MESSAGE].include?(ctor)
|
|
57
|
+
inner = offset + 4
|
|
58
|
+
msg = Message.deserialize(data, inner, data[inner, 4].unpack1('L<'))
|
|
59
|
+
messages << msg.to_h if msg
|
|
60
|
+
elsif ctor == UPDATE_BOT_CALLBACK_QUERY
|
|
61
|
+
callback_queries << extract_callback_query(data, offset)
|
|
62
|
+
elsif ctor == UPDATE_BOT_GUEST_CHAT_QUERY
|
|
63
|
+
guest_queries << extract_guest_query(data, offset)
|
|
64
|
+
end
|
|
65
|
+
offset = Reader.schema.skip(data, offset)
|
|
66
|
+
end
|
|
67
|
+
[messages, callback_queries, guest_queries, offset]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer
|
|
71
|
+
# msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string
|
|
72
|
+
# A bot receives this when a user taps an inline callback button; the data is
|
|
73
|
+
# the button's callback payload. The outer loop advances past it via Schema#skip.
|
|
74
|
+
def self.extract_callback_query(data, offset)
|
|
75
|
+
offset += 4 # constructor
|
|
76
|
+
flags = data[offset, 4].unpack1('L<')
|
|
77
|
+
offset += 4
|
|
78
|
+
query_id = data[offset, 8].unpack1('Q<')
|
|
79
|
+
offset += 8
|
|
80
|
+
user_id = data[offset, 8].unpack1('Q<')
|
|
81
|
+
offset += 8
|
|
82
|
+
peer_type, peer_id, offset = Reader.parse_peer(data, offset)
|
|
83
|
+
msg_id = data[offset, 4].unpack1('l<')
|
|
84
|
+
offset += 4
|
|
85
|
+
offset += 8 # chat_instance (unused)
|
|
86
|
+
payload = nil
|
|
87
|
+
payload, = Reader.read_tl_bytes(data, offset) if flags.anybits?(1 << 0)
|
|
88
|
+
|
|
89
|
+
{ query_id: query_id, user_id: user_id, peer_type: peer_type, peer_id: peer_id,
|
|
90
|
+
msg_id: msg_id, data: payload }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# updateBotGuestChatQuery#cdd4093d flags:# query_id:long message:Message
|
|
94
|
+
# reference_messages:flags.0?Vector<Message> qts:int
|
|
95
|
+
# A guest-mode bot receives this when a user @-mentions it in a chat it is NOT a
|
|
96
|
+
# member of; the inner message is the mentioning message (sender in from_id). The
|
|
97
|
+
# bot answers once with messages.setBotGuestChatResult(query_id, result). The outer
|
|
98
|
+
# loop advances past the whole update via Schema#skip, so this only needs to read
|
|
99
|
+
# the fields we surface (query_id + the parsed message + qts).
|
|
100
|
+
def self.extract_guest_query(data, offset)
|
|
101
|
+
offset += 4 # constructor
|
|
102
|
+
flags = data[offset, 4].unpack1('L<')
|
|
103
|
+
offset += 4
|
|
104
|
+
query_id = data[offset, 8].unpack1('Q<')
|
|
105
|
+
offset += 8
|
|
106
|
+
msg = Message.deserialize(data, offset, data[offset, 4].unpack1('L<'))
|
|
107
|
+
offset = Reader.schema.skip(data, offset) # message:Message
|
|
108
|
+
if flags.anybits?(1 << 0) # reference_messages:Vector<Message>
|
|
109
|
+
offset = Reader.schema.skip_vector(data, offset) { |d, o| Reader.schema.skip(d, o) }
|
|
110
|
+
end
|
|
111
|
+
qts = data[offset, 4].unpack1('l<')
|
|
112
|
+
|
|
113
|
+
{ query_id: query_id, message: msg&.to_h, qts: qts }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../schema'
|
|
4
|
+
require_relative 'message'
|
|
5
|
+
require_relative 'dialogs'
|
|
6
|
+
require_relative 'updates'
|
|
4
7
|
|
|
5
8
|
module MTProto
|
|
6
9
|
module TL
|
|
7
10
|
class UpdatesDifference
|
|
8
|
-
attr_reader :type, :date, :seq, :new_messages, :pts, :state
|
|
11
|
+
attr_reader :type, :date, :seq, :new_messages, :pts, :state, :chats, :users, :guest_queries
|
|
9
12
|
|
|
10
13
|
SCHEMA_PATH = File.expand_path('../../../../data/tl-schema.json', __dir__)
|
|
11
14
|
|
|
12
|
-
def initialize(type:, date: nil, seq: nil, new_messages: [], pts: nil, state: nil
|
|
15
|
+
def initialize(type:, date: nil, seq: nil, new_messages: [], pts: nil, state: nil, chats: [], users: [],
|
|
16
|
+
guest_queries: [])
|
|
13
17
|
@type = type
|
|
14
18
|
@date = date
|
|
15
19
|
@seq = seq
|
|
16
20
|
@new_messages = new_messages
|
|
17
21
|
@pts = pts
|
|
18
22
|
@state = state
|
|
23
|
+
@chats = chats
|
|
24
|
+
@users = users
|
|
25
|
+
@guest_queries = guest_queries
|
|
19
26
|
end
|
|
20
27
|
|
|
21
28
|
def self.schema
|
|
@@ -38,9 +45,8 @@ module MTProto
|
|
|
38
45
|
end
|
|
39
46
|
end
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
MESSAGE_EMPTY = 0x90a6ca84
|
|
48
|
+
UPDATE_NEW_MESSAGE = 0x1f2b0afd
|
|
49
|
+
UPDATE_NEW_CHANNEL_MESSAGE = 0x62ba04d9
|
|
44
50
|
|
|
45
51
|
class << self
|
|
46
52
|
private
|
|
@@ -53,95 +59,65 @@ module MTProto
|
|
|
53
59
|
)
|
|
54
60
|
end
|
|
55
61
|
|
|
56
|
-
# updates.difference: new_messages new_encrypted_messages other_updates chats users state
|
|
62
|
+
# updates.difference: new_messages new_encrypted_messages other_updates chats users state.
|
|
63
|
+
# Channel/supergroup messages arrive in other_updates as
|
|
64
|
+
# updateNewChannelMessage (not in new_messages), so we mine those too; and
|
|
65
|
+
# chats/users carry the peers' access_hashes for the bot's peer cache.
|
|
57
66
|
def parse_difference(data, constructor)
|
|
58
67
|
offset = 4
|
|
59
68
|
|
|
60
69
|
messages, offset = parse_messages_vector(data, offset)
|
|
61
70
|
offset = schema.skip_vector(data, offset) # new_encrypted_messages
|
|
62
|
-
offset =
|
|
63
|
-
offset =
|
|
64
|
-
offset =
|
|
71
|
+
other_messages, guest_queries, offset = parse_other_updates(data, offset)
|
|
72
|
+
chats, offset = Dialogs.chats_at(data, offset)
|
|
73
|
+
users, offset = Dialogs.users_at(data, offset)
|
|
65
74
|
state = UpdatesState.deserialize(data[offset..])
|
|
66
75
|
|
|
67
76
|
new(
|
|
68
77
|
type: constructor == Constructors::UPDATES_DIFFERENCE ? :difference : :slice,
|
|
69
|
-
new_messages: messages,
|
|
70
|
-
|
|
78
|
+
new_messages: messages + other_messages,
|
|
79
|
+
guest_queries: guest_queries,
|
|
80
|
+
chats: chats, users: users, state: state
|
|
71
81
|
)
|
|
72
82
|
end
|
|
73
83
|
|
|
74
|
-
|
|
84
|
+
# other_updates: Vector<Update>. Mine the message-bearing updates
|
|
85
|
+
# (updateNewMessage / updateNewChannelMessage — each is the update ctor,
|
|
86
|
+
# then an inner Message, then pts/pts_count); advance the rest via schema.
|
|
87
|
+
def parse_other_updates(data, offset)
|
|
75
88
|
offset += 4 # vector constructor
|
|
76
89
|
count = data[offset, 4].unpack1('L<')
|
|
77
90
|
offset += 4
|
|
78
91
|
|
|
79
92
|
messages = []
|
|
93
|
+
guest_queries = []
|
|
80
94
|
count.times do
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
ctor = data[offset, 4].unpack1('L<')
|
|
96
|
+
if [UPDATE_NEW_MESSAGE, UPDATE_NEW_CHANNEL_MESSAGE].include?(ctor)
|
|
97
|
+
inner_off = offset + 4
|
|
98
|
+
msg = Message.deserialize(data, inner_off, data[inner_off, 4].unpack1('L<'))
|
|
99
|
+
messages << msg.to_h if msg
|
|
100
|
+
elsif ctor == Updates::UPDATE_BOT_GUEST_CHAT_QUERY
|
|
101
|
+
guest_queries << Updates.extract_guest_query(data, offset)
|
|
102
|
+
end
|
|
84
103
|
offset = schema.skip(data, offset)
|
|
85
104
|
end
|
|
86
|
-
[messages, offset]
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def extract_message(data, offset, constructor)
|
|
90
|
-
return unless constructor == MESSAGE_CONSTRUCTOR
|
|
91
|
-
|
|
92
|
-
offset += 4 # constructor
|
|
93
|
-
flags = data[offset, 4].unpack1('L<')
|
|
94
|
-
offset += 4
|
|
95
|
-
flags2 = data[offset, 4].unpack1('L<')
|
|
96
|
-
offset += 4
|
|
97
|
-
id = data[offset, 4].unpack1('l<')
|
|
98
|
-
offset += 4
|
|
99
|
-
|
|
100
|
-
offset = schema.skip(data, offset) if flags.anybits?(1 << 8) # from_id (Peer)
|
|
101
|
-
offset += 4 if flags.anybits?(1 << 29) # from_boosts_applied
|
|
102
|
-
peer_type, peer_id, offset = parse_peer(data, offset)
|
|
103
|
-
offset = schema.skip(data, offset) if flags.anybits?(1 << 28) # saved_peer_id
|
|
104
|
-
offset = schema.skip(data, offset) if flags.anybits?(1 << 2) # fwd_from
|
|
105
|
-
offset += 8 if flags.anybits?(1 << 11) # via_bot_id
|
|
106
|
-
offset += 8 if flags2.anybits?(1 << 0) # via_business_bot_id
|
|
107
|
-
offset = schema.skip(data, offset) if flags.anybits?(1 << 3) # reply_to
|
|
108
|
-
|
|
109
|
-
date = data[offset, 4].unpack1('L<')
|
|
110
|
-
offset += 4
|
|
111
|
-
message_text, = read_tl_string(data, offset)
|
|
112
|
-
|
|
113
|
-
{ id: id, date: date, message: message_text, peer_type: peer_type, peer_id: peer_id }
|
|
105
|
+
[messages, guest_queries, offset]
|
|
114
106
|
end
|
|
115
107
|
|
|
116
|
-
def
|
|
117
|
-
|
|
108
|
+
def parse_messages_vector(data, offset)
|
|
109
|
+
offset += 4 # vector constructor
|
|
110
|
+
count = data[offset, 4].unpack1('L<')
|
|
118
111
|
offset += 4
|
|
119
112
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
[:channel, data[offset, 8].unpack1('Q<'), offset + 8]
|
|
127
|
-
else
|
|
128
|
-
raise "Unknown peer constructor: 0x#{constructor.to_s(16)}"
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def read_tl_string(data, offset)
|
|
133
|
-
first_byte = data.getbyte(offset)
|
|
134
|
-
if first_byte == 254
|
|
135
|
-
len = data.getbyte(offset + 1) |
|
|
136
|
-
(data.getbyte(offset + 2) << 8) |
|
|
137
|
-
(data.getbyte(offset + 3) << 16)
|
|
138
|
-
total = 4 + len
|
|
139
|
-
else
|
|
140
|
-
len = first_byte
|
|
141
|
-
total = 1 + len
|
|
113
|
+
messages = []
|
|
114
|
+
count.times do
|
|
115
|
+
constructor = data[offset, 4].unpack1('L<')
|
|
116
|
+
msg = Message.deserialize(data, offset, constructor)
|
|
117
|
+
messages << msg.to_h if msg
|
|
118
|
+
offset = schema.skip(data, offset)
|
|
142
119
|
end
|
|
143
|
-
|
|
144
|
-
[data[offset + (total - len), len].force_encoding('UTF-8'), offset + total + padding]
|
|
120
|
+
[messages, offset]
|
|
145
121
|
end
|
|
146
122
|
end
|
|
147
123
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MTProto
|
|
4
|
+
module TL
|
|
5
|
+
class UploadProfilePhoto
|
|
6
|
+
include Binary
|
|
7
|
+
|
|
8
|
+
CONSTRUCTOR = 0x0388a3b5
|
|
9
|
+
INPUT_USER = 0xf21158c6
|
|
10
|
+
INPUT_USER_SELF = 0xf7c1b13f
|
|
11
|
+
INPUT_FILE = 0xf52ff27f
|
|
12
|
+
|
|
13
|
+
# bot: { id:, access_hash: } or { type: :self }; nil targets the calling bot
|
|
14
|
+
# file: { id:, parts:, name:, md5_checksum: }
|
|
15
|
+
def initialize(file:, bot: nil)
|
|
16
|
+
@bot = bot
|
|
17
|
+
@file = file
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def serialize
|
|
21
|
+
flags = (1 << 0) # file
|
|
22
|
+
flags |= (1 << 5) if @bot
|
|
23
|
+
|
|
24
|
+
result = u32_b(CONSTRUCTOR)
|
|
25
|
+
result += u32_b(flags)
|
|
26
|
+
result += serialize_input_user(@bot) if @bot
|
|
27
|
+
result += serialize_input_file(@file)
|
|
28
|
+
result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def serialize_input_user(user)
|
|
34
|
+
if user[:type] == :self
|
|
35
|
+
u32_b(INPUT_USER_SELF)
|
|
36
|
+
else
|
|
37
|
+
u32_b(INPUT_USER) + u64_b(user[:id]) + u64_b(user[:access_hash] || 0)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def serialize_input_file(file)
|
|
42
|
+
u32_b(INPUT_FILE) +
|
|
43
|
+
u64_b(file[:id]) +
|
|
44
|
+
u32_b(file[:parts]) +
|
|
45
|
+
serialize_tl_string(file[:name].to_s) +
|
|
46
|
+
serialize_tl_string(file[:md5_checksum].to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def serialize_tl_string(str)
|
|
50
|
+
bytes = str.encode('UTF-8').bytes
|
|
51
|
+
length = bytes.length
|
|
52
|
+
if length <= 253
|
|
53
|
+
[length] + bytes + padding(length + 1)
|
|
54
|
+
else
|
|
55
|
+
[254] + u32_b(length)[0, 3] + bytes + padding(length + 4)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def padding(current_length)
|
|
60
|
+
pad_length = (4 - (current_length % 4)) % 4
|
|
61
|
+
[0] * pad_length
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'schema'
|
|
4
|
+
|
|
5
|
+
module MTProto
|
|
6
|
+
module TL
|
|
7
|
+
# Shared read-only TL primitives — peer/string/bytes/media/document reads and
|
|
8
|
+
# the schema handle — reused by the message parser (TL::Message) and the other
|
|
9
|
+
# update parsers. Each method only READS; advancing past a whole object is the
|
|
10
|
+
# caller's job via `Schema#skip`, so any unmodelled tail is sized by the schema.
|
|
11
|
+
module Reader
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
SCHEMA_PATH = File.expand_path('../../../data/tl-schema.json', __dir__)
|
|
15
|
+
|
|
16
|
+
MESSAGE_REPLY_HEADER = 0x1b97dd66
|
|
17
|
+
MEDIA_PHOTO = 0xe216eb63
|
|
18
|
+
MEDIA_DOCUMENT = 0x52d8ccd9
|
|
19
|
+
DOCUMENT = 0x8fd4c4d8
|
|
20
|
+
PHOTO = 0xfb197a65
|
|
21
|
+
DOCUMENT_ATTRIBUTE_AUDIO = 0x9852f9c6
|
|
22
|
+
DOCUMENT_ATTRIBUTE_FILENAME = 0x15590068
|
|
23
|
+
|
|
24
|
+
def schema
|
|
25
|
+
@schema ||= Schema.new(SCHEMA_PATH)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse_peer(data, offset)
|
|
29
|
+
constructor = data[offset, 4].unpack1('L<')
|
|
30
|
+
offset += 4
|
|
31
|
+
|
|
32
|
+
case constructor
|
|
33
|
+
when Constructors::PEER_USER
|
|
34
|
+
[:user, data[offset, 8].unpack1('Q<'), offset + 8]
|
|
35
|
+
when Constructors::PEER_CHAT
|
|
36
|
+
[:chat, data[offset, 8].unpack1('Q<'), offset + 8]
|
|
37
|
+
when Constructors::PEER_CHANNEL
|
|
38
|
+
[:channel, data[offset, 8].unpack1('Q<'), offset + 8]
|
|
39
|
+
else
|
|
40
|
+
raise "Unknown peer constructor: 0x#{constructor.to_s(16)}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# messageReplyHeader: ctor, flags, reply_to_msg_id:flags.4?int (first payload
|
|
45
|
+
# field). A plain message in a forum topic carries this header (forum_topic
|
|
46
|
+
# flags.3) but is a reply only when it targets a message in the topic
|
|
47
|
+
# (reply_to_top_id flags.1). => [reply_to_msg_id, is_reply]
|
|
48
|
+
def parse_reply_to(data, offset)
|
|
49
|
+
return [nil, false] unless data[offset, 4].unpack1('L<') == MESSAGE_REPLY_HEADER
|
|
50
|
+
|
|
51
|
+
io = offset + 4
|
|
52
|
+
flags = data[io, 4].unpack1('L<')
|
|
53
|
+
io += 4
|
|
54
|
+
forum_topic = flags.anybits?(1 << 3)
|
|
55
|
+
msg_id = flags.anybits?(1 << 4) ? data[io, 4].unpack1('l<') : nil
|
|
56
|
+
[msg_id, forum_topic ? flags.anybits?(1 << 1) : true]
|
|
57
|
+
rescue StandardError
|
|
58
|
+
[nil, false]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Classify message media into :photo / :video / :audio / :document, or nil.
|
|
62
|
+
def parse_media(data, offset)
|
|
63
|
+
case data[offset, 4].unpack1('L<')
|
|
64
|
+
when MEDIA_PHOTO
|
|
65
|
+
:photo
|
|
66
|
+
when MEDIA_DOCUMENT
|
|
67
|
+
document_kind(data[offset + 4, 4].unpack1('L<'))
|
|
68
|
+
end
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# messageMediaDocument flags: video flags.6 / round flags.7 / voice flags.8.
|
|
74
|
+
def document_kind(flags)
|
|
75
|
+
return :video if flags.anybits?(1 << 6) || flags.anybits?(1 << 7)
|
|
76
|
+
return :audio if flags.anybits?(1 << 8)
|
|
77
|
+
|
|
78
|
+
:document
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# When the media is messageMediaDocument, pull the document's downloadable
|
|
82
|
+
# fields (file location + audio metadata); nil for any other media. The
|
|
83
|
+
# thumb vectors before dc_id are sized by the schema, so this reaches dc_id
|
|
84
|
+
# and the attribute list correctly regardless of thumbnails.
|
|
85
|
+
def parse_document(data, offset)
|
|
86
|
+
return unless data[offset, 4].unpack1('L<') == MEDIA_DOCUMENT
|
|
87
|
+
|
|
88
|
+
media_flags = data[offset + 4, 4].unpack1('L<')
|
|
89
|
+
return unless media_flags.anybits?(1 << 0) # document present
|
|
90
|
+
|
|
91
|
+
offset += 8
|
|
92
|
+
return unless data[offset, 4].unpack1('L<') == DOCUMENT
|
|
93
|
+
|
|
94
|
+
offset += 4
|
|
95
|
+
flags = data[offset, 4].unpack1('L<')
|
|
96
|
+
offset += 4
|
|
97
|
+
id = data[offset, 8].unpack1('q<')
|
|
98
|
+
access_hash = data[offset + 8, 8].unpack1('q<')
|
|
99
|
+
offset += 16
|
|
100
|
+
file_reference, offset = read_tl_bytes(data, offset)
|
|
101
|
+
offset += 4 # date
|
|
102
|
+
mime_type, offset = read_tl_string(data, offset)
|
|
103
|
+
size = data[offset, 8].unpack1('q<')
|
|
104
|
+
offset += 8
|
|
105
|
+
offset = schema.skip_vector(data, offset) if flags.anybits?(1 << 0) # thumbs
|
|
106
|
+
offset = schema.skip_vector(data, offset) if flags.anybits?(1 << 1) # video_thumbs
|
|
107
|
+
dc_id = data[offset, 4].unpack1('l<')
|
|
108
|
+
offset += 4
|
|
109
|
+
voice, duration, file_name = parse_document_attributes(data, offset)
|
|
110
|
+
|
|
111
|
+
{ id: id, access_hash: access_hash, file_reference: file_reference, dc_id: dc_id,
|
|
112
|
+
mime_type: mime_type, size: size, voice: voice, duration: duration, file_name: file_name }
|
|
113
|
+
rescue StandardError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# When the media is messageMediaPhoto, pull the photo's input reference
|
|
118
|
+
# (id/access_hash/file_reference) so it can be re-sent via inputMediaPhoto —
|
|
119
|
+
# e.g. re-posting a guest-mention's photo, where forwarding is not allowed.
|
|
120
|
+
# has_stickers is a true-flag (no body), so id starts right after photo's flags.
|
|
121
|
+
def parse_photo(data, offset)
|
|
122
|
+
return unless data[offset, 4].unpack1('L<') == MEDIA_PHOTO
|
|
123
|
+
|
|
124
|
+
media_flags = data[offset + 4, 4].unpack1('L<')
|
|
125
|
+
return unless media_flags.anybits?(1 << 0) # photo present
|
|
126
|
+
|
|
127
|
+
offset += 8 # messageMediaPhoto ctor + flags
|
|
128
|
+
return unless data[offset, 4].unpack1('L<') == PHOTO
|
|
129
|
+
|
|
130
|
+
offset += 8 # photo ctor + flags
|
|
131
|
+
id = data[offset, 8].unpack1('q<')
|
|
132
|
+
access_hash = data[offset + 8, 8].unpack1('q<')
|
|
133
|
+
offset += 16
|
|
134
|
+
file_reference, = read_tl_bytes(data, offset)
|
|
135
|
+
|
|
136
|
+
{ id: id, access_hash: access_hash, file_reference: file_reference }
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Vector<DocumentAttribute>: pull voice/duration from documentAttributeAudio
|
|
142
|
+
# and the name from documentAttributeFilename; advance over the rest via
|
|
143
|
+
# schema so an unknown attribute never throws off the read.
|
|
144
|
+
def parse_document_attributes(data, offset)
|
|
145
|
+
offset += 4 # vector constructor
|
|
146
|
+
count = data[offset, 4].unpack1('L<')
|
|
147
|
+
offset += 4
|
|
148
|
+
|
|
149
|
+
voice = false
|
|
150
|
+
duration = nil
|
|
151
|
+
file_name = nil
|
|
152
|
+
count.times do
|
|
153
|
+
ctor = data[offset, 4].unpack1('L<')
|
|
154
|
+
case ctor
|
|
155
|
+
when DOCUMENT_ATTRIBUTE_AUDIO
|
|
156
|
+
aflags = data[offset + 4, 4].unpack1('L<')
|
|
157
|
+
voice ||= aflags.anybits?(1 << 10)
|
|
158
|
+
duration = data[offset + 8, 4].unpack1('l<')
|
|
159
|
+
when DOCUMENT_ATTRIBUTE_FILENAME
|
|
160
|
+
file_name, = read_tl_string(data, offset + 4)
|
|
161
|
+
end
|
|
162
|
+
offset = schema.skip(data, offset)
|
|
163
|
+
end
|
|
164
|
+
[voice, duration, file_name]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def read_tl_string(data, offset)
|
|
168
|
+
bytes, offset = read_tl_bytes(data, offset)
|
|
169
|
+
[bytes.force_encoding('UTF-8'), offset]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def read_tl_bytes(data, offset)
|
|
173
|
+
first_byte = data.getbyte(offset)
|
|
174
|
+
if first_byte == 254
|
|
175
|
+
len = data.getbyte(offset + 1) |
|
|
176
|
+
(data.getbyte(offset + 2) << 8) |
|
|
177
|
+
(data.getbyte(offset + 3) << 16)
|
|
178
|
+
total = 4 + len
|
|
179
|
+
else
|
|
180
|
+
len = first_byte
|
|
181
|
+
total = 1 + len
|
|
182
|
+
end
|
|
183
|
+
padding = (4 - (total % 4)) % 4
|
|
184
|
+
[data[offset + (total - len), len], offset + total + padding]
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -22,7 +22,11 @@ module MTProto
|
|
|
22
22
|
@stream.write packet.data.pack('C*')
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# The largest packet the abridged length field can express: a 3-byte
|
|
26
|
+
# little-endian count of 4-byte words, i.e. ((1 << 24) - 1) words << 2.
|
|
27
|
+
# The old 1 MiB cap couldn't receive a full 1 MiB getFile reply (~1 MiB +
|
|
28
|
+
# envelope ≈ 1048680 B), so files larger than 1 MiB never downloaded.
|
|
29
|
+
MAX_PACKET_SIZE = ((1 << 24) - 1) << 2
|
|
26
30
|
|
|
27
31
|
def scan
|
|
28
32
|
first_byte = read_exactly(1)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MTProto
|
|
4
|
+
# The unencrypted MTProto transport envelope (auth_key_id=0 | msg_id | len | body),
|
|
5
|
+
# used for the plaintext request/response of the DH key exchange. Its encrypted
|
|
6
|
+
# sibling is EncryptedMessage; the runtime never decrypts through this class.
|
|
7
|
+
class UnencryptedMessage
|
|
8
|
+
include Binary
|
|
9
|
+
extend Binary
|
|
10
|
+
|
|
11
|
+
attr_reader :auth_key_id, :msg_id, :body
|
|
12
|
+
|
|
13
|
+
def initialize(body, msg_id: self.class.generate_msg_id, auth_key_id: 0)
|
|
14
|
+
@auth_key_id = auth_key_id
|
|
15
|
+
@msg_id = msg_id
|
|
16
|
+
@body = body
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.parse(packet)
|
|
20
|
+
data = packet.data
|
|
21
|
+
msg_id = b_u64(data[8, 8])
|
|
22
|
+
body_length = b_u32(data[16, 4])
|
|
23
|
+
body = data[20, body_length]
|
|
24
|
+
|
|
25
|
+
new(body, msg_id: msg_id, auth_key_id: 0)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def bytes
|
|
29
|
+
u64_b(@auth_key_id) +
|
|
30
|
+
u64_b(@msg_id) +
|
|
31
|
+
u32_b(@body.length) +
|
|
32
|
+
@body
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.generate_msg_id
|
|
36
|
+
(Time.now.to_f * (2**32)).to_i & ~3
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/mtproto/version.rb
CHANGED
data/lib/mtproto.rb
CHANGED
|
@@ -9,13 +9,15 @@ require_relative 'mtproto/transport/packet'
|
|
|
9
9
|
require_relative 'mtproto/transport/abridged_packet_codec'
|
|
10
10
|
require_relative 'mtproto/transport/tcp_connection'
|
|
11
11
|
require_relative 'mtproto/transport/connection'
|
|
12
|
-
require_relative 'mtproto/
|
|
12
|
+
require_relative 'mtproto/unencrypted_message'
|
|
13
13
|
require_relative 'mtproto/tl/constructors'
|
|
14
14
|
require_relative 'mtproto/tl/constructor_names'
|
|
15
|
+
require_relative 'mtproto/tl/reader'
|
|
15
16
|
require_relative 'mtproto/tl/object'
|
|
16
17
|
require_relative 'mtproto/tl/objects/update'
|
|
17
18
|
require_relative 'mtproto/tl/objects/update_short'
|
|
18
19
|
require_relative 'mtproto/tl/objects/update_short_message'
|
|
20
|
+
require_relative 'mtproto/tl/objects/updates'
|
|
19
21
|
require_relative 'mtproto/tl/objects/message'
|
|
20
22
|
require_relative 'mtproto/tl/objects/msg_container'
|
|
21
23
|
require_relative 'mtproto/tl/objects/new_session_created'
|
|
@@ -33,6 +35,7 @@ require_relative 'mtproto/auth_key_generator'
|
|
|
33
35
|
require_relative 'mtproto/session'
|
|
34
36
|
require_relative 'mtproto/encrypted_message'
|
|
35
37
|
require_relative 'mtproto/client'
|
|
38
|
+
require_relative 'mtproto/file_downloader'
|
|
36
39
|
|
|
37
40
|
module MTProto
|
|
38
41
|
end
|