async-matrix 3.0.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.
@@ -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
@@ -10,6 +10,12 @@ require "console"
10
10
  require "securerandom"
11
11
  require "time"
12
12
 
13
+ # The action surface, mixed in below. Required explicitly because the library's
14
+ # loader globs this directory in sorted order, which reaches client.rb before
15
+ # client/ -- so `include Encryption` would resolve against nothing.
16
+ require_relative "client/encryption"
17
+ require_relative "client/rooms"
18
+
13
19
  module Async
14
20
  module Matrix
15
21
  # Async HTTP client for the Matrix Client-Server API.
@@ -37,6 +43,9 @@ module Async
37
43
  DEFAULT_RESPONSE_SIZE_LIMIT = 50 * 1024 * 1024 # 50 MiB for JSON API responses
38
44
  DEFAULT_ERROR_RESPONSE_SIZE_LIMIT = 512 * 1024 # 512 KiB for error bodies
39
45
 
46
+ include Encryption
47
+ include Rooms
48
+
40
49
  attr_reader :config
41
50
 
42
51
  def initialize(config, max_retries: DEFAULT_MAX_RETRIES,
@@ -84,12 +93,18 @@ module Async
84
93
 
85
94
  # ── Room actions ───────────────────────────────────────────
86
95
 
87
- def join_room(room_id)
88
- post("#{CLIENT_PREFIX}/join/#{encode(room_id)}")
96
+ # Positional OR keyword: `join_room("!r:example.org")` is how this was
97
+ # always called, and `join_room(room_id: "!r:example.org")` is the form
98
+ # every other action here takes. Keeping both means the action surface is
99
+ # consistent without breaking callers written against 3.0.
100
+ def join_room(room_id = nil, **options)
101
+ target = room_id || options[:room_id]
102
+ post("#{CLIENT_PREFIX}/join/#{encode(target)}")
89
103
  end
90
104
 
91
- def leave_room(room_id)
92
- post("#{CLIENT_PREFIX}/rooms/#{encode(room_id)}/leave")
105
+ def leave_room(room_id = nil, **options)
106
+ target = room_id || options[:room_id]
107
+ post("#{CLIENT_PREFIX}/rooms/#{encode(target)}/leave")
93
108
  end
94
109
 
95
110
  # ── Profile ────────────────────────────────────────────────
@@ -108,6 +123,21 @@ module Async
108
123
  get("#{CLIENT_PREFIX}/account/whoami")
109
124
  end
110
125
 
126
+ # ── Syncing ────────────────────────────────────────────────────────────
127
+
128
+ # A stream of messages from this account, decrypted by +store+.
129
+ #
130
+ # client.sync(store: device_store).each do |message|
131
+ # ...
132
+ # end
133
+ #
134
+ # See Client::Sync: the stream yields room messages only, and to-device
135
+ # events are fed to the store on the way through, which is how room keys
136
+ # arrive.
137
+ def sync(store: nil, since: nil, timeout: Sync::DEFAULT_TIMEOUT, filter: nil)
138
+ Sync.new(self, store: store, since: since, timeout: timeout, filter: filter)
139
+ end
140
+
111
141
  # ── Full API (runtime-generated from OpenAPI schemas) ─────
112
142
 
113
143
  # Returns a Gateway that provides method-chained access to every
@@ -177,6 +207,16 @@ module Async
177
207
  )
178
208
  end
179
209
 
210
+ # Query parameters added to EVERY request this client makes.
211
+ #
212
+ # EMPTY FOR AN ORDINARY CLIENT: a user's own access token already says who
213
+ # the request is for. AppServiceClient overrides it, because an as_token
214
+ # says only "an appservice" -- without `?user_id=` the homeserver assumes
215
+ # the registration's sender_localpart, and without `?device_id=` the
216
+ # request has no device at all, which fails precisely the calls encryption
217
+ # depends on.
218
+ def default_query = {}
219
+
180
220
  def close
181
221
  @internet&.close
182
222
  @internet = nil
@@ -190,8 +230,38 @@ module Async
190
230
  @internet ||= Async::HTTP::Internet.new
191
231
  end
192
232
 
233
+ # Merge #default_query into a path that may already carry a query --
234
+ # Api::Chain appends its own parameters before handing the path over, so
235
+ # this is the single place both routes pass through.
236
+ #
237
+ # A parameter the caller already set WINS. Overwriting it would mean a
238
+ # client silently acting as somebody other than the caller asked for,
239
+ # which is worse than the request failing.
240
+ def apply_default_query(path)
241
+ missing = default_query.reject { |key, _|
242
+ path.match?(/[?&]#{Regexp.escape(key.to_s)}=/)
243
+ }
244
+
245
+ if missing.empty?
246
+ path
247
+ else
248
+ if path.include?("?")
249
+ separator = "&"
250
+ else
251
+ separator = "?"
252
+ end
253
+
254
+ pairs = missing.map { |key, value| "#{encode(key.to_s)}=#{encode(value.to_s)}" }
255
+
256
+ "#{path}#{separator}#{pairs.join('&')}"
257
+ end
258
+ end
259
+
260
+ # PUBLIC, declared below: Api::Chain#execute calls `@client.request` for
261
+ # DELETE, so leaving this private makes every DELETE through the chain
262
+ # raise NoMethodError.
193
263
  def request(method, path, body = nil, max_retries: nil)
194
- url = "#{@base}#{path}"
264
+ url = "#{@base}#{apply_default_query(path)}"
195
265
  if body
196
266
  json_body = JSON.generate(body)
197
267
  else
@@ -349,6 +419,8 @@ module Async
349
419
  def encode(value)
350
420
  ERB::Util.url_encode(value)
351
421
  end
422
+
423
+ public :request
352
424
  end
353
425
  end
354
426
  end