mtproto 0.0.15 → 0.0.19
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_edit_admin.rb +80 -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 +19 -13
- 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 +26 -2
- data/lib/mtproto/message/message.rb +0 -85
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module MTProto
|
|
6
|
+
module TL
|
|
7
|
+
# messages.setBotGuestChatResult#b8f106e3 query_id:long result:InputBotInlineResult
|
|
8
|
+
# = InputBotInlineMessageID
|
|
9
|
+
# How a guest-mode bot answers an updateBotGuestChatQuery: the reply text is wrapped
|
|
10
|
+
# in inputBotInlineResult(type "article") -> inputBotInlineMessageText, and the server
|
|
11
|
+
# posts that message into the guest chat. Text-only for now (no media / buttons).
|
|
12
|
+
class SetBotGuestChatResult
|
|
13
|
+
include Binary
|
|
14
|
+
|
|
15
|
+
CONSTRUCTOR = 0xb8f106e3
|
|
16
|
+
INPUT_BOT_INLINE_RESULT = 0x88bf9319 # inputBotInlineResult
|
|
17
|
+
INPUT_BOT_INLINE_MESSAGE_TEXT = 0x3dcd7a87 # inputBotInlineMessageText
|
|
18
|
+
|
|
19
|
+
def initialize(query_id:, message:, result_id: nil, result_type: 'article', result_title: nil)
|
|
20
|
+
@query_id = query_id
|
|
21
|
+
@message = message
|
|
22
|
+
@result_id = result_id || SecureRandom.hex(8)
|
|
23
|
+
@result_type = result_type
|
|
24
|
+
# The "article" result type requires a non-empty title (the server rejects an
|
|
25
|
+
# empty one with ARTICLE_TITLE_EMPTY); the actual reply shown is send_message.
|
|
26
|
+
@result_title = result_title || message_title
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def serialize
|
|
30
|
+
result = u32_b(CONSTRUCTOR)
|
|
31
|
+
result += u64_b(@query_id)
|
|
32
|
+
result += serialize_result
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# inputBotInlineResult#88bf9319 flags:# id:string type:string
|
|
39
|
+
# title:flags.1?string description:flags.2? ... send_message:InputBotInlineMessage
|
|
40
|
+
# flags = 1<<1: id/type + the required title, then send_message.
|
|
41
|
+
def serialize_result
|
|
42
|
+
result = u32_b(INPUT_BOT_INLINE_RESULT)
|
|
43
|
+
result += u32_b(1 << 1) # title present
|
|
44
|
+
result += serialize_tl_string(@result_id)
|
|
45
|
+
result += serialize_tl_string(@result_type)
|
|
46
|
+
result += serialize_tl_string(@result_title)
|
|
47
|
+
result += serialize_send_message
|
|
48
|
+
result
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def message_title
|
|
52
|
+
title = @message.to_s.strip
|
|
53
|
+
title = 'Ответ' if title.empty?
|
|
54
|
+
title[0, 64]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true
|
|
58
|
+
# invert_media:flags.3?true message:string entities:flags.1? reply_markup:flags.2?
|
|
59
|
+
# flags = 0: a plain text message.
|
|
60
|
+
def serialize_send_message
|
|
61
|
+
result = u32_b(INPUT_BOT_INLINE_MESSAGE_TEXT)
|
|
62
|
+
result += u32_b(0)
|
|
63
|
+
result += serialize_tl_string(@message)
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def serialize_tl_string(str)
|
|
68
|
+
bytes = str.encode('UTF-8').bytes
|
|
69
|
+
length = bytes.length
|
|
70
|
+
|
|
71
|
+
if length <= 253
|
|
72
|
+
[length] + bytes + padding(length + 1)
|
|
73
|
+
else
|
|
74
|
+
[254] + u32_b(length)[0, 3] + bytes + padding(length + 4)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def padding(current_length)
|
|
79
|
+
pad_length = (4 - (current_length % 4)) % 4
|
|
80
|
+
[0] * pad_length
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -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)
|