async-matrix 1.2.1 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/lib/async/discord/api/path_tree.rb +2 -5
  3. data/lib/async/discord/api.rb +1 -6
  4. data/lib/async/discord/client.rb +1 -4
  5. data/lib/async/discord/error.rb +1 -5
  6. data/lib/async/discord/gateway.rb +1 -4
  7. data/lib/async/discord.rb +0 -1
  8. data/lib/async/matrix/api/chain.rb +1 -4
  9. data/lib/async/matrix/api/concat.rb +1 -5
  10. data/lib/async/matrix/api/path_tree.rb +1 -4
  11. data/lib/async/matrix/api.rb +1 -5
  12. data/lib/async/matrix/application_service/bot.rb +4 -7
  13. data/lib/async/matrix/application_service/config/vivify.rb +1 -5
  14. data/lib/async/matrix/application_service/config.rb +1 -4
  15. data/lib/async/matrix/application_service/dispatcher.rb +65 -9
  16. data/lib/async/matrix/application_service/error_response.rb +1 -5
  17. data/lib/async/matrix/application_service/event.rb +1 -5
  18. data/lib/async/matrix/application_service/server.rb +339 -211
  19. data/lib/async/matrix/application_service/transaction.rb +1 -5
  20. data/lib/async/matrix/application_service/transaction_handler.rb +185 -0
  21. data/lib/async/matrix/application_service/transaction_store.rb +1 -5
  22. data/lib/async/matrix/bridge/discord/db/connection.rb +1 -3
  23. data/lib/async/matrix/bridge/discord/db/file.rb +2 -4
  24. data/lib/async/matrix/bridge/discord/db/guild.rb +2 -4
  25. data/lib/async/matrix/bridge/discord/db/message.rb +2 -4
  26. data/lib/async/matrix/bridge/discord/db/portal.rb +2 -4
  27. data/lib/async/matrix/bridge/discord/db/puppet.rb +2 -4
  28. data/lib/async/matrix/bridge/discord/db/reaction.rb +2 -4
  29. data/lib/async/matrix/bridge/discord/db/schema.rb +18 -0
  30. data/lib/async/matrix/bridge/discord/db/user.rb +2 -4
  31. data/lib/async/matrix/bridge/discord/db.rb +14 -16
  32. data/lib/async/matrix/client.rb +1 -4
  33. data/lib/async/matrix/connection.rb +1 -4
  34. data/lib/async/matrix/double_puppet_client.rb +2 -4
  35. data/lib/async/matrix/e2ee.rb +1 -2
  36. data/lib/async/matrix/endpoint.rb +1 -4
  37. data/lib/async/matrix/error.rb +1 -4
  38. data/lib/async/matrix/media_client.rb +1 -4
  39. data/lib/async/matrix/notifier.rb +1 -4
  40. data/lib/async/matrix/schema/registry.rb +1 -2
  41. data/lib/async/matrix/schema/validation_error.rb +1 -2
  42. data/lib/async/matrix/schema.rb +1 -5
  43. data/lib/async/matrix/server.rb +1 -4
  44. data/lib/async/matrix/stream.rb +1 -4
  45. data/lib/async/matrix/version.rb +1 -1
  46. data/lib/async/matrix.rb +4 -1
  47. metadata +23 -7
@@ -3,300 +3,428 @@
3
3
  # Released under the Apache License, Version 2.0.
4
4
  # Copyright, 2026, by General Intelligence Systems.
5
5
 
6
- require "bundler/setup"
6
+ require "forwardable"
7
7
  require "json"
8
- require "rack"
8
+ require "grape"
9
9
  require "console"
10
- require "async/matrix"
11
10
 
12
11
  module Async
13
12
  module Matrix
14
13
  module ApplicationService
15
- # Rack 3 application implementing the Matrix Application Service API.
14
+ # Matrix Application Service (server side).
16
15
  #
