async-matrix 2.1.0-x86_64-linux → 3.0.1-x86_64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -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,
@@ -72,7 +81,7 @@ module Async
72
81
  msgtype: "m.text",
73
82
  body: plaintext || html.gsub(/<[^>]+>/, ""),
74
83
  format: "org.matrix.custom.html",
75
- formatted_body: html
84
+ formatted_body: html,
76
85
  }
77
86
  send_message_event(room_id, "m.room.message", content)
78
87
  end
@@ -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 ────────────────────────────────────────────────
@@ -98,7 +113,7 @@ module Async
98
113
  uid = user_id || @config.bot_mxid
99
114
  put(
100
115
  "#{CLIENT_PREFIX}/profile/#{encode(uid)}/displayname",
101
- {displayname: name}
116
+ {displayname: name},
102
117
  )
103
118
  end
104
119
 
@@ -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
@@ -151,17 +181,42 @@ module Async
151
181
  end
152
182
 
153
183
  def get(path, max_retries: nil)
154
- request("GET", path, nil, max_retries: max_retries)
184
+ request(
185
+ "GET",
186
+ path,
187
+ nil,
188
+ max_retries: max_retries,
189
+ )
155
190
  end
156
191
 
157
192
  def put(path, body = {}, max_retries: nil)
158
- request("PUT", path, body, max_retries: max_retries)
193
+ request(
194
+ "PUT",
195
+ path,
196
+ body,
197
+ max_retries: max_retries,
198
+ )
159
199
  end
160
200
 
161
201
  def post(path, body = {}, max_retries: nil)
162
- request("POST", path, body, max_retries: max_retries)
202
+ request(
203
+ "POST",
204
+ path,
205
+ body,
206
+ max_retries: max_retries,
207
+ )
163
208
  end
164
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
+
165
220
  def close
166
221
  @internet&.close
167
222
  @internet = nil
@@ -171,51 +226,97 @@ module Async
171
226
 
172
227
  private
173
228
 
174
- def internet
175
- @internet ||= Async::HTTP::Internet.new
176
- end
177
-
178
- def request(method, path, body = nil, max_retries: nil)
179
- url = "#{@base}#{path}"
180
- json_body = body ? JSON.generate(body) : nil
181
- effective_max_retries = max_retries || @max_retries
182
-
183
- Console.debug(self) { "#{method} #{path}" }
229
+ def internet
230
+ @internet ||= Async::HTTP::Internet.new
231
+ end
184
232
 
185
- attempt = 0
186
- loop do
187
- response = internet.call(method, url, @headers, json_body)
188
- status = response.status
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
189
259
 
190
- if (200..299).cover?(status)
191
- payload = read_limited(response, @response_size_limit)
192
- return payload && !payload.empty? ? JSON.parse(payload) : {}
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.
263
+ def request(method, path, body = nil, max_retries: nil)
264
+ url = "#{@base}#{apply_default_query(path)}"
265
+ if body
266
+ json_body = JSON.generate(body)
267
+ else
268
+ json_body = nil
193
269
  end
270
+ effective_max_retries = max_retries || @max_retries
194
271
 
195
- attempt += 1
272
+ Console.debug(self) { "#{method} #{path}" }
196
273
 
197
- if attempt <= effective_max_retries && retryable_status?(status)
198
- delay = compute_retry_delay(status, response, attempt)
199
- Console.warn(self) {
200
- "#{method} #{path} returned #{status}, retry #{attempt}/#{effective_max_retries} in #{delay.round(2)}s"
201
- }
202
- response.close if response.respond_to?(:close)
203
- sleep(delay)
204
- next
274
+ attempt = 0
275
+ loop do
276
+ response = internet.call(
277
+ method,
278
+ url,
279
+ @headers,
280
+ json_body,
281
+ )
282
+ status = response.status
283
+
284
+ if (200..299).cover?(status)
285
+ payload = read_limited(response, @response_size_limit)
286
+
287
+ if payload && !payload.empty?
288
+ break JSON.parse(payload)
289
+ else
290
+ break {}
291
+ end
292
+ end
293
+
294
+ attempt += 1
295
+
296
+ if attempt <= effective_max_retries && retryable_status?(status)
297
+ delay = compute_retry_delay(status, response, attempt)
298
+ Console.warn(self) {
299
+ "#{method} #{path} returned #{status}, retry #{attempt}/#{effective_max_retries} in #{delay.round(2)}s"
300
+ }
301
+ if response.respond_to?(:close)
302
+ response.close
303
+ end
304
+ sleep(delay)
305
+ next
306
+ end
307
+
308
+ payload = read_limited(response, @error_response_size_limit)
309
+ parsed = ErrorResponse.new(
310
+ begin; JSON.parse(payload); rescue; {} end,
311
+ )
312
+ Console.error(self) { "Matrix API #{status}: #{parsed.errcode} — #{parsed.error}" }
313
+ raise HomeserverError.new(
314
+ parsed.errcode || "UNKNOWN",
315
+ parsed.error || payload.to_s[0..200],
316
+ status: status,
317
+ )
205
318
  end
