async-matrix 3.0.0-x86_64-linux → 3.0.1-x86_64-linux
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/async/matrix/app_service_client.rb +213 -0
- data/lib/async/matrix/client/encryption.rb +413 -0
- data/lib/async/matrix/client/rooms.rb +478 -0
- data/lib/async/matrix/client/sync.rb +508 -0
- data/lib/async/matrix/client.rb +77 -5
- data/lib/async/matrix/device_store.rb +1621 -0
- data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
- data/lib/async/matrix/error.rb +9 -9
- data/lib/async/matrix/version.rb +3 -3
- data/lib/async/matrix.rb +16 -0
- data/lib/protocol/matrix/canonical_json.rb +200 -0
- data/lib/{async → protocol}/matrix/content.rb +9 -9
- data/lib/protocol/matrix/encrypted_message.rb +1018 -0
- data/lib/protocol/matrix/error.rb +49 -0
- data/lib/{async → protocol}/matrix/event.rb +28 -17
- data/lib/protocol/matrix/key_backup.rb +347 -0
- data/lib/protocol/matrix/keys.rb +381 -0
- data/lib/protocol/matrix/message_batch.rb +482 -0
- data/lib/{async → protocol}/matrix/schema/registry.rb +17 -17
- data/lib/{async → protocol}/matrix/schema/validation_error.rb +14 -12
- data/lib/{async → protocol}/matrix/schema.rb +17 -17
- data/lib/protocol/matrix/secret_storage.rb +535 -0
- data/lib/protocol/matrix/signing.rb +278 -0
- data/lib/protocol/matrix.rb +25 -0
- metadata +22 -7
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
require_relative "error"
|
|
7
|
+
require_relative "event"
|
|
8
|
+
require_relative "encrypted_message"
|
|
9
|
+
|
|
10
|
+
module Protocol
|
|
11
|
+
module Matrix
|
|
12
|
+
# One batch of messages, read exactly once.
|
|
13
|
+
#
|
|
14
|
+
# A homeserver hands over messages in batches, never one at a time: a /sync
|
|
15
|
+
# response or an application service transaction arrives whole, carrying
|
|
16
|
+
# to-device messages and room timelines together. This is that batch, turned
|
|
17
|
+
# into a single-pass stream — #read gives the next message or nil, in the
|
|
18
|
+
# shape Protocol::GRPC::Body::Readable uses for gRPC frames.
|
|
19
|
+
#
|
|
20
|
+
# batch = MessageBatch.from_sync(response)
|
|
21
|
+
#
|
|
22
|
+
# while message = batch.read
|
|
23
|
+
# message.to_device? # plumbing (room keys) or timeline?
|
|
24
|
+
# message.encrypted? # needs a session before it can be read?
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# batch.next_batch # the cursor this batch ends at
|
|
28
|
+
#
|
|
29
|
+
# NOT REWINDABLE, AND THAT IS THE POINT. Reading a message is not free:
|
|
30
|
+
# decrypting one ratchets a session forward, so "read it again" is not a
|
|
31
|
+
# thing the protocol can offer. A drained batch answers nil forever, and
|
|
32
|
+
# #each consumes rather than iterating a collection. If a consumer needs to
|
|
33
|
+
# keep a message, it keeps the message — not the batch.
|
|
34
|
+
#
|
|
35
|
+
# TO-DEVICE MESSAGES COME FIRST, always. A batch routinely carries both an
|
|
36
|
+
# `m.room_key` and the timeline event that key unlocks, and the order they
|
|
37
|
+
# are read in decides whether that event decrypts on the first pass or is
|
|
38
|
+
# stored as a placeholder and repaired later. Making the stream ordered is
|
|
39
|
+
# what takes that decision away from the caller and from any scheduler: a
|
|
40
|
+
# timeline message is unreachable until the to-device messages ahead of it
|
|
41
|
+
# have been read.
|
|
42
|
+
#
|
|
43
|
+
# The two wire formats differ, and only in how they are unpacked:
|
|
44
|
+
#
|
|
45
|
+
# /sync to_device.events, then rooms.{join,invite,leave}
|
|
46
|
+
# AS transaction de.sorunome.msc2409.to_device, then events
|
|
47
|
+
#
|
|
48
|
+
# So they get two constructors and produce one stream, which is why an
|
|
49
|
+
# appservice and a syncing client can share everything downstream of here.
|
|
50
|
+
class MessageBatch
|
|
51
|
+
# Where a message sat in the batch it came from. Carried because a
|
|
52
|
+
# consumer projecting rooms needs to know that an event arrived as invite
|
|
53
|
+
# state rather than timeline, which the event itself does not say.
|
|
54
|
+
SECTIONS = %i[to_device join invite leave transaction].freeze
|
|
55
|
+
|
|
56
|
+
class Error < Protocol::Matrix::Error; end
|
|
57
|
+
|
|
58
|
+
# #read after the batch was drained is fine (nil). This is for a consumer
|
|
59
|
+
# that asks for a second pass over something already consumed.
|
|
60
|
+
class ConsumedError < Error; end
|
|
61
|
+
|
|
62
|
+
# Unpack a /sync response.
|
|
63
|
+
#
|
|
64
|
+
# @parameter response [Hash] the parsed response body, string keys.
|
|
65
|
+
def self.from_sync(response)
|
|
66
|
+
messages = []
|
|
67
|
+
|
|
68
|
+
to_device = response.dig("to_device", "events") || []
|
|
69
|
+
to_device.each { |event| messages << build(event, :to_device, nil) }
|
|
70
|
+
|
|
71
|
+
rooms = response["rooms"] || {}
|
|
72
|
+
|
|
73
|
+
(rooms["join"] || {}).each do |room_id, data|
|
|
74
|
+
timeline = data.dig("timeline", "events") || []
|
|
75
|
+
timeline.each { |event| messages << build(event, :join, room_id) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Invites carry stripped state and no timeline, but it is the only thing
|
|
79
|
+
# that says who invited us, so dropping it loses the invite itself.
|
|
80
|
+
(rooms["invite"] || {}).each do |room_id, data|
|
|
81
|
+
state = data.dig("invite_state", "events") || []
|
|
82
|
+
state.each { |event| messages << build(event, :invite, room_id) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
(rooms["leave"] || {}).each do |room_id, data|
|
|
86
|
+
timeline = data.dig("timeline", "events") || []
|
|
87
|
+
timeline.each { |event| messages << build(event, :leave, room_id) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
new(messages, next_batch: response["next_batch"])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Unpack an application service transaction.
|
|
94
|
+
#
|
|
95
|
+
# To-device messages ride the MSC4203 key, which Synapse still sends under
|
|
96
|
+
# its original MSC2409 name; the stable spelling is accepted first so this
|
|
97
|
+
# keeps working when that changes.
|
|
98
|
+
#
|
|
99
|
+
# @parameter transaction [Hash] the parsed PUT body, string keys.
|
|
100
|
+
# @parameter txn_id [String] the transaction id, which is this batch's
|
|
101
|
+
# cursor — the appservice equivalent of next_batch.
|
|
102
|
+
def self.from_transaction(transaction, txn_id: nil)
|
|
103
|
+
messages = []
|
|
104
|
+
|
|
105
|
+
to_device = transaction["to_device"] ||
|
|
106
|
+
transaction["de.sorunome.msc2409.to_device"] || []
|
|
107
|
+
to_device.each { |event| messages << build(event, :to_device, nil) }
|
|
108
|
+
|
|
109
|
+
# Transaction events carry their own room_id; there is no per-room
|
|
110
|
+
# grouping to unpack.
|
|
111
|
+
(transaction["events"] || []).each do |event|
|
|
112
|
+
messages << build(event, :transaction, event["room_id"])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
new(messages, next_batch: txn_id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# An encrypted event becomes an EncryptedMessage, anything else an Event.
|
|
119
|
+
# Both answer #type, #content, #encrypted?, #decrypted? and #to_device?,
|
|
120
|
+
# so a consumer reads either without asking which it has.
|
|
121
|
+
def self.build(event, section, room_id)
|
|
122
|
+
# A to-device event has no room_id of its own; a /sync room section
|
|
123
|
+
# knows the room its events belong to, and the events themselves may
|
|
124
|
+
# omit it. Supplying it keeps #to_device? honest either way.
|
|
125
|
+
if room_id && event["room_id"].nil?
|
|
126
|
+
event = event.merge("room_id" => room_id)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if EncryptedMessage.encrypted?(event)
|
|
130
|
+
[EncryptedMessage.new(event), section]
|
|
131
|
+
else
|
|
132
|
+
[Event.new(event), section]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def initialize(messages, next_batch: nil)
|
|
137
|
+
@messages = messages
|
|
138
|
+
@next_batch = next_batch
|
|
139
|
+
@position = 0
|
|
140
|
+
@section = nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The cursor this batch ends at: `next_batch` from /sync, the transaction
|
|
144
|
+
# id from an appservice.
|
|
145
|
+
#
|
|
146
|
+
# COMMIT IT ONLY ONCE THE BATCH IS DRAINED AND ITS EFFECTS ARE DURABLE.
|
|
147
|
+
# Storing it earlier means a crash loses every message still in this
|
|
148
|
+
# batch, with nothing anywhere recording that they were missed.
|
|
149
|
+
attr_reader :next_batch
|
|
150
|
+
|
|
151
|
+
# How many messages the batch held. Fixed at construction — it does not
|
|
152
|
+
# count down as the batch is read.
|
|
153
|
+
def size = @messages.length
|
|
154
|
+
|
|
155
|
+
# Have all of them been read?
|
|
156
|
+
def drained? = @position >= @messages.length
|
|
157
|
+
|
|
158
|
+
# The next message, or nil once drained. To-device messages first.
|
|
159
|
+
def read
|
|
160
|
+
if drained?
|
|
161
|
+
nil
|
|
162
|
+
else
|
|
163
|
+
message, section = @messages[@position]
|
|
164
|
+
@position += 1
|
|
165
|
+
@section = section
|
|
166
|
+
message
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The section the message most recently returned by #read came from, one
|
|
171
|
+
# of SECTIONS. Read it after #read, not before.
|
|
172
|
+
attr_reader :section
|
|
173
|
+
|
|
174
|
+
# Did the last message read arrive as to-device rather than from a room?
|
|
175
|
+
#
|
|
176
|
+
# ON THE BATCH, NOT THE MESSAGE, because the batch is the only thing that
|
|
177
|
+
# knows. Asking the message would mean inferring it from a missing
|
|
178
|
+
# room_id, and that is wrong twice over: a transaction timeline event may
|
|
179
|
+
# omit the room_id the section supplies, and inference would call it
|
|
180
|
+
# to-device. The section is stated by the wire format; nothing has to
|
|
181
|
+
# guess.
|
|
182
|
+
def to_device? = @section == :to_device
|
|
183
|
+
|
|
184
|
+
# Read every remaining message. CONSUMES the batch: this is a drain, not
|
|
185
|
+
# an iteration, and a second call yields nothing because there is nothing
|
|
186
|
+
# left rather than because iteration restarted.
|
|
187
|
+
def each
|
|
188
|
+
unless block_given?
|
|
189
|
+
raise ConsumedError, "MessageBatch#each requires a block; a batch is not enumerable"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
while (message = read)
|
|
193
|
+
yield(message, @section)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
self
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
__END__
|
|
203
|
+
describe "Protocol::Matrix::MessageBatch" do
|
|
204
|
+
def to_device_event
|
|
205
|
+
{
|
|
206
|
+
"type" => "m.room.encrypted",
|
|
207
|
+
"sender" => "@bob:example.org",
|
|
208
|
+
"content" => {
|
|
209
|
+
"algorithm" => "m.olm.v1.curve25519-aes-sha2",
|
|
210
|
+
"sender_key" => "senderkey",
|
|
211
|
+
"ciphertext" => {"ourkey" => {"type" => 0, "body" => "body"}},
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def encrypted_timeline_event(event_id = "$enc1")
|
|
217
|
+
{
|
|
218
|
+
"type" => "m.room.encrypted",
|
|
219
|
+
"event_id" => event_id,
|
|
220
|
+
"sender" => "@alice:example.org",
|
|
221
|
+
"origin_server_ts" => 1,
|
|
222
|
+
"content" => {
|
|
223
|
+
"algorithm" => "m.megolm.v1.aes-sha2",
|
|
224
|
+
"ciphertext" => "AwgAEnAC",
|
|
225
|
+
"session_id" => "session1",
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def plaintext_event(event_id = "$msg1")
|
|
231
|
+
{
|
|
232
|
+
"type" => "m.room.message",
|
|
233
|
+
"event_id" => event_id,
|
|
234
|
+
"sender" => "@alice:example.org",
|
|
235
|
+
"origin_server_ts" => 2,
|
|
236
|
+
"content" => {"msgtype" => "m.text", "body" => "hello"},
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def sync_response(extra = {})
|
|
241
|
+
{
|
|
242
|
+
"next_batch" => "s72_1234",
|
|
243
|
+
"to_device" => {"events" => [to_device_event]},
|
|
244
|
+
"rooms" => {
|
|
245
|
+
"join" => {
|
|
246
|
+
"!room:example.org" => {
|
|
247
|
+
"timeline" => {"events" => [encrypted_timeline_event, plaintext_event]},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
}.merge(extra)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# ── Unpacking /sync ───────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
it "reads every message in the batch, then nil" do
|
|
257
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
258
|
+
|
|
259
|
+
batch.size.should == 3
|
|
260
|
+
batch.read.should.be.kind_of Protocol::Matrix::EncryptedMessage
|
|
261
|
+
batch.read.should.be.kind_of Protocol::Matrix::EncryptedMessage
|
|
262
|
+
batch.read.should.be.kind_of Protocol::Matrix::Event
|
|
263
|
+
batch.read.should.be.nil
|
|
264
|
+
batch.drained?.should == true
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# THE ORDERING GUARANTEE. A batch carrying both a room key and the event it
|
|
268
|
+
# unlocks must hand over the key first, or the event decrypts as a
|
|
269
|
+
# placeholder on the first pass.
|
|
270
|
+
it "yields to-device messages before any timeline message" do
|
|
271
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
272
|
+
|
|
273
|
+
batch.read
|
|
274
|
+
batch.to_device?.should == true
|
|
275
|
+
batch.section.should == :to_device
|
|
276
|
+
|
|
277
|
+
batch.read
|
|
278
|
+
batch.to_device?.should == false
|
|
279
|
+
batch.section.should == :join
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "carries the cursor the batch ends at" do
|
|
283
|
+
Protocol::Matrix::MessageBatch.from_sync(sync_response).next_batch.should == "s72_1234"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it "builds an EncryptedMessage for an encrypted event and an Event otherwise" do
|
|
287
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
288
|
+
batch.read
|
|
289
|
+
|
|
290
|
+
encrypted = batch.read
|
|
291
|
+
encrypted.encrypted?.should == true
|
|
292
|
+
encrypted.decrypted?.should == false
|
|
293
|
+
encrypted.session_id.should == "session1"
|
|
294
|
+
|
|
295
|
+
plain = batch.read
|
|
296
|
+
plain.encrypted?.should == false
|
|
297
|
+
plain.decrypted?.should == true
|
|
298
|
+
plain.type.should == "m.room.message"
|
|
299
|
+
plain.content.body.should == "hello"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# A /sync room section knows the room; the events inside it may not repeat it.
|
|
303
|
+
it "attributes a timeline event to its room" do
|
|
304
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
305
|
+
batch.read
|
|
306
|
+
|
|
307
|
+
batch.read.room_id.should == "!room:example.org"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
it "leaves a to-device message without a room" do
|
|
311
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
312
|
+
|
|
313
|
+
batch.read.room_id.should.be.nil
|
|
314
|
+
batch.to_device?.should == true
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it "does not overwrite a room_id the event already carries" do
|
|
318
|
+
event = encrypted_timeline_event.merge("room_id" => "!actual:example.org")
|
|
319
|
+
response = {
|
|
320
|
+
"rooms" => {"join" => {"!section:example.org" => {"timeline" => {"events" => [event]}}}},
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
Protocol::Matrix::MessageBatch.from_sync(response).read.room_id.should == "!actual:example.org"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
it "reads invite state, which is the only record of the invite" do
|
|
327
|
+
response = {
|
|
328
|
+
"rooms" => {
|
|
329
|
+
"invite" => {
|
|
330
|
+
"!invited:example.org" => {
|
|
331
|
+
"invite_state" => {
|
|
332
|
+
"events" => [{
|
|
333
|
+
"type" => "m.room.member",
|
|
334
|
+
"sender" => "@carol:example.org",
|
|
335
|
+
"state_key" => "@us:example.org",
|
|
336
|
+
"content" => {"membership" => "invite"},
|
|
337
|
+
}],
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(response)
|
|
344
|
+
message = batch.read
|
|
345
|
+
|
|
346
|
+
message.type.should == "m.room.member"
|
|
347
|
+
message.room_id.should == "!invited:example.org"
|
|
348
|
+
batch.section.should == :invite
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
it "reads the timeline of a room we left" do
|
|
352
|
+
response = {
|
|
353
|
+
"rooms" => {
|
|
354
|
+
"leave" => {"!gone:example.org" => {"timeline" => {"events" => [plaintext_event]}}},
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(response)
|
|
358
|
+
batch.read
|
|
359
|
+
|
|
360
|
+
batch.section.should == :leave
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
it "handles an empty sync with nothing in it" do
|
|
364
|
+
batch = Protocol::Matrix::MessageBatch.from_sync({"next_batch" => "s1"})
|
|
365
|
+
|
|
366
|
+
batch.size.should == 0
|
|
367
|
+
batch.drained?.should == true
|
|
368
|
+
batch.read.should.be.nil
|
|
369
|
+
batch.next_batch.should == "s1"
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# ── Unpacking an appservice transaction ───────────────────────────────────
|
|
373
|
+
|
|
374
|
+
it "unpacks a transaction into the same stream" do
|
|
375
|
+
transaction = {
|
|
376
|
+
"de.sorunome.msc2409.to_device" => [to_device_event],
|
|
377
|
+
"events" => [encrypted_timeline_event.merge("room_id" => "!room:example.org")],
|
|
378
|
+
}
|
|
379
|
+
batch = Protocol::Matrix::MessageBatch.from_transaction(transaction, txn_id: "txn42")
|
|
380
|
+
|
|
381
|
+
batch.size.should == 2
|
|
382
|
+
batch.read
|
|
383
|
+
batch.to_device?.should == true
|
|
384
|
+
batch.section.should == :to_device
|
|
385
|
+
|
|
386
|
+
timeline = batch.read
|
|
387
|
+
timeline.room_id.should == "!room:example.org"
|
|
388
|
+
batch.section.should == :transaction
|
|
389
|
+
batch.next_batch.should == "txn42"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Synapse sends the unstable MSC2409 spelling today; the stable MSC4203 key
|
|
393
|
+
# is preferred so this keeps working when that lands.
|
|
394
|
+
it "prefers the stable to_device key over the unstable one" do
|
|
395
|
+
transaction = {
|
|
396
|
+
"to_device" => [to_device_event],
|
|
397
|
+
"de.sorunome.msc2409.to_device" => [to_device_event, to_device_event],
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
Protocol::Matrix::MessageBatch.from_transaction(transaction).size.should == 1
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
it "handles a transaction with no to-device messages" do
|
|
404
|
+
transaction = {"events" => [plaintext_event]}
|
|
405
|
+
batch = Protocol::Matrix::MessageBatch.from_transaction(transaction)
|
|
406
|
+
|
|
407
|
+
batch.size.should == 1
|
|
408
|
+
batch.read.type.should == "m.room.message"
|
|
409
|
+
batch.to_device?.should == false
|
|
410
|
+
batch.section.should == :transaction
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
it "handles an empty transaction" do
|
|
414
|
+
batch = Protocol::Matrix::MessageBatch.from_transaction({})
|
|
415
|
+
|
|
416
|
+
batch.size.should == 0
|
|
417
|
+
batch.read.should.be.nil
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# ── Read exactly once ─────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
# Decrypting ratchets a session forward, so "read it again" is not something
|
|
423
|
+
# the protocol can offer. A drained batch stays drained.
|
|
424
|
+
it "cannot be rewound" do
|
|
425
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
426
|
+
3.times { batch.read }
|
|
427
|
+
|
|
428
|
+
batch.read.should.be.nil
|
|
429
|
+
batch.read.should.be.nil
|
|
430
|
+
batch.drained?.should == true
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
it "consumes the batch when drained with each" do
|
|
434
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
435
|
+
seen = []
|
|
436
|
+
batch.each { |message| seen << message }
|
|
437
|
+
|
|
438
|
+
seen.length.should == 3
|
|
439
|
+
batch.drained?.should == true
|
|
440
|
+
|
|
441
|
+
again = []
|
|
442
|
+
batch.each { |message| again << message }
|
|
443
|
+
again.should == []
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
it "reports the section alongside each message in each" do
|
|
447
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
448
|
+
sections = []
|
|
449
|
+
batch.each { |_message, section| sections << section }
|
|
450
|
+
|
|
451
|
+
sections.should == [:to_device, :join, :join]
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
it "continues a partly read batch rather than restarting" do
|
|
455
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
456
|
+
batch.read
|
|
457
|
+
|
|
458
|
+
remaining = []
|
|
459
|
+
batch.each { |message| remaining << message }
|
|
460
|
+
|
|
461
|
+
remaining.length.should == 2
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# A batch is a stream, not a collection: there is no non-consuming read, so
|
|
465
|
+
# #each without a block has nothing sensible to return.
|
|
466
|
+
it "refuses to be treated as an enumerable" do
|
|
467
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
468
|
+
|
|
469
|
+
lambda { batch.each }.should.raise(Protocol::Matrix::MessageBatch::ConsumedError)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
it "keeps size fixed as it is read" do
|
|
473
|
+
batch = Protocol::Matrix::MessageBatch.from_sync(sync_response)
|
|
474
|
+
batch.read
|
|
475
|
+
|
|
476
|
+
batch.size.should == 3
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
it "has no section before anything has been read" do
|
|
480
|
+
Protocol::Matrix::MessageBatch.from_sync(sync_response).section.should.be.nil
|
|
481
|
+
end
|
|
482
|
+
end
|
|
@@ -8,7 +8,7 @@ require "json"
|
|
|
8
8
|
require "json_schemer"
|
|
9
9
|
require "pathname"
|
|
10
10
|
|
|
11
|
-
module
|
|
11
|
+
module Protocol
|
|
12
12
|
module Matrix
|
|
13
13
|
module Schema
|
|
14
14
|
# Loads and indexes Matrix event schemas from the official matrix-org/matrix-spec
|
|
@@ -18,7 +18,7 @@ module Async
|
|
|
18
18
|
# process. Each schema is a JSONSchemer::Schema instance that resolves relative
|
|
19
19
|
# $ref paths (e.g. core-event-schema/room_event.yaml) via a file-based resolver.
|
|
20
20
|
#
|
|
21
|
-
# registry =
|
|
21
|
+
# registry = Protocol::Matrix::Schema::Registry.instance
|
|
22
22
|
# registry["m.room.message"] # => JSONSchemer::Schema
|
|
23
23
|
# registry.variant("m.room.message", "m.text") # => JSONSchemer::Schema
|
|
24
24
|
# registry.event_types # => ["m.accepted_terms", ...]
|
|
@@ -262,13 +262,13 @@ module Async
|
|
|
262
262
|
end
|
|
263
263
|
|
|
264
264
|
__END__
|
|
265
|
-
describe "
|
|
265
|
+
describe "Protocol::Matrix::Schema::Registry" do
|
|
266
266
|
it "loads schemas from disk" do
|
|
267
|
-
|
|
267
|
+
Protocol::Matrix::Schema::Registry.instance.size.should.be > 0
|
|
268
268
|
end
|
|
269
269
|
|
|
270
270
|
it "has known event types" do
|
|
271
|
-
types =
|
|
271
|
+
types = Protocol::Matrix::Schema::Registry.instance.event_types
|
|
272
272
|
types.should.include "m.room.message"
|
|
273
273
|
types.should.include "m.room.member"
|
|
274
274
|
types.should.include "m.room.create"
|
|
@@ -276,21 +276,21 @@ __END__
|
|
|
276
276
|
end
|
|
277
277
|
|
|
278
278
|
it "returns a JSONSchemer::Schema for a known type" do
|
|
279
|
-
schema =
|
|
279
|
+
schema = Protocol::Matrix::Schema::Registry.instance["m.room.message"]
|
|
280
280
|
schema.should.not.be.nil
|
|
281
281
|
schema.should.be.kind_of JSONSchemer::Schema
|
|
282
282
|
end
|
|
283
283
|
|
|
284
284
|
it "returns nil for an unknown type" do
|
|
285
|
-
|
|
285
|
+
Protocol::Matrix::Schema::Registry.instance["m.fake.event"].should.be.nil
|
|
286
286
|
end
|
|
287
287
|
|
|
288
288
|
it "has variant schemas" do
|
|
289
|
-
|
|
289
|
+
Protocol::Matrix::Schema::Registry.instance.variant_types.should.not.be.empty
|
|
290
290
|
end
|
|
291
291
|
|
|
292
292
|
it "resolves variant schemas" do
|
|
293
|
-
schema =
|
|
293
|
+
schema = Protocol::Matrix::Schema::Registry.instance.variant("m.room.message", "m.text")
|
|
294
294
|
schema.should.not.be.nil
|
|
295
295
|
schema.should.be.kind_of JSONSchemer::Schema
|
|
296
296
|
end
|
|
@@ -304,7 +304,7 @@ __END__
|
|
|
304
304
|
"origin_server_ts" => 1234567890,
|
|
305
305
|
"room_id" => "!room:example.org"
|
|
306
306
|
}
|
|
307
|
-
|
|
307
|
+
Protocol::Matrix::Schema::Registry.instance.valid?(event).should == true
|
|
308
308
|
end
|
|
309
309
|
|
|
310
310
|
it "rejects m.room.message missing body" do
|
|
@@ -316,7 +316,7 @@ __END__
|
|
|
316
316
|
"origin_server_ts" => 1234567890,
|
|
317
317
|
"room_id" => "!room:example.org"
|
|
318
318
|
}
|
|
319
|
-
|
|
319
|
+
Protocol::Matrix::Schema::Registry.instance.valid?(event).should == false
|
|
320
320
|
end
|
|
321
321
|
|
|
322
322
|
it "is lenient with unknown event types" do
|
|
@@ -328,7 +328,7 @@ __END__
|
|
|
328
328
|
"origin_server_ts" => 1234567890,
|
|
329
329
|
"room_id" => "!room:example.org"
|
|
330
330
|
}
|
|
331
|
-
|
|
331
|
+
Protocol::Matrix::Schema::Registry.instance.valid?(event).should == true
|
|
332
332
|
end
|
|
333
333
|
|
|
334
334
|
it "validates m.room.member with correct membership" do
|
|
@@ -341,7 +341,7 @@ __END__
|
|
|
341
341
|
"origin_server_ts" => 1234567890,
|
|
342
342
|
"room_id" => "!room:example.org"
|
|
343
343
|
}
|
|
344
|
-
|
|
344
|
+
Protocol::Matrix::Schema::Registry.instance.valid?(event).should == true
|
|
345
345
|
end
|
|
346
346
|
|
|
347
347
|
it "rejects m.room.member with invalid membership" do
|
|
@@ -354,17 +354,17 @@ __END__
|
|
|
354
354
|
"origin_server_ts" => 1234567890,
|
|
355
355
|
"room_id" => "!room:example.org"
|
|
356
356
|
}
|
|
357
|
-
|
|
357
|
+
Protocol::Matrix::Schema::Registry.instance.valid?(event).should == false
|
|
358
358
|
end
|
|
359
359
|
|
|
360
360
|
it "returns content properties for known types" do
|
|
361
|
-
props =
|
|
361
|
+
props = Protocol::Matrix::Schema::Registry.instance.content_properties("m.room.message")
|
|
362
362
|
props.should.include "msgtype"
|
|
363
363
|
props.should.include "body"
|
|
364
364
|
end
|
|
365
365
|
|
|
366
366
|
it "returns empty content properties for unknown types" do
|
|
367
|
-
|
|
367
|
+
Protocol::Matrix::Schema::Registry.instance.content_properties("m.fake.type").should == []
|
|
368
368
|
end
|
|
369
369
|
|
|
370
370
|
it "performs variant validation for m.room.message with msgtype" do
|
|
@@ -376,6 +376,6 @@ __END__
|
|
|
376
376
|
"origin_server_ts" => 1234567890,
|
|
377
377
|
"room_id" => "!room:example.org"
|
|
378
378
|
}
|
|
379
|
-
|
|
379
|
+
Protocol::Matrix::Schema::Registry.instance.validate(event).should == []
|
|
380
380
|
end
|
|
381
381
|
end
|