mtproto 0.0.27 → 0.0.29

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5077ec3124cfdacfdadca11188ff9a028ceb982a69e85ab78b05f4d9847430e
4
- data.tar.gz: be1a48d79d0f0f36bd032ceaec9df07ab2ecc9c3f3a0dccd2029b75083f2cddb
3
+ metadata.gz: 0a50a3572643e17c2435e3b310979e9a9ddbbf498bf8febb5819d96a73443899
4
+ data.tar.gz: 622d75782346d1cf190e3daa6097305afc3520b5fe5834b727a79bf4c0e86b36
5
5
  SHA512:
6
- metadata.gz: 4b7d8d18e86aca190aef0f7309f7f1235e926035ac62858efb429700d19d701a16465f7cd34fc1a94a49eb845739da10a9fef4579b4d98ab95497c0c52e3afa7
7
- data.tar.gz: ece9d015beeadd70fdf95422ec924aad10d0f585b0c9a7ac2e51f8d415069703e05d065426451fdb33bbebe8b90e33b5fd027acd4ebc6fdf162d2fdaaeb325ff
6
+ metadata.gz: 7ec7ee0beac3ee1143720d8e4972dba6a6f7db379069b0cc2a3274b5c81becb8e1353f448654b4c6c9d0385472d4985725292da56c040a50476ec94d2f980a93
7
+ data.tar.gz: c0951ce0991d6c960f6963647849156333bf5bebe4d59b0857af6703707430249c54bc4805be7b8e2527df84141f68dce9c43e6668ca5200eb4f4b7f3d146d4c
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../../tl/objects/get_channel_difference'
4
4
  require_relative '../../tl/objects/channel_difference'
5
+ require_relative '../../tl/objects/raw_response'
5
6
 
6
7
  module MTProto
7
8
  class Client
@@ -17,6 +18,13 @@ module MTProto
17
18
  TL::ChannelDifference
18
19
  ).body
19
20
  end
21
+
22
+ # The raw getChannelDifference bytes, for the update state machine (which parses
23
+ # the reply into its rich event shape). Goes through rpc_call for FLOOD_WAIT
24
+ # retry and CONNECTION_NOT_INITED reinit, same as get_channel_difference.
25
+ def get_channel_difference_raw(channel:, pts:, limit: 100)
26
+ rpc_call(TL::GetChannelDifference.new(channel: channel, pts: pts, limit: limit), TL::RawResponse).body.raw_bytes
27
+ end
20
28
  end
21
29
  end
22
30
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../../tl/objects/get_difference'
4
4
  require_relative '../../tl/objects/updates_difference'
5
+ require_relative '../../tl/objects/raw_response'
5
6
 
6
7
  module MTProto
7
8
  class Client
@@ -16,6 +17,15 @@ module MTProto
16
17
  TL::UpdatesDifference
17
18
  ).body
18
19
  end
20
+
21
+ # The raw getDifference bytes, for a caller that parses the reply itself (the
22
+ # update state machine, which needs the rich event shape TL::UpdatesDifference
23
+ # does not surface). Goes through rpc_call, so it inherits FLOOD_WAIT retry and
24
+ # CONNECTION_NOT_INITED reinit — both common while draining a backlog / after a
25
+ # reconnect.
26
+ def get_updates_difference_raw(pts:, date:, qts:)
27
+ rpc_call(TL::GetDifference.new(pts: pts, date: date, qts: qts), TL::RawResponse).body.raw_bytes
28
+ end
19
29
  end
20
30
  end
21
31
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../updates'
4
+
5
+ module MTProto
6
+ class Client
7
+ # The high-level update-consumption surface of a Client, mixed into it: register
8
+ # update callbacks (with a supported replace/clear seam instead of reaching into
9
+ # the callback list), the post-reconnect hook, and run_updates — the normalized,
10
+ # ordered, exactly-once event stream. The low-level fan-out (dispatch_update) and
11
+ # the @on_update_callbacks / @on_reconnect state stay in Client.
12
+ module UpdateApi
13
+ def on_update(&block)
14
+ @on_update_callbacks << block
15
+ end
16
+
17
+ # Replace every registered update callback with a single one, so a
18
+ # re-registration never stacks handlers (run_updates relies on this).
19
+ def replace_update_callback(&block)
20
+ @on_update_callbacks = [block]
21
+ end
22
+
23
+ def clear_update_callbacks
24
+ @on_update_callbacks = []
25
+ end
26
+
27
+ # Register a callback fired after the transport reconnects (a known gap point).
28
+ # It runs on the receiver fiber, so it must NOT block — enqueue work instead.
29
+ def on_reconnect(&block)
30
+ @on_reconnect = block
31
+ end
32
+
33
+ # High-level update consumption: hand the block a normalized, ordered,
34
+ # de-duplicated stream of Updates::Event, with the account and per-channel
35
+ # positions read/written through `store` (see Updates::MemoryStore for the
36
+ # contract). on_start, if given, runs once inside the started mainloop before
37
+ # the first update is consumed — where host startup that needs the API belongs,
38
+ # since an API call raises until the mainloop is running. Blocks until the client
39
+ # shuts down; Updates::Runner owns the wiring.
40
+ def run_updates(store:, logger: nil, on_start: nil, &)
41
+ Updates::Runner.new(client: self, store: store, logger: logger).run(on_start: on_start, &)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -5,6 +5,7 @@ require 'digest'
5
5
  require 'base64'
6
6
  require 'async'
7
7
  require 'async/condition'
8
+ require_relative 'updates'
8
9
  require_relative 'tl/constructors'
9
10
  require_relative 'transport/tcp_connection'
10
11
  require_relative 'transport/connection'
@@ -12,6 +13,7 @@ require_relative 'transport/abridged_packet_codec'
12
13
  require_relative 'tl/objects/message'
13
14
  require_relative 'client/rpc'
14
15
  require_relative 'client/api'
16
+ require_relative 'client/update_api'
15
17
  require_relative 'tl/objects/invoke_with_layer'
16
18
  require_relative 'tl/objects/init_connection'
17
19
  require_relative 'tl/objects/get_config'
