async-matrix 2.0.1 → 3.0.0

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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +8 -8
  3. data/README.md +33 -58
  4. data/ext/async_matrix_e2ee/Cargo.toml +7 -1
  5. data/ext/async_matrix_e2ee/src/lib.rs +43 -2
  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/auth_error.rb +20 -0
  11. data/lib/async/matrix/bad_json_error.rb +20 -0
  12. data/lib/async/matrix/client.rb +142 -110
  13. data/lib/async/matrix/config/vivify.rb +107 -0
  14. data/lib/async/matrix/config.rb +184 -0
  15. data/lib/async/matrix/content.rb +106 -0
  16. data/lib/async/matrix/double_puppet_client.rb +1 -1
  17. data/lib/async/matrix/e2ee.rb +37 -0
  18. data/lib/async/matrix/error.rb +0 -27
  19. data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
  20. data/lib/async/matrix/event.rb +197 -0
  21. data/lib/async/matrix/homeserver_error.rb +20 -0
  22. data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
  23. data/lib/async/matrix/media_client.rb +46 -35
  24. data/lib/async/matrix/not_found_error.rb +20 -0
  25. data/lib/async/matrix/notifier.rb +7 -3
  26. data/lib/async/matrix/response_too_large_error.rb +20 -0
  27. data/lib/async/matrix/schema/registry.rb +367 -340
  28. data/lib/async/matrix/schema/validation_error.rb +221 -214
  29. data/lib/async/matrix/schema.rb +161 -161
  30. data/lib/async/matrix/version.rb +3 -3
  31. data/lib/async/matrix.rb +0 -7
  32. metadata +28 -122
  33. data/lib/async/discord/api/path_tree.rb +0 -127
  34. data/lib/async/discord/api.rb +0 -151
  35. data/lib/async/discord/client.rb +0 -283
  36. data/lib/async/discord/error.rb +0 -84
  37. data/lib/async/discord/gateway.rb +0 -359
  38. data/lib/async/discord.rb +0 -15
  39. data/lib/async/matrix/application_service/bot.rb +0 -232
  40. data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
  41. data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
  42. data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
  43. data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
  44. data/lib/async/matrix/application_service/config/schema/config.json +0 -61
  45. data/lib/async/matrix/application_service/config/schema/database.json +0 -38
  46. data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
  47. data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
  48. data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
  49. data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
  50. data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
  51. data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
  52. data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
  53. data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
  54. data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
  55. data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
  56. data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
  57. data/lib/async/matrix/application_service/config/vivify.rb +0 -109
  58. data/lib/async/matrix/application_service/config.rb +0 -225
  59. data/lib/async/matrix/application_service/dispatcher.rb +0 -185
  60. data/lib/async/matrix/application_service/event.rb +0 -285
  61. data/lib/async/matrix/application_service/server.rb +0 -430
  62. data/lib/async/matrix/application_service/transaction.rb +0 -66
  63. data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
  64. data/lib/async/matrix/application_service/transaction_store.rb +0 -80
  65. data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
  66. data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
  67. data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
  68. data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
  69. data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
  70. data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
  71. data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
  72. data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
  73. data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
  74. data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
  75. data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
  76. data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
  77. data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
  78. data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
  79. data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
  80. data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
  81. data/lib/async/matrix/bridge/discord/db.rb +0 -138
@@ -22,7 +22,13 @@ module Async
22
22
  class PathTree
23
23
  SCHEMA_DIR = Pathname.new(File.expand_path("../../../../data/matrix-spec/api/client-server", __dir__))
24
24
 
25
- Node = Struct.new(:children, :wildcard, :methods, :operation_ids, keyword_init: true) do
25
+ Node = Struct.new(
26
+ :children,
27
+ :wildcard,
28
+ :methods,
29
+ :operation_ids,
30
+ keyword_init: true,
31
+ ) do
26
32
  def initialize(**)
27
33
  super
28
34
  self.children ||= {}
@@ -39,34 +45,46 @@ module Async
39
45
 
40
46
  # Load all OpenAPI schemas from data/ and build the tree.
