async-matrix 2.1.0-arm-linux → 3.0.1-arm-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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -58
  3. data/lib/async/matrix/3.3/async_matrix_e2ee.so +0 -0
  4. data/lib/async/matrix/3.4/async_matrix_e2ee.so +0 -0
  5. data/lib/async/matrix/4.0/async_matrix_e2ee.so +0 -0
  6. data/lib/async/matrix/api/chain.rb +120 -94
  7. data/lib/async/matrix/api/concat.rb +28 -22
  8. data/lib/async/matrix/api/path_tree.rb +71 -44
  9. data/lib/async/matrix/api.rb +6 -1
  10. data/lib/async/matrix/app_service_client.rb +213 -0
  11. data/lib/async/matrix/auth_error.rb +20 -0
  12. data/lib/async/matrix/bad_json_error.rb +20 -0
  13. data/lib/async/matrix/client/encryption.rb +413 -0
  14. data/lib/async/matrix/client/rooms.rb +478 -0
  15. data/lib/async/matrix/client/sync.rb +508 -0
  16. data/lib/async/matrix/client.rb +217 -113
  17. data/lib/async/matrix/config/vivify.rb +107 -0
  18. data/lib/async/matrix/config.rb +184 -0
  19. data/lib/async/matrix/device_store.rb +1621 -0
  20. data/lib/async/matrix/double_puppet_client.rb +1 -1
  21. data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
  22. data/lib/async/matrix/error.rb +9 -36
  23. data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
  24. data/lib/async/matrix/homeserver_error.rb +20 -0
  25. data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
  26. data/lib/async/matrix/media_client.rb +46 -35
  27. data/lib/async/matrix/not_found_error.rb +20 -0
  28. data/lib/async/matrix/notifier.rb +7 -3
  29. data/lib/async/matrix/response_too_large_error.rb +20 -0
  30. data/lib/async/matrix/version.rb +1 -1
  31. data/lib/async/matrix.rb +16 -7
  32. data/lib/protocol/matrix/canonical_json.rb +200 -0
  33. data/lib/protocol/matrix/content.rb +106 -0
  34. data/lib/protocol/matrix/encrypted_message.rb +1018 -0
  35. data/lib/protocol/matrix/error.rb +49 -0
  36. data/lib/protocol/matrix/event.rb +208 -0
  37. data/lib/protocol/matrix/key_backup.rb +347 -0
  38. data/lib/protocol/matrix/keys.rb +381 -0
  39. data/lib/protocol/matrix/message_batch.rb +482 -0
  40. data/lib/protocol/matrix/schema/registry.rb +381 -0
  41. data/lib/protocol/matrix/schema/validation_error.rb +234 -0
  42. data/lib/protocol/matrix/schema.rb +170 -0
  43. data/lib/protocol/matrix/secret_storage.rb +535 -0
  44. data/lib/protocol/matrix/signing.rb +278 -0
  45. data/lib/protocol/matrix.rb +25 -0
  46. metadata +43 -136
  47. data/lib/async/discord/api/path_tree.rb +0 -127
  48. data/lib/async/discord/api.rb +0 -151
  49. data/lib/async/discord/client.rb +0 -283
  50. data/lib/async/discord/error.rb +0 -84
  51. data/lib/async/discord/gateway.rb +0 -359
  52. data/lib/async/discord.rb +0 -15
  53. data/lib/async/matrix/application_service/bot.rb +0 -232
  54. data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
  55. data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
  56. data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
  57. data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
  58. data/lib/async/matrix/application_service/config/schema/config.json +0 -61
  59. data/lib/async/matrix/application_service/config/schema/database.json +0 -38
  60. data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
  61. data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
  62. data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
  63. data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
  64. data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
  65. data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
  66. data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
  67. data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
  68. data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
  69. data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
  70. data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
  71. data/lib/async/matrix/application_service/config/vivify.rb +0 -109
  72. data/lib/async/matrix/application_service/config.rb +0 -225
  73. data/lib/async/matrix/application_service/dispatcher.rb +0 -185
  74. data/lib/async/matrix/application_service/event.rb +0 -285
  75. data/lib/async/matrix/application_service/server.rb +0 -430
  76. data/lib/async/matrix/application_service/transaction.rb +0 -66
  77. data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
  78. data/lib/async/matrix/application_service/transaction_store.rb +0 -80
  79. data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
  80. data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
  81. data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
  82. data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
  83. data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
  84. data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
  85. data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
  86. data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
  87. data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
  88. data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
  89. data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
  90. data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
  91. data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
  92. data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
  93. data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
  94. data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
  95. data/lib/async/matrix/bridge/discord/db.rb +0 -138
  96. data/lib/async/matrix/schema/registry.rb +0 -354
  97. data/lib/async/matrix/schema/validation_error.rb +0 -225
  98. data/lib/async/matrix/schema.rb +0 -170