206
-
207
- payload = read_limited(response, @error_response_size_limit)
208
- parsed = ApplicationService::ErrorResponse.new(
209
- begin; JSON.parse(payload); rescue; {} end
210
- )
211
- Console.error(self) { "Matrix API #{status}: #{parsed.errcode} — #{parsed.error}" }
212
- raise HomeserverError.new(
213
- parsed.errcode || "UNKNOWN",
214
- parsed.error || payload.to_s[0..200],
215
- status: status
216
- )
217
319
  end
218
- end
219
320
 
220
321
  # ── Response size limiting ──────────────────────────────────
221
322
 
@@ -226,46 +327,45 @@ module Async
226
327
  # @param response [Protocol::HTTP::Response] the HTTP response
227
328
  # @param limit [Integer] maximum allowed body size in bytes
228
329
  # @return [String, nil] the response body, or nil if empty
229
- def read_limited(response, limit)
230
- body = response.body
231
- return nil unless body
232
-
233
- # Fast path: reject immediately if Content-Length exceeds limit
234
- if body.respond_to?(:length) && body.length && body.length > limit
235
- body.close
236
- raise ResponseTooLargeError.new(
237
- "M_TOO_LARGE",
238
- "Response Content-Length #{body.length} bytes exceeds limit of #{limit} bytes"
239
- )
240
- end
241
-
242
- # Streaming read with enforcement
243
- buffer = String.new(encoding: Encoding::BINARY)
244
- body.each do |chunk|
245
- buffer << chunk
246
- if buffer.bytesize > limit
247
- body.close
248
- raise ResponseTooLargeError.new(
249
- "M_TOO_LARGE",
250
- "Response body exceeds limit of #{limit} bytes"
251
- )
330
+ def read_limited(response, limit)
331
+ body = response.body
332
+ if body
333
+ if body.respond_to?(:length) && body.length && body.length > limit
334
+ body.close
335
+ raise ResponseTooLargeError.new(
336
+ "M_TOO_LARGE",
337
+ "Response Content-Length #{body.length} bytes exceeds limit of #{limit} bytes",
338
+ )
339
+ end
340
+ buffer = String.new(encoding: Encoding::BINARY)
341
+ body.each do |chunk|
342
+ buffer << chunk
343
+ if buffer.bytesize > limit
344
+ body.close
345
+ raise ResponseTooLargeError.new(
346
+ "M_TOO_LARGE",
347
+ "Response body exceeds limit of #{limit} bytes",
348
+ )
349
+ end
350
+ end
351
+ buffer.empty? ? nil : buffer
352
+ else
353
+ nil
252
354
  end
253
355
  end
254
- buffer.empty? ? nil : buffer
255
- end
256
356
 
257
357
  # ── Retry logic ─────────────────────────────────────────────
258
358
 
259
359
  # Whether the given HTTP status code should trigger a retry.
260
360
  # 429 is only retried if @ignore_rate_limit is false.
261
361
  # 502/503/504 are always retried.
262
- def retryable_status?(status)
263
- if status == RATE_LIMIT_STATUS
264
- !@ignore_rate_limit
265
- else
266
- GATEWAY_ERROR_STATUSES.include?(status)
362
+ def retryable_status?(status)
363
+ if status == RATE_LIMIT_STATUS
364
+ !@ignore_rate_limit
365
+ else
366
+ GATEWAY_ERROR_STATUSES.include?(status)
367
+ end
267
368
  end