41
47
  def self.load(schema_dir: SCHEMA_DIR)
42
- tree = new
43
- Pathname.glob(schema_dir / "*.yaml").each do |path|
44
- tree.load_schema(path)
48
+ new.tap do |tree|
49
+ Pathname.glob(schema_dir / "*.yaml").each do |path|
50
+ tree.load_schema(path)
51
+ end
45
52
  end
46
- tree
47
53
  end
48
54
 
49
55
  # Parse a single OpenAPI YAML file and insert its paths into the tree.
50
56
  def load_schema(path)
51
57
  doc = YAML.safe_load(File.read(path), permitted_classes: [Symbol], aliases: true)
52
- return unless doc.is_a?(Hash)
53
-
54
- base_path = extract_base_path(doc)
55
- paths = doc["paths"]
56
- return unless paths.is_a?(Hash)
57
-
58
- paths.each do |path_template, methods_hash|
59
- next unless methods_hash.is_a?(Hash)
60
-
61
- # Build full path: basePath + path_template
62
- full_path = "#{base_path}#{path_template.strip}"
63
- segments = full_path.split("/").reject(&:empty?)
64
-
65
- methods_hash.each do |method, operation|
66
- next unless %w[get post put delete patch head].include?(method)
67
- operation_id = operation.is_a?(Hash) ? operation["operationId"] : nil
68
- insert(segments, method, operation_id)
58
+ if doc.is_a?(Hash)
59
+ base_path = extract_base_path(doc)
60
+ paths = doc["paths"]
61
+ if paths.is_a?(Hash)
62
+ paths.each do |path_template, methods_hash|
63
+ unless methods_hash.is_a?(Hash)
64
+ next
65
+ end
66
+
67
+ # Build full path: basePath + path_template
68
+ full_path = "#{base_path}#{path_template.strip}"
69
+ segments = full_path.split("/").reject(&:empty?)
70
+
71
+ methods_hash.each do |method, operation|
72
+ unless %w[get post put delete patch head].include?(method)
73
+ next
74
+ end
75
+ if operation.is_a?(Hash)
76
+ operation_id = operation["operationId"]
77
+ else
78
+ operation_id = nil
79
+ end
80
+ insert(segments, method, operation_id)
81
+ end
82
+ end
83
+ else
84
+ nil
69
85
  end
86
+ else
87
+ nil
70
88
  end
71
89
  end
72
90
 
@@ -83,25 +101,30 @@ module Async
83
101
  node = node.children[segment]
84
102
  end
85
103
  end
86
- node.methods << method.downcase unless node.methods.include?(method.downcase)
87
- node.operation_ids[method.downcase] = operation_id if operation_id
104
+ unless node.methods.include?(method.downcase)
105
+ node.methods << method.downcase
106
+ end
107
+ if operation_id
108
+ node.operation_ids[method.downcase] = operation_id
109
+ end
88
110
  end
89
111
 
90
112
  # Match a concrete path (array of segments) against the trie.
91
113
  # Returns a result hash.
92
114
  def match(segments, method = nil)
93
- node = @root
94
- segments.each do |segment|
95
- if node.children.key?(segment)
96
- node = node.children[segment]
97
- elsif node.wildcard
98
- node = node.wildcard
99
- else
100
- return {valid: false, methods: [], operation_id: nil}
115
+ node = segments.inject(@root) do |current, segment|
116
+ if current.nil?
117
+ nil
118
+ elsif current.children.key?(segment)
119
+ current.children[segment]
120
+ elsif current.wildcard
121
+ current.wildcard
101
122
  end
102
123
  end
103
124
 
104
- if method
125
+ if node.nil?
126
+ {valid: false, methods: [], operation_id: nil}
127
+ elsif method
105
128
  method_down = method.downcase
106
129
  valid = node.methods.include?(method_down)
107
130
  {valid: valid, methods: node.methods, operation_id: node.operation_ids[method_down]}
@@ -112,17 +135,21 @@ module Async
112
135
 
113
136
  private
114
137
 
