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.
- checksums.yaml +7 -0
- data/LICENSE +200 -0
- data/README.md +195 -0
- data/lib/async/matrix/bridge/endpoint.rb +482 -0
- data/lib/async/matrix/bridge/registration/schema/namespace_list.json +22 -0
- data/lib/async/matrix/bridge/registration/schema/registration.json +91 -0
- data/lib/async/matrix/bridge/registration.rb +244 -0
- data/lib/async/matrix/bridge/transaction_store.rb +82 -0
- data/lib/async/matrix/bridge/version.rb +12 -0
- data/lib/async/matrix/bridge.rb +20 -0
- metadata +139 -0
|
@@ -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
|
+
}
|