async-matrix 2.1.0-aarch64-linux → 3.0.0-aarch64-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 (80) 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/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/error.rb +0 -27
  18. data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
  19. data/lib/async/matrix/event.rb +197 -0
  20. data/lib/async/matrix/homeserver_error.rb +20 -0
  21. data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
  22. data/lib/async/matrix/media_client.rb +46 -35
  23. data/lib/async/matrix/not_found_error.rb +20 -0
  24. data/lib/async/matrix/notifier.rb +7 -3
  25. data/lib/async/matrix/response_too_large_error.rb +20 -0
  26. data/lib/async/matrix/schema/registry.rb +367 -340
  27. data/lib/async/matrix/schema/validation_error.rb +221 -214
  28. data/lib/async/matrix/schema.rb +161 -161
  29. data/lib/async/matrix/version.rb +3 -3
  30. data/lib/async/matrix.rb +0 -7
  31. metadata +25 -133
  32. data/lib/async/discord/api/path_tree.rb +0 -127
  33. data/lib/async/discord/api.rb +0 -151
  34. data/lib/async/discord/client.rb +0 -283
  35. data/lib/async/discord/error.rb +0 -84
  36. data/lib/async/discord/gateway.rb +0 -359
  37. data/lib/async/discord.rb +0 -15
  38. data/lib/async/matrix/application_service/bot.rb +0 -232
  39. data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
  40. data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
  41. data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
  42. data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
  43. data/lib/async/matrix/application_service/config/schema/config.json +0 -61
  44. data/lib/async/matrix/application_service/config/schema/database.json +0 -38
  45. data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
  46. data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
  47. data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
  48. data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
  49. data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
  50. data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
  51. data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
  52. data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
  53. data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
  54. data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
  55. data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
  56. data/lib/async/matrix/application_service/config/vivify.rb +0 -109
  57. data/lib/async/matrix/application_service/config.rb +0 -225
  58. data/lib/async/matrix/application_service/dispatcher.rb +0 -185
  59. data/lib/async/matrix/application_service/event.rb +0 -285
  60. data/lib/async/matrix/application_service/server.rb +0 -430
  61. data/lib/async/matrix/application_service/transaction.rb +0 -66
  62. data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
  63. data/lib/async/matrix/application_service/transaction_store.rb +0 -80
  64. data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
  65. data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
  66. data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
  67. data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
  68. data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
  69. data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
  70. data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
  71. data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
  72. data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
  73. data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
  74. data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
  75. data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
  76. data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
  77. data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
  78. data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
  79. data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
  80. data/lib/async/matrix/bridge/discord/db.rb +0 -138
