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.
- checksums.yaml +7 -0
- data/LICENSE +200 -0
- data/lib/async/matrix/application_service/config.rb +137 -0
- data/lib/async/matrix/application_service/dispatcher.rb +127 -0
- data/lib/async/matrix/application_service/error_response.rb +40 -0
- data/lib/async/matrix/application_service/event.rb +83 -0
- data/lib/async/matrix/application_service/server.rb +232 -0
- data/lib/async/matrix/application_service/transaction.rb +70 -0
- data/lib/async/matrix/application_service/transaction_store.rb +83 -0
- data/lib/async/matrix/client.rb +200 -0
- data/lib/async/matrix/connection.rb +21 -0
- data/lib/async/matrix/endpoint.rb +59 -0
- data/lib/async/matrix/error.rb +64 -0
- data/lib/async/matrix/notifier.rb +106 -0
- data/lib/async/matrix/server.rb +20 -0
- data/lib/async/matrix/stream.rb +20 -0
- data/lib/async/matrix/version.rb +10 -0
- data/lib/async/matrix.rb +16 -0
- data/lib/async.rb +9 -0
- metadata +131 -0
|
@@ -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
|