115
- def extract_base_path(doc)
116
- servers = doc["servers"]
117
- return "" unless servers.is_a?(Array) && servers.first.is_a?(Hash)
118
-
119
- server = servers.first
120
- variables = server["variables"] || {}
121
- base_path_var = variables["basePath"]
122
- return "" unless base_path_var.is_a?(Hash)
123
-
124
- base_path_var["default"] || ""
125
- end
138
+ def extract_base_path(doc)
139
+ servers = doc["servers"]
140
+ if servers.is_a?(Array) && servers.first.is_a?(Hash)
141
+ server = servers.first
142
+ variables = server["variables"] || {}
143
+ base_path_var = variables["basePath"]
144
+ if base_path_var.is_a?(Hash)
145
+ base_path_var["default"] || ""
146
+ else
147
+ ""
148
+ end
149
+ else
150
+ ""
151
+ end
152
+ end
126
153
  end
127
154
  end
128
155
  end
@@ -54,7 +54,12 @@ module Async
54
54
  if name.start_with?("to_")
55
55
  super
56
56
  else
57
- chain.__send__(name, *args, **kwargs, &block)
57
+ chain.__send__(
58
+ name,
59
+ *args,
60
+ **kwargs,
61
+ &block
62
+ )
58
63
  end
59
64
  end
60
65
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ # Async::Matrix::Error is this class's superclass, so it has to exist before
7
+ # this file finishes loading — the glob in async/matrix.rb sorts
8
+ # alphabetically and would otherwise get here first.
9
+ require_relative "error"
10
+
11
+ module Async
12
+ module Matrix
13
+ class AuthError < Error; end
14
+ end
15
+ end
16
+
17
+ __END__
18
+ it "AuthError inherits from Error" do
19
+ Async::Matrix::AuthError.new("M_FORBIDDEN", "denied").should.be.kind_of Async::Matrix::Error
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ # Async::Matrix::Error is this class's superclass, so it has to exist before
7
+ # this file finishes loading — the glob in async/matrix.rb sorts
8
+ # alphabetically and would otherwise get here first.
9
+ require_relative "error"
10
+
11
+ module Async
12
+ module Matrix
13
+ class BadJsonError < Error; end
14
+ end
15
+ end
16
+
17
+ __END__
18
+ it "BadJsonError inherits from Error" do
19
+ Async::Matrix::BadJsonError.new("M_BAD_JSON", "invalid").should.be.kind_of Async::Matrix::Error
20
+ end
@@ -72,7 +72,7 @@ module Async
72
72
  msgtype: "m.text",
73
73
  body: plaintext || html.gsub(/<[^>]+>/, ""),
74
74
  format: "org.matrix.custom.html",
75
- formatted_body: html
75
+ formatted_body: html,
76
76
  }
77
77
  send_message_event(room_id, "m.room.message", content)
78
78
  end
@@ -98,7 +98,7 @@ module Async
98
98
  uid = user_id || @config.bot_mxid
99
99
  put(
100
100
  "#{CLIENT_PREFIX}/profile/#{encode(uid)}/displayname",
101
- {displayname: name}
101
+ {displayname: name},
102
102
  )
103
103
  end
104
104
 
@@ -151,15 +151,30 @@ module Async
151
151
  end
152
152
 
153
153
  def get(path, max_retries: nil)
154
- request("GET", path, nil, max_retries: max_retries)
154
+ request(
155
+ "GET",
156
+ path,
157
+ nil,
158
+ max_retries: max_retries,
159
+ )
155
160
  end
156
161
 
157
162
  def put(path, body = {}, max_retries: nil)
158
- request("PUT", path, body, max_retries: max_retries)
163
+ request(
164
+ "PUT",
165
+ path,
166
+ body,
167
+ max_retries: max_retries,
168
+ )
159
169
  end
160
170
 
161
171
  def post(path, body = {}, max_retries: nil)
162
- request("POST", path, body, max_retries: max_retries)
172
+ request(
173
+ "POST",
174
+ path,
175
+ body,
176
+ max_retries: max_retries,
177
+ )
163
178
  end
