async-matrix 2.1.0 → 3.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 +4 -4
- data/Cargo.lock +8 -8
- data/README.md +33 -58
- data/ext/async_matrix_e2ee/Cargo.toml +7 -1
- data/lib/async/matrix/api/chain.rb +120 -94
- data/lib/async/matrix/api/concat.rb +28 -22
- data/lib/async/matrix/api/path_tree.rb +71 -44
- data/lib/async/matrix/api.rb +6 -1
- data/lib/async/matrix/auth_error.rb +20 -0
- data/lib/async/matrix/bad_json_error.rb +20 -0
- data/lib/async/matrix/client.rb +142 -110
- data/lib/async/matrix/config/vivify.rb +107 -0
- data/lib/async/matrix/config.rb +184 -0
- data/lib/async/matrix/content.rb +106 -0
- data/lib/async/matrix/double_puppet_client.rb +1 -1
- data/lib/async/matrix/error.rb +0 -27
- data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
- data/lib/async/matrix/event.rb +197 -0
- data/lib/async/matrix/homeserver_error.rb +20 -0
- data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
- data/lib/async/matrix/media_client.rb +46 -35
- data/lib/async/matrix/not_found_error.rb +20 -0
- data/lib/async/matrix/notifier.rb +7 -3
- data/lib/async/matrix/response_too_large_error.rb +20 -0
- data/lib/async/matrix/schema/registry.rb +367 -340
- data/lib/async/matrix/schema/validation_error.rb +221 -214
- data/lib/async/matrix/schema.rb +161 -161
- data/lib/async/matrix/version.rb +3 -3
- data/lib/async/matrix.rb +0 -7
- metadata +24 -132
- data/lib/async/discord/api/path_tree.rb +0 -127
- data/lib/async/discord/api.rb +0 -151
- data/lib/async/discord/client.rb +0 -283
- data/lib/async/discord/error.rb +0 -84
- data/lib/async/discord/gateway.rb +0 -359
- data/lib/async/discord.rb +0 -15
- data/lib/async/matrix/application_service/bot.rb +0 -232
- data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
- data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
- data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
- data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
- data/lib/async/matrix/application_service/config/schema/config.json +0 -61
- data/lib/async/matrix/application_service/config/schema/database.json +0 -38
- data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
- data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
- data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
- data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
- data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
- data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
- data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
- data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
- data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
- data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
- data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
- data/lib/async/matrix/application_service/config/vivify.rb +0 -109
- data/lib/async/matrix/application_service/config.rb +0 -225
- data/lib/async/matrix/application_service/dispatcher.rb +0 -185
- data/lib/async/matrix/application_service/event.rb +0 -285
- data/lib/async/matrix/application_service/server.rb +0 -430
- data/lib/async/matrix/application_service/transaction.rb +0 -66
- data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
- data/lib/async/matrix/application_service/transaction_store.rb +0 -80
- data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
- data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
- data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
- data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
- data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
- data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
- data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
- data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
- data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
- data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
- data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
- data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
- data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
- data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
- data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
- data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
- data/lib/async/matrix/bridge/discord/db.rb +0 -138
|
@@ -0,0 +1,184 @@
|
|
|
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 "forwardable"
|
|
7
|
+
require "yaml"
|
|
8
|
+
require "pathname"
|
|
9
|
+
require_relative "config/vivify"
|
|
10
|
+
|
|
11
|
+
module Async
|
|
12
|
+
module Matrix
|
|
13
|
+
# Dot-notation access to a Matrix service's YAML configuration.
|
|
14
|
+
#
|
|
15
|
+
# {Client} only reads two fields — `homeserver.address` and
|
|
16
|
+
# `appservice.as_token` — so this class validates nothing: it vivifies
|
|
17
|
+
# whatever hash (or YAML file) it is handed and exposes every nested field
|
|
18
|
+
# as a method call.
|
|
19
|
+
#
|
|
20
|
+
# config = Config.load("config/appservice.yml")
|
|
21
|
+
# config.homeserver.address # => "http://synapse:8008"
|
|
22
|
+
# config.appservice.as_token # => "secret..."
|
|
23
|
+
# config.appservice.bot.username # => "bot"
|
|
24
|
+
# config.bot_mxid # => "@bot:localhost"
|
|
25
|
+
#
|
|
26
|
+
# A subclass layers validation on by overriding {.validate!}; that is how
|
|
27
|
+
# async-matrix-bridge's Config applies the mautrix bridgev2 JSON Schema
|
|
28
|
+
# suite without reimplementing any of the loading below.
|
|
29
|
+
class Config
|
|
30
|
+
extend Forwardable
|
|
31
|
+
|
|
32
|
+
# Delegate every top-level config section to the vivified data hash. An
|
|
33
|
+
# explicit list, not a method_missing forward: a typo'd section should
|
|
34
|
+
# raise NoMethodError rather than autovivify into an empty hash.
|
|
35
|
+
def_delegators :@data,
|
|
36
|
+
:network,
|
|
37
|
+
:bridge,
|
|
38
|
+
:database,
|
|
39
|
+
:homeserver,
|
|
40
|
+
:appservice,
|
|
41
|
+
:matrix,
|
|
42
|
+
:analytics,
|
|
43
|
+
:provisioning,
|
|
44
|
+
:public_media,
|
|
45
|
+
:direct_media,
|
|
46
|
+
:backfill,
|
|
47
|
+
:double_puppet,
|
|
48
|
+
:encryption,
|
|
49
|
+
:logging,
|
|
50
|
+
:management_room_texts,
|
|
51
|
+
:env_config_prefix
|
|
52
|
+
|
|
53
|
+
def initialize(data)
|
|
54
|
+
self.class.validate!(data)
|
|
55
|
+
@data = Vivify.deep_vivify(data)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Load a YAML config file from disk.
|
|
59
|
+
def self.load(path)
|
|
60
|
+
unless File.exist?(path)
|
|
61
|
+
raise Async::Matrix::NotFoundError.new(
|
|
62
|
+
"M_NOT_FOUND",
|
|
63
|
+
"Config not found: #{path}",
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
data = YAML.safe_load_file(path, permitted_classes: [Symbol])
|
|
68
|
+
new(data)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Validation hook, called with the raw (string-keyed) hash before it is
|
|
72
|
+
# vivified. A no-op here; subclasses override it to raise on bad input,
|
|
73
|
+
# and may mutate `data` to insert defaults.
|
|
74
|
+
def self.validate!(data)
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Convenience: derive the bot's full Matrix ID from
|
|
79
|
+
# appservice.bot.username + homeserver.domain.
|
|
80
|
+
def bot_mxid
|
|
81
|
+
"@#{appservice.bot.username}:#{homeserver.domain}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
__END__
|
|
88
|
+
require "tempfile"
|
|
89
|
+
|
|
90
|
+
describe "Async::Matrix::Config" do
|
|
91
|
+
def minimal_data
|
|
92
|
+
{
|
|
93
|
+
"homeserver" => {
|
|
94
|
+
"address" => "http://localhost:8008",
|
|
95
|
+
"domain" => "localhost",
|
|
96
|
+
},
|
|
97
|
+
"appservice" => {
|
|
98
|
+
"as_token" => "as_secret_token_value",
|
|
99
|
+
"hs_token" => "hs_secret_token_value",
|
|
100
|
+
"bot" => {"username" => "bot"},
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "exposes top-level sections" do
|
|
106
|
+
config = Async::Matrix::Config.new(minimal_data)
|
|
107
|
+
config.homeserver.address.should == "http://localhost:8008"
|
|
108
|
+
config.homeserver.domain.should == "localhost"
|
|
109
|
+
config.appservice.as_token.should == "as_secret_token_value"
|
|
110
|
+
config.appservice.hs_token.should == "hs_secret_token_value"
|
|
111
|
+
config.appservice.bot.username.should == "bot"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "derives bot_mxid from appservice.bot.username and homeserver.domain" do
|
|
115
|
+
Async::Matrix::Config.new(minimal_data).bot_mxid.should == "@bot:localhost"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "provides dot-notation access to deeply nested fields" do
|
|
119
|
+
data = minimal_data.merge(
|
|
120
|
+
"encryption" => {
|
|
121
|
+
"allow" => true,
|
|
122
|
+
"rotation" => {"messages" => 200},
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
config = Async::Matrix::Config.new(data)
|
|
126
|
+
config.encryption.allow.should == true
|
|
127
|
+
config.encryption.rotation.messages.should == 200
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "autovivifies missing optional sections as empty hashes" do
|
|
131
|
+
config = Async::Matrix::Config.new(minimal_data)
|
|
132
|
+
config.analytics.should.be.kind_of Hash
|
|
133
|
+
config.analytics.should.be.empty?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "raises NoMethodError for sections it does not delegate" do
|
|
137
|
+
config = Async::Matrix::Config.new(minimal_data)
|
|
138
|
+
lambda { config.bogus }.should.raise(NoMethodError)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "validates nothing by default" do
|
|
142
|
+
lambda { Async::Matrix::Config.new({}) }.should.not.raise
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "loads from a YAML file" do
|
|
146
|
+
file = Tempfile.new(["config", ".yml"])
|
|
147
|
+
file.write(<<~YAML)
|
|
148
|
+
homeserver:
|
|
149
|
+
address: "http://localhost:8008"
|
|
150
|
+
domain: "localhost"
|
|
151
|
+
appservice:
|
|
152
|
+
as_token: "as123_secret"
|
|
153
|
+
hs_token: "hs456_secret"
|
|
154
|
+
bot:
|
|
155
|
+
username: "testbot"
|
|
156
|
+
YAML
|
|
157
|
+
file.close
|
|
158
|
+
|
|
159
|
+
config = Async::Matrix::Config.load(file.path)
|
|
160
|
+
config.homeserver.address.should == "http://localhost:8008"
|
|
161
|
+
config.bot_mxid.should == "@testbot:localhost"
|
|
162
|
+
ensure
|
|
163
|
+
file.unlink
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "raises NotFoundError for a missing file" do
|
|
167
|
+
lambda {
|
|
168
|
+
Async::Matrix::Config.load("/nonexistent/path.yml")
|
|
169
|
+
}.should.raise(Async::Matrix::NotFoundError)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "runs the validate! hook a subclass overrides" do
|
|
173
|
+
strict = Class.new(Async::Matrix::Config) do
|
|
174
|
+
def self.validate!(data)
|
|
175
|
+
unless data.key?("homeserver")
|
|
176
|
+
raise Async::Matrix::BadJsonError.new("M_BAD_JSON", "no homeserver")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
lambda { strict.new({}) }.should.raise(Async::Matrix::BadJsonError)
|
|
182
|
+
strict.new(minimal_data).homeserver.domain.should == "localhost"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
module Async
|
|
7
|
+
module Matrix
|
|
8
|
+
# Wraps the content object of a Matrix event, providing typed access to
|
|
9
|
+
# schema-defined properties via method_missing.
|
|
10
|
+
#
|
|
11
|
+
# Common fields like `msgtype`, `body`, and `membership` have dedicated
|
|
12
|
+
# accessors for convenience. Any other field defined in the event's schema
|
|
13
|
+
# (or present in the raw hash) is accessible as a method call:
|
|
14
|
+
#
|
|
15
|
+
# content.msgtype # => "m.text"
|
|
16
|
+
# content.body # => "hello"
|
|
17
|
+
# content.membership # => "join"
|
|
18
|
+
# content.avatar_url # => "mxc://example.org/abc"
|
|
19
|
+
# content["custom"] # => direct hash access for non-standard fields
|
|
20
|
+
#
|
|
21
|
+
class Content
|
|
22
|
+
attr_reader :msgtype, :body, :membership
|
|
23
|
+
|
|
24
|
+
def initialize(data)
|
|
25
|
+
@data = data
|
|
26
|
+
@msgtype = data["msgtype"]
|
|
27
|
+
@body = data["body"]
|
|
28
|
+
@membership = data["membership"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h = @data
|
|
32
|
+
def to_s = @body.to_s
|
|
33
|
+
def to_str = to_s
|
|
34
|
+
|
|
35
|
+
# Direct hash access for any content field.
|
|
36
|
+
def [](key) = @data[key.to_s]
|
|
37
|
+
|
|
38
|
+
# Dynamic access to any content field present in the raw data.
|
|
39
|
+
def method_missing(name, *args)
|
|
40
|
+
key = name.to_s
|
|
41
|
+
|
|
42
|
+
if @data.key?(key)
|
|
43
|
+
@data[key]
|
|
44
|
+
else
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def respond_to_missing?(name, include_private = false)
|
|
50
|
+
@data.key?(name.to_s) || super
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
__END__
|
|
57
|
+
describe "Async::Matrix::Content" do
|
|
58
|
+
it "parses msgtype, body, and membership" do
|
|
59
|
+
content = Async::Matrix::Content.new({
|
|
60
|
+
"msgtype" => "m.text",
|
|
61
|
+
"body" => "hello",
|
|
62
|
+
"membership" => "join"
|
|
63
|
+
})
|
|
64
|
+
content.msgtype.should == "m.text"
|
|
65
|
+
content.body.should == "hello"
|
|
66
|
+
content.membership.should == "join"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "handles missing fields gracefully" do
|
|
70
|
+
content = Async::Matrix::Content.new({})
|
|
71
|
+
content.msgtype.should.be.nil
|
|
72
|
+
content.body.should.be.nil
|
|
73
|
+
content.membership.should.be.nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "provides hash access via []" do
|
|
77
|
+
content = Async::Matrix::Content.new({"custom_field" => "value"})
|
|
78
|
+
content["custom_field"].should == "value"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "provides dynamic access via method_missing" do
|
|
82
|
+
content = Async::Matrix::Content.new({
|
|
83
|
+
"avatar_url" => "mxc://example.org/abc",
|
|
84
|
+
"displayname" => "Alice"
|
|
85
|
+
})
|
|
86
|
+
content.avatar_url.should == "mxc://example.org/abc"
|
|
87
|
+
content.displayname.should == "Alice"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "returns nil for unknown fields via method_missing" do
|
|
91
|
+
content = Async::Matrix::Content.new({})
|
|
92
|
+
content.nonexistent.should.be.nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "returns the raw hash via to_h" do
|
|
96
|
+
data = {"msgtype" => "m.text", "body" => "hi"}
|
|
97
|
+
content = Async::Matrix::Content.new(data)
|
|
98
|
+
content.to_h.should == data
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "responds to keys present in the data" do
|
|
102
|
+
content = Async::Matrix::Content.new({"avatar_url" => "mxc://x/y"})
|
|
103
|
+
content.respond_to?(:avatar_url).should == true
|
|
104
|
+
content.respond_to?(:nonexistent).should == false
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -29,7 +29,7 @@ end
|
|
|
29
29
|
__END__
|
|
30
30
|
describe "Async::Matrix::DoublePuppetClient" do
|
|
31
31
|
def make_config
|
|
32
|
-
Async::Matrix::
|
|
32
|
+
Async::Matrix::Config.new({
|
|
33
33
|
"homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
|
|
34
34
|
"appservice" => { "as_token" => "as_token_value", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
|
|
35
35
|
})
|
data/lib/async/matrix/error.rb
CHANGED
|
@@ -14,13 +14,6 @@ module Async
|
|
|
14
14
|
super(message)
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
|
-
|
|
18
|
-
class NotFoundError < Error; end
|
|
19
|
-
class BadJsonError < Error; end
|
|
20
|
-
class AuthError < Error; end
|
|
21
|
-
class HomeserverError < Error; end
|
|
22
|
-
class InvalidEndpointError < Error; end
|
|
23
|
-
class ResponseTooLargeError < Error; end
|
|
24
17
|
end
|
|
25
18
|
end
|
|
26
19
|
|
|
@@ -45,23 +38,3 @@ __END__
|
|
|
45
38
|
Async::Matrix::Error.new("M_UNKNOWN", "bad").should.be.kind_of StandardError
|
|
46
39
|
end
|
|
47
40
|
end
|
|
48
|
-
|
|
49
|
-
it "NotFoundError inherits from Error" do
|
|
50
|
-
Async::Matrix::NotFoundError.new("M_NOT_FOUND", "gone").should.be.kind_of Async::Matrix::Error
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
it "BadJsonError inherits from Error" do
|
|
54
|
-
Async::Matrix::BadJsonError.new("M_BAD_JSON", "invalid").should.be.kind_of Async::Matrix::Error
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
it "AuthError inherits from Error" do
|
|
58
|
-
Async::Matrix::AuthError.new("M_FORBIDDEN", "denied").should.be.kind_of Async::Matrix::Error
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
it "HomeserverError inherits from Error" do
|
|
62
|
-
Async::Matrix::HomeserverError.new("M_UNKNOWN", "upstream").should.be.kind_of Async::Matrix::Error
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
it "ResponseTooLargeError inherits from Error" do
|
|
66
|
-
Async::Matrix::ResponseTooLargeError.new("M_TOO_LARGE", "too big").should.be.kind_of Async::Matrix::Error
|
|
67
|
-
end
|
|
@@ -5,23 +5,23 @@
|
|
|
5
5
|
|
|
6
6
|
module Async
|
|
7
7
|
module Matrix
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
# A Matrix standard error response body: `{"errcode": ..., "error": ...}`.
|
|
9
|
+
# Both {Client} and {MediaClient} parse failed responses into one of these.
|
|
10
|
+
class ErrorResponse
|
|
11
|
+
attr_reader :errcode, :error
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
end
|
|
13
|
+
def initialize(data)
|
|
14
|
+
@errcode = data["errcode"]
|
|
15
|
+
@error = data["error"]
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
__END__
|
|
22
|
-
describe "Async::Matrix::
|
|
22
|
+
describe "Async::Matrix::ErrorResponse" do
|
|
23
23
|
it "parses errcode and error" do
|
|
24
|
-
resp = Async::Matrix::
|
|
24
|
+
resp = Async::Matrix::ErrorResponse.new({
|
|
25
25
|
"errcode" => "M_FORBIDDEN",
|
|
26
26
|
"error" => "Access denied"
|
|
27
27
|
})
|
|
@@ -30,7 +30,7 @@ __END__
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
it "handles missing fields" do
|
|
33
|
-
resp = Async::Matrix::
|
|
33
|
+
resp = Async::Matrix::ErrorResponse.new({})
|
|
34
34
|
resp.errcode.should.be.nil
|
|
35
35
|
resp.error.should.be.nil
|
|
36
36
|
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
module Async
|
|
7
|
+
module Matrix
|
|
8
|
+
# Represents a Matrix event, however it arrived — a /sync response, an
|
|
9
|
+
# application service transaction, or a hash you built yourself.
|
|
10
|
+
#
|
|
11
|
+
# Provides typed accessors for all envelope fields plus schema-driven
|
|
12
|
+
# validation using the official Matrix spec YAML schemas.
|
|
13
|
+
#
|
|
14
|
+
# event = Event.new(raw_hash)
|
|
15
|
+
# event.type # => "m.room.message"
|
|
16
|
+
# event.sender # => "@alice:example.org"
|
|
17
|
+
# event.content.body # => "hello"
|
|
18
|
+
# event.valid? # => true
|
|
19
|
+
# event.valid! # => true (or raises Schema::ValidationError)
|
|
20
|
+
#
|
|
21
|
+
class Event
|
|
22
|
+
attr_reader :type,
|
|
23
|
+
:sender,
|
|
24
|
+
:room_id,
|
|
25
|
+
:state_key,
|
|
26
|
+
:content,
|
|
27
|
+
:event_id,
|
|
28
|
+
:origin_server_ts,
|
|
29
|
+
:unsigned,
|
|
30
|
+
:raw
|
|
31
|
+
|
|
32
|
+
def initialize(data)
|
|
33
|
+
@raw = data
|
|
34
|
+
@type = data["type"]
|
|
35
|
+
@sender = data["sender"]
|
|
36
|
+
@room_id = data["room_id"]
|
|
37
|
+
@state_key = data["state_key"]
|
|
38
|
+
@event_id = data["event_id"]
|
|
39
|
+
@origin_server_ts = data["origin_server_ts"]
|
|
40
|
+
@unsigned = data["unsigned"]
|
|
41
|
+
@content = Content.new(data["content"] || {})
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The JSONSchemer::Schema for this event's type, or nil if unknown.
|
|
45
|
+
def schema = Schema[@type]
|
|
46
|
+
|
|
47
|
+
# Validate this event against its schema.
|
|
48
|
+
# Returns true if valid or if no schema exists (lenient).
|
|
49
|
+
def valid? = Schema.valid?(@raw)
|
|
50
|
+
|
|
51
|
+
# Validate this event against its schema.
|
|
52
|
+
# Raises Schema::ValidationError with detailed errors on failure.
|
|
53
|
+
# Returns true if valid or if no schema exists.
|
|
54
|
+
def valid!
|
|
55
|
+
errors = Schema.validate(@raw)
|
|
56
|
+
|
|
57
|
+
unless errors.empty?
|
|
58
|
+
raise Schema::ValidationError.new(
|
|
59
|
+
errors,
|
|
60
|
+
event_type: @type,
|
|
61
|
+
event_id: @event_id,
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Content property names defined by the schema for this event type.
|
|
69
|
+
# @return [Array<String>]
|
|
70
|
+
def content_properties = Schema.content_properties(@type)
|
|
71
|
+
|
|
72
|
+
# Is this a state event? (has a state_key)
|
|
73
|
+
def state_event? = !@state_key.nil?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
__END__
|
|
79
|
+
describe "Async::Matrix::Event" do
|
|
80
|
+
it "parses all event fields" do
|
|
81
|
+
event = Async::Matrix::Event.new({
|
|
82
|
+
"type" => "m.room.message",
|
|
83
|
+
"sender" => "@alice:example.com",
|
|
84
|
+
"room_id" => "!abc:example.com",
|
|
85
|
+
"state_key" => "",
|
|
86
|
+
"event_id" => "$evt1",
|
|
87
|
+
"origin_server_ts" => 1234567890,
|
|
88
|
+
"unsigned" => {"age" => 1000},
|
|
89
|
+
"content" => {"msgtype" => "m.text", "body" => "hi"}
|
|
90
|
+
})
|
|
91
|
+
event.type.should == "m.room.message"
|
|
92
|
+
event.sender.should == "@alice:example.com"
|
|
93
|
+
event.room_id.should == "!abc:example.com"
|
|
94
|
+
event.state_key.should == ""
|
|
95
|
+
event.event_id.should == "$evt1"
|
|
96
|
+
event.origin_server_ts.should == 1234567890
|
|
97
|
+
event.unsigned.should == {"age" => 1000}
|
|
98
|
+
event.content.should.be.kind_of Async::Matrix::Content
|
|
99
|
+
event.content.body.should == "hi"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "defaults content to empty Content when missing" do
|
|
103
|
+
event = Async::Matrix::Event.new({"type" => "m.room.message"})
|
|
104
|
+
event.content.should.be.kind_of Async::Matrix::Content
|
|
105
|
+
event.content.body.should.be.nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "exposes the raw hash" do
|
|
109
|
+
data = {"type" => "m.room.message", "content" => {"body" => "hi", "msgtype" => "m.text"}}
|
|
110
|
+
event = Async::Matrix::Event.new(data)
|
|
111
|
+
event.raw.should.equal data
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "detects state events" do
|
|
115
|
+
state = Async::Matrix::Event.new({"type" => "m.room.member", "state_key" => "@a:b"})
|
|
116
|
+
state.state_event?.should == true
|
|
117
|
+
|
|
118
|
+
msg = Async::Matrix::Event.new({"type" => "m.room.message"})
|
|
119
|
+
msg.state_event?.should == false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "returns the schema for known event types" do
|
|
123
|
+
event = Async::Matrix::Event.new({"type" => "m.room.message", "content" => {}})
|
|
124
|
+
event.schema.should.not.be.nil
|
|
125
|
+
event.schema.should.be.kind_of JSONSchemer::Schema
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "returns nil schema for unknown event types" do
|
|
129
|
+
event = Async::Matrix::Event.new({"type" => "m.custom.event", "content" => {}})
|
|
130
|
+
event.schema.should.be.nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "validates a correct event" do
|
|
134
|
+
event = Async::Matrix::Event.new({
|
|
135
|
+
"type" => "m.room.message",
|
|
136
|
+
"content" => {"msgtype" => "m.text", "body" => "hello"},
|
|
137
|
+
"event_id" => "$abc123",
|
|
138
|
+
"sender" => "@alice:example.org",
|
|
139
|
+
"origin_server_ts" => 1234567890,
|
|
140
|
+
"room_id" => "!room:example.org"
|
|
141
|
+
})
|
|
142
|
+
event.valid?.should == true
|
|
143
|
+
event.valid!.should == true
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "rejects an invalid event" do
|
|
147
|
+
event = Async::Matrix::Event.new({
|
|
148
|
+
"type" => "m.room.message",
|
|
149
|
+
"content" => {"msgtype" => "m.text"},
|
|
150
|
+
"event_id" => "$abc123",
|
|
151
|
+
"sender" => "@alice:example.org",
|
|
152
|
+
"origin_server_ts" => 1234567890,
|
|
153
|
+
"room_id" => "!room:example.org"
|
|
154
|
+
})
|
|
155
|
+
event.valid?.should == false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "raises ValidationError from valid!" do
|
|
159
|
+
event = Async::Matrix::Event.new({
|
|
160
|
+
"type" => "m.room.member",
|
|
161
|
+
"content" => {"membership" => "invalid_state"},
|
|
162
|
+
"state_key" => "@alice:example.org",
|
|
163
|
+
"event_id" => "$abc123",
|
|
164
|
+
"sender" => "@alice:example.org",
|
|
165
|
+
"origin_server_ts" => 1234567890,
|
|
166
|
+
"room_id" => "!room:example.org"
|
|
167
|
+
})
|
|
168
|
+
begin
|
|
169
|
+
event.valid!
|
|
170
|
+
raise "should have raised"
|
|
171
|
+
rescue Async::Matrix::Schema::ValidationError => e
|
|
172
|
+
e.message.should.include "m.room.member"
|
|
173
|
+
e.message.should.include "$abc123"
|
|
174
|
+
e.errors.should.not.be.empty
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "is lenient with unknown event types" do
|
|
179
|
+
event = Async::Matrix::Event.new({
|
|
180
|
+
"type" => "com.custom.event",
|
|
181
|
+
"content" => {"anything" => "goes"},
|
|
182
|
+
"event_id" => "$x",
|
|
183
|
+
"sender" => "@a:b"
|
|
184
|
+
})
|
|
185
|
+
event.valid?.should == true
|
|
186
|
+
event.valid!.should == true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it "returns content properties for known types" do
|
|
190
|
+
event = Async::Matrix::Event.new({
|
|
191
|
+
"type" => "m.room.member",
|
|
192
|
+
"content" => {"membership" => "join"}
|
|
193
|
+
})
|
|
194
|
+
props = event.content_properties
|
|
195
|
+
props.should.include "membership"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
# Async::Matrix::Error is this class's superclass, so it has to exist before
|
|
7
|
+
# this file finishes loading — the glob in async/matrix.rb sorts
|
|
8
|
+
# alphabetically and would otherwise get here first.
|
|
9
|
+
require_relative "error"
|
|
10
|
+
|
|
11
|
+
module Async
|
|
12
|
+
module Matrix
|
|
13
|
+
class HomeserverError < Error; end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
__END__
|
|
18
|
+
it "HomeserverError inherits from Error" do
|
|
19
|
+
Async::Matrix::HomeserverError.new("M_UNKNOWN", "upstream").should.be.kind_of Async::Matrix::Error
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
# Async::Matrix::Error is this class's superclass, so it has to exist before
|
|
7
|
+
# this file finishes loading — the glob in async/matrix.rb sorts
|
|
8
|
+
# alphabetically and would otherwise get here first.
|
|
9
|
+
require_relative "error"
|
|
10
|
+
|
|
11
|
+
module Async
|
|
12
|
+
module Matrix
|
|
13
|
+
class InvalidEndpointError < Error; end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
__END__
|
|
18
|
+
it "InvalidEndpointError inherits from Error" do
|
|
19
|
+
Async::Matrix::InvalidEndpointError.new("M_UNRECOGNIZED", "no such route").should.be.kind_of Async::Matrix::Error
|
|
20
|
+
end
|