@@ -20,6 +22,7 @@ require_relative 'tl/objects/help_config'
20
22
  module MTProto
21
23
  class Client
22
24
  extend DelegateMethods
25
+ include UpdateApi
23
26
 
24
27
  API_LAYER = 227
25
28
 
@@ -82,6 +85,7 @@ module MTProto
82
85
  @ack_ids = []
83
86
  @running = false
84
87
  @on_update_callbacks = []
88
+ @on_reconnect = nil
85
89
  end
86
90
 
87
91
  # Keepalive cadence: flush pending acks every ACK_INTERVAL seconds, send a
@@ -94,10 +98,6 @@ module MTProto
94
98
  RECONNECT_BACKOFF_BASE = 1
95
99
  RECONNECT_BACKOFF_MAX = 30
96
100
 
97
- def on_update(&block)
98
- @on_update_callbacks << block
99
- end
100
-
101
101
  def run_mainloop
102
102
  raise 'Auth key not set' unless auth_key?
103
103
  raise 'Mainloop already running' if @running
@@ -288,6 +288,7 @@ module MTProto
288
288
  return unless @running
289
289
 
290
290
  Async { init_connection! }
291
+ @on_reconnect&.call
291
292
  end
292
293
 
293
294
  # Reconnect the transport, retrying with exponential backoff until it succeeds
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MTProto
4
+ module Updates
5
+ # One normalized update the state machine emits, unified across the live push
6
+ # and the getDifference/getChannelDifference recovery paths so a host sees one
7
+ # shape however an update arrived. `kind` selects which payload fields are set:
8
+ # :message / :edit message (TL::Message#to_h)
9
+ # :join joined_user_id
10
+ # :leave left_user_id
11
+ # :reaction / :unreaction msg_id, reactors
12
+ # :delete deleted_ids
13
+ # :callback query_id, user_id, data, msg_id
14
+ # :guest_query query_id, message, qts
15
+ # `pts`/`pts_count`/`channel` drive ordering and gap detection; they are nil for
16
+ # updates outside the pts sequence (e.g. getDifference's common new_messages,
17
+ # which the protocol delivers without a per-message pts). `users`/`chats` are the
18
+ # peers that rode in the same batch (shared by reference) — the access_hash source
19
+ # a host caches and enriches from.
20
+ Event = Struct.new(
21
+ :kind, :channel, :pts, :pts_count,
22
+ :peer_type, :peer_id, :msg_id,
23
+ :message, :joined_user_id, :left_user_id, :deleted_ids, :reactors,
24
+ :query_id, :user_id, :data, :qts,
25
+ :users, :chats,
26
+ keyword_init: true
27
+ )
28
+ end
29
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tl/reader'
4
+ require_relative '../tl/objects/message'
5
+ require_relative '../tl/objects/updates'
6
+ require_relative 'event'
7
+
8
+ module MTProto
9
+ module Updates
10
+ # Turns ONE Telegram Update (a single entry of a Vector<Update>, or the inner
11
+ # update of an updateShort) into a normalized Event, or nil for an update we
12
+ # don't surface. Reuses the gem's TL::Message / Reader / Schema and the existing
13
+ # TL::Updates callback/guest-query readers. The envelope walking (which vector,
14
+ # which reply) lives in Parser; this module owns the per-update semantics.
15
+ module EventBuilder
16
+ module_function
17
+
18
+ UPDATE_NEW_MESSAGE = 0x1f2b0afd
19
+ UPDATE_NEW_CHANNEL_MESSAGE = 0x62ba04d9
20
+ UPDATE_EDIT_MESSAGE = 0xe40370a3
21
+ UPDATE_EDIT_CHANNEL_MESSAGE = 0x1b3f4df7
22
+ UPDATE_MESSAGE_REACTIONS = 0x1e297bfa
23
+ UPDATE_BOT_MESSAGE_REACTION = 0xac21d3ce
24
+ UPDATE_DELETE_CHANNEL_MESSAGES = 0xc32d5b12
25
+ UPDATE_BOT_CALLBACK_QUERY = 0xb9cfc48d
26
+ UPDATE_CHANNEL_PARTICIPANT = 0x985d3abb
27
+ UPDATE_BOT_GUEST_CHAT_QUERY = 0xcdd4093d
28
+
29
+ MESSAGE_SERVICE = 0x7a800e0a
30
+ ACTION_CHAT_ADD_USER = 0x15cefd00
31
+ ACTION_JOINED_BY_LINK = 0x031224c3
32
+ ACTION_CHAT_DELETE_USER = 0xa43f30cc
33
+
34
+ CHANNEL_PARTICIPANT = 0x1bd54456
35
+ # channelParticipant / Self / Admin / Creator — a still-present member.
36
+ MEMBER_PARTICIPANTS = [0x1bd54456, 0xa9478a1a, 0x34c3bb53, 0x2fe601d3].freeze
37
+
38
+ REACTION_EMOJI = 0x1b2286b8
39
+ REACTION_EMPTY = 0x79f5d419
40
+ REACTION_CUSTOM_EMOJI = 0x8935fc73
41
+ REACTION_PAID = 0x523da4eb
42
+ VECTOR = 0x1cb5c415
43
+
44
+ # One Vector<Update> entry at `offset` → its Event, or nil for an update we
45
+ # don't surface (the caller's Schema#skip still advances past it).
46
+ def classify_update(data, offset, ctor)
47
+ case ctor
48
+ when UPDATE_NEW_MESSAGE, UPDATE_NEW_CHANNEL_MESSAGE
49
+ new_message(data, offset, channel: ctor == UPDATE_NEW_CHANNEL_MESSAGE)
50
+ when UPDATE_EDIT_MESSAGE, UPDATE_EDIT_CHANNEL_MESSAGE
51
+ edit_message(data, offset, channel: ctor == UPDATE_EDIT_CHANNEL_MESSAGE)
52
+ when UPDATE_MESSAGE_REACTIONS
53
+ reaction_event(data, offset)
54
+ when UPDATE_BOT_MESSAGE_REACTION
55
+ bot_reaction_event(data, offset)
56
+ when UPDATE_DELETE_CHANNEL_MESSAGES
57
+ delete_event(data, offset)
58
+ when UPDATE_BOT_CALLBACK_QUERY
59
+ callback_event(data, offset)
60
+ when UPDATE_CHANNEL_PARTICIPANT
61
+ participant_event(data, offset)
62
+ when UPDATE_BOT_GUEST_CHAT_QUERY
63
+ guest_query_event(data, offset)
64
+ end
65
+ end
66
+
67
+ # A Vector<Message> entry (a difference's new_messages) → a :message / :join /
68
+ # :leave Event WITHOUT pts (the protocol omits per-message pts there), or nil.
69
+ # `channel` is the envelope's kind (true for a channelDifference), since the
70
+ # message itself doesn't say.
71
+ def message_or_service(data, offset, ctor, channel:)
72
+ if ctor == TL::Constructors::MESSAGE
73
+ message_event(data, offset, ctor, kind: :message, channel: channel, pts: nil, pts_count: nil)
74
+ elsif ctor == MESSAGE_SERVICE
75
+ service_event(data, offset, channel: channel, pts: nil, pts_count: nil)
76
+ end
77
+ end
78
+
79
+ # updateNew(Channel)Message: the update tag, an inner Message (plain message, or
80
+ # a messageService that may be a join/leave), then pts:int pts_count:int.
81
+ def new_message(data, offset, channel:)
82
+ inner_off = offset + 4
83
+ inner_ctor = data[inner_off, 4].unpack1('L<')
84
+ pts, pts_count = pts_after(data, inner_off)
85
+
86
+ if inner_ctor == TL::Constructors::MESSAGE
87
+ message_event(data, inner_off, inner_ctor, kind: :message, channel: channel, pts: pts, pts_count: pts_count)
88
+ elsif inner_ctor == MESSAGE_SERVICE
89
+ service_event(data, inner_off, channel: channel, pts: pts, pts_count: pts_count)
90
+ end
91
+ end
92
+
93
+ def edit_message(data, offset, channel:)
94
+ inner_off = offset + 4
95
+ inner_ctor = data[inner_off, 4].unpack1('L<')
96
+ return unless inner_ctor == TL::Constructors::MESSAGE
97
+
98
+ pts, pts_count = pts_after(data, inner_off)
99
+ message_event(data, inner_off, inner_ctor, kind: :edit, channel: channel, pts: pts, pts_count: pts_count)
100
+ end
101
+
102
+ def message_event(data, offset, constructor, kind:, channel:, pts:, pts_count:)
103
+ msg = TL::Message.deserialize(data, offset, constructor)
104
+ return unless msg
105
+
106
+ Event.new(kind: kind, channel: channel, pts: pts, pts_count: pts_count,
107
+ peer_type: msg.peer_type, peer_id: msg.peer_id, msg_id: msg.id, message: msg.to_h)
108
+ end
109
+
110
+ # pts:int pts_count:int sit right after the inner Message; skip it to read them.
111
+ def pts_after(data, msg_off)
112
+ after = TL::Reader.schema.skip(data, msg_off)
113
+ [data[after, 4].unpack1('l<'), data[after + 4, 4].unpack1('l<')]
114
+ end
115
+
116
+ # messageService carrying a membership action → a :join / :leave Event, else nil.
117
+ # peer_id is the service message's own chat (where the event happened).
118
+ def service_event(data, offset, channel:, pts:, pts_count:)
119
+ io = offset + 4
120
+ flags = data[io, 4].unpack1('L<')
121
+ io += 8 # flags + id
122
+ _from_type, from_id, io = TL::Reader.parse_peer(data, io) if flags.anybits?(1 << 8) # from_id
123
+ peer_type, peer_id, io = TL::Reader.parse_peer(data, io)
124
+ io = TL::Reader.schema.skip(data, io) if flags.anybits?(1 << 28) # saved_peer_id
125
+ io = TL::Reader.schema.skip(data, io) if flags.anybits?(1 << 3) # reply_to
126
+ io += 4 # date
127
+ action = data[io, 4].unpack1('L<')
128
+ id = data[offset + 8, 4].unpack1('l<')
129
+
130
+ event = membership_event(data, io, action, from_id, peer_type, peer_id, id)
131
+ return unless event
132
+
133
+ event.channel = channel
134
+ event.pts = pts
135
+ event.pts_count = pts_count
136
+ event
137
+ rescue StandardError
138
+ nil
139
+ end
140
+
141
+ def membership_event(data, io, action, from_id, peer_type, peer_id, id)
142
+ case action
143
+ when ACTION_CHAT_ADD_USER
144
+ joined = from_id
145
+ if data[io + 4, 4].unpack1('L<') == VECTOR && data[io + 8, 4].unpack1('L<').positive?
146
+ joined = data[io + 12, 8].unpack1('Q<')
147
+ end
148
+ Event.new(kind: :join, joined_user_id: joined, peer_type: peer_type, peer_id: peer_id, msg_id: id)
149
+ when ACTION_JOINED_BY_LINK
150
+ Event.new(kind: :join, joined_user_id: from_id, peer_type: peer_type, peer_id: peer_id, msg_id: id)
151
+ when ACTION_CHAT_DELETE_USER
152
+ Event.new(kind: :leave, left_user_id: data[io + 4, 8].unpack1('Q<'),
153
+ peer_type: peer_type, peer_id: peer_id, msg_id: id)
154
+ end
155
+ end
156
+
157
+ # updateChannelParticipant#985d3abb: a member joining a supergroup arrives here
158
+ # (not as a service message). Surface only a real join — a new_participant that
159
+ # is present AND was not present before.
160
+ def participant_event(data, offset)
161
+ io = offset + 4
162
+ flags = data[io, 4].unpack1('L<')
163
+ channel_id = data[io + 4, 8].unpack1('Q<')
164
+ user_id = data[io + 24, 8].unpack1('Q<')
165
+ io += 32
166
+
167
+ return unless flags.anybits?(1 << 1) # no new_participant → not a join
168
+
169
+ if flags.anybits?(1 << 0) # prev_participant
170
+ return if MEMBER_PARTICIPANTS.include?(data[io, 4].unpack1('L<'))
171
+
172
+ io = TL::Reader.schema.skip(data, io)
173
+ end
174
+ return unless data[io, 4].unpack1('L<') == CHANNEL_PARTICIPANT
175
+
176
+ Event.new(kind: :join, channel: true, joined_user_id: user_id, peer_type: :channel, peer_id: channel_id)
177
+ rescue StandardError
178
+ nil
179
+ end
180
+
181
+ # updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector<int>
182
+ # pts:int pts_count:int.
183
+ def delete_event(data, offset)
184
+ io = offset + 4
185
+ channel_id = data[io, 8].unpack1('Q<')
186
+ io += 8
187
+ deleted_ids, io = read_int_vector(data, io)
188
+ Event.new(kind: :delete, channel: true, peer_type: :channel, peer_id: channel_id,
189
+ deleted_ids: deleted_ids, pts: data[io, 4].unpack1('l<'), pts_count: data[io + 4, 4].unpack1('l<'))
190
+ end
191
+
192
+ def callback_event(data, offset)
193
+ query = TL::Updates.extract_callback_query(data, offset)
194
+ Event.new(kind: :callback, query_id: query[:query_id], user_id: query[:user_id],
195
+ peer_type: query[:peer_type], peer_id: query[:peer_id], msg_id: query[:msg_id], data: query[:data])
196
+ end
197
+
198
+ def guest_query_event(data, offset)
199
+ query = TL::Updates.extract_guest_query(data, offset)
200
+ message = query[:message]
201
+ Event.new(kind: :guest_query, query_id: query[:query_id], qts: query[:qts], message: message,
202
+ peer_type: message && message[:peer_type], peer_id: message && message[:peer_id],
203
+ user_id: message && message[:from_id])
204
+ end
205
+
206
+ # updateMessageReactions# → { peer, msg_id } (the message whose reactions changed;
207
+ # the recent reactors are read from the message tail elsewhere).
208
+ def reaction_event(data, offset)
209
+ io = offset + 8 # constructor + flags
210
+ peer_type, peer_id, io = TL::Reader.parse_peer(data, io)
211
+ Event.new(kind: :reaction, peer_type: peer_type, peer_id: peer_id, msg_id: data[io, 4].unpack1('l<'))
212
+ end
213
+
214
+ # updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer
215
+ # old_reactions:Vector<Reaction> new_reactions:Vector<Reaction> qts:int — the
216
+ # bot-facing per-user reaction, the one that names the reactor. Diff old vs new to
217
+ # classify add (:reaction) vs remove (:unreaction).
218
+ def bot_reaction_event(data, offset)
219
+ io = offset + 4
220
+ peer_type, peer_id, io = TL::Reader.parse_peer(data, io)
221
+ msg_id = data[io, 4].unpack1('l<')
222
+ io += 4
223
+ date = data[io, 4].unpack1('L<')
224
+ io += 4
225
+ actor_type, actor_id, io = TL::Reader.parse_peer(data, io)
226
+ old_reactions, io = read_reactions_vector(data, io)
227
+ new_reactions, io = read_reactions_vector(data, io)
228
+ qts = data[io, 4].unpack1('l<')
229
+
230
+ added = new_reactions.reject { |r| old_reactions.any? { |o| o[:key] == r[:key] } }
231
+ removed = old_reactions.reject { |r| new_reactions.any? { |o| o[:key] == r[:key] } }
232
+ kind, diff = if added.any? then [:reaction, added]
233
+ elsif removed.any? then [:unreaction, removed]
234
+ end
235
+ return unless kind
236
+
237
+ reactor = { peer_type: actor_type, peer_id: actor_id, date: date, emoji: diff.first[:emoji] }
238
+ Event.new(kind: kind, msg_id: msg_id, qts: qts, peer_type: peer_type, peer_id: peer_id,
239
+ user_id: actor_id, reactors: [reactor])
240
+ rescue StandardError
241
+ nil
242
+ end
243
+
244
+ # Vector<int> at `offset` → [[int, …], offset_past].
245
+ def read_int_vector(data, offset)
246
+ offset += 4 # vector constructor
247
+ count = data[offset, 4].unpack1('L<')
248
+ offset += 4
249
+ ids = Array.new(count) { |i| data[offset + (i * 4), 4].unpack1('l<') }
250
+ [ids, offset + (count * 4)]
251
+ end
252
+
253
+ # Vector<Reaction> at `offset` → [ [{ key:, emoji: }, …], offset_past ]. key is a
254
+ # stable identity for the add/remove diff; emoji is set for plain reactions.
255
+ def read_reactions_vector(data, offset)
256
+ offset += 4 # vector constructor
257
+ count = data[offset, 4].unpack1('L<')
258
+ offset += 4
259
+ items = []
260
+ count.times do
261
+ key, emoji, offset = read_reaction(data, offset)
262
+ items << { key: key, emoji: emoji }
263
+ end
264
+ [items, offset]
265
+ end
266
+
267
+ # One Reaction at `offset` → [key, emoji_or_nil, offset_past]. An unknown reaction
268
+ # constructor is stepped over via the schema so the vector stays aligned.
269
+ def read_reaction(data, offset)
270
+ case data[offset, 4].unpack1('L<')
271
+ when REACTION_EMOJI
272
+ emoji, io = TL::Reader.read_tl_string(data, offset + 4)
273
+ [emoji, emoji, io]
274
+ when REACTION_CUSTOM_EMOJI
275
+ ["custom:#{data[offset + 4, 8].unpack1('Q<')}", nil, offset + 12]
276
+ when REACTION_PAID
277
+ ['paid', nil, offset + 4]
278
+ when REACTION_EMPTY
279
+ ['empty', nil, offset + 4]
280
+ else
281
+ [nil, nil, TL::Reader.schema.skip(data, offset)]
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MTProto
4
+ module Updates
5
+ # In-memory update-state store: the default backend behind the state machine's
6
+ # persistence port. A real host swaps in its own object answering the same
7
+ # messages over DURABLE storage — load_state / save_state, channel_pts /
8
+ # set_channel_pts, channel_access / set_channel_access, transaction.
9
+ #
10
+ # load_state returns { pts:, qts:, date:, seq: } or nil on a cold start.
11
+ # A channel's access_hash is persisted next to its pts because a bot cannot
12
+ # getDialogs — it learns the hash only by seeing the channel — so without it a gap
13
+ # in a channel not seen since the last restart could not be recovered.
14
+ # transaction yields; a host may wrap it — the block runs event delivery plus the
15
+ # pts checkpoint — in one DB transaction, so a crash mid-handler re-delivers the
16
+ # update instead of skipping it.
17
+ class MemoryStore
18
+ def initialize
19
+ @state = nil
20
+ @channel_pts = {}
21
+ @channel_access = {}
22
+ end
23
+
24
+ def load_state
25
+ @state&.dup
26
+ end
27
+
28
+ def save_state(pts:, qts:, date:, seq:)
29
+ @state = { pts: pts, qts: qts, date: date, seq: seq }
30
+ end
31
+
32
+ def channel_pts(channel_id)
33
+ @channel_pts[channel_id]
34
+ end
35
+
36
+ def set_channel_pts(channel_id, pts)
37
+ @channel_pts[channel_id] = pts
38
+ end
39
+
40
+ def channel_access(channel_id)
41
+ @channel_access[channel_id]
42
+ end
43
+
44
+ def set_channel_access(channel_id, access_hash)
45
+ @channel_access[channel_id] = access_hash
46
+ end
47
+
48
+ def transaction
49
+ yield
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tl/reader'
4
+ require_relative '../tl/objects/dialogs'
5
+ require_relative '../tl/objects/updates_state'
6
+ require_relative 'event_builder'
7
+
8
+ module MTProto
9
+ module Updates
10
+ # Parses a raw Telegram update reply into normalized Events, shared by the live
11
+ # push path and the getDifference/getChannelDifference recovery paths so both
12
+ # yield one event shape. This module walks the ENVELOPE (which container, which
13
+ # vector, the trailing peers and state); EventBuilder turns each Update into an
14
+ # Event. Every vector entry is advanced via Schema#skip, so an update type we
15
+ # don't model never drops the ones around it.
16
+ module Parser
17
+ module_function
18
+
19
+ UPDATES = 0x74ae4240
20
+ UPDATES_COMBINED = 0x725b04c3
21
+ UPDATES_TOO_LONG = 0xe317af7e
22
+ UPDATE_SHORT = 0x78d4dec1
23
+
24
+ UPDATES_DIFFERENCE = 0x00f49ca0
25
+ UPDATES_DIFFERENCE_SLICE = 0xa8fb1981
26
+ UPDATES_DIFFERENCE_EMPTY = 0x5d75a138
27
+ UPDATES_DIFFERENCE_TOO_LONG = 0x4afe8f6d
28
+
29
+ CHANNEL_DIFFERENCE = 0x2064674e
30
+ CHANNEL_DIFFERENCE_EMPTY = 0x3e11affb
31
+ CHANNEL_DIFFERENCE_TOO_LONG = 0xa4bcc6fe
32
+
33
+ UPDATE_CHANNEL_TOO_LONG = 0x108d941f
34
+
35
+ # Batch is the live-container result; Difference is the recovery-reply result and
36
+ # additionally carries the new authoritative position (pts/qts/date/seq), or, for
37
+ # a channel, the new channel pts + `final` (whether the backlog is drained).
38
+ Batch = Struct.new(:events, :users, :chats, :channel_too_long, :too_long, :date, keyword_init: true)
39
+ Difference = Struct.new(:type, :final, :events, :users, :chats, :channel_too_long,
40
+ :pts, :qts, :date, :seq, keyword_init: true)
41
+
42
+ # A live `updates` / `updatesCombined` push container (or a bodyless
43
+ # `updatesTooLong` gap signal) → a Batch of ordered events plus the batch peers
44
+ # and gap signals.
45
+ def parse_container(constructor, data)
46
+ case constructor
47
+ when UPDATES_TOO_LONG
48
+ Batch.new(events: [], users: [], chats: [], channel_too_long: [], too_long: true, date: nil)
49
+ when UPDATES, UPDATES_COMBINED
50
+ parse_updates(data)
51
+ when UPDATE_SHORT
52
+ parse_short(data)
53
+ else
54
+ Batch.new(events: [], users: [], chats: [], channel_too_long: [], too_long: false, date: nil)
55
+ end
56
+ end
57
+
58
+ # updateShort#78d4dec1 update:Update date:int — a single update with no peers
59
+ # attached. Channel messages never arrive this way, but edits/reactions do, so
60
+ # classify the one update (or surface an updateChannelTooLong gap signal).
61
+ def parse_short(data)
62
+ inner_ctor = data[4, 4].unpack1('L<')
63
+ if inner_ctor == UPDATE_CHANNEL_TOO_LONG
64
+ return Batch.new(events: [], users: [], chats: [], channel_too_long: [data[12, 8].unpack1('Q<')],
65
+ too_long: false, date: nil)
66
+ end
67
+
68
+ event = EventBuilder.classify_update(data, 4, inner_ctor)
69
+ Batch.new(events: [event].compact, users: [], chats: [], channel_too_long: [],
70
+ too_long: false, date: data[TL::Reader.schema.skip(data, 4), 4].unpack1('L<'))
71
+ end
72
+
73
+ # updates: updates:Vector<Update> users:Vector<User> chats:Vector<Chat> date:int
74
+ # (updatesCombined only inserts seq_start before the trailing seq, past date).
75
+ def parse_updates(data)
76
+ events, channel_too_long, offset = updates_vector(data, 4)
77
+ users, offset = TL::Dialogs.users_at(data, offset)
78
+ chats, offset = TL::Dialogs.chats_at(data, offset)
79
+ date = data[offset, 4].unpack1('L<')
80
+ attach_peers(events, users, chats)
81
+ Batch.new(events: events, users: users, chats: chats,
82
+ channel_too_long: channel_too_long, too_long: false, date: date)
83
+ end
84
+
85
+ # updates.difference / differenceSlice reply → a Difference with the recovered
86
+ # events and the new account position (in the trailing updates.State). Common
87
+ # new_messages arrive WITHOUT a per-message pts (the protocol omits it), so their
88
+ # events carry pts nil; the state machine advances to the batch-terminal state.pts.
89
+ def parse_difference(data)
90
+ case data[0, 4].unpack1('L<')
91
+ when UPDATES_DIFFERENCE_EMPTY
92
+ empty_difference(date: data[4, 4].unpack1('L<'), seq: data[8, 4].unpack1('L<'))
93
+ when UPDATES_DIFFERENCE, UPDATES_DIFFERENCE_SLICE
94
+ difference_body(data, data[0, 4].unpack1('L<'))
95
+ when UPDATES_DIFFERENCE_TOO_LONG
96
+ empty_difference(type: :too_long, pts: data[4, 4].unpack1('l<'))
97
+ else
98
+ empty_difference
99
+ end
100
+ end
101
+
102
+ # updates.channelDifference reply → a Difference carrying the channel's new pts
103
+ # and `final`. channel messages ride in new_messages without a per-message pts;
104
+ # the state machine advances the channel pts to this reply's pts.
105
+ def parse_channel_difference(data)
106
+ case data[0, 4].unpack1('L<')
107
+ when CHANNEL_DIFFERENCE_EMPTY
108
+ flags = data[4, 4].unpack1('L<')
109
+ empty_difference(final: flags.anybits?(1 << 0), pts: data[8, 4].unpack1('l<'))
110
+ when CHANNEL_DIFFERENCE
111
+ channel_difference_body(data)
112
+ when CHANNEL_DIFFERENCE_TOO_LONG
113
+ empty_difference(type: :too_long, final: true)
114
+ else
115
+ empty_difference(final: true)
116
+ end
117
+ end
118
+
119
+ def empty_difference(type: :empty, final: false, pts: nil, date: nil, seq: nil)
120
+ Difference.new(type: type, final: final, events: [], users: [], chats: [],
121
+ channel_too_long: [], pts: pts, date: date, seq: seq)
122
+ end
123
+
124
+ # difference / differenceSlice: new_messages new_encrypted_messages other_updates
125
+ # chats users state. Channel messages ride in other_updates (with pts); common
126
+ # new_messages ride in new_messages (no pts).
127
+ def difference_body(data, constructor)
128
+ msg_events, offset = messages_vector(data, 4, channel: false)
129
+ offset = TL::Reader.schema.skip_vector(data, offset) # new_encrypted_messages
130
+ upd_events, channel_too_long, offset = updates_vector(data, offset)
131
+ chats, offset = TL::Dialogs.chats_at(data, offset)
132
+ users, offset = TL::Dialogs.users_at(data, offset)
133
+ state = TL::UpdatesState.deserialize(data[offset..])
134
+
135
+ events = msg_events + upd_events
136
+ attach_peers(events, users, chats)
137
+ Difference.new(
138
+ type: constructor == UPDATES_DIFFERENCE ? :difference : :slice, final: false,
139
+ events: events, users: users, chats: chats, channel_too_long: channel_too_long,
140
+ pts: state.pts, qts: state.qts, date: state.date, seq: state.seq
141
+ )
142
+ end
143
+
144
+ # channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int
145
+ # new_messages:Vector<Message> other_updates:Vector<Update> chats users.
146
+ def channel_difference_body(data)
147
+ flags = data[4, 4].unpack1('L<')
148
+ pts = data[8, 4].unpack1('l<')
149
+ offset = 12
150
+ offset += 4 if flags.anybits?(1 << 1) # timeout
151
+
152
+ msg_events, offset = messages_vector(data, offset, channel: true)
153
+ upd_events, channel_too_long, offset = updates_vector(data, offset)
154
+ chats, offset = TL::Dialogs.chats_at(data, offset)
155
+ users, = TL::Dialogs.users_at(data, offset)
156
+
157
+ events = msg_events + upd_events
158
+ attach_peers(events, users, chats)
159
+ Difference.new(type: :difference, final: flags.anybits?(1 << 0), pts: pts,
160
+ events: events, users: users, chats: chats, channel_too_long: channel_too_long)
161
+ end
162
+
163
+ # A Vector<Message> (a difference's new_messages) → ordered :message / :join /
164
+ # :leave events WITHOUT pts (the protocol omits per-message pts here).
165
+ def messages_vector(data, offset, channel:)
166
+ offset += 4 # vector constructor
167
+ count = data[offset, 4].unpack1('L<')
168
+ offset += 4
169
+
170
+ events = []
171
+ count.times do
172
+ event = EventBuilder.message_or_service(data, offset, data[offset, 4].unpack1('L<'), channel: channel)
173
+ events << event if event
174
+ offset = TL::Reader.schema.skip(data, offset)
175
+ end
176
+ [events, offset]
177
+ end
178
+
179
+ # A Vector<Update> (a live container's updates, or a difference's other_updates)
180
+ # → [ordered events, updateChannelTooLong channel ids, offset past the vector].
181
+ def updates_vector(data, offset)
182
+ offset += 4 # vector constructor
183
+ count = data[offset, 4].unpack1('L<')
184
+ offset += 4
185
+
186
+ events = []
187
+ channel_too_long = []
188
+ count.times do
189
+ ctor = data[offset, 4].unpack1('L<')
190
+ if ctor == UPDATE_CHANNEL_TOO_LONG
191
+ channel_too_long << data[offset + 8, 8].unpack1('Q<') # constructor(4) + flags(4)
192
+ else
193
+ event = EventBuilder.classify_update(data, offset, ctor)
194
+ events << event if event
195
+ end
196
+ offset = TL::Reader.schema.skip(data, offset)
197
+ end
198
+ [events, channel_too_long, offset]
199
+ end
200
+
201
+ def attach_peers(events, users, chats)
202
+ events.each do |event|
203
+ event.users = users
204
+ event.chats = chats
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async/queue'
4
+ require_relative 'state_machine'
5
+
6
+ module MTProto
7
+ module Updates
8
+ # Drives the high-level update loop over a Client (Client#run_updates is the entry
9
+ # point). The on_update callback runs on the receiver fiber and only ENQUEUES raw
10
+ # containers; a single worker fiber drains the queue and feeds the state machine,
11
+ # so recovery RPCs never block the receiver (no deadlock) and never race each other
12
+ # on pts (serial by construction). A reconnect enqueues a re-sync. An optional
13
+ # on_start runs once inside the started mainloop before the first update, the point
14
+ # at which host startup that needs the API belongs.
15
+ class Runner
16
+ def initialize(client:, store:, logger: nil)
17
+ @client = client
18
+ @store = store
19
+ @logger = logger
20
+ @machine = StateMachine.new(client: client, store: store, logger: logger)
21
+ end
22
+
23
+ def run(on_start: nil, &block)
24
+ raise ArgumentError, 'block is required' unless block
25
+
26
+ @client.run_mainloop do
27
+ queue = Async::Queue.new
28
+ @client.replace_update_callback { |constructor, body| queue.enqueue([constructor, body]) }
29
+ @client.on_reconnect { queue.enqueue(:reconnect) }
30
+ on_start&.call
31
+ @machine.resume(&block)
32
+ drain(queue, &block)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # A handler error is logged and swallowed so one bad update never kills the loop.
39
+ def drain(queue, &)
40
+ loop do
41
+ item = queue.dequeue
42
+ if item == :reconnect
43
+ @machine.resync(&)
44
+ else
45
+ @machine.process(item[0], item[1], &)
46
+ end
47
+ rescue StandardError => e
48
+ @logger&.error("mtproto updates: #{e.class}: #{e.message}")
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'parser'
4
+
5
+ module MTProto
6
+ module Updates
7
+ # The authoritative update state machine: owns the account position
8
+ # (pts/qts/date/seq) and per-channel pts, applies live updates in order, and
9
+ # recovers gaps via getDifference/getChannelDifference — but ONLY when a real
10
+ # sequence gap is detected, never on a blind timer. Because a gap-triggered
11
+ # getDifference returns exactly the missed range and the position is advanced
12
+ # atomically, the host never sees a duplicate in steady state (a crash
13
+ # mid-handler may re-deliver one update — the accepted at-least-once edge).
14
+ #
15
+ # Sequential and Async-free: it issues blocking RPCs through the injected client,
16
+ # so it MUST be driven off the receiver fiber (run_updates feeds it from a single
17
+ # worker fiber, which also serializes recovery). Checkpoints run through
18
+ # store.transaction, so a host can commit the pts advance together with its own
19
+ # handling of the event.
20
+ class StateMachine
21
+ RECOVERY_PAGES = 500
22
+
23
+ def initialize(client:, store:, logger: nil)
24
+ @client = client
25
+ @store = store
26
+ @logger = logger
27
+ end
28
+
29
+ def resume(&)
30
+ stored = @store.load_state
31
+ base = @client.api.get_updates_state
32
+ @pts = nonzero(stored && stored[:pts]) || base.pts
33
+ @qts = nonzero(stored && stored[:qts]) || base.qts
34
+ @date = nonzero(stored && stored[:date]) || base.date
35
+ @seq = nonzero(stored && stored[:seq]) || base.seq
36
+ persist_state
37
+ catch_up(&) if stored
38
+ end
39
+
40
+ # Feed one raw live update container (constructor + body) from the push stream.
41
+ def process(constructor, body, &)
42
+ dispatch(Parser.parse_container(constructor, body), &)
43
+ end
44
+
45
+ # A gap point the host detected out of band (notably a reconnect): re-sync now.
46
+ def resync(&)
47
+ catch_up(&)
48
+ end
49
+
50
+ private
51
+
52
+ def dispatch(batch, &)
53
+ remember_channels(batch.chats)
54
+ batch.events.each { |event| ingest(event, &) }
55
+ batch.channel_too_long.each { |channel_id| recover_channel(channel_id, &) }
56
+ catch_up(&) if batch.too_long
57
+ end
58
+
59
+ def ingest(event, &)
60
+ detect_gap(event, &)
61
+ emit(event, &)
62
+ end
63
+
64
+ # A live update whose pts jumps past our stored position means updates were
65
+ # dropped in between; pull the hole before applying the update that revealed it.
66
+ # A channel needs a known local baseline first (its first update seeds it).
67
+ def detect_gap(event, &)
68
+ return unless event.pts && event.pts_count
69
+
70
+ if event.channel
71
+ local = @store.channel_pts(event.peer_id)
72
+ recover_channel(event.peer_id, &) if local && (event.pts - event.pts_count) > local
73
+ elsif @pts && (event.pts - event.pts_count) > @pts
74
+ catch_up(&)
75
+ end
76
+ end
77
+
78
+ # A re-delivery we already passed (pts <= local) is dropped; otherwise deliver the
79
+ # event and persist the position it advances. The store write runs inside the
80
+ # host's transaction (atomic with the host's own handling); the in-memory @pts is
81
+ # advanced only AFTER the transaction commits, so a rollback can never leave the
82
+ # cache ahead of the store and silently drop later account updates.
83
+ def emit(event, &block)
84
+ return if outdated?(event)
85
+
86
+ @store.transaction do
87
+ block.call(event)
88
+ checkpoint(event)
89
+ end
90
+ cache_account_position(event)
91
+ end
92
+
93
+ def outdated?(event)
94
+ return false unless event.pts
95
+
96
+ if event.channel
97
+ local = @store.channel_pts(event.peer_id)
98
+ local && event.pts <= local
99
+ else
100
+ @pts && event.pts <= @pts
101
+ end
102
+ end
103
+
104
+ def checkpoint(event)
105
+ return unless event.pts
106
+
107
+ if event.channel
108
+ local = @store.channel_pts(event.peer_id)
109
+ @store.set_channel_pts(event.peer_id, event.pts) if local.nil? || event.pts > local
110
+ elsif account_newer?(event)
111
+ @store.save_state(pts: event.pts, qts: @qts, date: @date, seq: @seq)
112
+ end
113
+ end
114
+
115
+ def cache_account_position(event)
116
+ @pts = event.pts if !event.channel && event.pts && account_newer?(event)
117
+ end
118
+
119
+ def account_newer?(event)
120
+ @pts.nil? || event.pts > @pts
121
+ end
122
+
123
+ def catch_up(&)
124
+ RECOVERY_PAGES.times do
125
+ diff = fetch_difference
126
+ case diff.type
127
+ when :empty
128
+ @date = diff.date if diff.date
129
+ @seq = diff.seq if diff.seq
130
+ persist_state
131
+ return
132
+ when :difference, :slice
133
+ apply_difference(diff, &)
134
+ return if diff.type == :difference # a slice means more remains; keep pulling
135
+ when :too_long
136
+ resync_to_current
137
+ return
138
+ end
139
+ end
140
+ @logger&.warn('mtproto updates: getDifference catch-up hit the page cap')
141
+ end
142
+
143
+ def apply_difference(diff, &)
144
+ remember_channels(diff.chats)
145
+ diff.events.each { |event| emit(event, &) }
146
+ diff.channel_too_long.each { |channel_id| recover_channel(channel_id, &) }
147
+ @pts = diff.pts if diff.pts
148
+ @qts = diff.qts if diff.qts
149
+ @date = diff.date if diff.date
150
+ @seq = diff.seq if diff.seq
151
+ persist_state
152
+ end
153
+
154
+ # Recover one channel via getChannelDifference from its stored pts. Without a
155
+ # known access_hash or local pts we can't target it, so fall back to the
156
+ # account-wide getDifference (whose reply carries the channel's messages too).
157
+ def recover_channel(channel_id, &)
158
+ access = @store.channel_access(channel_id)
159
+ from = @store.channel_pts(channel_id)
160
+ return catch_up(&) unless access && from
161
+
162
+ RECOVERY_PAGES.times do
163
+ diff = fetch_channel_difference(channel_id, access, from)
164
+ return catch_up(&) if diff.type == :too_long
165
+
166
+ remember_channels(diff.chats)
167
+ diff.events.each { |event| emit(event, &) }
168
+ if diff.pts&.positive?
169
+ from = diff.pts
170
+ @store.set_channel_pts(channel_id, diff.pts)
171
+ end
172
+ return if diff.final
173
+ end
174
+ @logger&.warn("mtproto updates: channel #{channel_id} recovery hit the page cap")
175
+ end
176
+
177
+ def resync_to_current
178
+ base = @client.api.get_updates_state
179
+ @pts = base.pts
180
+ @qts = base.qts
181
+ @date = base.date
182
+ @seq = base.seq
183
+ persist_state
184
+ end
185
+
186
+ def fetch_difference
187
+ Parser.parse_difference(@client.api.get_updates_difference_raw(pts: @pts, date: @date, qts: @qts))
188
+ end
189
+
190
+ def fetch_channel_difference(channel_id, access_hash, pts)
191
+ Parser.parse_channel_difference(
192
+ @client.api.get_channel_difference_raw(channel: { id: channel_id, access_hash: access_hash }, pts: pts,
193
+ limit: 100)
194
+ )
195
+ end
196
+
197
+ # A bot learns a channel's access_hash only by seeing it; persist the latest so a
198
+ # gap — even after a restart, before the channel is seen again — can be recovered
199
+ # with a targeted getChannelDifference rather than a lossy account-wide fallback.
200
+ def remember_channels(chats)
201
+ Array(chats).each do |chat|
202
+ @store.set_channel_access(chat.id, chat.access_hash) if chat.access_hash
203
+ end
204
+ end
205
+
206
+ def persist_state
207
+ @store.save_state(pts: @pts, qts: @qts, date: @date, seq: @seq)
208
+ end
209
+
210
+ def nonzero(value)
211
+ value if value&.nonzero?
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'updates/event'
4
+ require_relative 'updates/event_builder'
5
+ require_relative 'updates/parser'
6
+ require_relative 'updates/memory_store'
7
+ require_relative 'updates/state_machine'
8
+ require_relative 'updates/runner'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MTProto
4
- VERSION = '0.0.27'
4
+ VERSION = '0.0.29'
5
5
  end