17
- # Routes:
18
- # PUT /_matrix/app/v1/transactions/{txnId} receive events from homeserver
19
- # GET /_matrix/app/v1/users/{userId} — user existence query
20
- # GET /_matrix/app/v1/rooms/{roomAlias} — room alias query
21
- # POST /_matrix/app/v1/ping — healthcheck
16
+ # A +Server+ wraps a Grape::API into which the Matrix wire-protocol routes
17
+ # are mixed (via the {Server::Grape} concern). It forwards the Grape route
18
+ # DSL, so you can declare application-specific endpoints right alongside
19
+ # the Matrix routes, and register event handlers with #dispatch:
20
+ #
21
+ # server = Async::Matrix::ApplicationService::Server.new(
22
+ # hs_token: config.appservice.hs_token,
23
+ # client: Async::Matrix::Client.new(config)
24
+ # ) do
25
+ # dispatch do
26
+ # on "m.room.member" do |event|
27
+ # join_room(event.room_id) if event.content.membership == "invite"
28
+ # end
29
+ # end
30
+ #
31
+ # dispatch SomeHandler.new # plain handler or Bot
32
+ #
33
+ # post "/_webhook/send" do # your own endpoint, no Matrix auth
34
+ # client.send_text(params[:room_id], params[:body])
35
+ # {ok: true}
36
+ # end
37
+ # end
38
+ #
39
+ # run server
40
+ #
41
+ # +Server+ is a Rack app (it delegates #call to the wrapped Grape::API), so
42
+ # `run server` works directly, and it can be mounted into a larger Rack app.
43
+ #
44
+ # {Server::Grape} is a plain mix-in, so you can also skip +Server+ entirely
45
+ # and mix the routes straight into your own Grape::API:
46
+ #
47
+ # class MyAppService < Grape::API
48
+ # include Async::Matrix::ApplicationService::Server::Grape
49
+ # post("/_webhook/send") { ... }
50
+ # end
51
+ # MyAppService.configure { |c| c[:hs_token] = ...; c[:dispatcher] = ... }
22
52
  class Server
23
- CONTENT_JSON = {"content-type" => "application/json"}.freeze
24
- EMPTY_BODY = ["{}"].freeze
25
-
26
- RESP_NOT_FOUND = [404, CONTENT_JSON, ['{"errcode":"M_UNRECOGNIZED"}']].freeze
27
- RESP_FORBIDDEN = [403, CONTENT_JSON, ['{"errcode":"M_FORBIDDEN"}']].freeze
28
- RESP_BAD_JSON = [400, CONTENT_JSON, ['{"errcode":"M_BAD_JSON"}']].freeze
29
- RESP_BAD_METHOD = [405, CONTENT_JSON, ['{"errcode":"M_UNRECOGNIZED"}']].freeze
30
-
31
- def initialize(hs_token:, dispatcher: Dispatcher.new)
32
- @hs_token = hs_token
33
- @dispatcher = dispatcher
34
- @txn_store = TransactionStore.new
35
- end
36
-
37
- # Register a Bot or a plain handler on the internal dispatcher.
53
+ extend Forwardable
54
+
55
+ # The Matrix Application Service wire protocol, as an includable concern.
56
+ # Mixing it into a Grape::API defines these routes (all under
57
+ # /_matrix/app/v1), reading its collaborators from Grape +configuration+:
58
+ # configuration[:hs_token] — homeserver token (authentication)
59
+ # configuration[:dispatcher] — Dispatcher receiving transactions
60
+ # configuration[:thirdparty] — optional third-party lookup handler
61
+ # configuration[:client] — optional Client (exposed to endpoints)
38
62
  #
39
- # Accepts either:
40
- # - A Bot (responds to #handlers) — registers all its handlers
41
- # - A plain handler (responds to #event_types and #call)
63
+ # Routes:
64
+ # PUT transactions/{txnId} — receive events (authenticated)
65
+ # POST ping — healthcheck (unauthenticated)
66
+ # GET users/{userId} — user existence query (authenticated)
67
+ # GET rooms/{roomAlias} — room alias query (authenticated)
68
+ # GET thirdparty/protocol/{proto} — protocol metadata (authenticated)
69
+ # GET thirdparty/location — location lookup (authenticated)
70
+ # GET thirdparty/location/{proto} — reverse location lookup (authenticated)
71
+ # GET thirdparty/user — user lookup (authenticated)
72
+ # GET thirdparty/user/{proto} — reverse user lookup (authenticated)
42
73
  #