268
- end
269
369
 
270
370
  # Compute the delay before the next retry attempt.
271
371
  #
@@ -276,47 +376,51 @@ module Async
276
376
  # For 502/503/504 (gateway errors): exponential backoff with full jitter.
277
377
  # Full jitter means rand(0..calculated), which is the AWS-recommended
278
378
  # approach to avoid thundering herd on shared homeservers.
279
- def compute_retry_delay(status, response, attempt)
280
- if status == RATE_LIMIT_STATUS
281
- server_delay = parse_retry_after(response)
282
- delay = server_delay || exponential_delay(attempt)
283
- [delay, @max_retry_delay].min
284
- else
285
- calculated = exponential_delay(attempt)
286
- rand(0.0..[calculated, @max_retry_delay].min)
379
+ def compute_retry_delay(status, response, attempt)
380
+ if status == RATE_LIMIT_STATUS
381
+ server_delay = parse_retry_after(response)
382
+ delay = server_delay || exponential_delay(attempt)
383
+ [delay, @max_retry_delay].min
384
+ else
385
+ calculated = exponential_delay(attempt)
386
+ rand(0.0..[calculated, @max_retry_delay].min)
387
+ end
287
388
  end
288
- end
289
389
 
290
390
  # Base * 2^(attempt-1): 0.5, 1.0, 2.0, 4.0, ...
291
- def exponential_delay(attempt)
292
- @retry_base_delay * (2 ** (attempt - 1))
293
- end
391
+ def exponential_delay(attempt)
392
+ @retry_base_delay * (2 ** (attempt - 1))
393
+ end
294
394
 
295
395
  # Parse the Retry-After header. Supports both delta-seconds ("120")
296
396
  # and HTTP-date ("Fri, 31 Dec 2026 23:59:59 GMT") formats per RFC 9110.
297
397
  # Returns seconds to wait as a Float, or nil if absent/unparseable.
298
- def parse_retry_after(response)
299
- value = response.headers["retry-after"]
300
- return nil unless value
301
-
302
- value = value.strip
303
- if value.match?(/\A\d+\z/)
304
- value.to_f
305
- else
306
- # HTTP-date format
307
- begin
308
- target = Time.httpdate(value)
309
- delay = target - Time.now
310
- delay > 0 ? delay : 0.0
311
- rescue ArgumentError
398
+ def parse_retry_after(response)
399
+ value = response.headers["retry-after"]
400
+ if value
401
+ value = value.strip
402
+ if value.match?(/\A\d+\z/)
403
+ value.to_f
404
+ else
405
+ # HTTP-date format
406
+ begin
407
+ target = Time.httpdate(value)
408
+ delay = target - Time.now
409
+ delay > 0 ? delay : 0.0
410
+ rescue ArgumentError
411
+ nil
412
+ end
413
+ end
414
+ else
312
415
  nil
313
416
  end
314
417
  end
315
- end
316
418
 
317
- def encode(value)
318
- ERB::Util.url_encode(value)
319
- end
419
+ def encode(value)
420
+ ERB::Util.url_encode(value)
421
+ end
422
+
423
+ public :request
320
424
  end
321
425
  end
322
426
  end
@@ -324,7 +428,7 @@ end
324
428
  __END__
325
429
  describe "Async::Matrix::Client" do
326
430
  def make_config
