mtproto 0.0.26 → 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 +4 -4
- data/lib/mtproto/client/api/get_channel_difference.rb +8 -0
- data/lib/mtproto/client/api/get_updates_difference.rb +10 -0
- data/lib/mtproto/client/update_api.rb +45 -0
- data/lib/mtproto/client.rb +5 -4
- data/lib/mtproto/tl/objects/send_media.rb +4 -1
- data/lib/mtproto/updates/event.rb +29 -0
- data/lib/mtproto/updates/event_builder.rb +286 -0
- data/lib/mtproto/updates/memory_store.rb +53 -0
- data/lib/mtproto/updates/parser.rb +209 -0
- data/lib/mtproto/updates/runner.rb +53 -0
- data/lib/mtproto/updates/state_machine.rb +215 -0
- data/lib/mtproto/updates.rb +8 -0
- data/lib/mtproto/version.rb +1 -1
- data/lib/mtproto.rb +1 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a50a3572643e17c2435e3b310979e9a9ddbbf498bf8febb5819d96a73443899
|
|
4
|
+
data.tar.gz: 622d75782346d1cf190e3daa6097305afc3520b5fe5834b727a79bf4c0e86b36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/mtproto/client.rb
CHANGED
|
@@ -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
|
|
@@ -13,18 +13,20 @@ module MTProto
|
|
|
13
13
|
INPUT_PHOTO = 0x3bb3b94a
|
|
14
14
|
INPUT_DOCUMENT = 0x1abfb575
|
|
15
15
|
|
|
16
|
-
def initialize(peer:, media:, message: '', random_id: nil, reply_to: nil, silent: false)
|
|
16
|
+
def initialize(peer:, media:, message: '', random_id: nil, reply_to: nil, silent: false, reply_markup: nil)
|
|
17
17
|
@peer = peer
|
|
18
18
|
@media = media
|
|
19
19
|
@message = message
|
|
20
20
|
@random_id = random_id || SecureRandom.random_number(2**63)
|
|
21
21
|
@reply_to = reply_to
|
|
22
22
|
@silent = silent
|
|
23
|
+
@reply_markup = reply_markup
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def serialize
|
|
26
27
|
flags = 0
|
|
27
28
|
flags |= (1 << 0) if @reply_to
|
|
29
|
+
flags |= (1 << 2) if @reply_markup
|
|
28
30
|
flags |= (1 << 5) if @silent
|
|
29
31
|
|
|
30
32
|
result = u32_b(Constructors::MESSAGES_SEND_MEDIA)
|
|
@@ -34,6 +36,7 @@ module MTProto
|
|
|
34
36
|
result += serialize_input_media
|
|
35
37
|
result += serialize_tl_string(@message)
|
|
36
38
|
result += u64_b(@random_id)
|
|
39
|
+
result += @reply_markup.serialize if @reply_markup
|
|
37
40
|
result
|
|
38
41
|
end
|
|
39
42
|
|
|
@@ -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'
|
data/lib/mtproto/version.rb
CHANGED
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.
|
|
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
|