43
- def register(handler)
44
- if handler.respond_to?(:handlers)
45
- handler.handlers.each { |h| @dispatcher.register(h) }
46
- else
47
- @dispatcher.register(handler)
48
- end
49
- end
74
+ # Third-party queries delegate to configuration[:thirdparty], a duck type:
75
+ # protocol(name) -> Hash | nil (nil ⇒ 404 M_NOT_FOUND)
76
+ # locations(proto, params) -> Array (default [])
77
+ # users(proto, params) -> Array (default [])
78
+ module Grape
79
+ def self.included(base)
80
+ base.class_eval do
81
+ format :json
82
+ content_type :json, "application/json"
83
+ default_format :json
84
+
85
+ helpers do
86
+ def hs_token
87
+ configuration[:hs_token]
88
+ end
50
89
 
51
- def call(env)
52
- request = Rack::Request.new(env)
53
- method = request.request_method
54
- path = request.path_info
90
+ def dispatcher
91
+ configuration[:dispatcher]
92
+ end
55
93
 
56
- case path
57
- when %r{\A/_matrix/app/v1/transactions/(.+)\z}
58
- return RESP_BAD_METHOD unless method == "PUT"
59
- return RESP_FORBIDDEN unless authorized?(request)
60
- handle_transaction(request, Regexp.last_match(1))
94
+ def thirdparty
95
+ configuration[:thirdparty]
96
+ end
61
97
 
62
- when %r{\A/_matrix/app/v1/users/(.+)\z}
63
- return RESP_BAD_METHOD unless method == "GET"
64
- return RESP_FORBIDDEN unless authorized?(request)
65
- [200, CONTENT_JSON, EMPTY_BODY]
98
+ def client
99
+ configuration[:client]
100
+ end
66
101
 
67
- when %r{\A/_matrix/app/v1/rooms/(.+)\z}
68
- return RESP_BAD_METHOD unless method == "GET"
69
- return RESP_FORBIDDEN unless authorized?(request)
70
- RESP_NOT_FOUND
102
+ # Homeserver token from the Authorization header (Bearer scheme)
103
+ # or the legacy access_token query parameter.
104
+ def request_token
105
+ auth = headers["Authorization"]
106
+ if auth&.start_with?("Bearer ")
107
+ auth.delete_prefix("Bearer ")
108
+ else
109
+ params[:access_token]
110
+ end
111
+ end
71
112
 
72
- when "/_matrix/app/v1/ping"
73
- return RESP_BAD_METHOD unless method == "POST"
74
- [200, CONTENT_JSON, EMPTY_BODY]
113
+ def authenticate!
114
+ token = request_token
115
+ unless token && secure_compare(token, hs_token.to_s)
116
+ error!({errcode: "M_FORBIDDEN"}, 403)
117
+ end
118
+ end
75
119
 
76
- else
77
- RESP_NOT_FOUND
78
- end
79
- end
120
+ def secure_compare(a, b)
121
+ return false unless a.bytesize == b.bytesize
122
+
123
+ l = a.unpack("C*")
124
+ r = b.unpack("C*")
125
+ result = 0
126
+ l.each_with_index { |byte, i| result |= byte ^ r[i] }
127
+ result.zero?
128
+ end
80
129
 
81
- private
130
+ # Raw JSON body, parsed here (rather than via Grape's param
131
+ # coercion) so malformed input yields the Matrix M_BAD_JSON.
132
+ def json_body
133
+ raw = request.body.read
134
+ request.body.rewind if request.body.respond_to?(:rewind)
135
+ return {} if raw.nil? || raw.empty?
136
+
137
+ JSON.parse(raw)
138
+ rescue JSON::ParserError => e
139
+ Console.error(self) { "Bad JSON in request: #{e.message}" }
140
+ error!({errcode: "M_BAD_JSON"}, 400)
141
+ end
142
+ end
82
143
 