@@ -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
@@ -0,0 +1,184 @@
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 "forwardable"
7
+ require "yaml"
8
+ require "pathname"
9
+ require_relative "config/vivify"
10
+
11
+ module Async
12
+ module Matrix
13
+ # Dot-notation access to a Matrix service's YAML configuration.
14
+ #
15
+ # {Client} only reads two fields — `homeserver.address` and
16
+ # `appservice.as_token` — so this class validates nothing: it vivifies
17
+ # whatever hash (or YAML file) it is handed and exposes every nested field
18
+ # as a method call.
19
+ #
20
+ # config = Config.load("config/appservice.yml")
21
+ # config.homeserver.address # => "http://synapse:8008"
22
+ # config.appservice.as_token # => "secret..."
23
+ # config.appservice.bot.username # => "bot"
24
+ # config.bot_mxid # => "@bot:localhost"
25
+ #
26
+ # A subclass layers validation on by overriding {.validate!}; that is how
27
+ # async-matrix-bridge's Config applies the mautrix bridgev2 JSON Schema
28
+ # suite without reimplementing any of the loading below.
29
+ class Config
30
+ extend Forwardable
31
+
32
+ # Delegate every top-level config section to the vivified data hash. An
33
+ # explicit list, not a method_missing forward: a typo'd section should
34
+ # raise NoMethodError rather than autovivify into an empty hash.
35
+ def_delegators :@data,
36
+ :network,
37
+ :bridge,
38
+ :database,
39
+ :homeserver,
40
+ :appservice,
41
+ :matrix,
42
+ :analytics,
43
+ :provisioning,
44
+ :public_media,
45
+ :direct_media,
46
+ :backfill,
47
+ :double_puppet,
48
+ :encryption,
49
+ :logging,
50
+ :management_room_texts,
51
+ :env_config_prefix
52
+
53
+ def initialize(data)
54
+ self.class.validate!(data)
55
+ @data = Vivify.deep_vivify(data)
56
+ end
57
+
58
+ # Load a YAML config file from disk.
59
+ def self.load(path)
60
+ unless File.exist?(path)
61
+ raise Async::Matrix::NotFoundError.new(
62
+ "M_NOT_FOUND",
63
+ "Config not found: #{path}",
64
+ )
65
+ end
66
+
67
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol])
68
+ new(data)
69
+ end
70
+
71
+ # Validation hook, called with the raw (string-keyed) hash before it is
72
+ # vivified. A no-op here; subclasses override it to raise on bad input,
73
+ # and may mutate `data` to insert defaults.
74
+ def self.validate!(data)
75
+ nil
76
+ end
77
+
78
+ # Convenience: derive the bot's full Matrix ID from
79
+ # appservice.bot.username + homeserver.domain.
80
+ def bot_mxid
81
+ "@#{appservice.bot.username}:#{homeserver.domain}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ __END__
88
+ require "tempfile"
89
+
90
+ describe "Async::Matrix::Config" do
91
+ def minimal_data
92
+ {
93
+ "homeserver" => {
94
+ "address" => "http://localhost:8008",
95
+ "domain" => "localhost",
96
+ },
97
+ "appservice" => {
98
+ "as_token" => "as_secret_token_value",
99
+ "hs_token" => "hs_secret_token_value",
100
+ "bot" => {"username" => "bot"},
101
+ },
102
+ }
103
+ end
104
+
105
+ it "exposes top-level sections" do
106
+ config = Async::Matrix::Config.new(minimal_data)
107
+ config.homeserver.address.should == "http://localhost:8008"
108
+ config.homeserver.domain.should == "localhost"
109
+ config.appservice.as_token.should == "as_secret_token_value"
110
+ config.appservice.hs_token.should == "hs_secret_token_value"
111
+ config.appservice.bot.username.should == "bot"
112
+ end
113
+
114
+ it "derives bot_mxid from appservice.bot.username and homeserver.domain" do
115
+ Async::Matrix::Config.new(minimal_data).bot_mxid.should == "@bot:localhost"
116
+ end
117
+
118
+ it "provides dot-notation access to deeply nested fields" do
119
+ data = minimal_data.merge(
120
+ "encryption" => {
121
+ "allow" => true,
122
+ "rotation" => {"messages" => 200},
123
+ },
124
+ )
125
+ config = Async::Matrix::Config.new(data)
126
+ config.encryption.allow.should == true
127
+ config.encryption.rotation.messages.should == 200
128
+ end
129
+
130
+ it "autovivifies missing optional sections as empty hashes" do
131
+ config = Async::Matrix::Config.new(minimal_data)
132
+ config.analytics.should.be.kind_of Hash
133
+ config.analytics.should.be.empty?
134
+ end
135
+
136
+ it "raises NoMethodError for sections it does not delegate" do
137
+ config = Async::Matrix::Config.new(minimal_data)
138
+ lambda { config.bogus }.should.raise(NoMethodError)
139
+ end
140
+
141
+ it "validates nothing by default" do
142
+ lambda { Async::Matrix::Config.new({}) }.should.not.raise
143
+ end
144
+
145
+ it "loads from a YAML file" do
146
+ file = Tempfile.new(["config", ".yml"])
147
+ file.write(<<~YAML)
148
+ homeserver:
149
+ address: "http://localhost:8008"
150
+ domain: "localhost"
151
+ appservice:
152
+ as_token: "as123_secret"
153
+ hs_token: "hs456_secret"
154
+ bot:
155
+ username: "testbot"
156
+ YAML
157
+ file.close
158
+
159
+ config = Async::Matrix::Config.load(file.path)
160
+ config.homeserver.address.should == "http://localhost:8008"
161
+ config.bot_mxid.should == "@testbot:localhost"
162
+ ensure
163
+ file.unlink
164
+ end
165
+
166
+ it "raises NotFoundError for a missing file" do
167
+ lambda {
168
+ Async::Matrix::Config.load("/nonexistent/path.yml")
169
+ }.should.raise(Async::Matrix::NotFoundError)
170
+ end
171
+
172
+ it "runs the validate! hook a subclass overrides" do
173
+ strict = Class.new(Async::Matrix::Config) do
174
+ def self.validate!(data)
175
+ unless data.key?("homeserver")
176
+ raise Async::Matrix::BadJsonError.new("M_BAD_JSON", "no homeserver")
177
+ end
178
+ end
179
+ end
180
+
181
+ lambda { strict.new({}) }.should.raise(Async::Matrix::BadJsonError)
182
+ strict.new(minimal_data).homeserver.domain.should == "localhost"
183
+ end
184
+ end