mtproto 0.0.19 → 0.0.20
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/lib/mtproto/client/api/get_bot_callback_answer.rb +22 -0
- data/lib/mtproto/client/api/get_channel_difference.rb +22 -0
- data/lib/mtproto/client/api/import_bot_authorization.rb +27 -0
- data/lib/mtproto/client/api/send_message.rb +18 -3
- data/lib/mtproto/client/api.rb +21 -9
- data/lib/mtproto/client/rpc.rb +13 -1
- data/lib/mtproto/client.rb +129 -34
- data/lib/mtproto/dc.rb +31 -0
- data/lib/mtproto/markdown.rb +439 -0
- data/lib/mtproto/tl/constructors.rb +24 -0
- data/lib/mtproto/tl/message_entity.rb +154 -0
- data/lib/mtproto/tl/objects/bot_callback_answer.rb +75 -0
- data/lib/mtproto/tl/objects/channel_difference.rb +163 -0
- data/lib/mtproto/tl/objects/channels_edit_banned.rb +23 -6
- data/lib/mtproto/tl/objects/get_bot_info.rb +58 -0
- data/lib/mtproto/tl/objects/message.rb +26 -6
- data/lib/mtproto/tl/objects/messages_add_chat_user.rb +27 -0
- data/lib/mtproto/tl/objects/messages_create_chat.rb +47 -0
- data/lib/mtproto/tl/objects/reply_keyboard_markup.rb +26 -1
- data/lib/mtproto/tl/objects/send_message.rb +37 -3
- data/lib/mtproto/tl/objects/update.rb +15 -0
- data/lib/mtproto/tl/reader.rb +35 -8
- data/lib/mtproto/version.rb +1 -1
- data/lib/mtproto.rb +3 -0
- metadata +12 -1
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'tl/message_entity'
|
|
4
|
+
|
|
5
|
+
module MTProto
|
|
6
|
+
# Telegram-MarkdownV2-style formatting: text markup <-> (plain text, entities).
|
|
7
|
+
#
|
|
8
|
+
# text, entities = MTProto::Markdown.parse("*bold* and _italic_ and ||spoiler||")
|
|
9
|
+
# markup = MTProto::Markdown.render(text, entities)
|
|
10
|
+
#
|
|
11
|
+
# Entity offsets/lengths are in **UTF-16 code units** (Telegram's unit), so the
|
|
12
|
+
# parser counts astral characters (emoji) as 2. Supported markup:
|
|
13
|
+
# *bold* _italic_ __underline__ ~strikethrough~ ||spoiler||
|
|
14
|
+
# `inline code` ```lang\npre block```
|
|
15
|
+
# [label](http://url) -> text_url
|
|
16
|
+
# [label](tg://user?id=123) -> mention (text-mention)
|
|
17
|
+
#  -> custom emoji
|
|
18
|
+
# >quoted line(s) -> blockquote
|
|
19
|
+
# \x escapes any markup character x.
|
|
20
|
+
#
|
|
21
|
+
# Malformed markup (an unclosed entity, a broken link) raises ParseError, matching
|
|
22
|
+
# Telegram's own "can't parse entities" rejection rather than sending garbage.
|
|
23
|
+
#
|
|
24
|
+
# Known limitations (vs full Telegram MarkdownV2): expandable/collapsed blockquotes
|
|
25
|
+
# are not produced; adjacent italic/underline runs (the `_`/`__` adjacency, e.g.
|
|
26
|
+
# `___x___`) are rejected as ambiguous; link labels are literal text (no nested
|
|
27
|
+
# formatting inside `[...]`); auto-detected entities (url/mention/hashtag/email/…)
|
|
28
|
+
# are left to Telegram's server-side detection and not emitted by parse.
|
|
29
|
+
module Markdown
|
|
30
|
+
class ParseError < StandardError; end
|
|
31
|
+
|
|
32
|
+
Entity = MTProto::TL::MessageEntity
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def parse(source)
|
|
37
|
+
Parser.new(utf8(source)).parse
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def render(text, entities)
|
|
41
|
+
Renderer.new(utf8(text), entities).render
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def utf16_size(char)
|
|
45
|
+
char.ord > 0xFFFF ? 2 : 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Treat input as UTF-8 text and reject invalid bytes with a clear error rather
|
|
49
|
+
# than letting a low-level ArgumentError/Encoding error escape from deep inside.
|
|
50
|
+
def utf8(str)
|
|
51
|
+
s = str.to_s
|
|
52
|
+
s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
|
|
53
|
+
raise ParseError, 'input is not valid UTF-8' unless s.valid_encoding?
|
|
54
|
+
|
|
55
|
+
s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Single-pass scanner. Paired markers (bold/italic/underline/strike/spoiler) use
|
|
59
|
+
# a nesting stack; code/pre/links/custom-emoji are consumed as self-contained
|
|
60
|
+
# spans; blockquotes are recognised at line starts. Everything is tracked in
|
|
61
|
+
# UTF-16 units so the emitted entity offsets match the wire.
|
|
62
|
+
class Parser
|
|
63
|
+
MARKERS = { '||' => :spoiler, '__' => :underline, '*' => :bold, '_' => :italic, '~' => :strike }.freeze
|
|
64
|
+
# Longest first so `__`/`||` win over `_`/`|`.
|
|
65
|
+
MARKER_ORDER = ['```', '||', '__', '`', '*', '_', '~'].freeze
|
|
66
|
+
|
|
67
|
+
def initialize(source)
|
|
68
|
+
@chars = source.to_s.chars
|
|
69
|
+
@len = @chars.length
|
|
70
|
+
@i = 0
|
|
71
|
+
@text = +''
|
|
72
|
+
@u16 = 0
|
|
73
|
+
@stack = []
|
|
74
|
+
@entities = []
|
|
75
|
+
@quote = nil
|
|
76
|
+
@at_line_start = true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse
|
|
80
|
+
scan while @i < @len
|
|
81
|
+
close_quote
|
|
82
|
+
raise ParseError, "unclosed #{@stack.last[:type]}" unless @stack.empty?
|
|
83
|
+
|
|
84
|
+
# Drop zero-length entities (e.g. from empty markup like "**") — Telegram
|
|
85
|
+
# rejects them with ENTITY_BOUNDS_INVALID.
|
|
86
|
+
kept = @entities.reject { |e| e.length.to_i <= 0 }
|
|
87
|
+
[@text, kept.sort_by { |e| [e.offset, -e.length] }]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def scan
|
|
93
|
+
handle_line_start if @at_line_start
|
|
94
|
+
return if @i >= @len # a bare '>' line at EOF advances past the end
|
|
95
|
+
|
|
96
|
+
c = @chars[@i]
|
|
97
|
+
|
|
98
|
+
return escape if c == '\\'
|
|
99
|
+
return newline if c == "\n"
|
|
100
|
+
return if try_custom_emoji || try_link || try_marker
|
|
101
|
+
|
|
102
|
+
emit(c)
|
|
103
|
+
@i += 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A blockquote is a run of lines each starting with '>'. Reaching a line that
|
|
107
|
+
# doesn't (or EOF) closes it. code/pre spans are consumed whole (incl. their
|
|
108
|
+
# newlines) by open_code/open_pre, so a line start is never reached inside one.
|
|
109
|
+
def handle_line_start
|
|
110
|
+
@at_line_start = false
|
|
111
|
+
|
|
112
|
+
if @chars[@i] == '>'
|
|
113
|
+
open_quote
|
|
114
|
+
@i += 1
|
|
115
|
+
@i += 1 if @chars[@i] == ' ' # one optional space after '>'
|
|
116
|
+
else
|
|
117
|
+
close_quote
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def open_quote
|
|
122
|
+
return if @quote
|
|
123
|
+
|
|
124
|
+
@quote = { start: @u16, end: @u16 }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Emit the blockquote entity for the accumulated quoted lines, ending at the
|
|
128
|
+
# last quoted content char (a trailing newline before a non-quote line is not
|
|
129
|
+
# part of the quote). Expandable/collapsed quotes are not produced (the `||`
|
|
130
|
+
# end-mark collides with spoiler markup), so `collapsed` is always false.
|
|
131
|
+
def close_quote
|
|
132
|
+
return unless @quote
|
|
133
|
+
|
|
134
|
+
@entities << Entity.new(type: :blockquote, offset: @quote[:start],
|
|
135
|
+
length: @quote[:end] - @quote[:start], collapsed: false)
|
|
136
|
+
@quote = nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def newline
|
|
140
|
+
emit("\n")
|
|
141
|
+
@i += 1
|
|
142
|
+
@at_line_start = true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def escape
|
|
146
|
+
nxt = @chars[@i + 1]
|
|
147
|
+
raise ParseError, 'dangling escape at end of input' if nxt.nil?
|
|
148
|
+
|
|
149
|
+
emit(nxt)
|
|
150
|
+
@i += 2
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def emit(char)
|
|
154
|
+
@text << char
|
|
155
|
+
@u16 += Markdown.utf16_size(char)
|
|
156
|
+
@quote[:end] = @u16 if @quote && char != "\n"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
#  -> custom_emoji. Only when a real emoji id parses.
|
|
160
|
+
def try_custom_emoji
|
|
161
|
+
return false unless @chars[@i] == '!' && @chars[@i + 1] == '['
|
|
162
|
+
|
|
163
|
+
save = @i
|
|
164
|
+
@i += 1
|
|
165
|
+
unless try_link(custom_emoji: true)
|
|
166
|
+
@i = save
|
|
167
|
+
return false
|
|
168
|
+
end
|
|
169
|
+
true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# [label](url). label is literal text (escapes honoured, no nested markup);
|
|
173
|
+
# url picks the entity kind: tg://user?id / tg://emoji?id / anything else. On
|
|
174
|
+
# any mismatch @i is untouched (the reads use a local cursor), so '[' falls
|
|
175
|
+
# through to a literal.
|
|
176
|
+
def try_link(custom_emoji: false)
|
|
177
|
+
return false unless @chars[@i] == '['
|
|
178
|
+
|
|
179
|
+
label, after_label = read_link_label(@i + 1)
|
|
180
|
+
return false if label.nil?
|
|
181
|
+
return false unless @chars[after_label] == '('
|
|
182
|
+
|
|
183
|
+
url, after_url = read_link_url(after_label + 1)
|
|
184
|
+
return false if url.nil?
|
|
185
|
+
|
|
186
|
+
entity = link_entity(url, custom_emoji: custom_emoji)
|
|
187
|
+
return false if entity.nil?
|
|
188
|
+
|
|
189
|
+
entity.offset = @u16
|
|
190
|
+
label.each_char { |ch| emit(ch) }
|
|
191
|
+
entity.length = @u16 - entity.offset
|
|
192
|
+
@entities << entity
|
|
193
|
+
@i = after_url
|
|
194
|
+
true
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Returns [label, index_just_after_']'] or [nil, _] when no ']' on this line.
|
|
198
|
+
def read_link_label(from)
|
|
199
|
+
out = +''
|
|
200
|
+
j = from
|
|
201
|
+
while j < @len
|
|
202
|
+
ch = @chars[j]
|
|
203
|
+
if ch == '\\' && j + 1 < @len
|
|
204
|
+
out << @chars[j + 1]
|
|
205
|
+
j += 2
|
|
206
|
+
elsif ch == ']'
|
|
207
|
+
return [out, j + 1]
|
|
208
|
+
elsif ch == "\n"
|
|
209
|
+
return [nil, from]
|
|
210
|
+
else
|
|
211
|
+
out << ch
|
|
212
|
+
j += 1
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
[nil, from]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def read_link_url(from)
|
|
219
|
+
out = +''
|
|
220
|
+
j = from
|
|
221
|
+
while j < @len
|
|
222
|
+
ch = @chars[j]
|
|
223
|
+
if ch == '\\' && j + 1 < @len
|
|
224
|
+
out << @chars[j + 1]
|
|
225
|
+
j += 2
|
|
226
|
+
elsif ch == ')'
|
|
227
|
+
return [out, j + 1]
|
|
228
|
+
elsif ch == "\n"
|
|
229
|
+
return [nil, from]
|
|
230
|
+
else
|
|
231
|
+
out << ch
|
|
232
|
+
j += 1
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
[nil, from]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def link_entity(url, custom_emoji:)
|
|
239
|
+
if custom_emoji
|
|
240
|
+
id = url[%r{\Atg://emoji\?id=(\d+)\z}, 1]
|
|
241
|
+
return id && Entity.new(type: :custom_emoji, document_id: id.to_i)
|
|
242
|
+
end
|
|
243
|
+
if (id = url[%r{\Atg://user\?id=(\d+)\z}, 1])
|
|
244
|
+
return Entity.new(type: :mention_name, user_id: id.to_i)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
Entity.new(type: :text_url, url: url)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def try_marker
|
|
251
|
+
MARKER_ORDER.each do |m|
|
|
252
|
+
next unless @chars[@i, m.length].join == m
|
|
253
|
+
|
|
254
|
+
@i += m.length
|
|
255
|
+
apply_marker(m)
|
|
256
|
+
return true
|
|
257
|
+
end
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def apply_marker(marker)
|
|
262
|
+
return open_pre if marker == '```'
|
|
263
|
+
return open_code if marker == '`'
|
|
264
|
+
|
|
265
|
+
type = MARKERS.fetch(marker)
|
|
266
|
+
if @stack.last && @stack.last[:marker] == marker
|
|
267
|
+
top = @stack.pop
|
|
268
|
+
@entities << Entity.new(type: type, offset: top[:start], length: @u16 - top[:start])
|
|
269
|
+
elsif @stack.any? { |s| s[:marker] == marker }
|
|
270
|
+
raise ParseError, "improperly nested #{type}"
|
|
271
|
+
else
|
|
272
|
+
@stack << { marker: marker, type: type, start: @u16 }
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# `inline code` — content is literal up to the next backtick (\` escapes one).
|
|
277
|
+
def open_code
|
|
278
|
+
start = @u16
|
|
279
|
+
while @i < @len
|
|
280
|
+
c = @chars[@i]
|
|
281
|
+
if c == '\\' && @chars[@i + 1] == '`'
|
|
282
|
+
emit('`')
|
|
283
|
+
@i += 2
|
|
284
|
+
elsif c == '`'
|
|
285
|
+
@i += 1
|
|
286
|
+
@entities << Entity.new(type: :code, offset: start, length: @u16 - start)
|
|
287
|
+
return
|
|
288
|
+
else
|
|
289
|
+
emit(c)
|
|
290
|
+
@i += 1
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
raise ParseError, 'unclosed code span'
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# ```lang\ncontent``` — the first line (up to \n) is the language when it is a
|
|
297
|
+
# bare token; otherwise there is no language and the whole body is content.
|
|
298
|
+
def open_pre
|
|
299
|
+
close = find_pre_close(@i)
|
|
300
|
+
raise ParseError, 'unclosed pre block' if close.nil?
|
|
301
|
+
|
|
302
|
+
body = @chars[@i...close].join
|
|
303
|
+
language, content = split_pre_language(body)
|
|
304
|
+
start = @u16
|
|
305
|
+
content.each_char { |ch| emit(ch) }
|
|
306
|
+
@entities << Entity.new(type: :pre, offset: start, length: @u16 - start, language: language)
|
|
307
|
+
@i = close + 3
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def find_pre_close(from)
|
|
311
|
+
j = from
|
|
312
|
+
while j < @len
|
|
313
|
+
return j if @chars[j, 3].join == '```'
|
|
314
|
+
|
|
315
|
+
j += 1
|
|
316
|
+
end
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def split_pre_language(body)
|
|
321
|
+
nl = body.index("\n")
|
|
322
|
+
return ['', body] if nl.nil?
|
|
323
|
+
|
|
324
|
+
first = body[0...nl]
|
|
325
|
+
return [first, body[(nl + 1)..]] if first.match?(/\A[A-Za-z0-9_+.-]*\z/)
|
|
326
|
+
|
|
327
|
+
['', body]
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# entities -> MarkdownV2 markup. Escapes literal markup characters and wraps each
|
|
332
|
+
# entity's UTF-16 range with its markers. Entities are opened outer-first and
|
|
333
|
+
# closed inner-first so proper nesting round-trips. Works in UTF-16 unit space.
|
|
334
|
+
class Renderer
|
|
335
|
+
SPECIAL = '_*[]()~`>#+-=|{}.!\\'
|
|
336
|
+
WRAP = { bold: '*', italic: '_', underline: '__', strike: '~', spoiler: '||' }.freeze
|
|
337
|
+
|
|
338
|
+
def initialize(text, entities)
|
|
339
|
+
@units = (text || '').encode('UTF-16LE').unpack('v*')
|
|
340
|
+
@entities = (entities || []).map { |e| e.is_a?(Hash) ? Entity.new(**e) : e }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def render
|
|
344
|
+
opens = Hash.new { |h, k| h[k] = [] }
|
|
345
|
+
closes = Hash.new { |h, k| h[k] = [] }
|
|
346
|
+
@entities.each_with_index do |e, i|
|
|
347
|
+
opens[e.offset] << [e, i]
|
|
348
|
+
closes[e.offset + e.length] << [e, i]
|
|
349
|
+
end
|
|
350
|
+
verbatim = ranges_of(%i[code pre]) # content here is literal — never escaped
|
|
351
|
+
quotes = ranges_of(%i[blockquote]) # rendered as a '>' line prefix
|
|
352
|
+
|
|
353
|
+
out = +''
|
|
354
|
+
pos = 0
|
|
355
|
+
line_start = true
|
|
356
|
+
loop do
|
|
357
|
+
# Close inner-first, the exact mirror of the open order (outer-first), so
|
|
358
|
+
# entities that end together don't emit crossed markers. Ties broken by the
|
|
359
|
+
# reverse of the open tiebreak.
|
|
360
|
+
closes[pos].sort_by { |(e, i)| [e.length, -i] }.each { |(e, _)| out << close_marker(e) }
|
|
361
|
+
out << '>' if line_start && in_range?(quotes, pos)
|
|
362
|
+
opens[pos].sort_by { |(e, i)| [-e.length, i] }.each { |(e, _)| out << open_marker(e) }
|
|
363
|
+
break if pos >= @units.length
|
|
364
|
+
|
|
365
|
+
u = @units[pos]
|
|
366
|
+
if high_surrogate?(u) && pos + 1 < @units.length
|
|
367
|
+
out << pair_to_s(u, @units[pos + 1]) # an astral char never needs escaping
|
|
368
|
+
pos += 2
|
|
369
|
+
line_start = false
|
|
370
|
+
else
|
|
371
|
+
ch = unit_to_s(u)
|
|
372
|
+
out << render_char(ch, in_range?(verbatim, pos))
|
|
373
|
+
line_start = ch == "\n"
|
|
374
|
+
pos += 1
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
out
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
private
|
|
381
|
+
|
|
382
|
+
def ranges_of(types)
|
|
383
|
+
@entities.select { |e| types.include?(e.type) }.map { |e| e.offset...(e.offset + e.length) }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def in_range?(ranges, pos)
|
|
387
|
+
ranges.any? { |r| r.cover?(pos) }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def high_surrogate?(unit)
|
|
391
|
+
unit.between?(0xD800, 0xDBFF)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def pair_to_s(high, low)
|
|
395
|
+
[high, low].pack('v2').force_encoding('UTF-16LE').encode('UTF-8')
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def unit_to_s(unit)
|
|
399
|
+
[unit].pack('v').force_encoding('UTF-16LE').encode('UTF-8')
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Literal chars inside code/pre are emitted verbatim; elsewhere MarkdownV2
|
|
403
|
+
# specials are backslash-escaped so they re-parse as text, not markup.
|
|
404
|
+
def render_char(char, verbatim)
|
|
405
|
+
return char if verbatim
|
|
406
|
+
|
|
407
|
+
SPECIAL.include?(char) ? "\\#{char}" : char
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def open_marker(entity)
|
|
411
|
+
case entity.type
|
|
412
|
+
when :bold, :italic, :underline, :strike, :spoiler then WRAP.fetch(entity.type)
|
|
413
|
+
when :code then '`'
|
|
414
|
+
when :pre then entity.language.to_s.empty? ? "```\n" : "```#{entity.language}\n"
|
|
415
|
+
when :text_url, :mention_name, :custom_emoji then entity.type == :custom_emoji ? '})"
|
|
426
|
+
when :mention_name then "](tg://user?id=#{entity.user_id})"
|
|
427
|
+
when :custom_emoji then "](tg://emoji?id=#{entity.document_id})"
|
|
428
|
+
else ''
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Inside a link's (...) the parser stops the URL at the first ')' and treats
|
|
433
|
+
# '\' as an escape, so those two must be escaped to survive a re-parse.
|
|
434
|
+
def escape_url(url)
|
|
435
|
+
url.to_s.gsub(/[\\)]/) { |c| "\\#{c}" }
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
@@ -13,6 +13,15 @@ module MTProto
|
|
|
13
13
|
BAD_SERVER_SALT = 0xedab447b
|
|
14
14
|
BAD_MSG_NOTIFICATION = 0xa7eff811
|
|
15
15
|
NEW_SESSION_CREATED = 0x9ec20908
|
|
16
|
+
PONG = 0x347773c5
|
|
17
|
+
MSGS_ACK = 0x62d6b459
|
|
18
|
+
MSGS_STATE_REQ = 0xda69fb52
|
|
19
|
+
MSGS_STATE_INFO = 0x04deb57d
|
|
20
|
+
MSGS_ALL_INFO = 0x8cc0d131
|
|
21
|
+
MSG_DETAILED_INFO = 0x276d3ec6
|
|
22
|
+
MSG_NEW_DETAILED_INFO = 0x809db6df
|
|
23
|
+
FUTURE_SALT = 0x0949d9dc
|
|
24
|
+
FUTURE_SALTS = 0xae500895
|
|
16
25
|
|
|
17
26
|
# Peers
|
|
18
27
|
PEER_USER = 0x59511722
|
|
@@ -108,10 +117,13 @@ module MTProto
|
|
|
108
117
|
# Updates
|
|
109
118
|
UPDATES = 0x74ae4240
|
|
110
119
|
UPDATES_COMBINED = 0x725b04c3
|
|
120
|
+
UPDATES_TOO_LONG = 0xe317af7e
|
|
111
121
|
UPDATE_NEW_MESSAGE = 0x1f2b0afd
|
|
112
122
|
UPDATE_SHORT = 0x78d4dec1
|
|
113
123
|
UPDATE_SHORT_MESSAGE = 0x313bc7f8
|
|
124
|
+
UPDATE_SHORT_CHAT_MESSAGE = 0x4d6deea5
|
|
114
125
|
UPDATE_SHORT_SENT_MESSAGE = 0x9015e101
|
|
126
|
+
UPDATE_LOGIN_TOKEN = 0x564fe691
|
|
115
127
|
|
|
116
128
|
# Channel updates
|
|
117
129
|
INPUT_CHANNEL = 0xf35aec28
|
|
@@ -128,6 +140,18 @@ module MTProto
|
|
|
128
140
|
UPDATES_DIFFERENCE = 0x00f49ca0
|
|
129
141
|
UPDATES_DIFFERENCE_SLICE = 0xa8fb1981
|
|
130
142
|
UPDATES_DIFFERENCE_TOO_LONG = 0x4afe8f6d
|
|
143
|
+
|
|
144
|
+
# The top-level `Updates` type — the only constructors dispatched as updates.
|
|
145
|
+
UPDATE_CONSTRUCTORS = [
|
|
146
|
+
UPDATES, UPDATES_COMBINED, UPDATES_TOO_LONG,
|
|
147
|
+
UPDATE_SHORT, UPDATE_SHORT_MESSAGE, UPDATE_SHORT_CHAT_MESSAGE, UPDATE_SHORT_SENT_MESSAGE
|
|
148
|
+
].freeze
|
|
149
|
+
|
|
150
|
+
# MTProto service/transport messages that carry no API payload.
|
|
151
|
+
SERVICE_MESSAGES = [
|
|
152
|
+
PONG, MSGS_ACK, MSGS_STATE_REQ, MSGS_STATE_INFO, MSGS_ALL_INFO,
|
|
153
|
+
MSG_DETAILED_INFO, MSG_NEW_DETAILED_INFO, FUTURE_SALT, FUTURE_SALTS
|
|
154
|
+
].freeze
|
|
131
155
|
end
|
|
132
156
|
end
|
|
133
157
|
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'reader'
|
|
4
|
+
|
|
5
|
+
module MTProto
|
|
6
|
+
module TL
|
|
7
|
+
# A single MessageEntity — one formatting span over a message's text. `offset`
|
|
8
|
+
# and `length` are in **UTF-16 code units** (Telegram's unit, what the wire
|
|
9
|
+
# carries), NOT bytes or codepoints. `type` is a symbol; any type-specific
|
|
10
|
+
# payload rides in the matching field:
|
|
11
|
+
# :text_url -> url
|
|
12
|
+
# :pre -> language
|
|
13
|
+
# :mention_name -> user_id (+ access_hash, needed to SEND a text-mention)
|
|
14
|
+
# :custom_emoji -> document_id
|
|
15
|
+
# :blockquote -> collapsed (expandable quote)
|
|
16
|
+
MessageEntity = Struct.new(
|
|
17
|
+
# :length intentionally shadows Struct#length — it is the span length, the field we want.
|
|
18
|
+
:type, :offset, :length, :url, :language, :user_id, :access_hash, :document_id, :collapsed, # rubocop:disable Lint/StructNewOverride
|
|
19
|
+
keyword_init: true
|
|
20
|
+
) do
|
|
21
|
+
def to_h
|
|
22
|
+
super.compact
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Codec for `Vector<MessageEntity>` — the formatting-span list carried by
|
|
27
|
+
# messages.sendMessage/editMessage (entities, flags.3) and parsed back out of an
|
|
28
|
+
# inbound message. `serialize` builds the wire byte array from [MessageEntity];
|
|
29
|
+
# `parse` reads it back. Layouts follow the layer-227 schema. The only input vs
|
|
30
|
+
# output split is the text-mention: sending uses inputMessageEntityMentionName
|
|
31
|
+
# (an InputUser), a received one is messageEntityMentionName (a bare user_id).
|
|
32
|
+
module MessageEntities
|
|
33
|
+
extend Binary
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
VECTOR = 0x1cb5c415
|
|
38
|
+
INPUT_USER = 0xf21158c6
|
|
39
|
+
INPUT_USER_SELF = 0xf7c1b13f
|
|
40
|
+
INPUT_MESSAGE_ENTITY_MENTION_NAME = 0x208e68c9
|
|
41
|
+
|
|
42
|
+
# type -> constructor id for the entities that are identical on input/output.
|
|
43
|
+
TYPE_TO_CTOR = {
|
|
44
|
+
unknown: 0xbb92ba95, mention: 0xfa04579d, hashtag: 0x6f635b0d,
|
|
45
|
+
bot_command: 0x6cef8ac7, url: 0x6ed02538, email: 0x64e475c2,
|
|
46
|
+
bold: 0xbd610bc9, italic: 0x826f8b60, code: 0x28a20571, pre: 0x73924be0,
|
|
47
|
+
text_url: 0x76a6d327, mention_name: 0xdc7b1140, phone: 0x9b69e34b,
|
|
48
|
+
cashtag: 0x4c4e743f, underline: 0x9c4e7e8b, strike: 0xbf0693d4,
|
|
49
|
+
bank_card: 0x761e6af4, spoiler: 0x32ca960f, custom_emoji: 0xc8cf05f8,
|
|
50
|
+
blockquote: 0xf1ccaaac
|
|
51
|
+
}.freeze
|
|
52
|
+
CTOR_TO_TYPE = TYPE_TO_CTOR.invert.freeze
|
|
53
|
+
|
|
54
|
+
# [MessageEntity] -> wire byte array for `Vector<MessageEntity>`.
|
|
55
|
+
def serialize(entities)
|
|
56
|
+
out = u32_b(VECTOR) + u32_b(entities.length)
|
|
57
|
+
entities.each { |e| out += serialize_entity(e) }
|
|
58
|
+
out
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Wire byte array for one entity, dispatched on its type. Sending a text-mention
|
|
62
|
+
# needs an InputUser: an explicit access_hash, or 0 for a user the DC already
|
|
63
|
+
# knows (e.g. a mutual contact); a wrong hash is rejected by the server.
|
|
64
|
+
def serialize_entity(entity)
|
|
65
|
+
return serialize_mention_name(entity) if entity.type == :mention_name
|
|
66
|
+
|
|
67
|
+
ctor = TYPE_TO_CTOR.fetch(entity.type) do
|
|
68
|
+
raise ArgumentError, "unknown entity type: #{entity.type.inspect}"
|
|
69
|
+
end
|
|
70
|
+
bytes = u32_b(ctor)
|
|
71
|
+
bytes += u32_b(entity.collapsed ? 1 : 0) if entity.type == :blockquote # flags.0 collapsed
|
|
72
|
+
bytes += u32_b(entity.offset) + u32_b(entity.length)
|
|
73
|
+
case entity.type
|
|
74
|
+
when :text_url then bytes += tl_string(entity.url.to_s)
|
|
75
|
+
when :pre then bytes += tl_string(entity.language.to_s)
|
|
76
|
+
when :custom_emoji then bytes += u64_b(entity.document_id)
|
|
77
|
+
end
|
|
78
|
+
bytes
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Wire byte array + new offset for one `Vector<MessageEntity>` at `offset`;
|
|
82
|
+
# returns [[MessageEntity], new_offset]. An entity constructor we don't model is
|
|
83
|
+
# stepped over via the schema sizer so the walk stays aligned (and dropped).
|
|
84
|
+
def parse(data, offset)
|
|
85
|
+
return [[], offset] unless data[offset, 4].unpack1('L<') == VECTOR
|
|
86
|
+
|
|
87
|
+
io = offset + 4
|
|
88
|
+
count = data[io, 4].unpack1('L<')
|
|
89
|
+
io += 4
|
|
90
|
+
entities = []
|
|
91
|
+
count.times do
|
|
92
|
+
entity, io = parse_entity(data, io)
|
|
93
|
+
entities << entity if entity
|
|
94
|
+
end
|
|
95
|
+
[entities, io]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parse_entity(data, offset)
|
|
99
|
+
ctor = data[offset, 4].unpack1('L<')
|
|
100
|
+
type = CTOR_TO_TYPE[ctor]
|
|
101
|
+
return [nil, Reader.schema.skip(data, offset)] if type.nil?
|
|
102
|
+
|
|
103
|
+
io = offset + 4
|
|
104
|
+
collapsed = nil
|
|
105
|
+
if type == :blockquote
|
|
106
|
+
collapsed = data[io, 4].unpack1('L<').anybits?(1 << 0)
|
|
107
|
+
io += 4
|
|
108
|
+
end
|
|
109
|
+
ent_offset = data[io, 4].unpack1('l<')
|
|
110
|
+
ent_length = data[io + 4, 4].unpack1('l<')
|
|
111
|
+
io += 8
|
|
112
|
+
extra = {}
|
|
113
|
+
case type
|
|
114
|
+
when :text_url
|
|
115
|
+
extra[:url], io = Reader.read_tl_string(data, io)
|
|
116
|
+
when :pre
|
|
117
|
+
extra[:language], io = Reader.read_tl_string(data, io)
|
|
118
|
+
when :mention_name
|
|
119
|
+
extra[:user_id] = data[io, 8].unpack1('q<')
|
|
120
|
+
io += 8
|
|
121
|
+
when :custom_emoji
|
|
122
|
+
extra[:document_id] = data[io, 8].unpack1('q<')
|
|
123
|
+
io += 8
|
|
124
|
+
end
|
|
125
|
+
[MessageEntity.new(type: type, offset: ent_offset, length: ent_length, collapsed: collapsed, **extra), io]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser.
|
|
129
|
+
def serialize_mention_name(entity)
|
|
130
|
+
input_user =
|
|
131
|
+
if entity.user_id.nil?
|
|
132
|
+
u32_b(INPUT_USER_SELF)
|
|
133
|
+
else
|
|
134
|
+
u32_b(INPUT_USER) + u64_b(entity.user_id) + u64_b(entity.access_hash || 0)
|
|
135
|
+
end
|
|
136
|
+
u32_b(INPUT_MESSAGE_ENTITY_MENTION_NAME) + u32_b(entity.offset) + u32_b(entity.length) + input_user
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# TL `string` serializer (byte array): a length-prefixed, 4-byte-padded blob.
|
|
140
|
+
# Mirrors SendMessage#serialize_tl_string; kept local so the codec stands alone.
|
|
141
|
+
def tl_string(str)
|
|
142
|
+
bytes = str.to_s.dup.force_encoding('BINARY').bytes
|
|
143
|
+
length = bytes.length
|
|
144
|
+
if length <= 253
|
|
145
|
+
padding = (4 - ((length + 1) % 4)) % 4
|
|
146
|
+
[length] + bytes + ([0] * padding)
|
|
147
|
+
else
|
|
148
|
+
padding = (4 - ((length + 4) % 4)) % 4
|
|
149
|
+
[254] + u32_b(length)[0, 3] + bytes + ([0] * padding)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|