83
- def authorized?(request)
84
- extract_token(request).then do |token|
85
- if token
86
- secure_compare(token, @hs_token)
87
- else
88
- false
144
+ # --- Healthcheck (unauthenticated) -----------------------------
145
+
146
+ post "/_matrix/app/v1/ping" do
147
+ status 200
148
+ {}
89
149
  end
90
- end
91
- end
92
150
 
93
- def extract_token(request)
94
- auth = request.get_header("HTTP_AUTHORIZATION")
95
- if auth && auth.start_with?("Bearer ")
96
- auth.delete_prefix("Bearer ")
97
- else
98
- request.params["access_token"]
99
- end
100
- end
151
+ # --- Authenticated homeserver-facing routes --------------------
101
152
 
102
- def secure_compare(a, b)
103
- if a.bytesize == b.bytesize
104
- l = a.unpack("C*")
105
- r = b.unpack("C*")
106
- result = 0
107
- l.each_with_index { |byte, i| result |= byte ^ r[i] }
108
- result.zero?
109
- else
110
- false
111
- end
112
- end
153
+ namespace "/_matrix/app/v1" do
154
+ before { authenticate! }
113
155
 
114
- def handle_transaction(request, txn_id)
115
- if @txn_store.seen?(txn_id)
116
- Console.debug(self) { "Duplicate transaction #{txn_id} — skipping" }
117
- [200, CONTENT_JSON, EMPTY_BODY]
118
- else
119
- parse_json(request).then do |body|
120
- if body
121
- Console.info(self) {
156
+ put "transactions/:txn_id" do
157
+ body = json_body
158
+ Console.info(self) do
122
159
  events = body["events"] || []
123
- "Transaction #{txn_id}: #{events.size} event(s) — #{JSON.generate(events)}"
124
- }
160
+ "Transaction #{params[:txn_id]}: #{events.size} event(s)"
161
+ end
162
+ dispatcher.receive_transaction(params[:txn_id], body)
163
+ {}
164
+ end
125
165
 
126
- @dispatcher.dispatch_transaction(body)
127
- @txn_store.mark_seen(txn_id)
166
+ get "users/:user_id", requirements: {user_id: %r{[^/]+}} do
167
+ {}
168
+ end
128
169
 
129
- [200, CONTENT_JSON, EMPTY_BODY]
130
- else
131
- RESP_BAD_JSON
170
+ get "rooms/:room_alias", requirements: {room_alias: %r{[^/]+}} do
171
+ error!({errcode: "M_NOT_FOUND"}, 404)
172
+ end
173
+
174
+ # --- Third-party protocol lookups ---
175
+
176
+ get "thirdparty/protocol/:protocol" do
177
+ result = thirdparty&.protocol(params[:protocol])
178
+ error!({errcode: "M_NOT_FOUND"}, 404) unless result
179
+
180
+ result
181
+ end
182
+
183
+ get "thirdparty/location" do
184
+ thirdparty&.locations(nil, params) || []
185
+ end
186
+
187
+ get "thirdparty/location/:protocol" do
188
+ thirdparty&.locations(params[:protocol], params) || []
189
+ end
190
+
191
+ get "thirdparty/user" do
192
+ thirdparty&.users(nil, params) || []
193
+ end
194
+
195
+ get "thirdparty/user/:protocol" do
196
+ thirdparty&.users(params[:protocol], params) || []
132
197
  end
133
198
  end
134
199
  end
135
200
  end
201
+ end
136
202
 