164
179
 
165
180
  def close
@@ -171,51 +186,67 @@ module Async
171
186
 
172
187
  private
173
188
 
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}" }
184
-
185
- attempt = 0
186
- loop do
187
- response = internet.call(method, url, @headers, json_body)
188
- status = response.status
189
+ def internet
190
+ @internet ||= Async::HTTP::Internet.new
191
+ end
189
192
 
190
- if (200..299).cover?(status)
191
- payload = read_limited(response, @response_size_limit)
192
- return payload && !payload.empty? ? JSON.parse(payload) : {}
193
+ def request(method, path, body = nil, max_retries: nil)
194
+ url = "#{@base}#{path}"
195
+ if body
196
+ json_body = JSON.generate(body)
197
+ else
198
+ json_body = nil
193
199
  end
200
+ effective_max_retries = max_retries || @max_retries
194
201
 
195
- attempt += 1
202
+ Console.debug(self) { "#{method} #{path}" }
196
203
 
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
204
+ attempt = 0
205
+ loop do
206
+ response = internet.call(
207
+ method,
208
+ url,
209
+ @headers,
210
+ json_body,
211
+ )
212
+ status = response.status
213
+
214
+ if (200..299).cover?(status)
215
+ payload = read_limited(response, @response_size_limit)
216
+
217
+ if payload && !payload.empty?
218
+ break JSON.parse(payload)
219
+ else
220
+ break {}
221
+ end
222
+ end
223
+
224
+ attempt += 1
225
+
226
+ if attempt <= effective_max_retries && retryable_status?(status)
227
+ delay = compute_retry_delay(status, response, attempt)
228
+ Console.warn(self) {
229
+ "#{method} #{path} returned #{status}, retry #{attempt}/#{effective_max_retries} in #{delay.round(2)}s"
230
+ }
231
+ if response.respond_to?(:close)
232
+ response.close
233
+ end
234
+ sleep(delay)
235
+ next
236
+ end
237
+
238
+ payload = read_limited(response, @error_response_size_limit)
239
+ parsed = ErrorResponse.new(
240
+ begin; JSON.parse(payload); rescue; {} end,
241
+ )
242
+ Console.error(self) { "Matrix API #{status}: #{parsed.errcode} — #{parsed.error}" }
243
+ raise HomeserverError.new(
244
+ parsed.errcode || "UNKNOWN",
245
+ parsed.error || payload.to_s[0..200],
246
+ status: status,
247
+ )
205
248
  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
249
  end
218
- end
219
250
 
220
251
  # ── Response size limiting ──────────────────────────────────
221
252
 
@@ -226,46 +257,45 @@ module Async
226
257
  # @param response [Protocol::HTTP::Response] the HTTP response
227
258
  # @param limit [Integer] maximum allowed body size in bytes
228
259
  # @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
- )
260
+ def read_limited(response, limit)
261
+ body = response.body
262
+ if body
263
+ if body.respond_to?(:length) && body.length && body.length > limit
264
+ body.close
265
+ raise ResponseTooLargeError.new(
266
+ "M_TOO_LARGE",
267
+ "Response Content-Length #{body.length} bytes exceeds limit of #{limit} bytes",
268
+ )
269
+ end
270
+ buffer = String.new(encoding: Encoding::BINARY)
271
+ body.each do |chunk|
272
+ buffer << chunk
273
+ if buffer.bytesize > limit
274
+ body.close
275
+ raise ResponseTooLargeError.new(
276
+ "M_TOO_LARGE",
277
+ "Response body exceeds limit of #{limit} bytes",
278
+ )
279
+ end
280
+ end
281
+ buffer.empty? ? nil : buffer
282
+ else
283
+ nil
252
284
  end
253
285
  end
254
- buffer.empty? ? nil : buffer
255
- end
256
286
 
257
287
  # ── Retry logic ─────────────────────────────────────────────
258
288
 
259
289
  # Whether the given HTTP status code should trigger a retry.
260
290
  # 429 is only retried if @ignore_rate_limit is false.
261
291
  # 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)