327
- Async::Matrix::ApplicationService::Config.new({
431
+ Async::Matrix::Config.new({
328
432
  "homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
329
433
  "appservice" => { "as_token" => "test_token", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
330
434
  })
@@ -465,7 +569,7 @@ __END__
465
569
 
466
570
  describe "Client retry logic" do
467
571
  def make_config
468
- Async::Matrix::ApplicationService::Config.new({
572
+ Async::Matrix::Config.new({
469
573
  "homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
470
574
  "appservice" => { "as_token" => "test_token", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
471
575
  })
@@ -689,7 +793,7 @@ __END__
689
793
 
690
794
  describe "Client ignore_rate_limit" do
691
795
  def make_config
692
- Async::Matrix::ApplicationService::Config.new({
796
+ Async::Matrix::Config.new({
693
797
  "homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
694
798
  "appservice" => { "as_token" => "test_token", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
695
799
  })
@@ -757,7 +861,7 @@ __END__
757
861
 
758
862
  describe "Client per-request max_retries" do
759
863
  def make_config
760
- Async::Matrix::ApplicationService::Config.new({
864
+ Async::Matrix::Config.new({
761
865
  "homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
762
866
  "appservice" => { "as_token" => "test_token", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
763
867
  })
@@ -848,7 +952,7 @@ __END__
848
952
 
849
953
  describe "Client response size limiting" do
850
954
  def make_config
851
- Async::Matrix::ApplicationService::Config.new({
955
+ Async::Matrix::Config.new({
852
956
  "homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
853
957
  "appservice" => { "as_token" => "test_token", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
854
958
  })
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ module Async
7
+ module Matrix
8
+ class Config
9
+ # Mixin that gives a Hash dot-notation accessors with autovivification.
10
+ #
11
+ # Not applied globally — only extended onto the validated config hash
12
+ # and its nested children so the rest of the world is unaffected.
13
+ #
14
+ # h = {}.extend(Vivify)
15
+ # h.foo.bar.baz = 42
16
+ # h # => { foo: { bar: { baz: 42 } } }
17
+ #
18
+ module Vivify
19
+ def method_missing(name, *args)
20
+ key = name.to_s
21
+
22
+ if key.end_with?("=")
23
+ self[key.chomp("=").to_sym] = args.first
24
+ elsif key?(name.to_sym)
25
+ self[name.to_sym]
26
+ elsif key.start_with?("to_")
27
+ super
28
+ else
29
+ self[name.to_sym] = {}.extend(Vivify)
30
+ end
31
+ end
32
+
33
+ def respond_to_missing?(name, _priv = false)
34
+ name.to_s.end_with?("=") || key?(name.to_sym) || super
35
+ end
36
+
37
+ # Recursively symbolize keys and extend every nested Hash with Vivify.
38
+ #
39
+ # Call this on the raw (string-keyed) hash that comes out of
40
+ # YAML.safe_load before wrapping it in Config.
41
+ def self.deep_vivify(obj)
42
+ case obj
43
+ when Hash
44
+ result = {}
45
+ obj.each { |k, v| result[k.to_s.to_sym] = deep_vivify(v) }
46
+ result.extend(Vivify)
47
+ when Array
48
+ obj.map { |v| deep_vivify(v) }
49
+ else
50
+ obj
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ __END__
59
+ V = Async::Matrix::Config::Vivify
60
+
61
+ describe "Vivify" do
62
+ it "provides dot-notation read access to symbol keys" do
63
+ h = { foo: "bar" }.extend(V)
64
+ h.foo.should == "bar"
65
+ end
66
+
67
+ it "provides dot-notation write access" do
68
+ h = {}.extend(V)
69
+ h.foo = 42
70
+ h[:foo].should == 42
71
+ end
72
+
73
+ it "autovivifies missing keys as vivified hashes" do
74
+ h = {}.extend(V)
75
+ h.a.b.c = "deep"
76
+ h[:a][:b][:c].should == "deep"
77
+ end
78
+
79
+ it "delegates to_* methods to super (no autovivify)" do
80
+ h = { x: 1 }.extend(V)
81
+ h.to_a.should == [[:x, 1]]
82
+ end
83
+ end
84
+
85
+ describe "Vivify.deep_vivify" do
86
+ it "symbolizes string keys" do
87
+ result = V.deep_vivify({"a" => 1})
88
+ result[:a].should == 1
89
+ end
90
+
91
+ it "recursively vivifies nested hashes" do
92
+ result = V.deep_vivify({"outer" => {"inner" => "val"}})
93
+ result.outer.inner.should == "val"
94
+ end
95
+
96
+ it "vivifies hashes inside arrays" do
97
+ result = V.deep_vivify({"list" => [{"name" => "x"}]})
98
+ result.list.first.name.should == "x"
99
+ end
100
+
101
+ it "passes scalars through unchanged" do
102
+ result = V.deep_vivify({"n" => 42, "s" => "hi", "b" => true})
103
+ result.n.should == 42
104
+ result.s.should == "hi"
105
+ result.b.should == true
106
+ end
107
+ end