137
- def parse_json(request)
138
- request.body&.read.then do |raw|
139
- if raw && !raw.empty?
140
- JSON.parse(raw)
141
- end
203
+ # Grape route DSL — forwarded to the wrapped API, so app-specific
204
+ # endpoints declared in the Server.new block land on the same Grape::API
205
+ # as the Matrix routes. `mount` is included, so you can compose in other
206
+ # Grape APIs too.
207
+ def_delegators :@api,
208
+ :get, :post, :put, :patch, :delete, :head, :options,
209
+ :namespace, :group, :resource, :resources, :route, :route_param,
210
+ :before, :after, :rescue_from, :helpers, :params, :desc, :use, :mount,
211
+ :version, :prefix, :format, :content_type,
212
+ # Rack + introspection:
213
+ :call, :routes, :recognize_path
214
+
215
+ attr_reader :api, :dispatcher, :client
216
+
217
+ def initialize(hs_token:, dispatcher: Dispatcher.new, client: nil, thirdparty: nil, &block)
218
+ @dispatcher = dispatcher
219
+ @client = client
220
+
221
+ # A fresh Grape::API with the Matrix routes mixed in, configured with
222
+ # our collaborators. No mounting — the routes are defined inline by the
223
+ # concern, so adding app-specific routes never triggers a re-mount.
224
+ @api = ::Class.new(::Grape::API)
225
+ @api.include(Grape)
226
+ @api.configure do |c|
227
+ c[:hs_token] = hs_token
228
+ c[:dispatcher] = dispatcher
229
+ c[:thirdparty] = thirdparty
230
+ c[:client] = client
231
+ end
232
+
233
+ instance_eval(&block) if block
234
+ end
235
+
236
+ # Register handlers with the dispatcher. Sugar over dispatcher.register:
237
+ #
238
+ # dispatch do # block → Bot built with `client`
239
+ # on "m.room.message" do |e| ... end
240
+ # end
241
+ # dispatch Bot.new(other_client) { } # explicit bot
242
+ # dispatch SomeHandler.new # plain handler
243
+ #
244
+ def dispatch(handler = nil, &block)
245
+ if block
246
+ unless @client
247
+ raise ArgumentError, "dispatch { ... } needs a client; pass `client:` to Server.new"
142
248
  end
143
- rescue JSON::ParserError => e
144
- Console.error(self) { "Bad JSON in transaction: #{e.message}" }
145
- nil
249
+
250
+ @dispatcher.register(Bot.new(@client, &block))
251
+ elsif handler
252
+ @dispatcher.register(handler)
253
+ else
254
+ raise ArgumentError, "dispatch requires a handler argument or a block"
146
255
  end
256
+ @dispatcher
257
+ end
147
258
  end
148
259
  end
149
260
  end
150
261
  end
151
262
 
152
- test do
153
- require "stringio"
263
+ __END__
264
+ require "rack/mock"
154
265
 
155
266
  describe "Async::Matrix::ApplicationService::Server" do
156
- def build_server(hs_token: "secret")
157
- dispatcher = Async::Matrix::ApplicationService::Dispatcher.new
158
- Async::Matrix::ApplicationService::Server.new(hs_token: hs_token, dispatcher: dispatcher)
267
+ Server = Async::Matrix::ApplicationService::Server
268
+ Dispatcher = Async::Matrix::ApplicationService::Dispatcher
269
+
270
+ # Positional opts hash (not kwargs) so the helper survives scampi's
271
+ # nested-describe delegation, which forwards via *args.
272
+ def mock_req(app, method, path, opts = {})
273
+ env = {}
274
+ env["HTTP_AUTHORIZATION"] = "Bearer #{opts[:token]}" if opts[:token]
275
+ if opts[:body]
276
+ env["CONTENT_TYPE"] = "application/json"
277
+ env[:input] = opts[:body]
278
+ end
279
+ Rack::MockRequest.new(app).request(method, path, env)
159
280
  end
160
281
 
