async-matrix 0.1.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,232 @@
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 "bundler/setup"
7
+ require "json"
8
+ require "rack"
9
+ require "console"
10
+
11
+ module Async
12
+ module Matrix
13
+ module ApplicationService
14
+ # Rack 3 application implementing the Matrix Application Service API.
15
+ #
16
+ # Routes:
17
+ # PUT /_matrix/app/v1/transactions/{txnId} — receive events from homeserver
18
+ # GET /_matrix/app/v1/users/{userId} — user existence query
19
+ # GET /_matrix/app/v1/rooms/{roomAlias} — room alias query
20
+ # POST /_matrix/app/v1/ping — healthcheck
21
+ class Server
22
+ CONTENT_JSON = {"content-type" => "application/json"}.freeze
23
+ EMPTY_BODY = ["{}"].freeze
24
+
25
+ RESP_NOT_FOUND = [404, CONTENT_JSON, ['{"errcode":"M_UNRECOGNIZED"}']].freeze
26
+ RESP_FORBIDDEN = [403, CONTENT_JSON, ['{"errcode":"M_FORBIDDEN"}']].freeze
27
+ RESP_BAD_JSON = [400, CONTENT_JSON, ['{"errcode":"M_BAD_JSON"}']].freeze
28
+ RESP_BAD_METHOD = [405, CONTENT_JSON, ['{"errcode":"M_UNRECOGNIZED"}']].freeze
29
+
30
+ def initialize(hs_token:, dispatcher:)
31
+ @hs_token = hs_token
32
+ @dispatcher = dispatcher
33
+ @txn_store = TransactionStore.new
34
+ end
35
+
36
+ def call(env)
37
+ request = Rack::Request.new(env)
38
+ method = request.request_method
39
+ path = request.path_info
40
+
41
+ case path
42
+ when %r{\A/_matrix/app/v1/transactions/(.+)\z}
43
+ return RESP_BAD_METHOD unless method == "PUT"
44
+ return RESP_FORBIDDEN unless authorized?(request)
45
+ handle_transaction(request, Regexp.last_match(1))
46
+
47
+ when %r{\A/_matrix/app/v1/users/(.+)\z}
48
+ return RESP_BAD_METHOD unless method == "GET"
49
+ return RESP_FORBIDDEN unless authorized?(request)
50
+ [200, CONTENT_JSON, EMPTY_BODY]
51
+
52
+ when %r{\A/_matrix/app/v1/rooms/(.+)\z}
53
+ return RESP_BAD_METHOD unless method == "GET"
54
+ return RESP_FORBIDDEN unless authorized?(request)
55
+ RESP_NOT_FOUND
56
+
57
+ when "/_matrix/app/v1/ping"
58
+ return RESP_BAD_METHOD unless method == "POST"
59
+ [200, CONTENT_JSON, EMPTY_BODY]
60
+
61
+ else
62
+ RESP_NOT_FOUND
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def authorized?(request)
69
+ token = extract_token(request)
70
+ return false unless token
71
+ secure_compare(token, @hs_token)
72
+ end
73
+
74
+ def extract_token(request)
75
+ auth = request.get_header("HTTP_AUTHORIZATION")
76
+ if auth && auth.start_with?("Bearer ")
77
+ return auth.delete_prefix("Bearer ")
78
+ end
79
+ request.params["access_token"]
80
+ end
81
+
82
+ def secure_compare(a, b)
83
+ return false unless a.bytesize == b.bytesize
84
+ l = a.unpack("C*")
85
+ r = b.unpack("C*")
86
+ result = 0
87
+ l.each_with_index { |byte, i| result |= byte ^ r[i] }
88
+ result.zero?
89
+ end
90
+
91
+ def handle_transaction(request, txn_id)
92
+ if @txn_store.seen?(txn_id)
93
+ Console.debug(self) { "Duplicate transaction #{txn_id} — skipping" }
94
+ return [200, CONTENT_JSON, EMPTY_BODY]
95
+ end
96
+
97
+ body = parse_json(request)
98
+ return RESP_BAD_JSON unless body
99
+
100
+ Console.info(self) {
101
+ event_count = (body["events"] || []).size
102
+ "Transaction #{txn_id}: #{event_count} event(s)"
103
+ }
104
+
105
+ @dispatcher.dispatch_transaction(body)
106
+ @txn_store.mark_seen(txn_id)
107
+
108
+ [200, CONTENT_JSON, EMPTY_BODY]
109
+ end
110
+
111
+ def parse_json(request)
112
+ raw = request.body&.read
113
+ return nil if raw.nil? || raw.empty?
114
+ JSON.parse(raw)
115
+ rescue JSON::ParserError => e
116
+ Console.error(self) { "Bad JSON in transaction: #{e.message}" }
117
+ nil
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ test do
125
+ require "stringio"
126
+ require_relative "transaction_store"
127
+ require_relative "dispatcher"
128
+ require_relative "event"
129
+
130
+ describe "Async::Matrix::ApplicationService::Server" do
131
+ def build_server(hs_token: "secret")
132
+ dispatcher = Async::Matrix::ApplicationService::Dispatcher.new
133
+ Async::Matrix::ApplicationService::Server.new(hs_token: hs_token, dispatcher: dispatcher)
134
+ end
135
+
136
+ def env(method, path, headers: {}, body: nil, params: {})
137
+ rack_env = {
138
+ "REQUEST_METHOD" => method,
139
+ "PATH_INFO" => path,
140
+ "QUERY_STRING" => params.map { |k, v| "#{k}=#{v}" }.join("&"),
141
+ "rack.input" => body ? StringIO.new(body) : StringIO.new("")
142
+ }
143
+ headers.each { |k, v| rack_env["HTTP_#{k.upcase.tr("-", "_")}"] = v }
144
+ rack_env
145
+ end
146
+
147
+ # --- Ping ---
148
+
149
+ it "responds to POST /ping with 200" do
150
+ server = build_server
151
+ status, _, _ = server.call(env("POST", "/_matrix/app/v1/ping"))
152
+ status.should == 200
153
+ end
154
+
155
+ it "rejects GET /ping with 405" do
156
+ server = build_server
157
+ status, _, _ = server.call(env("GET", "/_matrix/app/v1/ping"))
158
+ status.should == 405
159
+ end
160
+
161
+ # --- Auth ---
162
+
163
+ it "rejects transactions without a token" do
164
+ server = build_server
165
+ status, _, _ = server.call(env("PUT", "/_matrix/app/v1/transactions/txn1"))
166
+ status.should == 403
167
+ end
168
+
169
+ it "rejects transactions with wrong token" do
170
+ server = build_server(hs_token: "correct")
171
+ status, _, _ = server.call(env("PUT", "/_matrix/app/v1/transactions/txn1",
172
+ headers: {"authorization" => "Bearer wrong"}))
173
+ status.should == 403
174
+ end
175
+
176
+ # --- Transactions ---
177
+
178
+ it "accepts valid transactions with Bearer auth" do
179
+ server = build_server(hs_token: "secret")
180
+ status, _, _ = server.call(env("PUT", "/_matrix/app/v1/transactions/txn1",
181
+ headers: {"authorization" => "Bearer secret"},
182
+ body: '{"events":[]}'))
183
+ status.should == 200
184
+ end
185
+
186
+ it "deduplicates transactions" do
187
+ server = build_server(hs_token: "secret")
188
+ rack = env("PUT", "/_matrix/app/v1/transactions/txn1",
189
+ headers: {"authorization" => "Bearer secret"},
190
+ body: '{"events":[]}')
191
+ server.call(rack)
192
+ # Second call with same txn_id
193
+ rack2 = env("PUT", "/_matrix/app/v1/transactions/txn1",
194
+ headers: {"authorization" => "Bearer secret"},
195
+ body: '{"events":[]}')
196
+ status, _, _ = server.call(rack2)
197
+ status.should == 200
198
+ end
199
+
200
+ it "rejects bad JSON" do
201
+ server = build_server(hs_token: "secret")
202
+ status, _, _ = server.call(env("PUT", "/_matrix/app/v1/transactions/txn1",
203
+ headers: {"authorization" => "Bearer secret"},
204
+ body: "not json"))
205
+ status.should == 400
206
+ end
207
+
208
+ # --- Users / Rooms ---
209
+
210
+ it "responds 200 for user queries" do
211
+ server = build_server(hs_token: "secret")
212
+ status, _, _ = server.call(env("GET", "/_matrix/app/v1/users/@bot:localhost",
213
+ headers: {"authorization" => "Bearer secret"}))
214
+ status.should == 200
215
+ end
216
+
217
+ it "responds 404 for room queries" do
218
+ server = build_server(hs_token: "secret")
219
+ status, _, _ = server.call(env("GET", "/_matrix/app/v1/rooms/#room:localhost",
220
+ headers: {"authorization" => "Bearer secret"}))
221
+ status.should == 404
222
+ end
223
+
224
+ # --- Unknown routes ---
225
+
226
+ it "responds 404 for unknown paths" do
227
+ server = build_server
228
+ status, _, _ = server.call(env("GET", "/unknown"))
229
+ status.should == 404
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,70 @@
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 "bundler/setup"
7
+ require_relative "event"
8
+
9
+ module Async
10
+ module Matrix
11
+ module ApplicationService
12
+ class Transaction
13
+ attr_reader :events, :ephemeral
14
+
15
+ def initialize(data)
16
+ @events = (data["events"] || []).map { |e| Event.new(e) }
17
+ @ephemeral = (data["de.sorunome.msc2409.ephemeral"] || data["ephemeral"] || []).map { |e| Event.new(e) }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ test do
25
+ describe "Async::Matrix::ApplicationService::Transaction" do
26
+ it "wraps events array" do
27
+ txn = Async::Matrix::ApplicationService::Transaction.new({
28
+ "events" => [
29
+ {"type" => "m.room.message", "content" => {"body" => "one"}},
30
+ {"type" => "m.room.message", "content" => {"body" => "two"}}
31
+ ]
32
+ })
33
+ txn.events.length.should == 2
34
+ txn.events.first.content.body.should == "one"
35
+ txn.events.last.content.body.should == "two"
36
+ end
37
+
38
+ it "defaults to empty events when missing" do
39
+ txn = Async::Matrix::ApplicationService::Transaction.new({})
40
+ txn.events.should.be.empty
41
+ end
42
+
43
+ it "parses MSC2409 ephemeral events" do
44
+ txn = Async::Matrix::ApplicationService::Transaction.new({
45
+ "events" => [],
46
+ "de.sorunome.msc2409.ephemeral" => [
47
+ {"type" => "m.typing", "content" => {}}
48
+ ]
49
+ })
50
+ txn.ephemeral.length.should == 1
51
+ txn.ephemeral.first.type.should == "m.typing"
52
+ end
53
+
54
+ it "falls back to ephemeral key" do
55
+ txn = Async::Matrix::ApplicationService::Transaction.new({
56
+ "events" => [],
57
+ "ephemeral" => [
58
+ {"type" => "m.receipt", "content" => {}}
59
+ ]
60
+ })
61
+ txn.ephemeral.length.should == 1
62
+ txn.ephemeral.first.type.should == "m.receipt"
63
+ end
64
+
65
+ it "defaults to empty ephemeral when missing" do
66
+ txn = Async::Matrix::ApplicationService::Transaction.new({"events" => []})
67
+ txn.ephemeral.should.be.empty
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,83 @@
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 "bundler/setup"
7
+
8
+ module Async
9
+ module Matrix
10
+ module ApplicationService
11
+ # In-memory idempotency store for appservice transaction IDs.
12
+ class TransactionStore
13
+ DEFAULT_CAPACITY = 1024
14
+
15
+ def initialize(capacity: DEFAULT_CAPACITY)
16
+ @seen = {}
17
+ @capacity = capacity
18
+ end
19
+
20
+ def seen?(txn_id)
21
+ @seen.key?(txn_id)
22
+ end
23
+
24
+ def mark_seen(txn_id)
25
+ prune! if @seen.size >= @capacity
26
+ @seen[txn_id] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ end
28
+
29
+ def size
30
+ @seen.size
31
+ end
32
+
33
+ private
34
+
35
+ def prune!
36
+ sorted = @seen.sort_by { |_, ts| ts }
37
+ drop = sorted.size / 2
38
+ sorted.first(drop).each { |id, _| @seen.delete(id) }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ test do
46
+ describe "Async::Matrix::ApplicationService::TransactionStore" do
47
+ it "tracks seen transaction IDs" do
48
+ store = Async::Matrix::ApplicationService::TransactionStore.new
49
+ store.seen?("txn1").should == false
50
+ store.mark_seen("txn1")
51
+ store.seen?("txn1").should == true
52
+ end
53
+
54
+ it "reports size" do
55
+ store = Async::Matrix::ApplicationService::TransactionStore.new
56
+ store.size.should == 0
57
+ store.mark_seen("a")
58
+ store.mark_seen("b")
59
+ store.size.should == 2
60
+ end
61
+
62
+ it "prunes oldest entries when capacity is reached" do
63
+ store = Async::Matrix::ApplicationService::TransactionStore.new(capacity: 4)
64
+ store.mark_seen("a")
65
+ store.mark_seen("b")
66
+ store.mark_seen("c")
67
+ store.mark_seen("d")
68
+ # This triggers prune — drops oldest half (a, b)
69
+ store.mark_seen("e")
70
+ store.seen?("a").should == false
71
+ store.seen?("b").should == false
72
+ store.seen?("d").should == true
73
+ store.seen?("e").should == true
74
+ end
75
+
76
+ it "does not prune below capacity" do
77
+ store = Async::Matrix::ApplicationService::TransactionStore.new(capacity: 10)
78
+ 5.times { |i| store.mark_seen("txn#{i}") }
79
+ store.size.should == 5
80
+ store.seen?("txn0").should == true
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,200 @@
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 "bundler/setup"
7
+ require "async/http/internet"
8
+ require "json"
9
+ require "erb"
10
+ require "console"
11
+ require "securerandom"
12
+ require_relative "error"
13
+ require_relative "application_service/error_response"
14
+
15
+ module Async
16
+ module Matrix
17
+ # Async HTTP client for the Matrix Client-Server API.
18
+ #
19
+ # Every outbound request is authenticated with the appservice `as_token`.
20
+ # All methods are fiber-safe and run naturally inside Falcon's async reactor.
21
+ #
22
+ # client = Async::Matrix::Client.new(config)
23
+ # client.send_text("!room:example.com", "Hello world")
24
+ # client.join_room("!room:example.com")
25
+ #
26
+ class Client
27
+ CLIENT_PREFIX = "/_matrix/client/v3"
28
+
29
+ attr_reader :config
30
+
31
+ def initialize(config)
32
+ @config = config
33
+ @base = config.homeserver_url
34
+ @headers = [
35
+ ["authorization", "Bearer #{config.as_token}"],
36
+ ["content-type", "application/json"],
37
+ ["user-agent", "AsyncMatrix/#{Async::Matrix::VERSION}"]
38
+ ]
39
+ end
40
+
41
+ # ── Messaging ──────────────────────────────────────────────
42
+
43
+ def send_text(room_id, text)
44
+ content = {msgtype: "m.text", body: text}
45
+ send_message_event(room_id, "m.room.message", content)
46
+ end
47
+
48
+ def send_html(room_id, html, plaintext = nil)
49
+ content = {
50
+ msgtype: "m.text",
51
+ body: plaintext || html.gsub(/<[^>]+>/, ""),
52
+ format: "org.matrix.custom.html",
53
+ formatted_body: html
54
+ }
55
+ send_message_event(room_id, "m.room.message", content)
56
+ end
57
+
58
+ def send_notice(room_id, text)
59
+ content = {msgtype: "m.notice", body: text}
60
+ send_message_event(room_id, "m.room.message", content)
61
+ end
62
+
63
+ # ── Room actions ───────────────────────────────────────────
64
+
65
+ def join_room(room_id)
66
+ post("#{CLIENT_PREFIX}/join/#{encode(room_id)}")
67
+ end
68
+
69
+ def leave_room(room_id)
70
+ post("#{CLIENT_PREFIX}/rooms/#{encode(room_id)}/leave")
71
+ end
72
+
73
+ # ── Profile ────────────────────────────────────────────────
74
+
75
+ def set_display_name(name, user_id = nil)
76
+ uid = user_id || @config.bot_mxid
77
+ put(
78
+ "#{CLIENT_PREFIX}/profile/#{encode(uid)}/displayname",
79
+ {displayname: name}
80
+ )
81
+ end
82
+
83
+ # ── Verification ───────────────────────────────────────────
84
+
85
+ def whoami
86
+ get("#{CLIENT_PREFIX}/account/whoami")
87
+ end
88
+
89
+ # ── Low-level HTTP ─────────────────────────────────────────
90
+
91
+ def send_message_event(room_id, event_type, content)
92
+ txn_id = SecureRandom.uuid
93
+ path = "#{CLIENT_PREFIX}/rooms/#{encode(room_id)}/send/#{encode(event_type)}/#{txn_id}"
94
+ put(path, content)
95
+ end
96
+
97
+ def get(path)
98
+ request("GET", path)
99
+ end
100
+
101
+ def put(path, body = {})
102
+ request("PUT", path, body)
103
+ end
104
+
105
+ def post(path, body = {})
106
+ request("POST", path, body)
107
+ end
108
+
109
+ def close
110
+ @internet&.close
111
+ @internet = nil
112
+ end
113
+
114
+ private
115
+
116
+ def internet
117
+ @internet ||= Async::HTTP::Internet.new
118
+ end
119
+
120
+ def request(method, path, body = nil)
121
+ url = "#{@base}#{path}"
122
+ json_body = body ? JSON.generate(body) : nil
123
+
124
+ Console.debug(self) { "#{method} #{path}" }
125
+
126
+ response = internet.call(method, url, @headers, json_body)
127
+ status = response.status
128
+ payload = response.read
129
+
130
+ unless (200..299).cover?(status)
131
+ parsed = ApplicationService::ErrorResponse.new(
132
+ begin; JSON.parse(payload); rescue; {} end
133
+ )
134
+ Console.error(self) { "Matrix API #{status}: #{parsed.errcode} — #{parsed.error}" }
135
+ raise HomeserverError.new(
136
+ parsed.errcode || "UNKNOWN",
137
+ parsed.error || payload.to_s[0..200],
138
+ status: status
139
+ )
140
+ end
141
+
142
+ payload && !payload.empty? ? JSON.parse(payload) : {}
143
+ end
144
+
145
+ def encode(value)
146
+ ERB::Util.url_encode(value)
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ test do
153
+ require_relative "version"
154
+
155
+ describe "Async::Matrix::Client" do
156
+ it "sets authorization header from config" do
157
+ config = Struct.new(:homeserver_url, :as_token, :bot_mxid).new(
158
+ "http://localhost:8008", "test_token", "@bot:localhost"
159
+ )
160
+ client = Async::Matrix::Client.new(config)
161
+ client.config.as_token.should == "test_token"
162
+ end
163
+
164
+ it "responds to messaging methods" do
165
+ config = Struct.new(:homeserver_url, :as_token, :bot_mxid).new(
166
+ "http://localhost:8008", "token", "@bot:localhost"
167
+ )
168
+ client = Async::Matrix::Client.new(config)
169
+ client.should.respond_to :send_text
170
+ client.should.respond_to :send_html
171
+ client.should.respond_to :send_notice
172
+ end
173
+
174
+ it "responds to room action methods" do
175
+ config = Struct.new(:homeserver_url, :as_token, :bot_mxid).new(
176
+ "http://localhost:8008", "token", "@bot:localhost"
177
+ )
178
+ client = Async::Matrix::Client.new(config)
179
+ client.should.respond_to :join_room
180
+ client.should.respond_to :leave_room
181
+ end
182
+
183
+ it "responds to profile and verification methods" do
184
+ config = Struct.new(:homeserver_url, :as_token, :bot_mxid).new(
185
+ "http://localhost:8008", "token", "@bot:localhost"
186
+ )
187
+ client = Async::Matrix::Client.new(config)
188
+ client.should.respond_to :set_display_name
189
+ client.should.respond_to :whoami
190
+ end
191
+
192
+ it "can be closed without error" do
193
+ config = Struct.new(:homeserver_url, :as_token, :bot_mxid).new(
194
+ "http://localhost:8008", "token", "@bot:localhost"
195
+ )
196
+ client = Async::Matrix::Client.new(config)
197
+ lambda { client.close }.should.not.raise
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,21 @@
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 "bundler/setup"
7
+ require "async/http"
8
+
9
+ module Async
10
+ module Matrix
11
+ module Connection
12
+ include Async::HTTP::Protocol::HTTP2::Connection
13
+ end
14
+ end
15
+ end
16
+
17
+ test do
18
+ it "includes Async::HTTP::Protocol::HTTP2::Connection" do
19
+ Async::Matrix::Connection.ancestors.should.include Async::HTTP::Protocol::HTTP2::Connection
20
+ end
21
+ end
@@ -0,0 +1,59 @@
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 "bundler/setup"
7
+ require "json"
8
+ require "async/http"
9
+
10
+ module Async
11
+ module Matrix
12
+ # Extends Async::HTTP::Endpoint with Matrix well-known discovery.
13
+ # spec.matrix.org/v1.9/client-server-api/#well-known-uri
14
+ #
15
+ # A server at example.com may serve clients from a completely
16
+ # different origin — e.g. https://matrix.example.com:8448.
17
+ # #discover resolves that indirection as a proper async I/O
18
+ # operation before constructing the Endpoint, so every downstream
19
+ # caller gets a correctly-pointed connection pool for free.
20
+ class Endpoint < Async::HTTP::Endpoint
21
+ WELL_KNOWN = "/.well-known/matrix/client"
22
+
23
+ # Resolves homeserver base URL, falls back to https://domain.
24
+ # Must be called inside an Async block — uses the running scheduler.
25
+ def self.discover(domain, **options)
26
+ internet = Async::HTTP::Internet.new
27
+ data = JSON.parse(internet.get("https://#{domain}#{WELL_KNOWN}").read)
28
+ base_url = data.dig("m.homeserver", "base_url") || "https://#{domain}"
29
+ parse(base_url, **options)
30
+ rescue StandardError
31
+ # Well-known is optional per spec — fall back gracefully
32
+ parse("https://#{domain}", **options)
33
+ ensure
34
+ internet&.close
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ test do
41
+ it "defines the WELL_KNOWN constant" do
42
+ Async::Matrix::Endpoint::WELL_KNOWN.should == "/.well-known/matrix/client"
43
+ end
44
+
45
+ it "responds to .discover" do
46
+ Async::Matrix::Endpoint.should.respond_to :discover
47
+ end
48
+
49
+ it "inherits from Async::HTTP::Endpoint" do
50
+ Async::Matrix::Endpoint.ancestors.should.include Async::HTTP::Endpoint
51
+ end
52
+
53
+ it "falls back to https://domain when well-known fails" do
54
+ Async do
55
+ endpoint = Async::Matrix::Endpoint.discover("nonexistent.invalid")
56
+ endpoint.to_s.should =~ /nonexistent\.invalid/
57
+ end.wait
58
+ end
59
+ end