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,478 @@
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 "securerandom"
7
+
8
+ module Async
9
+ module Matrix
10
+ class Client
11
+ # Room actions: membership, state, events, moderation. Mixed into Client.
12
+ #
13
+ # Every method takes keyword arguments and routes through #api, so the
14
+ # path is checked against the vendored OpenAPI tree before any request is
15
+ # made. Room ids are full of punctuation -- `!id:server`, `@user:server`,
16
+ # `$event` -- and the chain URL-encodes each segment, which is the thing
17
+ # hand-built paths get wrong.
18
+ #
19
+ # NOTHING HERE ENCRYPTS. #send_event sends exactly the content it is
20
+ # given; deciding whether a room is encrypted, and encrypting if so, is
21
+ # the device's job, because only the device holds the keys.
22
+ module Rooms
23
+ # ── Membership ────────────────────────────────────────────────────────
24
+
25
+ def invite(room_id:, user_id:, reason: nil)
26
+ api.rooms(room_id).invite.post({user_id: user_id, reason: reason}.compact)
27
+ end
28
+
29
+ def kick(room_id:, user_id:, reason: nil)
30
+ api.rooms(room_id).kick.post({user_id: user_id, reason: reason}.compact)
31
+ end
32
+
33
+ def ban(room_id:, user_id:, reason: nil)
34
+ api.rooms(room_id).ban.post({user_id: user_id, reason: reason}.compact)
35
+ end
36
+
37
+ def unban(room_id:, user_id:, reason: nil)
38
+ api.rooms(room_id).unban.post({user_id: user_id, reason: reason}.compact)
39
+ end
40
+
41
+ # Drop a left room from our room list entirely.
42
+ def forget_room(room_id:)
43
+ api.rooms(room_id).forget.post({})
44
+ end
45
+
46
+ # The room's CURRENT joined members, straight from the homeserver.
47
+ #
48
+ # AUTHORITATIVE IN A WAY A LOCAL PROJECTION IS NOT. A projection is built
49
+ # from the m.room.member events we happened to receive, so a device that
50
+ # joined an existing room knows only whoever its own join batch
51
+ # mentioned. Encrypting to that list means encrypting to a subset of the
52
+ # room, and the rest see an undecryptable message.
53
+ def joined_members(room_id:)
54
+ api.rooms(room_id).joined_members.get
55
+ end
56
+
57
+ def joined_rooms = api.joined_rooms.get
58
+
59
+ def create_room(**options)
60
+ api.createRoom.post(options)
61
+ end
62
+
63
+ # ── Events ────────────────────────────────────────────────────────────
64
+
65
+ # PUT /rooms/{roomId}/send/{eventType}/{txnId}
66
+ #
67
+ # The transaction id is what makes a retry safe: the homeserver
68
+ # deduplicates on it, so a request that timed out after being processed
69
+ # does not post the message twice.
70
+ def send_event(room_id:, event_type:, content:, txn_id: nil)
71
+ api.rooms(room_id).send(event_type, txn_id || SecureRandom.uuid).put(content)
72
+ end
73
+
74
+ # Deleting an event, in Matrix's own vocabulary.
75
+ def redact(room_id:, event_id:, reason: nil, txn_id: nil)
76
+ api.rooms(room_id).redact(event_id, txn_id || SecureRandom.uuid)
77
+ .put({reason: reason}.compact)
78
+ end
79
+
80
+ # Backwards pagination, for "load older messages". `dir: "b"` is
81
+ # backwards from the token given.
82
+ def messages(room_id:, from: nil, to: nil, dir: "b", limit: 50, filter: nil)
83
+ query = {dir: dir, limit: limit}
84
+
85
+ if from
86
+ query[:from] = from
87
+ end
88
+
89
+ if to
90
+ query[:to] = to
91
+ end
92
+
93
+ if filter
94
+ query[:filter] = filter
95
+ end
96
+
97
+ api.rooms(room_id).messages.get(**query)
98
+ end
99
+
100
+ def event(room_id:, event_id:)
101
+ api.rooms(room_id).event(event_id).get
102
+ end
103
+
104
+ # ── State ─────────────────────────────────────────────────────────────
105
+
106
+ # The empty state key is the common case and still a real path segment,
107
+ # which is why it is sent rather than omitted.
108
+ def send_state(room_id:, event_type:, content:, state_key: "")
109
+ api.rooms(room_id).state(event_type, state_key).put(content)
110
+ end
111
+
112
+ def get_state(room_id:, event_type:, state_key: "")
113
+ api.rooms(room_id).state(event_type, state_key).get
114
+ end
115
+
116
+ def room_state(room_id:)
117
+ api.rooms(room_id).state.get
118
+ end
119
+
120
+ def set_room_name(room_id:, name:)
121
+ send_state(room_id: room_id, event_type: "m.room.name", content: {name: name})
122
+ end
123
+
124
+ def set_room_topic(room_id:, topic:)
125
+ send_state(room_id: room_id, event_type: "m.room.topic", content: {topic: topic})
126
+ end
127
+
128
+ def set_pinned_events(room_id:, event_ids:)
129
+ send_state(
130
+ room_id: room_id,
131
+ event_type: "m.room.pinned_events",
132
+ content: {pinned: event_ids},
133
+ )
134
+ end
135
+
136
+ # Read-modify-write, because the whole power_levels content is replaced
137
+ # by a state send: writing only the one user would strip every other
138
+ # level in the room.
139
+ def set_power_level(room_id:, user_id:, level:)
140
+ levels = get_state(room_id: room_id, event_type: "m.room.power_levels")
141
+ users = (levels["users"] || {}).merge(user_id => level)
142
+
143
+ send_state(
144
+ room_id: room_id,
145
+ event_type: "m.room.power_levels",
146
+ content: levels.merge("users" => users),
147
+ )
148
+ end
149
+
150
+ # ── Ephemeral ─────────────────────────────────────────────────────────
151
+
152
+ def read_receipt(room_id:, event_id:, receipt_type: "m.read")
153
+ api.rooms(room_id).receipt(receipt_type, event_id).post({})
154
+ end
155
+
156
+ def typing(room_id:, user_id:, typing:, timeout: 30_000)
157
+ body = {typing: typing}
158
+
159
+ if typing
160
+ body[:timeout] = timeout
161
+ end
162
+
163
+ api.rooms(room_id).typing(user_id).put(body)
164
+ end
165
+
166
+ # ── Account data ──────────────────────────────────────────────────────
167
+ #
168
+ # Where 4S secrets and the m.direct map live, among other things.
169
+
170
+ def account_data(user_id:, type:)
171
+ api.user(user_id).account_data(type).get
172
+ end
173
+
174
+ def set_account_data(user_id:, type:, content:)
175
+ api.user(user_id).account_data(type).put(content)
176
+ end
177
+
178
+ def room_account_data(user_id:, room_id:, type:)
179
+ api.user(user_id).rooms(room_id).account_data(type).get
180
+ end
181
+
182
+ def set_room_account_data(user_id:, room_id:, type:, content:)
183
+ api.user(user_id).rooms(room_id).account_data(type).put(content)
184
+ end
185
+
186
+ # ── Profile ───────────────────────────────────────────────────────────
187
+
188
+ def profile(user_id:) = api.profile(user_id).get
189
+
190
+ def set_avatar_url(user_id:, avatar_url:)
191
+ api.profile(user_id).avatar_url.put({avatar_url: avatar_url})
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ __END__
199
+ describe "Async::Matrix::Client::Rooms" do
200
+ def recording_client(response = {})
201
+ # The REAL path tree. Api memoises it process-wide, and the Api specs
202
+ # inject a small fixture tree without restoring it (Api.reset! exists for
203
+ # that and goes uncalled), so without this these specs pass or fail
204
+ # according to the order scampi happens to load files in.
205
+ Async::Matrix::Api.reset!
206
+
207
+ config = Async::Matrix::Config.new({
208
+ "homeserver" => {"address" => "http://synapse:8008", "domain" => "example.org"},
209
+ "appservice" => {"as_token" => "as", "hs_token" => "hs", "bot" => {"username" => "bot"}},
210
+ })
211
+ client = Async::Matrix::Client.new(config)
212
+ calls = []
213
+ client.define_singleton_method(:calls) { calls }
214
+ client.define_singleton_method(:request) do |method, path, body = nil, **_options|
215
+ calls << [method, path, body]
216
+ response
217
+ end
218
+ client
219
+ end
220
+
221
+ def last(client) = client.calls.last
222
+ def room = "!ops:example.org"
223
+
224
+ # ── Encoding ──────────────────────────────────────────────────────────────
225
+
226
+ # Room ids are `!id:server`, users `@user:server`, events `$base64`. Getting
227
+ # this wrong is what hand-built paths do.
228
+ it "url-encodes the punctuation in matrix identifiers" do
229
+ client = recording_client
230
+ client.invite(room_id: room, user_id: "@ada:example.org")
231
+
232
+ last(client)[1].should == "/_matrix/client/v3/rooms/%21ops%3Aexample.org/invite"
233
+ end
234
+
235
+ it "encodes an event id's reserved characters" do
236
+ client = recording_client
237
+ client.event(room_id: room, event_id: "$abc+def/ghi")
238
+
239
+ last(client)[1].should.be.include? "%24abc%2Bdef%2Fghi"
240
+ end
241
+
242
+ # ── Membership ────────────────────────────────────────────────────────────
243
+
244
+ it "invites, kicks, bans and unbans" do
245
+ client = recording_client
246
+
247
+ client.invite(room_id: room, user_id: "@ada:example.org")
248
+ last(client)[1].should.be.end_with? "/invite"
249
+ last(client)[2].should == {user_id: "@ada:example.org"}
250
+
251
+ client.kick(room_id: room, user_id: "@ada:example.org", reason: "spam")
252
+ last(client)[1].should.be.end_with? "/kick"
253
+ last(client)[2].should == {user_id: "@ada:example.org", reason: "spam"}
254
+
255
+ client.ban(room_id: room, user_id: "@ada:example.org")
256
+ last(client)[1].should.be.end_with? "/ban"
257
+
258
+ client.unban(room_id: room, user_id: "@ada:example.org")
259
+ last(client)[1].should.be.end_with? "/unban"
260
+ end
261
+
262
+ it "omits a reason it was not given" do
263
+ client = recording_client
264
+ client.kick(room_id: room, user_id: "@ada:example.org")
265
+
266
+ last(client)[2].key?(:reason).should == false
267
+ end
268
+
269
+ # Both forms, so the action surface is consistent without breaking callers
270
+ # written against the positional signature.
271
+ it "joins and leaves by keyword or positionally" do
272
+ client = recording_client
273
+
274
+ client.join_room(room_id: room)
275
+ last(client)[1].should == "/_matrix/client/v3/join/%21ops%3Aexample.org"
276
+
277
+ client.join_room(room)
278
+ last(client)[1].should == "/_matrix/client/v3/join/%21ops%3Aexample.org"
279
+
280
+ client.leave_room(room_id: room)
281
+ last(client)[1].should.be.end_with? "/leave"
282
+
283
+ client.leave_room(room)
284
+ last(client)[1].should.be.end_with? "/leave"
285
+ end
286
+
287
+ it "forgets a room" do
288
+ client = recording_client
289
+ client.forget_room(room_id: room)
290
+
291
+ last(client)[1].should.be.end_with? "/forget"
292
+ end
293
+
294
+ # Authoritative in a way a local projection is not: a device that joined an
295
+ # existing room knows only whoever its own join batch mentioned.
296
+ it "reads the room's joined members from the homeserver" do
297
+ client = recording_client
298
+ client.joined_members(room_id: room)
299
+
300
+ last(client)[0].should == "GET"
301
+ last(client)[1].should.be.end_with? "/joined_members"
302
+ end
303
+
304
+ it "creates a room" do
305
+ client = recording_client
306
+ client.create_room(name: "ops", preset: "private_chat", invite: ["@ada:example.org"])
307
+
308
+ last(client)[1].should == "/_matrix/client/v3/createRoom"
309
+ last(client)[2].should == {name: "ops", preset: "private_chat", invite: ["@ada:example.org"]}
310
+ end
311
+
312
+ # ── Events ────────────────────────────────────────────────────────────────
313
+
314
+ # The transaction id is what makes a retry safe.
315
+ it "sends an event with a transaction id" do
316
+ client = recording_client
317
+ client.send_event(
318
+ room_id: room, event_type: "m.room.message", content: {msgtype: "m.text", body: "hi"},
319
+ )
320
+
321
+ last(client)[0].should == "PUT"
322
+ last(client)[1].should.be.include? "/send/m.room.message/"
323
+ last(client)[2].should == {msgtype: "m.text", body: "hi"}
324
+ end
325
+
326
+ it "accepts a caller's transaction id" do
327
+ client = recording_client
328
+ client.send_event(room_id: room, event_type: "m.room.message", content: {}, txn_id: "t1")
329
+
330
+ last(client)[1].should.be.end_with? "/send/m.room.message/t1"
331
+ end
332
+
333
+ # NOTHING HERE ENCRYPTS: the content is sent exactly as given, because only
334
+ # the device holds the keys.
335
+ it "sends encrypted content as-is when handed it" do
336
+ client = recording_client
337
+ client.send_event(
338
+ room_id: room,
339
+ event_type: "m.room.encrypted",
340
+ content: {"algorithm" => "m.megolm.v1.aes-sha2", "ciphertext" => "AwgAEnAC"},
341
+ )
342
+
343
+ last(client)[2]["ciphertext"].should == "AwgAEnAC"
344
+ end
345
+
346
+ it "redacts an event" do
347
+ client = recording_client
348
+ client.redact(room_id: room, event_id: "$evt", reason: "mistake", txn_id: "t1")
349
+
350
+ last(client)[1].should.be.end_with? "/redact/%24evt/t1"
351
+ last(client)[2].should == {reason: "mistake"}
352
+ end
353
+
354
+ it "paginates backwards by default" do
355
+ client = recording_client
356
+ client.messages(room_id: room, from: "t42", limit: 20)
357
+
358
+ last(client)[0].should == "GET"
359
+ last(client)[1].should ==
360
+ "/_matrix/client/v3/rooms/%21ops%3Aexample.org/messages?dir=b&limit=20&from=t42"
361
+ end
362
+
363
+ it "omits pagination parameters it was not given" do
364
+ client = recording_client
365
+ client.messages(room_id: room)
366
+
367
+ last(client)[1].should.not.be.include? "from="
368
+ last(client)[1].should.not.be.include? "to="
369
+ end
370
+
371
+ # ── State ─────────────────────────────────────────────────────────────────
372
+
373
+ # The empty state key is the common case and still a real path segment.
374
+ it "sends and reads state with an empty state key" do
375
+ client = recording_client
376
+
377
+ client.send_state(room_id: room, event_type: "m.room.name", content: {name: "ops"})
378
+ last(client)[1].should.be.end_with? "/state/m.room.name/"
379
+
380
+ client.get_state(room_id: room, event_type: "m.room.name")
381
+ last(client)[0].should == "GET"
382
+ end
383
+
384
+ it "sends state with a state key" do
385
+ client = recording_client
386
+ client.send_state(
387
+ room_id: room, event_type: "m.room.member",
388
+ content: {membership: "join"}, state_key: "@ada:example.org",
389
+ )
390
+
391
+ last(client)[1].should.be.end_with? "/state/m.room.member/%40ada%3Aexample.org"
392
+ end
393
+
394
+ it "sets the name, topic and pinned events" do
395
+ client = recording_client
396
+
397
+ client.set_room_name(room_id: room, name: "ops")
398
+ last(client)[2].should == {name: "ops"}
399
+
400
+ client.set_room_topic(room_id: room, topic: "operations")
401
+ last(client)[2].should == {topic: "operations"}
402
+
403
+ client.set_pinned_events(room_id: room, event_ids: ["$a", "$b"])
404
+ last(client)[2].should == {pinned: ["$a", "$b"]}
405
+ end
406
+
407
+ # Read-modify-write: a state send REPLACES the content, so writing only the
408
+ # one user would strip every other level in the room.
409
+ it "preserves the other power levels when setting one" do
410
+ client = recording_client(
411
+ {"users" => {"@bot:example.org" => 100}, "users_default" => 0, "kick" => 50},
412
+ )
413
+ client.set_power_level(room_id: room, user_id: "@ada:example.org", level: 50)
414
+
415
+ last(client)[2].should == {
416
+ "users" => {"@bot:example.org" => 100, "@ada:example.org" => 50},
417
+ "users_default" => 0,
418
+ "kick" => 50,
419
+ }
420
+ end
421
+
422
+ # ── Ephemeral ─────────────────────────────────────────────────────────────
423
+
424
+ it "posts a read receipt" do
425
+ client = recording_client
426
+ client.read_receipt(room_id: room, event_id: "$evt")
427
+
428
+ last(client)[0].should == "POST"
429
+ last(client)[1].should.be.end_with? "/receipt/m.read/%24evt"
430
+ end
431
+
432
+ it "sets typing with a timeout, and clears it without one" do
433
+ client = recording_client
434
+
435
+ client.typing(room_id: room, user_id: "@bot:example.org", typing: true)
436
+ last(client)[2].should == {typing: true, timeout: 30_000}
437
+
438
+ client.typing(room_id: room, user_id: "@bot:example.org", typing: false)
439
+ last(client)[2].should == {typing: false}
440
+ end
441
+
442
+ # ── Account data ──────────────────────────────────────────────────────────
443
+
444
+ # Where 4S secrets and the m.direct map live.
445
+ it "reads and writes account data" do
446
+ client = recording_client
447
+
448
+ client.account_data(user_id: "@bot:example.org", type: "m.secret_storage.default_key")
449
+ last(client)[0].should == "GET"
450
+ last(client)[1].should ==
451
+ "/_matrix/client/v3/user/%40bot%3Aexample.org/account_data/m.secret_storage.default_key"
452
+
453
+ client.set_account_data(
454
+ user_id: "@bot:example.org", type: "m.direct", content: {"@ada:example.org" => [room]},
455
+ )
456
+ last(client)[0].should == "PUT"
457
+ last(client)[2].should == {"@ada:example.org" => [room]}
458
+ end
459
+
460
+ it "reads and writes per-room account data" do
461
+ client = recording_client
462
+ client.room_account_data(user_id: "@bot:example.org", room_id: room, type: "m.tag")
463
+
464
+ last(client)[1].should.be.include? "/rooms/%21ops%3Aexample.org/account_data/m.tag"
465
+ end
466
+
467
+ # ── Profile ───────────────────────────────────────────────────────────────
468
+
469
+ it "reads a profile and sets an avatar" do
470
+ client = recording_client
471
+
472
+ client.profile(user_id: "@ada:example.org")
473
+ last(client)[1].should == "/_matrix/client/v3/profile/%40ada%3Aexample.org"
474
+
475
+ client.set_avatar_url(user_id: "@bot:example.org", avatar_url: "mxc://example.org/abc")
476
+ last(client)[2].should == {avatar_url: "mxc://example.org/abc"}
477
+ end
478
+ end