@@ -0,0 +1,508 @@
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 "console"
7
+
8
+ require_relative "../../../protocol/matrix/message_batch"
9
+
10
+ module Async
11
+ module Matrix
12
+ class Client
13
+ # A stream of messages from the homeserver, decrypted on the way through.
14
+ #
15
+ # stream = client.sync(store: device_store)
16
+ #
17
+ # stream.each do |message|
18
+ # message.decrypted? # false means we hold no key for it YET
19
+ # message.type # the real type, once decrypted
20
+ # message.content
21
+ # checkpoint(stream.next_batch)
22
+ # end
23
+ #
24
+ # WHAT A CONSUMER SEES IS MESSAGES. /sync also carries to-device events,
25
+ # device lists and key counts, and none of that is a message: to-device
26
+ # events are how room keys travel, so they are fed to the store and never
27
+ # yielded. A consumer that had to recognise and route them would be
28
+ # reimplementing this class.
29
+ #
30
+ # ORDER IS THE WHOLE TRICK, and MessageBatch provides it. A batch
31
+ # routinely carries both an `m.room_key` and the timeline event that key
32
+ # unlocks; because the batch yields to-device first and the store absorbs
33
+ # keys as it reads them, that event decrypts on its first pass instead of
34
+ # being stored as a placeholder and repaired later.
35
+ #
36
+ # THE STORE IS PASSED IN, holding this device's keys. It is what makes any
37
+ # room readable, and it is optional only in the sense that a consumer
38
+ # interested solely in unencrypted rooms can omit it and receive encrypted
39
+ # messages undecrypted.
40
+ #
41
+ # NOT RESILIENT, DELIBERATELY. A failed request raises. How to react to a
42
+ # homeserver that is down -- retry, back off, give up, alert -- is policy,
43
+ # and policy belongs to whatever is driving the loop rather than to the
44
+ # thing reading the protocol.
45
+ class Sync
46
+ # Synapse holds a /sync request open until something happens or this
47
+ # elapses, so it is a normal quiet-room duration rather than a failure
48
+ # timeout.
49
+ DEFAULT_TIMEOUT = 30_000
50
+
51
+ def initialize(client, store: nil, since: nil, timeout: DEFAULT_TIMEOUT, filter: nil)
52
+ @client = client
53
+ @store = store
54
+ @next_batch = since
55
+ @timeout = timeout
56
+ @filter = filter
57
+ @one_time_keys_count = {}
58
+ end
59
+
60
+ attr_reader :store, :timeout, :filter
61
+
62
+ # The cursor to resume from.
63
+ #
64
+ # COMMIT IT ONLY AFTER THE MESSAGES OF ITS BATCH ARE DURABLE. It advances
65
+ # when a batch is drained, so reading it inside the loop and storing it
66
+ # before handling the message is how a crash loses messages with nothing
67
+ # recording that it happened.
68
+ attr_reader :next_batch
69
+
70
+ # What the server last said it still holds of our one-time keys.
71
+ #
72
+ # Surfaced rather than acted on. Running low means topping up, which is
73
+ # an upload, and deciding when to upload is not this object's business --
74
+ # but a device whose keys run out silently stops being reachable, so the
75
+ # number has to be visible to someone.
76
+ attr_reader :one_time_keys_count
77
+
78
+ # One round trip. Returns the batch, undecrypted.
79
+ #
80
+ # An INITIAL sync -- no cursor -- asks for the full state of every room
81
+ # the account is in, which is as large as a response gets. A filter is
82
+ # the only thing that makes it smaller.
83
+ def poll
84
+ Protocol::Matrix::MessageBatch.from_sync(fetch)
85
+ end
86
+
87
+ # Read one batch, yielding each room message with whatever the store
88
+ # could decrypt.
89
+ #
90
+ # @returns [Integer] how many messages were yielded.
91
+ def read(&block)
92
+ batch = poll
93
+ yielded = 0
94
+
95
+ batch.each do |message, _section|
96
+ if batch.to_device?
97
+ handle_to_device(message)
98
+ else
99
+ yielded += 1
100
+ block.call(decrypt(message))
101
+ end
102
+ end
103
+
104
+ # AFTER the batch, never during: the cursor says "everything before
105
+ # this has been handed over", and advancing it mid-batch would promise
106
+ # that of messages still in it.
107
+ @next_batch = batch.next_batch
108
+ yielded
109
+ end
110
+
111
+ # Poll forever, yielding every message. Stops when +stop+ says so, which
112
+ # defaults to never -- a sync stream has no natural end.
113
+ def each(stop: -> { false }, &block)
114
+ unless block
115
+ raise ArgumentError, "Client::Sync#each requires a block; a sync stream is not enumerable"
116
+ end
117
+
118
+ until stop.call
119
+ read(&block)
120
+ end
121
+
122
+ self
123
+ end
124
+
125
+ private
126
+
127
+ def fetch
128
+ query = {timeout: @timeout}
129
+
130
+ if @next_batch
131
+ query[:since] = @next_batch
132
+ end
133
+
134
+ if @filter
135
+ query[:filter] = @filter
136
+ end
137
+
138
+ response = @client.api.sync.get(**query)
139
+ remember_key_counts(response)
140
+ response
141
+ end
142
+
143
+ def remember_key_counts(response)
144
+ counts = response["device_one_time_keys_count"]
145
+
146
+ if counts
147
+ @one_time_keys_count = counts
148
+ end
149
+ end
150
+
151
+ # To-device messages are plumbing: decrypting one is how a room key
152
+ # reaches the store, and the store absorbs it as it reads. Nothing is
153
+ # yielded.
154
+ #
155
+ # A FAILURE HERE MUST NOT REACH THE LOOP. Anyone sharing a room can
156
+ # send us a to-device message, so a malformed one that propagated
157
+ # would be a remote-controlled crash -- and the loss is recoverable
158
+ # anyway, because a sender who sees us unable to decrypt re-shares the
159
+ # key. Protocol errors are swallowed; anything else is a bug in us and
160
+ # still raises.
161
+ def handle_to_device(message)
162
+ if @store && message.encrypted?
163
+ @store.decrypt(message)
164
+ end
165
+ rescue Protocol::Matrix::Error => error
166
+ Console.warn(self, "Discarding undecryptable to-device message.", error: error)
167
+ nil
168
+ end
169
+
170
+ # An encrypted message with no key is returned AS IT IS, undecrypted.
171
+ # That is not a failure: it is the commonest state in an encrypted
172
+ # room, the key may still arrive, and a consumer that stores the row
173
+ # now can have it repaired in place later.
174
+ def decrypt(message)
175
+ if @store && message.encrypted?
176
+ @store.decrypt(message)
177
+ end
178
+
179
+ message
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ __END__
187
+ describe "Async::Matrix::Client::Sync" do
188
+ # A client faked at the api-chain level, so the specs exercise the real call
189
+ # path (client.api.sync.get) rather than a shortcut around it.
190
+ def fake_client(*responses)
191
+ queried = []
192
+ remaining = responses.dup
193
+ client = Object.new
194
+ client.define_singleton_method(:queried) { queried }
195
+ client.define_singleton_method(:api) do
196
+ chain = Object.new
197
+ chain.define_singleton_method(:sync) do
198
+ endpoint = Object.new
199
+ endpoint.define_singleton_method(:get) do |**query|
200
+ queried << query
201
+ remaining.shift || {"next_batch" => "empty"}
202
+ end
203
+ endpoint
204
+ end
205
+ chain
206
+ end
207
+ client
208
+ end
209
+
210
+ # A store that records what it was asked to read and "decrypts" whatever it
211
+ # has a key for.
212
+ def fake_store(readable: [])
213
+ store = Object.new
214
+ seen = []
215
+ # Captured as locals: inside define_singleton_method, self is the store,
216
+ # so the spec's own helpers are out of scope.
217
+ olm = olm_session_double
218
+ megolm = megolm_session_double
219
+
220
+ store.define_singleton_method(:seen) { seen }
221
+ store.define_singleton_method(:decrypt) do |message|
222
+ seen << message
223
+
224
+ if message.olm?
225
+ message.decrypt!(olm, identity_key: message.recipients.first)
226
+ message
227
+ elsif readable.include?(message.session_id)
228
+ message.decrypt!(megolm)
229
+ message
230
+ end
231
+ end
232
+ store
233
+ end
234
+
235
+ def megolm_session_double
236
+ session = Object.new
237
+ session.define_singleton_method(:decrypt) do |_ciphertext|
238
+ [JSON.generate({"type" => "m.room.message", "content" => {"body" => "plain"}}), 0]
239
+ end
240
+ session
241
+ end
242
+
243
+ # A real olm payload: the OlmPayload schema's required fields are what make
244
+ # the message attributable, and EncryptedMessage refuses one without them.
245
+ def olm_session_double(type: "m.room_key")
246
+ session = Object.new
247
+ session.define_singleton_method(:decrypt) do |_type, _body|
248
+ JSON.generate({
249
+ "type" => type,
250
+ "content" => {"algorithm" => "m.megolm.v1.aes-sha2"},
251
+ "sender" => "@bob:example.org",
252
+ "recipient" => "@us:example.org",
253
+ "recipient_keys" => {"ed25519" => "ours"},
254
+ "keys" => {"ed25519" => "theirs"},
255
+ })
256
+ end
257
+ session
258
+ end
259
+
260
+ def encrypted_timeline(session_id: "session1", event_id: "$enc")
261
+ {
262
+ "type" => "m.room.encrypted",
263
+ "event_id" => event_id,
264
+ "sender" => "@alice:example.org",
265
+ "content" => {
266
+ "algorithm" => "m.megolm.v1.aes-sha2",
267
+ "ciphertext" => "AwgAEnAC",
268
+ "session_id" => session_id,
269
+ },
270
+ }
271
+ end
272
+
273
+ def plaintext_timeline(event_id = "$msg")
274
+ {
275
+ "type" => "m.room.message",
276
+ "event_id" => event_id,
277
+ "sender" => "@alice:example.org",
278
+ "content" => {"msgtype" => "m.text", "body" => "hello"},
279
+ }
280
+ end
281
+
282
+ def to_device_event
283
+ {
284
+ "type" => "m.room.encrypted",
285
+ "sender" => "@bob:example.org",
286
+ "content" => {
287
+ "algorithm" => "m.olm.v1.curve25519-aes-sha2",
288
+ "sender_key" => "theircurve",
289
+ "ciphertext" => {"ourcurve" => {"type" => 0, "body" => "olmbody"}},
290
+ },
291
+ }
292
+ end
293
+
294
+ def response(events: [plaintext_timeline], to_device: [], next_batch: "s2", **extra)
295
+ {
296
+ "next_batch" => next_batch,
297
+ "to_device" => {"events" => to_device},
298
+ "rooms" => {"join" => {"!room:example.org" => {"timeline" => {"events" => events}}}},
299
+ }.merge(extra)
300
+ end
301
+
302
+ def sync_for(client, **options)
303
+ Async::Matrix::Client::Sync.new(client, **options)
304
+ end
305
+
306
+ # ── The request ───────────────────────────────────────────────────────────
307
+
308
+ it "polls with a timeout and no cursor on the first request" do
309
+ client = fake_client(response)
310
+ sync_for(client).read { |_message| nil }
311
+
312
+ client.queried.should == [{timeout: 30_000}]
313
+ end
314
+
315
+ it "sends the cursor it was given" do
316
+ client = fake_client(response)
317
+ sync_for(client, since: "s1").read { |_message| nil }
318
+
319
+ client.queried.should == [{timeout: 30_000, since: "s1"}]
320
+ end
321
+
322
+ it "sends a filter when it has one" do
323
+ client = fake_client(response)
324
+ sync_for(client, filter: "2", timeout: 100).read { |_message| nil }
325
+
326
+ client.queried.should == [{timeout: 100, filter: "2"}]
327
+ end
328
+
329
+ it "advances the cursor across polls" do
330
+ client = fake_client(response(next_batch: "s2"), response(next_batch: "s3"))
331
+ sync = sync_for(client)
332
+
333
+ sync.next_batch.should.be.nil
334
+ sync.read { |_message| nil }
335
+ sync.next_batch.should == "s2"
336
+ sync.read { |_message| nil }
337
+ sync.next_batch.should == "s3"
338
+ client.queried.last.should == {timeout: 30_000, since: "s2"}
339
+ end
340
+
341
+ # ── What is yielded ───────────────────────────────────────────────────────
342
+
343
+ it "yields room messages" do
344
+ sync = sync_for(fake_client(response(events: [plaintext_timeline("$a"), plaintext_timeline("$b")])))
345
+ seen = []
346
+ sync.read { |message| seen << message.event_id }
347
+
348
+ seen.should == ["$a", "$b"]
349
+ end
350
+
351
+ # To-device events are how room keys travel. They are not messages, and a
352
+ # consumer that had to recognise them would be reimplementing this class.
353
+ it "never yields a to-device message" do
354
+ sync = sync_for(
355
+ fake_client(response(to_device: [to_device_event], events: [plaintext_timeline])),
356
+ store: fake_store,
357
+ )
358
+ seen = []
359
+ sync.read { |message| seen << message }
360
+
361
+ seen.length.should == 1
362
+ seen.first.type.should == "m.room.message"
363
+ end
364
+
365
+ it "still feeds to-device messages to the store" do
366
+ store = fake_store
367
+ sync = sync_for(fake_client(response(to_device: [to_device_event], events: [])), store: store)
368
+ sync.read { |_message| nil }
369
+
370
+ store.seen.length.should == 1
371
+ store.seen.first.olm?.should == true
372
+ end
373
+
374
+ it "counts what it yielded" do
375
+ sync = sync_for(fake_client(response(events: [plaintext_timeline("$a"), plaintext_timeline("$b")])))
376
+
377
+ sync.read { |_message| nil }.should == 2
378
+ end
379
+
380
+ it "yields nothing for an empty batch" do
381
+ sync = sync_for(fake_client(response(events: [])))
382
+ seen = []
383
+
384
+ sync.read { |message| seen << message }.should == 0
385
+ seen.should == []
386
+ end
387
+
388
+ # ── Decryption ────────────────────────────────────────────────────────────
389
+
390
+ it "decrypts a room message with the store's keys" do
391
+ sync = sync_for(
392
+ fake_client(response(events: [encrypted_timeline(session_id: "known")])),
393
+ store: fake_store(readable: ["known"]),
394
+ )
395
+ seen = []
396
+ sync.read { |message| seen << message }
397
+
398
+ seen.first.decrypted?.should == true
399
+ seen.first.type.should == "m.room.message"
400
+ seen.first.content.should == {"body" => "plain"}
401
+ end
402
+
403
+ # NOT A FAILURE, and not a reason to drop the message: the key may still
404
+ # arrive, and a consumer that stored this row can have it repaired later.
405
+ it "yields an encrypted message it has no key for, undecrypted" do
406
+ sync = sync_for(
407
+ fake_client(response(events: [encrypted_timeline(session_id: "unknown")])),
408
+ store: fake_store(readable: []),
409
+ )
410
+ seen = []
411
+ sync.read { |message| seen << message }
412
+
413
+ seen.length.should == 1
414
+ seen.first.decrypted?.should == false
415
+ seen.first.encrypted?.should == true
416
+ seen.first.session_id.should == "unknown"
417
+ end
418
+
419
+ # A consumer interested only in unencrypted rooms needs no keys at all.
420
+ it "works with no store, leaving encrypted messages alone" do
421
+ sync = sync_for(fake_client(response(events: [encrypted_timeline, plaintext_timeline])))
422
+ seen = []
423
+ sync.read { |message| seen << message }
424
+
425
+ seen.length.should == 2
426
+ seen.first.decrypted?.should == false
427
+ seen.last.type.should == "m.room.message"
428
+ end
429
+
430
+ it "leaves a plaintext message untouched by the store" do
431
+ store = fake_store
432
+ sync = sync_for(fake_client(response(events: [plaintext_timeline])), store: store)
433
+ sync.read { |_message| nil }
434
+
435
+ store.seen.should == []
436
+ end
437
+
438
+ # Anyone sharing a room can send us one, so a malformed to-device message
439
+ # must not take the loop down with it.
440
+ it "discards an undecryptable to-device message and keeps going" do
441
+ exploding = Object.new
442
+ exploding.define_singleton_method(:decrypt) do |_message|
443
+ raise(Protocol::Matrix::EncryptedMessage::MalformedError, "nonsense")
444
+ end
445
+
446
+ sync = sync_for(
447
+ fake_client(response(to_device: [to_device_event], events: [plaintext_timeline])),
448
+ store: exploding,
449
+ )
450
+ seen = []
451
+
452
+ sync.read { |message| seen << message }.should == 1
453
+ seen.first.type.should == "m.room.message"
454
+ end
455
+
456
+ # ── Key counts ────────────────────────────────────────────────────────────
457
+
458
+ # A device whose one-time keys run out silently stops being reachable, so
459
+ # the number has to be visible to someone.
460
+ it "remembers what the server says it holds of our one-time keys" do
461
+ sync = sync_for(
462
+ fake_client(response("device_one_time_keys_count" => {"signed_curve25519" => 12})),
463
+ )
464
+
465
+ sync.one_time_keys_count.should == {}
466
+ sync.read { |_message| nil }
467
+ sync.one_time_keys_count.should == {"signed_curve25519" => 12}
468
+ end
469
+
470
+ it "keeps the last count when a response omits it" do
471
+ client = fake_client(
472
+ response("device_one_time_keys_count" => {"signed_curve25519" => 12}),
473
+ response,
474
+ )
475
+ sync = sync_for(client)
476
+ sync.read { |_message| nil }
477
+ sync.read { |_message| nil }
478
+
479
+ sync.one_time_keys_count.should == {"signed_curve25519" => 12}
480
+ end
481
+
482
+ # ── Looping ───────────────────────────────────────────────────────────────
483
+
484
+ it "polls until told to stop" do
485
+ client = fake_client(response(next_batch: "s2"), response(next_batch: "s3"))
486
+ polls = 0
487
+ sync = sync_for(client)
488
+
489
+ sync.each(stop: -> { polls >= 2 }) { |_message| polls += 1 }
490
+
491
+ polls.should == 2
492
+ client.queried.length.should == 2
493
+ end
494
+
495
+ it "requires a block, because a sync stream is not enumerable" do
496
+ lambda { sync_for(fake_client(response)).each }.should.raise(ArgumentError)
497
+ end
498
+
499
+ # ── The batch, unprocessed ────────────────────────────────────────────────
500
+
501
+ it "exposes the raw batch for a caller that wants to drive it itself" do
502
+ batch = sync_for(fake_client(response(to_device: [to_device_event]))).poll
503
+
504
+ batch.should.be.kind_of Protocol::Matrix::MessageBatch
505
+ batch.size.should == 2
506
+ batch.next_batch.should == "s2"
507
+ end
508
+ end