mtproto 0.0.15 → 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.rb +1 -1
- data/lib/mtproto/file_downloader.rb +122 -0
- data/lib/mtproto/tl/constructor_names.rb +314 -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/dialogs.rb +15 -10
- data/lib/mtproto/tl/objects/export_authorization.rb +17 -0
- data/lib/mtproto/tl/objects/exported_authorization.rb +32 -0
- data/lib/mtproto/tl/objects/forward_messages.rb +5 -2
- 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/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/reply_inline_markup.rb +30 -0
- data/lib/mtproto/tl/objects/reset_bot_commands.rb +45 -0
- data/lib/mtproto/tl/objects/send_media.rb +76 -4
- data/lib/mtproto/tl/objects/send_message.rb +4 -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 +6 -3
- data/lib/mtproto/tl/objects/set_bot_guest_chat_result.rb +84 -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 +16 -119
- 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 +25 -2
- data/lib/mtproto/message/message.rb +0 -85
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MTProto
|
|
4
|
+
module TL
|
|
5
|
+
# messages.setTyping — show a chat action ("typing…", "sending photo…", …)
|
|
6
|
+
# in the target peer. action is a SendMessageAction; top_msg_id targets a
|
|
7
|
+
# forum topic / thread. Returns Bool.
|
|
8
|
+
class SetTyping
|
|
9
|
+
include Binary
|
|
10
|
+
|
|
11
|
+
CONSTRUCTOR = 0x58943ee2
|
|
12
|
+
|
|
13
|
+
def initialize(peer:, action:, top_msg_id: nil)
|
|
14
|
+
@peer = peer
|
|
15
|
+
@action = action
|
|
16
|
+
@top_msg_id = top_msg_id
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def serialize
|
|
20
|
+
flags = 0
|
|
21
|
+
flags |= (1 << 0) if @top_msg_id
|
|
22
|
+
|
|
23
|
+
result = u32_b(CONSTRUCTOR)
|
|
24
|
+
result += u32_b(flags)
|
|
25
|
+
result += serialize_input_peer
|
|
26
|
+
result += u32_b(@top_msg_id) if @top_msg_id
|
|
27
|
+
result += @action.serialize
|
|
28
|
+
result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def serialize_input_peer
|
|
34
|
+
case @peer[:type]
|
|
35
|
+
when :self
|
|
36
|
+
u32_b(Constructors::INPUT_PEER_SELF)
|
|
37
|
+
when :user
|
|
38
|
+
u32_b(Constructors::INPUT_PEER_USER) + u64_b(@peer[:id]) + u64_b(@peer[:access_hash] || 0)
|
|
39
|
+
when :chat
|
|
40
|
+
u32_b(Constructors::INPUT_PEER_CHAT) + u64_b(@peer[:id])
|
|
41
|
+
when :channel
|
|
42
|
+
u32_b(Constructors::INPUT_PEER_CHANNEL) + u64_b(@peer[:id]) + u64_b(@peer[:access_hash] || 0)
|
|
43
|
+
else
|
|
44
|
+
raise "Unknown peer type: #{@peer[:type]}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MTProto
|
|
4
|
+
module TL
|
|
5
|
+
# account.updateStatus — mark the session online or offline. offline:false
|
|
6
|
+
# is what a foreground client sends to go online (and to start receiving
|
|
7
|
+
# transient updates like updateUserTyping). Returns Bool.
|
|
8
|
+
class UpdateStatus
|
|
9
|
+
include Binary
|
|
10
|
+
|
|
11
|
+
CONSTRUCTOR = 0x6628562c
|
|
12
|
+
BOOL_TRUE = 0x997275b5
|
|
13
|
+
BOOL_FALSE = 0xbc799737
|
|
14
|
+
|
|
15
|
+
def initialize(offline:)
|
|
16
|
+
@offline = offline
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def serialize
|
|
20
|
+
u32_b(CONSTRUCTOR) + u32_b(@offline ? BOOL_TRUE : BOOL_FALSE)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -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,16 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../schema'
|
|
4
|
+
require_relative 'message'
|
|
4
5
|
require_relative 'dialogs'
|
|
6
|
+
require_relative 'updates'
|
|
5
7
|
|
|
6
8
|
module MTProto
|
|
7
9
|
module TL
|
|
8
10
|
class UpdatesDifference
|
|
9
|
-
attr_reader :type, :date, :seq, :new_messages, :pts, :state, :chats, :users
|
|
11
|
+
attr_reader :type, :date, :seq, :new_messages, :pts, :state, :chats, :users, :guest_queries
|
|
10
12
|
|
|
11
13
|
SCHEMA_PATH = File.expand_path('../../../../data/tl-schema.json', __dir__)
|
|
12
14
|
|
|
13
|
-
def initialize(type:, date: nil, seq: nil, new_messages: [], pts: nil, state: nil, chats: [], users: []
|
|
15
|
+
def initialize(type:, date: nil, seq: nil, new_messages: [], pts: nil, state: nil, chats: [], users: [],
|
|
16
|
+
guest_queries: [])
|
|
14
17
|
@type = type
|
|
15
18
|
@date = date
|
|
16
19
|
@seq = seq
|
|
@@ -19,6 +22,7 @@ module MTProto
|
|
|
19
22
|
@state = state
|
|
20
23
|
@chats = chats
|
|
21
24
|
@users = users
|
|
25
|
+
@guest_queries = guest_queries
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def self.schema
|
|
@@ -41,12 +45,6 @@ module MTProto
|
|
|
41
45
|
end
|
|
42
46
|
end
|
|
43
47
|
|
|
44
|
-
MESSAGE_CONSTRUCTOR = Constructors::MESSAGE
|
|
45
|
-
MESSAGE_SERVICE = 0x7a800e0a
|
|
46
|
-
MESSAGE_EMPTY = 0x90a6ca84
|
|
47
|
-
MESSAGE_REPLY_HEADER = 0x6917560b
|
|
48
|
-
MEDIA_PHOTO = 0x695150d7
|
|
49
|
-
MEDIA_DOCUMENT = 0x52d8ccd9
|
|
50
48
|
UPDATE_NEW_MESSAGE = 0x1f2b0afd
|
|
51
49
|
UPDATE_NEW_CHANNEL_MESSAGE = 0x62ba04d9
|
|
52
50
|
|
|
@@ -70,7 +68,7 @@ module MTProto
|
|
|
70
68
|
|
|
71
69
|
messages, offset = parse_messages_vector(data, offset)
|
|
72
70
|
offset = schema.skip_vector(data, offset) # new_encrypted_messages
|
|
73
|
-
other_messages, offset = parse_other_updates(data, offset)
|
|
71
|
+
other_messages, guest_queries, offset = parse_other_updates(data, offset)
|
|
74
72
|
chats, offset = Dialogs.chats_at(data, offset)
|
|
75
73
|
users, offset = Dialogs.users_at(data, offset)
|
|
76
74
|
state = UpdatesState.deserialize(data[offset..])
|
|
@@ -78,6 +76,7 @@ module MTProto
|
|
|
78
76
|
new(
|
|
79
77
|
type: constructor == Constructors::UPDATES_DIFFERENCE ? :difference : :slice,
|
|
80
78
|
new_messages: messages + other_messages,
|
|
79
|
+
guest_queries: guest_queries,
|
|
81
80
|
chats: chats, users: users, state: state
|
|
82
81
|
)
|
|
83
82
|
end
|
|
@@ -91,16 +90,19 @@ module MTProto
|
|
|
91
90
|
offset += 4
|
|
92
91
|
|
|
93
92
|
messages = []
|
|
93
|
+
guest_queries = []
|
|
94
94
|
count.times do
|
|
95
95
|
ctor = data[offset, 4].unpack1('L<')
|
|
96
96
|
if [UPDATE_NEW_MESSAGE, UPDATE_NEW_CHANNEL_MESSAGE].include?(ctor)
|
|
97
97
|
inner_off = offset + 4
|
|
98
|
-
msg =
|
|
99
|
-
messages << msg if msg
|
|
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)
|
|
100
102
|
end
|
|
101
103
|
offset = schema.skip(data, offset)
|
|
102
104
|
end
|
|
103
|
-
[messages, offset]
|
|
105
|
+
[messages, guest_queries, offset]
|
|
104
106
|
end
|
|
105
107
|
|
|
106
108
|
def parse_messages_vector(data, offset)
|
|
@@ -111,117 +113,12 @@ module MTProto
|
|
|
111
113
|
messages = []
|
|
112
114
|
count.times do
|
|
113
115
|
constructor = data[offset, 4].unpack1('L<')
|
|
114
|
-
msg =
|
|
115
|
-
messages << msg if msg
|
|
116
|
+
msg = Message.deserialize(data, offset, constructor)
|
|
117
|
+
messages << msg.to_h if msg
|
|
116
118
|
offset = schema.skip(data, offset)
|
|
117
119
|
end
|
|
118
120
|
[messages, offset]
|
|
119
121
|
end
|
|
120
|
-
|
|
121
|
-
def extract_message(data, offset, constructor)
|
|
122
|
-
return unless constructor == MESSAGE_CONSTRUCTOR
|
|
123
|
-
|
|
124
|
-
offset += 4 # constructor
|
|
125
|
-
flags = data[offset, 4].unpack1('L<')
|
|
126
|
-
offset += 4
|
|
127
|
-
flags2 = data[offset, 4].unpack1('L<')
|
|
128
|
-
offset += 4
|
|
129
|
-
id = data[offset, 4].unpack1('l<')
|
|
130
|
-
offset += 4
|
|
131
|
-
|
|
132
|
-
from_type = from_id = nil
|
|
133
|
-
from_type, from_id, offset = parse_peer(data, offset) if flags.anybits?(1 << 8) # from_id (Peer)
|
|
134
|
-
offset += 4 if flags.anybits?(1 << 29) # from_boosts_applied
|
|
135
|
-
peer_type, peer_id, offset = parse_peer(data, offset)
|
|
136
|
-
offset = schema.skip(data, offset) if flags.anybits?(1 << 28) # saved_peer_id
|
|
137
|
-
offset = schema.skip(data, offset) if flags.anybits?(1 << 2) # fwd_from
|
|
138
|
-
offset += 8 if flags.anybits?(1 << 11) # via_bot_id
|
|
139
|
-
offset += 8 if flags2.anybits?(1 << 0) # via_business_bot_id
|
|
140
|
-
|
|
141
|
-
is_reply = false
|
|
142
|
-
reply_to_msg_id = nil
|
|
143
|
-
if flags.anybits?(1 << 3) # reply_to
|
|
144
|
-
reply_to_msg_id, is_reply = parse_reply_to(data, offset)
|
|
145
|
-
offset = schema.skip(data, offset)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
date = data[offset, 4].unpack1('L<')
|
|
149
|
-
offset += 4
|
|
150
|
-
message_text, offset = read_tl_string(data, offset)
|
|
151
|
-
media = flags.anybits?(1 << 9) ? parse_media(data, offset) : nil
|
|
152
|
-
|
|
153
|
-
{ id: id, date: date, message: message_text, peer_type: peer_type, peer_id: peer_id,
|
|
154
|
-
from_type: from_type, from_id: from_id, out: flags.anybits?(1 << 1),
|
|
155
|
-
media: media, is_reply: is_reply, reply_to_msg_id: reply_to_msg_id }
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def parse_peer(data, offset)
|
|
159
|
-
constructor = data[offset, 4].unpack1('L<')
|
|
160
|
-
offset += 4
|
|
161
|
-
|
|
162
|
-
case constructor
|
|
163
|
-
when Constructors::PEER_USER
|
|
164
|
-
[:user, data[offset, 8].unpack1('Q<'), offset + 8]
|
|
165
|
-
when Constructors::PEER_CHAT
|
|
166
|
-
[:chat, data[offset, 8].unpack1('Q<'), offset + 8]
|
|
167
|
-
when Constructors::PEER_CHANNEL
|
|
168
|
-
[:channel, data[offset, 8].unpack1('Q<'), offset + 8]
|
|
169
|
-
else
|
|
170
|
-
raise "Unknown peer constructor: 0x#{constructor.to_s(16)}"
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# messageReplyHeader: ctor, flags, reply_to_msg_id:flags.4?int (first payload
|
|
175
|
-
# field). A plain message in a forum topic carries this header (forum_topic
|
|
176
|
-
# flags.3) but is a reply only when it targets a message in the topic
|
|
177
|
-
# (reply_to_top_id flags.1). => [reply_to_msg_id, is_reply]
|
|
178
|
-
def parse_reply_to(data, offset)
|
|
179
|
-
return [nil, false] unless data[offset, 4].unpack1('L<') == MESSAGE_REPLY_HEADER
|
|
180
|
-
|
|
181
|
-
io = offset + 4
|
|
182
|
-
flags = data[io, 4].unpack1('L<')
|
|
183
|
-
io += 4
|
|
184
|
-
forum_topic = flags.anybits?(1 << 3)
|
|
185
|
-
msg_id = flags.anybits?(1 << 4) ? data[io, 4].unpack1('l<') : nil
|
|
186
|
-
[msg_id, forum_topic ? flags.anybits?(1 << 1) : true]
|
|
187
|
-
rescue StandardError
|
|
188
|
-
[nil, false]
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# Classify message media into :photo / :video / :audio / :document, or nil.
|
|
192
|
-
def parse_media(data, offset)
|
|
193
|
-
case data[offset, 4].unpack1('L<')
|
|
194
|
-
when MEDIA_PHOTO
|
|
195
|
-
:photo
|
|
196
|
-
when MEDIA_DOCUMENT
|
|
197
|
-
document_kind(data[offset + 4, 4].unpack1('L<'))
|
|
198
|
-
end
|
|
199
|
-
rescue StandardError
|
|
200
|
-
nil
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
# messageMediaDocument flags: video flags.6 / round flags.7 / voice flags.8.
|
|
204
|
-
def document_kind(flags)
|
|
205
|
-
return :video if flags.anybits?(1 << 6) || flags.anybits?(1 << 7)
|
|
206
|
-
return :audio if flags.anybits?(1 << 8)
|
|
207
|
-
|
|
208
|
-
:document
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def read_tl_string(data, offset)
|
|
212
|
-
first_byte = data.getbyte(offset)
|
|
213
|
-
if first_byte == 254
|
|
214
|
-
len = data.getbyte(offset + 1) |
|
|
215
|
-
(data.getbyte(offset + 2) << 8) |
|
|
216
|
-
(data.getbyte(offset + 3) << 16)
|
|
217
|
-
total = 4 + len
|
|
218
|
-
else
|
|
219
|
-
len = first_byte
|
|
220
|
-
total = 1 + len
|
|
221
|
-
end
|
|
222
|
-
padding = (4 - (total % 4)) % 4
|
|
223
|
-
[data[offset + (total - len), len].force_encoding('UTF-8'), offset + total + padding]
|
|
224
|
-
end
|
|
225
122
|
end
|
|
226
123
|
end
|
|
227
124
|
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
|