async-matrix 2.1.0-x86_64-linux → 3.0.0-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.
- checksums.yaml +4 -4
- data/README.md +33 -58
- data/lib/async/matrix/3.3/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/3.4/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/4.0/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/api/chain.rb +120 -94
- data/lib/async/matrix/api/concat.rb +28 -22
- data/lib/async/matrix/api/path_tree.rb +71 -44
- data/lib/async/matrix/api.rb +6 -1
- data/lib/async/matrix/auth_error.rb +20 -0
- data/lib/async/matrix/bad_json_error.rb +20 -0
- data/lib/async/matrix/client.rb +142 -110
- data/lib/async/matrix/config/vivify.rb +107 -0
- data/lib/async/matrix/config.rb +184 -0
- data/lib/async/matrix/content.rb +106 -0
- data/lib/async/matrix/double_puppet_client.rb +1 -1
- data/lib/async/matrix/error.rb +0 -27
- data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
- data/lib/async/matrix/event.rb +197 -0
- data/lib/async/matrix/homeserver_error.rb +20 -0
- data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
- data/lib/async/matrix/media_client.rb +46 -35
- data/lib/async/matrix/not_found_error.rb +20 -0
- data/lib/async/matrix/notifier.rb +7 -3
- data/lib/async/matrix/response_too_large_error.rb +20 -0
- data/lib/async/matrix/schema/registry.rb +367 -340
- data/lib/async/matrix/schema/validation_error.rb +221 -214
- data/lib/async/matrix/schema.rb +161 -161
- data/lib/async/matrix/version.rb +3 -3
- data/lib/async/matrix.rb +0 -7
- metadata +25 -133
- data/lib/async/discord/api/path_tree.rb +0 -127
- data/lib/async/discord/api.rb +0 -151
- data/lib/async/discord/client.rb +0 -283
- data/lib/async/discord/error.rb +0 -84
- data/lib/async/discord/gateway.rb +0 -359
- data/lib/async/discord.rb +0 -15
- data/lib/async/matrix/application_service/bot.rb +0 -232
- data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
- data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
- data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
- data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
- data/lib/async/matrix/application_service/config/schema/config.json +0 -61
- data/lib/async/matrix/application_service/config/schema/database.json +0 -38
- data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
- data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
- data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
- data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
- data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
- data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
- data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
- data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
- data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
- data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
- data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
- data/lib/async/matrix/application_service/config/vivify.rb +0 -109
- data/lib/async/matrix/application_service/config.rb +0 -225
- data/lib/async/matrix/application_service/dispatcher.rb +0 -185
- data/lib/async/matrix/application_service/event.rb +0 -285
- data/lib/async/matrix/application_service/server.rb +0 -430
- data/lib/async/matrix/application_service/transaction.rb +0 -66
- data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
- data/lib/async/matrix/application_service/transaction_store.rb +0 -80
- data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
- data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
- data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
- data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
- data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
- data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
- data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
- data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
- data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
- data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
- data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
- data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
- data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
- data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
- data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
- data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
- 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
|
data/lib/async/matrix/client.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
202
|
+
Console.debug(self) { "#{method} #{path}" }
|
|
196
203
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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
|