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.
- 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,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
|
data/lib/async/matrix/client.rb
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|