mtproto 0.0.33 → 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.
- checksums.yaml +4 -4
- data/docs/test_architecture_level1.md +23 -107
- data/lib/mtproto/client/api/send_message.rb +5 -2
- data/lib/mtproto/client/api/set_typing.rb +4 -4
- data/lib/mtproto/client/api/stream_draft.rb +13 -0
- data/lib/mtproto/client/api.rb +1 -0
- data/lib/mtproto/draft_stream.rb +52 -0
- data/lib/mtproto/tl/objects/media_parts.rb +68 -0
- data/lib/mtproto/tl/objects/reply_keyboard_force_reply.rb +46 -0
- data/lib/mtproto/tl/objects/send_message_text_draft_action.rb +52 -0
- data/lib/mtproto/tl/objects/send_multi_media.rb +52 -0
- data/lib/mtproto/tl/objects/upload_media.rb +26 -0
- data/lib/mtproto/tl/peers.rb +9 -6
- data/lib/mtproto/tl/reader.rb +29 -2
- data/lib/mtproto/updates/state_machine.rb +7 -7
- data/lib/mtproto/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e9121e732982986db9b71a12288993dfb18888e3fe42e2e2b3bb4db55ca494a
|
|
4
|
+
data.tar.gz: 98b72d7cc330c681c6552a9e3f5b1081e7d614283e685c3f6be70837dae0390b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9a6fe6fc4c86420ee93a35f44f21fc39778b28fbc7a4c92e2294545dde285de72683db01a282e0517e5b8f4172af211465d17d7141185637d90fccba67b12b7b
|
|
7
|
+
data.tar.gz: 276c70eaffb140cd6b8d5f34582f9009c205d00e46afbefdd5f6050c67e79600c1afd6c5238f38d7cd8121fe0fb611b09a00b5237017bbcb69bc9df2d3e6cbd8
|
|
@@ -22,11 +22,13 @@ once.
|
|
|
22
22
|
|
|
23
23
|
## How It Works
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Each test case is a script that drives the whole scenario itself:
|
|
26
26
|
|
|
27
|
-
1. Connects to Telegram with
|
|
28
|
-
|
|
29
|
-
|
|
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).
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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/
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
237
|
-
|
|
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.
|
|
@@ -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
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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)
|
|
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
|
data/lib/mtproto/client/api.rb
CHANGED
|
@@ -17,6 +17,7 @@ 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'
|
|
21
22
|
require_relative 'api/resolve_username'
|
|
22
23
|
require_relative 'api/export_bot_token'
|
|
@@ -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
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MTProto
|
|
4
|
+
module TL
|
|
5
|
+
# The pieces every media-carrying call writes the same way: who it is addressed to,
|
|
6
|
+
# the file just uploaded, and the strings around it.
|
|
7
|
+
#
|
|
8
|
+
# Held together here because an album is sent in two calls — one to turn each upload
|
|
9
|
+
# into a photo Telegram holds, one to send them as a group — and both write the same
|
|
10
|
+
# peer and the same file.
|
|
11
|
+
module MediaParts
|
|
12
|
+
include Binary
|
|
13
|
+
|
|
14
|
+
INPUT_MEDIA_PHOTO = 0xe3af4434
|
|
15
|
+
INPUT_PHOTO = 0x3bb3b94a
|
|
16
|
+
|
|
17
|
+
def serialize_peer(peer)
|
|
18
|
+
case peer[:type]
|
|
19
|
+
when :self
|
|
20
|
+
u32_b(Constructors::INPUT_PEER_SELF)
|
|
21
|
+
when :user
|
|
22
|
+
u32_b(Constructors::INPUT_PEER_USER) + u64_b(peer[:id]) + u64_b(peer[:access_hash] || 0)
|
|
23
|
+
when :chat
|
|
24
|
+
u32_b(Constructors::INPUT_PEER_CHAT) + u64_b(peer[:id])
|
|
25
|
+
when :channel
|
|
26
|
+
u32_b(Constructors::INPUT_PEER_CHANNEL) + u64_b(peer[:id]) + u64_b(peer[:access_hash] || 0)
|
|
27
|
+
else
|
|
28
|
+
raise "Unknown peer type: #{peer[:type]}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def serialize_uploaded_photo(file)
|
|
33
|
+
u32_b(Constructors::INPUT_MEDIA_UPLOADED_PHOTO) + u32_b(0) + serialize_file(file)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A photo Telegram already holds, named by what it answered when the upload was
|
|
37
|
+
# handed to it.
|
|
38
|
+
def serialize_held_photo(photo)
|
|
39
|
+
u32_b(INPUT_MEDIA_PHOTO) + u32_b(0) +
|
|
40
|
+
u32_b(INPUT_PHOTO) + u64_b(photo[:id]) + u64_b(photo[:access_hash]) +
|
|
41
|
+
serialize_raw(photo[:file_reference])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def serialize_file(file)
|
|
45
|
+
return u32_b(Constructors::INPUT_FILE_BIG) + u64_b(file[:id]) + u32_b(file[:parts]) +
|
|
46
|
+
serialize_text(file[:name].to_s) if file[:big]
|
|
47
|
+
|
|
48
|
+
u32_b(Constructors::INPUT_FILE) + u64_b(file[:id]) + u32_b(file[:parts]) +
|
|
49
|
+
serialize_text(file[:name].to_s) + serialize_text(file[:md5_checksum].to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def serialize_text(str)
|
|
53
|
+
serialize_byte_array(str.encode('UTF-8').bytes)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def serialize_raw(raw)
|
|
57
|
+
serialize_byte_array(raw.to_s.b.bytes)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def serialize_byte_array(bytes)
|
|
61
|
+
length = bytes.length
|
|
62
|
+
head = length <= 253 ? [length] : [254] + u32_b(length)[0, 3]
|
|
63
|
+
padded = head + bytes
|
|
64
|
+
padded + [0] * ((4 - (padded.length % 4)) % 4)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MTProto
|
|
4
|
+
module TL
|
|
5
|
+
class ReplyKeyboardForceReply
|
|
6
|
+
include Binary
|
|
7
|
+
|
|
8
|
+
CONSTRUCTOR = 0x86b40b08
|
|
9
|
+
|
|
10
|
+
def initialize(single_use: false, selective: false, placeholder: nil)
|
|
11
|
+
@single_use = single_use
|
|
12
|
+
@selective = selective
|
|
13
|
+
@placeholder = placeholder
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def serialize
|
|
17
|
+
flags = 0
|
|
18
|
+
flags |= (1 << 1) if @single_use
|
|
19
|
+
flags |= (1 << 2) if @selective
|
|
20
|
+
flags |= (1 << 3) if @placeholder
|
|
21
|
+
|
|
22
|
+
result = u32_b(CONSTRUCTOR)
|
|
23
|
+
result += u32_b(flags)
|
|
24
|
+
result += serialize_tl_string(@placeholder) if @placeholder
|
|
25
|
+
result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def serialize_tl_string(str)
|
|
31
|
+
bytes = str.encode('UTF-8').bytes
|
|
32
|
+
length = bytes.length
|
|
33
|
+
if length <= 253
|
|
34
|
+
[length] + bytes + padding(length + 1)
|
|
35
|
+
else
|
|
36
|
+
[254] + u32_b(length)[0, 3] + bytes + padding(length + 4)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def padding(current_length)
|
|
41
|
+
pad_length = (4 - (current_length % 4)) % 4
|
|
42
|
+
[0] * pad_length
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../message_entity'
|
|
4
|
+
|
|
5
|
+
module MTProto
|
|
6
|
+
module TL
|
|
7
|
+
# A SendMessageAction carrying a live draft: the partially generated answer the
|
|
8
|
+
# recipient's client shows at the bottom of the chat while it is still being
|
|
9
|
+
# written. Unlike the other actions it is not a bare constructor id, so it does
|
|
10
|
+
# not fit TL::SendMessageAction's id-plus-progress table.
|
|
11
|
+
#
|
|
12
|
+
# `random_id` identifies the draft: the same value updates the draft already on
|
|
13
|
+
# screen, a different one puts a second draft beside it.
|
|
14
|
+
class SendMessageTextDraftAction
|
|
15
|
+
include Binary
|
|
16
|
+
|
|
17
|
+
CONSTRUCTOR = 0x376d975c
|
|
18
|
+
TEXT_WITH_ENTITIES = 0x751f3146
|
|
19
|
+
|
|
20
|
+
def initialize(random_id:, text:, entities: [])
|
|
21
|
+
@random_id = random_id
|
|
22
|
+
@text = text
|
|
23
|
+
@entities = entities
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# textWithEntities#751f3146 text:string entities:Vector<MessageEntity> — the
|
|
27
|
+
# entity vector is not optional, so an empty list still goes on the wire.
|
|
28
|
+
def serialize
|
|
29
|
+
u32_b(CONSTRUCTOR) + u64_b(@random_id) +
|
|
30
|
+
u32_b(TEXT_WITH_ENTITIES) + serialize_tl_string(@text) +
|
|
31
|
+
MessageEntities.serialize(@entities)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def serialize_tl_string(str)
|
|
37
|
+
bytes = str.encode('UTF-8').bytes
|
|
38
|
+
length = bytes.length
|
|
39
|
+
if length <= 253
|
|
40
|
+
[length] + bytes + padding(length + 1)
|
|
41
|
+
else
|
|
42
|
+
[254] + u32_b(length)[0, 3] + bytes + padding(length + 4)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def padding(current_length)
|
|
47
|
+
pad_length = (4 - (current_length % 4)) % 4
|
|
48
|
+
[0] * pad_length
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
require_relative 'media_parts'
|
|
6
|
+
|
|
7
|
+
module MTProto
|
|
8
|
+
module TL
|
|
9
|
+
# messages.sendMultiMedia — several photos as ONE message, the way a person sends an
|
|
10
|
+
# album.
|
|
11
|
+
#
|
|
12
|
+
# The words ride on the first of them and nowhere else: an album carries a single
|
|
13
|
+
# caption, and repeating it under each picture is how it looks when somebody sends
|
|
14
|
+
# them one at a time.
|
|
15
|
+
class SendMultiMedia
|
|
16
|
+
include MediaParts
|
|
17
|
+
|
|
18
|
+
CONSTRUCTOR = 0x1bf89d74
|
|
19
|
+
INPUT_SINGLE_MEDIA = 0x1cc6e91f
|
|
20
|
+
INPUT_REPLY_TO_MESSAGE = 0x3bd4b7c2
|
|
21
|
+
|
|
22
|
+
def initialize(peer:, photos:, message: '', reply_to: nil, silent: false)
|
|
23
|
+
@peer = peer
|
|
24
|
+
@photos = photos
|
|
25
|
+
@message = message
|
|
26
|
+
@reply_to = reply_to
|
|
27
|
+
@silent = silent
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def serialize
|
|
31
|
+
flags = 0
|
|
32
|
+
flags |= (1 << 0) if @reply_to
|
|
33
|
+
flags |= (1 << 5) if @silent
|
|
34
|
+
|
|
35
|
+
result = u32_b(CONSTRUCTOR) + u32_b(flags) + serialize_peer(@peer)
|
|
36
|
+
result += u32_b(INPUT_REPLY_TO_MESSAGE) + u32_b(0) + u32_b(@reply_to) if @reply_to
|
|
37
|
+
result += u32_b(Constructors::VECTOR) + u32_b(@photos.length)
|
|
38
|
+
@photos.each_with_index { |photo, nth| result += serialize_single(photo, nth) }
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def serialize_single(photo, nth)
|
|
45
|
+
u32_b(INPUT_SINGLE_MEDIA) + u32_b(0) +
|
|
46
|
+
serialize_held_photo(photo) +
|
|
47
|
+
u64_b(SecureRandom.random_number(2**63)) +
|
|
48
|
+
serialize_text(nth.zero? ? @message.to_s : '')
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'media_parts'
|
|
4
|
+
|
|
5
|
+
module MTProto
|
|
6
|
+
module TL
|
|
7
|
+
# messages.uploadMedia — hand Telegram an upload and get back the photo it now holds.
|
|
8
|
+
#
|
|
9
|
+
# The step before an album: a group is sent by naming photos Telegram already has, so
|
|
10
|
+
# each file is offered once on its own before the group is sent at all.
|
|
11
|
+
class UploadMedia
|
|
12
|
+
include MediaParts
|
|
13
|
+
|
|
14
|
+
CONSTRUCTOR = 0x14967978
|
|
15
|
+
|
|
16
|
+
def initialize(peer:, file:)
|
|
17
|
+
@peer = peer
|
|
18
|
+
@file = file
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def serialize
|
|
22
|
+
u32_b(CONSTRUCTOR) + u32_b(0) + serialize_peer(@peer) + serialize_uploaded_photo(@file)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/mtproto/tl/peers.rb
CHANGED
|
@@ -7,7 +7,7 @@ module MTProto
|
|
|
7
7
|
module Peers
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
Chat = Struct.new(:id, :title, :type, :access_hash, :username, keyword_init: true)
|
|
10
|
+
Chat = Struct.new(:id, :title, :type, :access_hash, :username, :min, keyword_init: true)
|
|
11
11
|
|
|
12
12
|
def chats_at(data, offset)
|
|
13
13
|
offset += 4 # vector constructor
|
|
@@ -119,7 +119,8 @@ module MTProto
|
|
|
119
119
|
offset += 8 if flags2.anybits?(1 << 18) # linked_monoforum_id (long)
|
|
120
120
|
|
|
121
121
|
type = flags.anybits?(1 << 8) ? :supergroup : :channel
|
|
122
|
-
[Chat.new(id: id, title: title, type: type, access_hash: access_hash, username: username
|
|
122
|
+
[Chat.new(id: id, title: title, type: type, access_hash: access_hash, username: username,
|
|
123
|
+
min: flags.anybits?(1 << 12)), offset]
|
|
123
124
|
end
|
|
124
125
|
|
|
125
126
|
# chatForbidden#6592a1a7 id:long title:string
|
|
@@ -192,8 +193,8 @@ module MTProto
|
|
|
192
193
|
offset += 4
|
|
193
194
|
id = data[offset, 8].unpack1('Q<')
|
|
194
195
|
offset += 8
|
|
195
|
-
|
|
196
|
-
if
|
|
196
|
+
carries_hash = flags.anybits?(1 << 0)
|
|
197
|
+
if carries_hash
|
|
197
198
|
access_hash = data[offset, 8].unpack1('Q<')
|
|
198
199
|
offset += 8
|
|
199
200
|
end
|
|
@@ -223,8 +224,10 @@ module MTProto
|
|
|
223
224
|
offset += 8 if flags2.anybits?(1 << 14) # bot_verification_icon
|
|
224
225
|
offset += 8 if flags2.anybits?(1 << 15) # send_paid_messages_stars
|
|
225
226
|
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
user = { id: id, first_name: first_name, last_name: last_name, username: username,
|
|
228
|
+
is_bot: flags.anybits?(1 << 14), min: flags.anybits?(1 << 20) }
|
|
229
|
+
user[:access_hash] = access_hash if carries_hash
|
|
230
|
+
[user, offset]
|
|
228
231
|
end
|
|
229
232
|
private_class_method :parse_chat, :parse_basic_chat, :parse_channel,
|
|
230
233
|
:parse_chat_forbidden, :parse_channel_forbidden, :parse_user
|
data/lib/mtproto/tl/reader.rb
CHANGED
|
@@ -18,6 +18,9 @@ module MTProto
|
|
|
18
18
|
MEDIA_DOCUMENT = 0x52d8ccd9
|
|
19
19
|
DOCUMENT = 0x8fd4c4d8
|
|
20
20
|
PHOTO = 0xfb197a65
|
|
21
|
+
PHOTO_SIZE = 0x75c78e60
|
|
22
|
+
PHOTO_CACHED_SIZE = 0x021e1ad6
|
|
23
|
+
PHOTO_SIZE_PROGRESSIVE = 0xfa3efb95
|
|
21
24
|
DOCUMENT_ATTRIBUTE_AUDIO = 0x9852f9c6
|
|
22
25
|
DOCUMENT_ATTRIBUTE_FILENAME = 0x15590068
|
|
23
26
|
|
|
@@ -158,13 +161,37 @@ module MTProto
|
|
|
158
161
|
id = data[offset, 8].unpack1('q<')
|
|
159
162
|
access_hash = data[offset + 8, 8].unpack1('q<')
|
|
160
163
|
offset += 16
|
|
161
|
-
file_reference, = read_tl_bytes(data, offset)
|
|
164
|
+
file_reference, offset = read_tl_bytes(data, offset)
|
|
162
165
|
|
|
163
|
-
{ id: id, access_hash: access_hash, file_reference: file_reference
|
|
166
|
+
{ id: id, access_hash: access_hash, file_reference: file_reference,
|
|
167
|
+
sizes: photo_sizes(data, offset + 4) } # +4: date, which precedes the sizes
|
|
164
168
|
rescue StandardError
|
|
165
169
|
nil
|
|
166
170
|
end
|
|
167
171
|
|
|
172
|
+
# Every size Telegram holds this photo in, by the name upload.getFile asks for it.
|
|
173
|
+
#
|
|
174
|
+
# A photo, unlike a document, is not fetched whole: the location names which size is
|
|
175
|
+
# wanted, and naming none is answered as if the reference had gone stale — which is
|
|
176
|
+
# what makes a missing size look like an expired one.
|
|
177
|
+
#
|
|
178
|
+
# Only the sizes with a width can be fetched: the other kinds carry their own bytes
|
|
179
|
+
# inline (a blur, an outline) and have nothing to ask for.
|
|
180
|
+
SIZES_WITH_WIDTH = [PHOTO_SIZE, PHOTO_CACHED_SIZE, PHOTO_SIZE_PROGRESSIVE].freeze
|
|
181
|
+
|
|
182
|
+
def photo_sizes(data, offset)
|
|
183
|
+
found = []
|
|
184
|
+
schema.skip_vector(data, offset) do |bytes, at|
|
|
185
|
+
constructor = bytes[at, 4].unpack1('L<')
|
|
186
|
+
name, after = read_tl_string(bytes, at + 4)
|
|
187
|
+
found << { type: name, width: bytes[after, 4].unpack1('l<') } if SIZES_WITH_WIDTH.include?(constructor)
|
|
188
|
+
schema.skip(bytes, at)
|
|
189
|
+
end
|
|
190
|
+
found
|
|
191
|
+
rescue StandardError
|
|
192
|
+
[]
|
|
193
|
+
end
|
|
194
|
+
|
|
168
195
|
# Vector<DocumentAttribute>: pull voice/duration from documentAttributeAudio
|
|
169
196
|
# and the name from documentAttributeFilename; advance over the rest via
|
|
170
197
|
# schema so an unknown attribute never throws off the read.
|
|
@@ -51,14 +51,14 @@ module MTProto
|
|
|
51
51
|
|
|
52
52
|
def dispatch(batch, &)
|
|
53
53
|
remember_channels(batch.chats)
|
|
54
|
-
batch.events.each { |event| ingest(event, &) }
|
|
54
|
+
batch.events.each { |event| ingest(event, batch, &) }
|
|
55
55
|
batch.channel_too_long.each { |channel_id| recover_channel(channel_id, &) }
|
|
56
56
|
catch_up(&) if batch.too_long
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
def ingest(event, &)
|
|
59
|
+
def ingest(event, batch, &)
|
|
60
60
|
detect_gap(event, &)
|
|
61
|
-
emit(event, &)
|
|
61
|
+
emit(event, batch, &)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
# A live update whose pts jumps past our stored position means updates were
|
|
@@ -80,11 +80,11 @@ module MTProto
|
|
|
80
80
|
# host's transaction (atomic with the host's own handling); the in-memory @pts is
|
|
81
81
|
# advanced only AFTER the transaction commits, so a rollback can never leave the
|
|
82
82
|
# cache ahead of the store and silently drop later account updates.
|
|
83
|
-
def emit(event, &block)
|
|
83
|
+
def emit(event, batch, &block)
|
|
84
84
|
return if outdated?(event)
|
|
85
85
|
|
|
86
86
|
@store.transaction do
|
|
87
|
-
block.call(event)
|
|
87
|
+
block.call(event, batch)
|
|
88
88
|
checkpoint(event)
|
|
89
89
|
end
|
|
90
90
|
cache_account_position(event)
|
|
@@ -142,7 +142,7 @@ module MTProto
|
|
|
142
142
|
|
|
143
143
|
def apply_difference(diff, &)
|
|
144
144
|
remember_channels(diff.chats)
|
|
145
|
-
diff.events.each { |event| emit(event, &) }
|
|
145
|
+
diff.events.each { |event| emit(event, diff, &) }
|
|
146
146
|
diff.channel_too_long.each { |channel_id| recover_channel(channel_id, &) }
|
|
147
147
|
@pts = diff.pts if diff.pts
|
|
148
148
|
@qts = diff.qts if diff.qts
|
|
@@ -164,7 +164,7 @@ module MTProto
|
|
|
164
164
|
return catch_up(&) if diff.type == :too_long
|
|
165
165
|
|
|
166
166
|
remember_channels(diff.chats)
|
|
167
|
-
diff.events.each { |event| emit(event, &) }
|
|
167
|
+
diff.events.each { |event| emit(event, diff, &) }
|
|
168
168
|
if diff.pts&.positive?
|
|
169
169
|
from = diff.pts
|
|
170
170
|
@store.set_channel_pts(channel_id, diff.pts)
|
data/lib/mtproto/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mtproto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.34
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Artem Levenkov
|
|
@@ -85,6 +85,7 @@ files:
|
|
|
85
85
|
- lib/mtproto/client/api/send_message.rb
|
|
86
86
|
- lib/mtproto/client/api/set_typing.rb
|
|
87
87
|
- lib/mtproto/client/api/sign_in.rb
|
|
88
|
+
- lib/mtproto/client/api/stream_draft.rb
|
|
88
89
|
- lib/mtproto/client/api/unpin_all_messages.rb
|
|
89
90
|
- lib/mtproto/client/rpc.rb
|
|
90
91
|
- lib/mtproto/client/rpc/response.rb
|
|
@@ -100,6 +101,7 @@ files:
|
|
|
100
101
|
- lib/mtproto/crypto/srp.rb
|
|
101
102
|
- lib/mtproto/dc.rb
|
|
102
103
|
- lib/mtproto/delegate_methods.rb
|
|
104
|
+
- lib/mtproto/draft_stream.rb
|
|
103
105
|
- lib/mtproto/encrypted_message.rb
|
|
104
106
|
- lib/mtproto/errors.rb
|
|
105
107
|
- lib/mtproto/file_downloader.rb
|
|
@@ -170,6 +172,7 @@ files:
|
|
|
170
172
|
- lib/mtproto/tl/objects/invoke_with_layer.rb
|
|
171
173
|
- lib/mtproto/tl/objects/keyboard_button_callback.rb
|
|
172
174
|
- lib/mtproto/tl/objects/login_token.rb
|
|
175
|
+
- lib/mtproto/tl/objects/media_parts.rb
|
|
173
176
|
- lib/mtproto/tl/objects/message.rb
|
|
174
177
|
- lib/mtproto/tl/objects/message_service.rb
|
|
175
178
|
- lib/mtproto/tl/objects/messages.rb
|
|
@@ -181,6 +184,7 @@ files:
|
|
|
181
184
|
- lib/mtproto/tl/objects/pq_inner_data.rb
|
|
182
185
|
- lib/mtproto/tl/objects/raw_response.rb
|
|
183
186
|
- lib/mtproto/tl/objects/reply_inline_markup.rb
|
|
187
|
+
- lib/mtproto/tl/objects/reply_keyboard_force_reply.rb
|
|
184
188
|
- lib/mtproto/tl/objects/reply_keyboard_markup.rb
|
|
185
189
|
- lib/mtproto/tl/objects/req_dh_params.rb
|
|
186
190
|
- lib/mtproto/tl/objects/req_pq_multi.rb
|
|
@@ -198,6 +202,8 @@ files:
|
|
|
198
202
|
- lib/mtproto/tl/objects/send_media.rb
|
|
199
203
|
- lib/mtproto/tl/objects/send_message.rb
|
|
200
204
|
- lib/mtproto/tl/objects/send_message_action.rb
|
|
205
|
+
- lib/mtproto/tl/objects/send_message_text_draft_action.rb
|
|
206
|
+
- lib/mtproto/tl/objects/send_multi_media.rb
|
|
201
207
|
- lib/mtproto/tl/objects/send_reaction.rb
|
|
202
208
|
- lib/mtproto/tl/objects/sent_code.rb
|
|
203
209
|
- lib/mtproto/tl/objects/server_dh_inner_data.rb
|
|
@@ -220,6 +226,7 @@ files:
|
|
|
220
226
|
- lib/mtproto/tl/objects/updates.rb
|
|
221
227
|
- lib/mtproto/tl/objects/updates_difference.rb
|
|
222
228
|
- lib/mtproto/tl/objects/updates_state.rb
|
|
229
|
+
- lib/mtproto/tl/objects/upload_media.rb
|
|
223
230
|
- lib/mtproto/tl/objects/upload_profile_photo.rb
|
|
224
231
|
- lib/mtproto/tl/objects/users.rb
|
|
225
232
|
- lib/mtproto/tl/peers.rb
|