data/lib/mtproto.rb CHANGED
@@ -37,6 +37,7 @@ require_relative 'mtproto/crypto/message_key'
37
37
  require_relative 'mtproto/auth_key_generator'
38
38
  require_relative 'mtproto/session'
39
39
  require_relative 'mtproto/encrypted_message'
40
+ require_relative 'mtproto/updates'
40
41
  require_relative 'mtproto/client'
41
42
  require_relative 'mtproto/file_downloader'
42
43
  require_relative 'mtproto/bot_authorizer'
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.27
4
+ version: 0.0.29
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artem Levenkov
@@ -86,6 +86,7 @@ files:
86
86
  - lib/mtproto/client/api/unpin_all_messages.rb
87
87
  - lib/mtproto/client/rpc.rb
88
88
  - lib/mtproto/client/rpc/response.rb
89
+ - lib/mtproto/client/update_api.rb
89
90
  - lib/mtproto/crypto/aes_ige.rb
90
91
  - lib/mtproto/crypto/auth_key_helper.rb
91
92
  - lib/mtproto/crypto/dh_key_exchange.rb
@@ -225,6 +226,13 @@ files:
225
226
  - lib/mtproto/transport/packet.rb
226
227
  - lib/mtproto/transport/tcp_connection.rb
227
228
  - lib/mtproto/unencrypted_message.rb
229
+ - lib/mtproto/updates.rb
230
+ - lib/mtproto/updates/event.rb
231
+ - lib/mtproto/updates/event_builder.rb
232
+ - lib/mtproto/updates/memory_store.rb
233
+ - lib/mtproto/updates/parser.rb
234
+ - lib/mtproto/updates/runner.rb
235
+ - lib/mtproto/updates/state_machine.rb
228
236
  - lib/mtproto/version.rb
229
237
  - scripts/gen_constructor_names.rb
230
238
  - scripts/generate_constructors.rb