async-matrix-bridge 2.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.
@@ -0,0 +1,482 @@
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 "json"
7
+ require "uri"
8
+ require "async"
9
+ require "async/semaphore"
10
+ require "async/barrier"
11
+ require "console"
12
+
13
+ module Async
14
+ module Matrix
15
+ module Bridge
16
+ class Endpoint
17
+ PREFIX = "/_matrix/app/v1"
18
+
19
+ JSON_HEADERS = {"content-type" => "application/json"}.freeze
20
+
21
+ DEFAULT_CONCURRENCY = 8
22
+
23
+ attr_reader :config, :store, :concurrency
24
+
25
+ def initialize(config:, store: nil, concurrency: DEFAULT_CONCURRENCY, &block)
26
+ @config = config
27
+ @store = store
28
+ @concurrency = concurrency
29
+ @block = block
30
+ end
31
+
32
+ def call(env)
33
+ Call.new(self, env, &@block).response
34
+ end
35
+
36
+ class Call
37
+ def initialize(endpoint, env, &block)
38
+ @endpoint = endpoint
39
+ @block = block
40
+
41
+ @verb = env["REQUEST_METHOD"]
42
+ @path = env["PATH_INFO"].to_s
43
+ @query = env["QUERY_STRING"].to_s
44
+ @authorization = env["HTTP_AUTHORIZATION"]
45
+ @input = env["rack.input"]
46
+ end
47
+
48
+ def response
49
+ dispatch || not_found
50
+ end
51
+
52
+ private
53
+
54
+ def hs_token = @endpoint.config.appservice.hs_token.to_s
55
+
56
+ def store = @endpoint.store
57
+
58
+ def path = @path.delete_suffix("/")
59
+
60
+ def dispatch
61
+ case [@verb, path]
62
+ in ["POST", "#{Endpoint::PREFIX}/ping"]
63
+ reply(200, {})
64
+ in [_, String => requested] if !requested.start_with?("#{Endpoint::PREFIX}/")
65
+ nil
66
+ else
67
+ authenticated { route }
68
+ end
69
+ end
70
+
71
+ def route
72
+ case [@verb, path.delete_prefix("#{Endpoint::PREFIX}/").split("/")]
73
+ in ["PUT", ["transactions", txn_id]]
74
+ transaction(txn_id)
75
+ in ["GET", ["users", _user_id]]
76
+ reply(200, {})
77
+ in ["GET", ["rooms", _room_alias]]
78
+ reply(404, {errcode: "M_NOT_FOUND"})
79
+ in ["GET", ["thirdparty", *rest]]
80
+ thirdparty(rest)
81
+ else
82
+ nil
83
+ end
84
+ end
85
+
86
+ def transaction(txn_id)
87
+ if store&.seen?(txn_id)
88
+ Console.debug(self) { "Duplicate transaction #{txn_id} - skipping" }
89
+ reply(200, {})
90
+ else
91
+ body = parse_body
92
+
93
+ if body.nil?
94
+ reply(400, {errcode: "M_BAD_JSON"})
95
+ else
96
+ deliver(txn_id, messages(body))
97
+ end
98
+ end
99
+ end
100
+
101
+ def messages(body)
102
+ Array(body[:events]) +
103
+ Array(body[:"de.sorunome.msc2409.ephemeral"] || body[:ephemeral])
104
+ end
105
+
106
+ def deliver(txn_id, messages)
107
+ Console.info(self) { "Transaction #{txn_id}: #{messages.size} event(s)" }
108
+
109
+ failures = each_message(messages)
110
+
111
+ if failures.empty?
112
+ store&.mark_seen(txn_id)
113
+ reply(200, {})
114
+ else
115
+ Console.error(self) do
116
+ "Transaction #{txn_id} failed (#{failures.size}/#{messages.size}); " \
117
+ "not recorded, homeserver will retry: #{failures.first.message}"
118
+ end
119
+ reply(500, {errcode: "M_UNKNOWN", error: failures.first.message})
120
+ end
121
+ end
122
+
123
+ def each_message(messages)
124
+ if messages.empty?
125
+ []
126
+ else
127
+ if Async::Task.current?
128
+ concurrently(messages)
129
+ else
130
+ messages.filter_map { |message| failure_of(message) }
131
+ end
132
+ end
133
+ end
134
+
135
+ def concurrently(messages)
136
+ [].tap do |failures|
137
+ semaphore = Async::Semaphore.new(@endpoint.concurrency)
138
+ barrier = Async::Barrier.new(parent: semaphore)
139
+
140
+ messages.each do |message|
141
+ barrier.async do
142
+ failure_of(message).then do |error|
143
+ if error
144
+ failures << error
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ barrier.wait
151
+ end
152
+ end
153
+
154
+ def failure_of(message)
155
+ @block&.call(message)
156
+ nil
157
+ rescue => error
158
+ Console.error(self) { "Handler raised #{error.class}: #{error.message}" }
159
+ error
160
+ end
161
+
162
+ def thirdparty(rest)
163
+ case rest
164
+ in ["protocol", protocol] then query({query: :protocol, protocol: protocol})
165
+ in ["location"] then query({query: :locations, protocol: nil})
166
+ in ["location", protocol] then query({query: :locations, protocol: protocol})
167
+ in ["user"] then query({query: :users, protocol: nil})
168
+ in ["user", protocol] then query({query: :users, protocol: protocol})
169
+ else nil
170
+ end
171
+ end
172
+
173
+ def query(message)
174
+ @block&.call(message.merge(params: params)).then do |result|
175
+ if result.nil?
176
+ reply(404, {errcode: "M_NOT_FOUND"})
177
+ else
178
+ reply(200, result)
179
+ end
180
+ end
181
+ end
182
+
183
+ def authenticated
184
+ if token && secure_compare(token, hs_token)
185
+ yield
186
+ else
187
+ reply(403, {errcode: "M_FORBIDDEN"})
188
+ end
189
+ end
190
+
191
+ def token
192
+ authorization = @authorization&.to_s
193
+
194
+ if authorization&.start_with?("Bearer ")
195
+ authorization.delete_prefix("Bearer ")
196
+ else
197
+ params["access_token"]
198
+ end
199
+ end
200
+
201
+ def params
202
+ @params ||= URI.decode_www_form(@query).to_h
203
+ rescue ArgumentError
204
+ {}
205
+ end
206
+
207
+ def secure_compare(a, b)
208
+ if a.bytesize == b.bytesize
209
+ a.unpack("C*").zip(b.unpack("C*")).inject(0) { |acc, (l, r)| acc | (l ^ r) }.zero?
210
+ else
211
+ false
212
+ end
213
+ end
214
+
215
+ def parse_body
216
+ parse(@input&.read)
217
+ end
218
+
219
+ def parse(raw)
220
+ if raw.nil? || raw.empty?
221
+ {}
222
+ else
223
+ JSON.parse(raw, symbolize_names: true)
224
+ end
225
+ rescue JSON::ParserError => error
226
+ Console.error(self) { "Bad JSON in request: #{error.message}" }
227
+ nil
228
+ end
229
+
230
+ def reply(status, body)
231
+ [status, JSON_HEADERS, [JSON.generate(body)]]
232
+ end
233
+
234
+ def not_found
235
+ reply(404, {errcode: "M_NOT_FOUND"})
236
+ end
237
+ end
238
+
239
+ private_constant :Call
240
+ end
241
+ end
242
+ end
243
+ end
244
+
245
+ __END__
246
+ require "stringio"
247
+ require "async"
248
+
249
+ describe "Async::Matrix::Bridge::Endpoint" do
250
+ Endpoint = Async::Matrix::Bridge::Endpoint
251
+ Store = Async::Matrix::Bridge::TransactionStore
252
+
253
+ CONFIG = Async::Matrix::Bridge::Registration.new(
254
+ {
255
+ "id" => "spec",
256
+ "url" => "http://appservice:9000",
257
+ "as_token" => "as_secret_token_value",
258
+ "hs_token" => "secret",
259
+ "sender_localpart" => "bot",
260
+ "namespaces" => {"users" => [], "aliases" => [], "rooms" => []},
261
+ },
262
+ homeserver: "http://localhost:8008",
263
+ domain: "localhost"
264
+ )
265
+
266
+ def config = CONFIG
267
+
268
+ def env(verb, path, opts = {})
269
+ {
270
+ "REQUEST_METHOD" => verb,
271
+ "PATH_INFO" => path,
272
+ "QUERY_STRING" => opts[:query].to_s,
273
+ }.tap do |e|
274
+ if opts[:token]
275
+ e["HTTP_AUTHORIZATION"] = "Bearer #{opts[:token]}"
276
+ end
277
+ if opts[:body]
278
+ e["rack.input"] = StringIO.new(opts[:body])
279
+ end
280
+ end
281
+ end
282
+
283
+ def transaction(*events)
284
+ JSON.generate({events: events})
285
+ end
286
+
287
+ MESSAGE = {
288
+ type: "m.room.message",
289
+ room_id: "!r:localhost",
290
+ sender: "@alice:localhost",
291
+ content: {msgtype: "m.text", body: "hi"},
292
+ }.freeze
293
+
294
+ def app(opts = {}, &block)
295
+ Endpoint.new(config: opts.fetch(:config, config), store: opts[:store],
296
+ concurrency: opts.fetch(:concurrency, 8), &block)
297
+ end
298
+
299
+ def status(env, opts = {}, &block)
300
+ app(opts, &block).call(env).first
301
+ end
302
+
303
+ it "is callable with a rack env and answers a rack triplet" do
304
+ response = app.call(env("POST", "/_matrix/app/v1/ping"))
305
+ response.length.should == 3
306
+ response.first.should == 200
307
+ response.last.first.should == "{}"
308
+ end
309
+
310
+ it "sets a JSON content type" do
311
+ app.call(env("POST", "/_matrix/app/v1/ping"))[1]["content-type"].should == "application/json"
312
+ end
313
+
314
+ it "serves many requests from one instance without leaking state between them" do
315
+ endpoint = app(store: Store.new)
316
+ endpoint.call(env("PUT", "/_matrix/app/v1/transactions/a", body: "{}", token: "secret")).first.should == 200
317
+ endpoint.call(env("GET", "/_matrix/app/v1/rooms/%23a:localhost", token: "secret")).first.should == 404
318
+ endpoint.call(env("POST", "/_matrix/app/v1/ping")).first.should == 200
319
+ end
320
+
321
+ it "answers POST /ping with 200 and no auth" do
322
+ status(env("POST", "/_matrix/app/v1/ping")).should == 200
323
+ end
324
+
325
+ it "rejects a transaction with no token" do
326
+ status(env("PUT", "/_matrix/app/v1/transactions/t1", body: "{}")).should == 403
327
+ end
328
+
329
+ it "rejects a transaction with the wrong token" do
330
+ status(env("PUT", "/_matrix/app/v1/transactions/t1", body: "{}", token: "wrong")).should == 403
331
+ end
332
+
333
+ it "accepts a transaction with a Bearer token" do
334
+ status(env("PUT", "/_matrix/app/v1/transactions/t1", body: "{}", token: "secret")).should == 200
335
+ end
336
+
337
+ it "accepts the legacy access_token query parameter" do
338
+ status(env("PUT", "/_matrix/app/v1/transactions/t1", body: "{}", query: "access_token=secret")).should == 200
339
+ end
340
+
341
+ it "answers a user query with 200 and a room alias query with 404" do
342
+ status(env("GET", "/_matrix/app/v1/users/@a:localhost", token: "secret")).should == 200
343
+ status(env("GET", "/_matrix/app/v1/rooms/%23a:localhost", token: "secret")).should == 404
344
+ end
345
+
346
+ it "answers 404 for a path it does not own" do
347
+ status(env("GET", "/healthz")).should == 404
348
+ end
349
+
350
+ it "answers M_BAD_JSON for a malformed body" do
351
+ status(env("PUT", "/_matrix/app/v1/transactions/t1", body: "{nope", token: "secret")).should == 400
352
+ end
353
+
354
+ it "hands the block symbol-keyed hashes so case/in matches nested content" do
355
+ matched = []
356
+ status(env("PUT", "/_matrix/app/v1/transactions/pm", body: transaction(MESSAGE), token: "secret")) do |message|
357
+ case message
358
+ in {type: "m.room.message", content: {msgtype: "m.text", body:}, room_id:}
359
+ matched << [room_id, body]
360
+ else
361
+ nil
362
+ end
363
+ end
364
+ matched.should == [["!r:localhost", "hi"]]
365
+ end
366
+
367
+ it "lets a guard filter the bot's own events" do
368
+ seen = []
369
+ events = [MESSAGE, MESSAGE.merge(sender: "@bot:localhost")]
370
+ status(env("PUT", "/_matrix/app/v1/transactions/guard", body: transaction(*events), token: "secret")) do |message|
371
+ case message
372
+ in {type: "m.room.message", sender:} if sender != "@bot:localhost"
373
+ seen << sender
374
+ else
375
+ nil
376
+ end
377
+ end
378
+ seen.should == ["@alice:localhost"]
379
+ end
380
+
381
+ it "dispatches ephemeral events under the unstable MSC2409 key" do
382
+ seen = []
383
+ body = JSON.generate({"de.sorunome.msc2409.ephemeral" => [{type: "m.typing"}]})
384
+ status(env("PUT", "/_matrix/app/v1/transactions/eph", body: body, token: "secret")) do |message|
385
+ seen << message[:type]
386
+ end
387
+ seen.should == ["m.typing"]
388
+ end
389
+
390
+ it "dedupes a repeated transaction id" do
391
+ store = Store.new
392
+ count = 0
393
+ endpoint = app(store: store) { |_m| count += 1 }
394
+ 2.times do
395
+ endpoint.call(env("PUT", "/_matrix/app/v1/transactions/dup", body: transaction(MESSAGE), token: "secret"))
396
+ end
397
+ count.should == 1
398
+ end
399
+
400
+ it "answers 500 and does not record the transaction when the block raises" do
401
+ store = Store.new
402
+ code = status(env("PUT", "/_matrix/app/v1/transactions/boom", body: transaction(MESSAGE), token: "secret"),
403
+ store: store) { |_m| raise "boom" }
404
+ code.should == 500
405
+ store.seen?("boom").should == false
406
+ end
407
+
408
+ it "dispatches the events of a transaction concurrently inside a reactor" do
409
+ fibers = []
410
+ events = Array.new(4) { MESSAGE }
411
+ Async do
412
+ status(env("PUT", "/_matrix/app/v1/transactions/conc", body: transaction(*events), token: "secret")) do |_m|
413
+ fibers << Fiber.current.object_id
414
+ Async::Task.current.sleep(0.01)
415
+ end
416
+ end.wait
417
+ fibers.uniq.size.should == 4
418
+ end
419
+
420
+ it "bounds concurrency to the configured limit" do
421
+ peak = 0
422
+ live = 0
423
+ events = Array.new(6) { MESSAGE }
424
+ Async do
425
+ status(env("PUT", "/_matrix/app/v1/transactions/bound", body: transaction(*events), token: "secret"),
426
+ concurrency: 2) do |_m|
427
+ live += 1
428
+ peak = live if live > peak
429
+ Async::Task.current.sleep(0.01)
430
+ live -= 1
431
+ end
432
+ end.wait
433
+ peak.should == 2
434
+ end
435
+
436
+ it "keeps concurrent requests independent" do
437
+ seen = []
438
+ endpoint = app(store: Store.new) { |message| seen << message[:room_id] }
439
+ Async do |task|
440
+ tasks = ["!a:localhost", "!b:localhost", "!c:localhost"].each_with_index.map do |room, index|
441
+ task.async do
442
+ body = transaction(MESSAGE.merge(room_id: room))
443
+ endpoint.call(env("PUT", "/_matrix/app/v1/transactions/i#{index}", body: body, token: "secret"))
444
+ end
445
+ end
446
+ tasks.each(&:wait)
447
+ end.wait
448
+ seen.sort.should == ["!a:localhost", "!b:localhost", "!c:localhost"]
449
+ end
450
+
451
+ it "routes a third-party protocol query to the block and returns its hash" do
452
+ endpoint = app do |message|
453
+ case message
454
+ in {query: :protocol, protocol:}
455
+ {instances: [protocol]}
456
+ else
457
+ nil
458
+ end
459
+ end
460
+
461
+ response = endpoint.call(env("GET", "/_matrix/app/v1/thirdparty/protocol/gitter", token: "secret"))
462
+ response.first.should == 200
463
+ JSON.parse(response.last.first).should == {"instances" => ["gitter"]}
464
+ end
465
+
466
+ it "answers M_NOT_FOUND when the block returns nil for a query" do
467
+ status(env("GET", "/_matrix/app/v1/thirdparty/protocol/nope", token: "secret")) { |_m| nil }.should == 404
468
+ end
469
+
470
+ it "reads a body from an input that cannot be rewound" do
471
+ seen = []
472
+ body = transaction(MESSAGE)
473
+ input = Object.new
474
+ input.define_singleton_method(:read) { |*| @done ? "" : (@done = true; body) }
475
+
476
+ e = env("PUT", "/_matrix/app/v1/transactions/stream", token: "secret")
477
+ e["rack.input"] = input
478
+
479
+ status(e) { |m| seen << m[:type] }.should == 200
480
+ seen.should == ["m.room.message"]
481
+ end
482
+ end
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "array",
4
+ "items": {
5
+ "type": "object",
6
+ "title": "Namespace",
7
+ "properties": {
8
+ "regex": {
9
+ "type": "string",
10
+ "description": "A POSIX regular expression defining which values this namespace includes."
11
+ },
12
+ "exclusive": {
13
+ "type": "boolean",
14
+ "description": "A true or false value stating whether this application service has exclusive access to events within this namespace."
15
+ }
16
+ },
17
+ "required": [
18
+ "regex",
19
+ "exclusive"
20
+ ]
21
+ }
22
+ }
@@ -0,0 +1,91 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "title": "Registration",
5
+ "properties": {
6
+ "id": {
7
+ "type": "string",
8
+ "description": "A unique, user-defined ID of the application service which will never change."
9
+ },
10
+ "url": {
11
+ "type": [
12
+ "null",
13
+ "string"
14
+ ],
15
+ "description": "The URL for the application service. May include a path after the domain name. Optionally set to null if no traffic is required."
16
+ },
17
+ "as_token": {
18
+ "type": "string",
19
+ "description": "A secret token that the application service will use to authenticate requests to the homeserver."
20
+ },
21
+ "hs_token": {
22
+ "type": "string",
23
+ "description": "A secret token that the homeserver will use to authenticate requests to the application service."
24
+ },
25
+ "sender_localpart": {
26
+ "type": "string",
27
+ "description": "The localpart of the user associated with the application service. Events will be sent to the AS if this user is the target of the event, or\nis a joined member of the room where the event occurred."
28
+ },
29
+ "receive_ephemeral": {
30
+ "type": "boolean",
31
+ "x-addedInMatrixVersion": "1.13",
32
+ "description": "Whether the application service wants to [receive ephemeral data](/application-service-api/#pushing-ephemeral-data).\n\nDefaults to `false` if not present."
33
+ },
34
+ "namespaces": {
35
+ "type": "object",
36
+ "title": "Namespaces",
37
+ "description": "The namespaces that the application service is interested in.",
38
+ "properties": {
39
+ "users": {
40
+ "allOf": [
41
+ {
42
+ "$ref": "namespace_list.json"
43
+ },
44
+ {
45
+ "description": "A list of namespaces defining the user IDs that the application\nservice is interested in, in addition to its `sender_localpart`.\nEvents will be sent to the AS if a local user matching one of the\nnamespaces is the target of the event, or is a joined member of\nthe room where the event occurred."
46
+ }
47
+ ]
48
+ },
49
+ "rooms": {
50
+ "allOf": [
51
+ {
52
+ "$ref": "namespace_list.json"
53
+ },
54
+ {
55
+ "description": "A list of namespaces defining the room IDs that the application\nservice is interested in. All events sent in a room with an ID\nwhich matches one of the namespaces will be sent to the AS."
56
+ }
57
+ ]
58
+ },
59
+ "aliases": {
60
+ "allOf": [
61
+ {
62
+ "$ref": "namespace_list.json"
63
+ },
64
+ {
65
+ "description": "A list of namespaces defining the room aliases that the application\nservice is interested in. All events sent in a room with an alias\nwhich matches one of the namespaces will be sent to the AS."
66
+ }
67
+ ]
68
+ }
69
+ }
70
+ },
71
+ "rate_limited": {
72
+ "type": "boolean",
73
+ "description": "Whether requests from masqueraded users are rate-limited. The sender is excluded."
74
+ },
75
+ "protocols": {
76
+ "type": "array",
77
+ "description": "The external protocols which the application service provides (e.g. IRC).",
78
+ "items": {
79
+ "type": "string"
80
+ }
81
+ }
82
+ },
83
+ "required": [
84
+ "id",
85
+ "url",
86
+ "as_token",
87
+ "hs_token",
88
+ "sender_localpart",
89
+ "namespaces"
90
+ ]
91
+ }