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.
@@ -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
+ # ![emoji](tg://emoji?id=123) -> 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
+ # ![label](tg://emoji?id=N) -> 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 ? '![' : '['
416
+ else '' # blockquote is prefix-based and not wrapped here; unknown types no-op
417
+ end
418
+ end
419
+
420
+ def close_marker(entity)
421
+ case entity.type
422
+ when :bold, :italic, :underline, :strike, :spoiler then WRAP.fetch(entity.type)
423
+ when :code then '`'
424
+ when :pre then '```'
425
+ when :text_url then "](#{escape_url(entity.url)})"
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