async-matrix 2.1.0-aarch64-linux → 3.0.1-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.
- 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/app_service_client.rb +213 -0
- data/lib/async/matrix/auth_error.rb +20 -0
- data/lib/async/matrix/bad_json_error.rb +20 -0
- data/lib/async/matrix/client/encryption.rb +413 -0
- data/lib/async/matrix/client/rooms.rb +478 -0
- data/lib/async/matrix/client/sync.rb +508 -0
- data/lib/async/matrix/client.rb +217 -113
- data/lib/async/matrix/config/vivify.rb +107 -0
- data/lib/async/matrix/config.rb +184 -0
- data/lib/async/matrix/device_store.rb +1621 -0
- data/lib/async/matrix/double_puppet_client.rb +1 -1
- data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
- data/lib/async/matrix/error.rb +9 -36
- data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
- 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/version.rb +1 -1
- data/lib/async/matrix.rb +16 -7
- data/lib/protocol/matrix/canonical_json.rb +200 -0
- data/lib/protocol/matrix/content.rb +106 -0
- data/lib/protocol/matrix/encrypted_message.rb +1018 -0
- data/lib/protocol/matrix/error.rb +49 -0
- data/lib/protocol/matrix/event.rb +208 -0
- data/lib/protocol/matrix/key_backup.rb +347 -0
- data/lib/protocol/matrix/keys.rb +381 -0
- data/lib/protocol/matrix/message_batch.rb +482 -0
- data/lib/protocol/matrix/schema/registry.rb +381 -0
- data/lib/protocol/matrix/schema/validation_error.rb +234 -0
- data/lib/protocol/matrix/schema.rb +170 -0
- data/lib/protocol/matrix/secret_storage.rb +535 -0
- data/lib/protocol/matrix/signing.rb +278 -0
- data/lib/protocol/matrix.rb +25 -0
- metadata +43 -136
- 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
- data/lib/async/matrix/schema/registry.rb +0 -354
- data/lib/async/matrix/schema/validation_error.rb +0 -225
- data/lib/async/matrix/schema.rb +0 -170
data/lib/async/matrix/client.rb
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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}" }
|
|
229
|
+
def internet
|
|
230
|
+
@internet ||= Async::HTTP::Internet.new
|
|
231
|
+
end
|
|
184
232
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
272
|
+
Console.debug(self) { "#{method} #{path}" }
|
|
196
273
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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
|