292
+ def retryable_status?(status)
293
+ if status == RATE_LIMIT_STATUS
294
+ !@ignore_rate_limit
295
+ else
296
+ GATEWAY_ERROR_STATUSES.include?(status)
297
+ end
267
298
  end
268
- end
269
299
 
270
300
  # Compute the delay before the next retry attempt.
271
301
  #
@@ -276,47 +306,49 @@ module Async
276
306
  # For 502/503/504 (gateway errors): exponential backoff with full jitter.
277
307
  # Full jitter means rand(0..calculated), which is the AWS-recommended
278
308
  # 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)
309
+ def compute_retry_delay(status, response, attempt)
310
+ if status == RATE_LIMIT_STATUS
311
+ server_delay = parse_retry_after(response)
312
+ delay = server_delay || exponential_delay(attempt)
313
+ [delay, @max_retry_delay].min
314
+ else
315
+ calculated = exponential_delay(attempt)
316
+ rand(0.0..[calculated, @max_retry_delay].min)
317
+ end
287
318
  end
288
- end
289
319
 
290
320
  # 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
321
+ def exponential_delay(attempt)
322
+ @retry_base_delay * (2 ** (attempt - 1))
323
+ end
294
324
 
295
325
  # Parse the Retry-After header. Supports both delta-seconds ("120")
296
326
  # and HTTP-date ("Fri, 31 Dec 2026 23:59:59 GMT") formats per RFC 9110.
297
327
  # 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
328
+ def parse_retry_after(response)
329
+ value = response.headers["retry-after"]
330
+ if value
331
+ value = value.strip
332
+ if value.match?(/\A\d+\z/)
333
+ value.to_f
334
+ else
335
+ # HTTP-date format
336
+ begin
337
+ target = Time.httpdate(value)
338
+ delay = target - Time.now
339
+ delay > 0 ? delay : 0.0
340
+ rescue ArgumentError
341
+ nil
342
+ end
343
+ end
344
+ else
312
345
  nil
313
346
  end
314
347
  end
315
- end
316
348
 
317
- def encode(value)
318
- ERB::Util.url_encode(value)
319
- end
349
+ def encode(value)
350
+ ERB::Util.url_encode(value)
351
+ end
320
352
  end
321
353
  end
322
354
  end
@@ -324,7 +356,7 @@ end
324
356
  __END__
325
357
  describe "Async::Matrix::Client" do
326
358
  def make_config
327
- Async::Matrix::ApplicationService::Config.new({
359
+ Async::Matrix::Config.new({
328
360
  "homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
329
361
  "appservice" => { "as_token" => "test_token", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
330
362
  })
@@ -465,7 +497,7 @@ __END__
465
497
 
466
498
  describe "Client retry logic" do
467
499
  def make_config
468
- Async::Matrix::ApplicationService::Config.new({
500
+ Async::Matrix::Config.new({
469
501
  "homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
470
502
  "appservice" => { "as_token" => "test_token", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
471
503
  })
@@ -689,7 +721,7 @@ __END__
689
721
 
690
722
  describe "Client ignore_rate_limit" do
691
723
  def make_config
692
- Async::Matrix::ApplicationService::Config.new({
724
+ Async::Matrix::Config.new({
693
725
  "homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
694
726
  "appservice" => { "as_token" => "test_token", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
695
727
  })
@@ -757,7 +789,7 @@ __END__
757
789
 
758
790
  describe "Client per-request max_retries" do
759
791
  def make_config
760
- Async::Matrix::ApplicationService::Config.new({
792
+ Async::Matrix::Config.new({
761
793
  "homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
762
794
  "appservice" => { "as_token" => "test_token", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
763
795
  })
@@ -848,7 +880,7 @@ __END__
848
880
 
849
881
  describe "Client response size limiting" do
850
882
  def make_config
851
- Async::Matrix::ApplicationService::Config.new({
883
+ Async::Matrix::Config.new({
852
884
  "homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
853
885
  "appservice" => { "as_token" => "test_token", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
854
886
  })
@@ -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