161
- def env(method, path, headers: {}, body: nil, params: {})
162
- rack_env = {
163
- "REQUEST_METHOD" => method,
164
- "PATH_INFO" => path,
165
- "QUERY_STRING" => params.map { |k, v| "#{k}=#{v}" }.join("&"),
166
- "rack.input" => body ? StringIO.new(body) : StringIO.new("")
167
- }
168
- headers.each { |k, v| rack_env["HTTP_#{k.upcase.tr("-", "_")}"] = v }
169
- rack_env
282
+ def build(hs_token: "secret", dispatcher: nil, client: nil, thirdparty: nil, &block)
283
+ Server.new(hs_token: hs_token, dispatcher: dispatcher || Dispatcher.new,
284
+ client: client, thirdparty: thirdparty, &block)
170
285
  end
171
286
 
172
- # --- Ping ---
287
+ # --- Matrix wire protocol ---
173
288
 
174
- it "responds to POST /ping with 200" do
175
- server = build_server
176
- status, _, _ = server.call(env("POST", "/_matrix/app/v1/ping"))
177
- status.should == 200
289
+ it "responds to POST /ping with 200 (no auth required)" do
290
+ mock_req(build, "POST", "/_matrix/app/v1/ping").status.should == 200
178
291
  end
179
292
 
180
293
  it "rejects GET /ping with 405" do
181
- server = build_server
182
- status, _, _ = server.call(env("GET", "/_matrix/app/v1/ping"))
183
- status.should == 405
294
+ mock_req(build, "GET", "/_matrix/app/v1/ping").status.should == 405
184
295
  end
185
296
 
186
- # --- Auth ---
187
-
188
297
  it "rejects transactions without a token" do
189
- server = build_server
190
- status, _, _ = server.call(env("PUT", "/_matrix/app/v1/transactions/txn1"))
191
- status.should == 403
298
+ mock_req(build, "PUT", "/_matrix/app/v1/transactions/txn1", body: "{}").status.should == 403
192
299
  end
193
300
 
194
301
  it "rejects transactions with wrong token" do
195
- server = build_server(hs_token: "correct")
196
- status, _, _ = server.call(env("PUT", "/_matrix/app/v1/transactions/txn1",
197
- headers: {"authorization" => "Bearer wrong"}))
198
- status.should == 403
302
+ app = build(hs_token: "correct")
303
+ mock_req(app, "PUT", "/_matrix/app/v1/transactions/txn1", token: "wrong", body: "{}").status.should == 403
199
304
  end
200
305
 
201
- # --- Transactions ---
202
-
203
306
  it "accepts valid transactions with Bearer auth" do
204
- server = build_server(hs_token: "secret")
205
- status, _, _ = server.call(env("PUT", "/_matrix/app/v1/transactions/txn1",
206
- headers: {"authorization" => "Bearer secret"},
207
- body: '{"events":[]}'))
208
- status.should == 200
307
+ mock_req(build, "PUT", "/_matrix/app/v1/transactions/txn1",
308
+ token: "secret", body: '{"events":[]}').status.should == 200
209
309
  end
210
310
 
211
- it "deduplicates transactions" do
212
- server = build_server(hs_token: "secret")
213
- rack = env("PUT", "/_matrix/app/v1/transactions/txn1",
214
- headers: {"authorization" => "Bearer secret"},
215
- body: '{"events":[]}')
216
- server.call(rack)
217
- # Second call with same txn_id
218
- rack2 = env("PUT", "/_matrix/app/v1/transactions/txn1",
219
- headers: {"authorization" => "Bearer secret"},
220
- body: '{"events":[]}')
221
- status, _, _ = server.call(rack2)
222
- status.should == 200
311
+ it "dispatches events from a transaction" do
312
+ received = []
313
+ handler = Object.new
314
+ handler.define_singleton_method(:event_types) { ["m.room.message"] }
315
+ handler.define_singleton_method(:call) { |e| received << e }
316
+
317
+ app = build { dispatch handler }
318
+ mock_req(app, "PUT", "/_matrix/app/v1/transactions/txn_disp", token: "secret",
319
+ body: '{"events":[{"type":"m.room.message","content":{"body":"hi"}}]}')
320
+ received.length.should == 1
223
321
  end
