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.
- checksums.yaml +4 -4
- data/Cargo.lock +8 -8
- data/README.md +33 -58
- data/ext/async_matrix_e2ee/Cargo.toml +7 -1
- data/ext/async_matrix_e2ee/src/lib.rs +43 -2
- 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/e2ee.rb +37 -0
- 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 +28 -122
- 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
|
@@ -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(
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
data/lib/async/matrix/api.rb
CHANGED
|
@@ -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
|