224
322
 
225
- it "rejects bad JSON" do
226
- server = build_server(hs_token: "secret")
227
- status, _, _ = server.call(env("PUT", "/_matrix/app/v1/transactions/txn1",
228
- headers: {"authorization" => "Bearer secret"},
229
- body: "not json"))
230
- status.should == 400
323
+ it "deduplicates transactions" do
324
+ received = []
325
+ handler = Object.new
326
+ handler.define_singleton_method(:event_types) { ["m.room.message"] }
327
+ handler.define_singleton_method(:call) { |e| received << e }
328
+
329
+ app = build { dispatch handler }
330
+ body = '{"events":[{"type":"m.room.message","content":{"body":"hi"}}]}'
331
+ mock_req(app, "PUT", "/_matrix/app/v1/transactions/dup", token: "secret", body: body)
332
+ resp = mock_req(app, "PUT", "/_matrix/app/v1/transactions/dup", token: "secret", body: body)
333
+
334
+ resp.status.should == 200
335
+ received.length.should == 1
231
336
  end
232
337
 
233
- # --- Users / Rooms ---
338
+ it "rejects bad JSON with 400" do
339
+ mock_req(build, "PUT", "/_matrix/app/v1/transactions/txn1",
340
+ token: "secret", body: "not json").status.should == 400
341
+ end
234
342
 
235
343
  it "responds 200 for user queries" do
236
- server = build_server(hs_token: "secret")
237
- status, _, _ = server.call(env("GET", "/_matrix/app/v1/users/@bot:localhost",
238
- headers: {"authorization" => "Bearer secret"}))
239
- status.should == 200
344
+ mock_req(build, "GET", "/_matrix/app/v1/users/@bot:localhost", token: "secret").status.should == 200
240
345
  end
241
346
 
242
347
  it "responds 404 for room queries" do
243
- server = build_server(hs_token: "secret")
244
- status, _, _ = server.call(env("GET", "/_matrix/app/v1/rooms/#room:localhost",
245
- headers: {"authorization" => "Bearer secret"}))
246
- status.should == 404
348
+ mock_req(build, "GET", "/_matrix/app/v1/rooms/%23room:localhost", token: "secret").status.should == 404
247
349
  end
248
350
 
249
- # --- Unknown routes ---
250
-
251
- it "responds 404 for unknown paths" do
252
- server = build_server
253
- status, _, _ = server.call(env("GET", "/unknown"))
254
- status.should == 404
351
+ it "returns [] for third-party user lookup with no handler" do
352
+ resp = mock_req(build, "GET", "/_matrix/app/v1/thirdparty/user", token: "secret")
353
+ resp.status.should == 200
354
+ resp.body.should == "[]"
255
355
  end
256
356
 
257
- # --- Default dispatcher + register ---
258
-
259
- it "creates a default dispatcher when none is provided" do
260
- server = Async::Matrix::ApplicationService::Server.new(hs_token: "secret")
261
- status, _, _ = server.call(env("POST", "/_matrix/app/v1/ping"))
262
- status.should == 200
357
+ it "404s third-party protocol lookup with no handler" do
358
+ mock_req(build, "GET", "/_matrix/app/v1/thirdparty/protocol/irc", token: "secret").status.should == 404
263
359
  end
264
360
 
265
- it "registers a plain handler via register" do
266
- server = Async::Matrix::ApplicationService::Server.new(hs_token: "secret")
361
+ it "delegates third-party protocol lookup to the handler" do
362
+ tp = Object.new
363
+ tp.define_singleton_method(:protocol) { |name| {"instances" => [], "name" => name} }
364
+ app = build(thirdparty: tp)
365
+ resp = mock_req(app, "GET", "/_matrix/app/v1/thirdparty/protocol/irc", token: "secret")
366
+ resp.status.should == 200
367
+ JSON.parse(resp.body)["name"].should == "irc"
368
+ end
267
369
 
268
- received = []
269
- handler = Object.new
270
- handler.define_singleton_method(:event_types) { ["m.room.message"] }
271
- handler.define_singleton_method(:call) { |e| received << e }
370
+ it "responds 404 for unknown paths" do
371
+ mock_req(build, "GET", "/unknown").status.should == 404
372
+ end
272
373
 
273
- server.register(handler)
374
+ # --- App-specific endpoints via the forwarded DSL ---
274
375
 
275
- server.call(env("PUT", "/_matrix/app/v1/transactions/txn_reg",
276
- headers: {"authorization" => "Bearer secret"},
277
- body: '{"events":[{"type":"m.room.message","content":{"body":"hi"}}]}'))
376
+ it "serves app-specific routes declared with the forwarded post DSL" do
377
+ app = build do
378
+ post "/_webhook/send" do
379
+ {ok: true}
380
+ end
381
+ end
382
+ resp = mock_req(app, "POST", "/_webhook/send")
383
+ resp.status.should == 201
384
+ JSON.parse(resp.body)["ok"].should == true
385
+ end
278
386
 
279
- received.length.should == 1
387
+ it "does not require Matrix auth for app-specific routes" do
388
+ app = build { post("/_webhook/send") { {ok: true} } }
389
+ mock_req(app, "POST", "/_webhook/send").status.should == 201
280
390
  end
281
391
 
282
- it "registers a bot (object with #handlers) via register" do
283
- server = Async::Matrix::ApplicationService::Server.new(hs_token: "secret")
392
+ it "does not duplicate the Matrix routes when app routes are added" do
393
+ app = build { post("/_webhook/send") { {} } }
394
+ ping_routes = app.routes.select { |r| r.path.include?("/ping") }
395
+ ping_routes.length.should == 1
396
+ end
284
397
 
285
- received = []
286
- handler = Object.new
287
- handler.define_singleton_method(:event_types) { ["m.room.message"] }
288
- handler.define_singleton_method(:call) { |e| received << e }
398
+ # --- dispatch DSL ---
289
399
 
290
- bot = Object.new
291
- bot.define_singleton_method(:handlers) { [handler] }
400
+ it "dispatch { on ... } builds a Bot from the block using the client" do
401
+ config = Async::Matrix::ApplicationService::Config.new({
402
+ "homeserver" => {"address" => "http://localhost:8008", "domain" => "localhost"},
403
+ "appservice" => {"as_token" => "a", "hs_token" => "secret", "bot" => {"username" => "bot"}}
404
+ })
405
+ bot_client = Async::Matrix::Client.new(config)
292
406
 
293
- server.register(bot)
407
+ app = build(client: bot_client) do
408
+ dispatch do
409
+ on "m.room.message" do |event|
410
+ # not executed in this test
411
+ end
412
+ end
413
+ end
414
+ app.dispatcher.handler_count.should == 1
415
+ end
294
416
 
295
- server.call(env("PUT", "/_matrix/app/v1/transactions/txn_bot",
296
- headers: {"authorization" => "Bearer secret"},
297
- body: '{"events":[{"type":"m.room.message","content":{"body":"hi"}}]}'))
417
+ it "dispatch { } without a client raises ArgumentError" do
418
+ lambda { build { dispatch { on("m.room.message") { |e| } } } }.should.raise(ArgumentError)
419
+ end
298
420
 
299
- received.length.should == 1
421
+ it "isolates dispatcher and routes between instances" do
422
+ a = build(hs_token: "token_a") { post("/only_a") { {} } }
423
+ b = build(hs_token: "token_b")
424
+ mock_req(a, "PUT", "/_matrix/app/v1/transactions/x", token: "token_b", body: "{}").status.should == 403
425
+ mock_req(a, "PUT", "/_matrix/app/v1/transactions/x", token: "token_a", body: "{}").status.should == 200
426
+ # /only_a exists on `a` but not on `b`
427
+ mock_req(a, "POST", "/only_a").status.should == 201
428
+ mock_req(b, "POST", "/only_a").status.should == 404
300
429
  end
301
430
  end
302
- end