async-matrix-bridge 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +200 -0
- data/README.md +195 -0
- data/lib/async/matrix/bridge/endpoint.rb +482 -0
- data/lib/async/matrix/bridge/registration/schema/namespace_list.json +22 -0
- data/lib/async/matrix/bridge/registration/schema/registration.json +91 -0
- data/lib/async/matrix/bridge/registration.rb +244 -0
- data/lib/async/matrix/bridge/transaction_store.rb +82 -0
- data/lib/async/matrix/bridge/version.rb +12 -0
- data/lib/async/matrix/bridge.rb +20 -0
- metadata +139 -0
|
@@ -0,0 +1,244 @@
|
|
|
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 "async/matrix"
|
|
7
|
+
require "json_schemer"
|
|
8
|
+
require "pathname"
|
|
9
|
+
require "yaml"
|
|
10
|
+
|
|
11
|
+
module Async
|
|
12
|
+
module Matrix
|
|
13
|
+
module Bridge
|
|
14
|
+
class Registration
|
|
15
|
+
SCHEMA_DIR = Pathname.new(__dir__).join("registration", "schema").freeze
|
|
16
|
+
|
|
17
|
+
Homeserver = Struct.new(:address, :domain)
|
|
18
|
+
|
|
19
|
+
Appservice = Struct.new(:as_token, :hs_token, :sender_localpart)
|
|
20
|
+
|
|
21
|
+
NAMESPACES = %w[users rooms aliases].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :raw, :homeserver, :appservice
|
|
24
|
+
|
|
25
|
+
def self.schema
|
|
26
|
+
@schema ||= JSONSchemer.schema(SCHEMA_DIR.join("registration.json"))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.load(path, homeserver:, domain:)
|
|
30
|
+
Pathname.new(path).then do |pathname|
|
|
31
|
+
unless pathname.exist?
|
|
32
|
+
raise Async::Matrix::NotFoundError.new(
|
|
33
|
+
"M_NOT_FOUND",
|
|
34
|
+
"Registration file not found: #{pathname}",
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
new(YAML.safe_load_file(pathname), homeserver: homeserver, domain: domain)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.validate!(data)
|
|
43
|
+
schema.validate(data).to_a.then do |errors|
|
|
44
|
+
unless errors.empty?
|
|
45
|
+
messages = errors.map { |error| error["error"] }.join("; ")
|
|
46
|
+
raise Async::Matrix::BadJsonError.new(
|
|
47
|
+
"M_BAD_JSON",
|
|
48
|
+
"Registration validation failed: #{messages}",
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(data, homeserver:, domain:)
|
|
55
|
+
self.class.validate!(data)
|
|
56
|
+
|
|
57
|
+
@raw = data
|
|
58
|
+
@homeserver = Homeserver.new(homeserver, domain)
|
|
59
|
+
@appservice = Appservice.new(
|
|
60
|
+
data["as_token"],
|
|
61
|
+
data["hs_token"],
|
|
62
|
+
data["sender_localpart"],
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def id = @raw["id"]
|
|
67
|
+
|
|
68
|
+
def url = @raw["url"]
|
|
69
|
+
|
|
70
|
+
def as_token = @appservice.as_token
|
|
71
|
+
|
|
72
|
+
def hs_token = @appservice.hs_token
|
|
73
|
+
|
|
74
|
+
def sender_localpart = @appservice.sender_localpart
|
|
75
|
+
|
|
76
|
+
def protocols = @raw["protocols"] || []
|
|
77
|
+
|
|
78
|
+
def rate_limited? = @raw.fetch("rate_limited", false)
|
|
79
|
+
|
|
80
|
+
def receive_ephemeral? = @raw.fetch("receive_ephemeral", false)
|
|
81
|
+
|
|
82
|
+
def namespaces(kind)
|
|
83
|
+
unless NAMESPACES.include?(kind.to_s)
|
|
84
|
+
raise ArgumentError, "unknown namespace #{kind.inspect}; expected one of #{NAMESPACES.join(", ")}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@raw.fetch("namespaces", {}).fetch(kind.to_s, [])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def covers?(kind, value)
|
|
91
|
+
namespaces(kind).any? { |entry| Regexp.new(entry["regex"]).match?(value) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def bot_mxid = "@#{sender_localpart}:#{@homeserver.domain}"
|
|
95
|
+
|
|
96
|
+
def exclusive?(kind, value)
|
|
97
|
+
namespaces(kind).any? do |entry|
|
|
98
|
+
entry["exclusive"] && Regexp.new(entry["regex"]).match?(value)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
__END__
|
|
107
|
+
require "tempfile"
|
|
108
|
+
|
|
109
|
+
describe "Async::Matrix::Bridge::Registration" do
|
|
110
|
+
Registration = Async::Matrix::Bridge::Registration
|
|
111
|
+
|
|
112
|
+
def valid_data
|
|
113
|
+
{
|
|
114
|
+
"id" => "agent",
|
|
115
|
+
"url" => "http://appservice:9000",
|
|
116
|
+
"as_token" => "as_secret",
|
|
117
|
+
"hs_token" => "hs_secret",
|
|
118
|
+
"sender_localpart" => "agent",
|
|
119
|
+
"namespaces" => {
|
|
120
|
+
"users" => [{"regex" => "@_agent_.*:localhost", "exclusive" => true}],
|
|
121
|
+
"aliases" => [],
|
|
122
|
+
"rooms" => [],
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def rejected?(data)
|
|
128
|
+
Registration.new(data, homeserver: "http://hs", domain: "d")
|
|
129
|
+
false
|
|
130
|
+
rescue Async::Matrix::BadJsonError
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build(overrides = {})
|
|
135
|
+
Registration.new(valid_data.merge(overrides), homeserver: "http://synapse:8008", domain: "localhost")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "reads the spec fields off the registration" do
|
|
139
|
+
registration = build
|
|
140
|
+
registration.id.should == "agent"
|
|
141
|
+
registration.url.should == "http://appservice:9000"
|
|
142
|
+
registration.sender_localpart.should == "agent"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "presents the config surface a Client and an Endpoint need" do
|
|
146
|
+
registration = build
|
|
147
|
+
registration.homeserver.address.should == "http://synapse:8008"
|
|
148
|
+
registration.appservice.as_token.should == "as_secret"
|
|
149
|
+
registration.appservice.hs_token.should == "hs_secret"
|
|
150
|
+
registration.bot_mxid.should == "@agent:localhost"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "derives bot_mxid from sender_localpart and the supplied domain" do
|
|
154
|
+
Registration.new(valid_data, homeserver: "http://hs", domain: "example.org")
|
|
155
|
+
.bot_mxid.should == "@agent:example.org"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "requires the spec's required fields" do
|
|
159
|
+
["id", "as_token", "hs_token", "sender_localpart", "namespaces"].map do |field|
|
|
160
|
+
rejected?(valid_data.reject { |k, _| k == field })
|
|
161
|
+
end.should == [true, true, true, true, true]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it "requires url to be present but allows an explicit null" do
|
|
165
|
+
build("url" => nil).url.should == nil
|
|
166
|
+
|
|
167
|
+
rejected?(valid_data.reject { |k, _| k == "url" }).should == true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "rejects a namespace entry missing regex or exclusive" do
|
|
171
|
+
[{"exclusive" => true}, {"regex" => "@a:b"}].map do |entry|
|
|
172
|
+
rejected?(valid_data.merge("namespaces" => {"users" => [entry]}))
|
|
173
|
+
end.should == [true, true]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "rejects a non-string id" do
|
|
177
|
+
rejected?(valid_data.merge("id" => 42)).should == true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it "reports whether a value falls in a namespace" do
|
|
181
|
+
registration = build
|
|
182
|
+
registration.covers?(:users, "@_agent_alice:localhost").should == true
|
|
183
|
+
registration.covers?(:users, "@someone:localhost").should == false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "reports exclusivity separately from coverage" do
|
|
187
|
+
registration = build("namespaces" => {"users" => [{"regex" => "@a.*:localhost", "exclusive" => false}]})
|
|
188
|
+
registration.covers?(:users, "@alice:localhost").should == true
|
|
189
|
+
registration.exclusive?(:users, "@alice:localhost").should == false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "returns an empty list for an absent namespace kind" do
|
|
193
|
+
build("namespaces" => {"users" => []}).namespaces(:rooms).should == []
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "raises for an unknown namespace kind" do
|
|
197
|
+
error =
|
|
198
|
+
begin
|
|
199
|
+
build.namespaces(:widgets)
|
|
200
|
+
nil
|
|
201
|
+
rescue ArgumentError => e
|
|
202
|
+
e
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
error.class.should == ArgumentError
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "defaults the spec's optional booleans to false" do
|
|
209
|
+
registration = build
|
|
210
|
+
registration.receive_ephemeral?.should == false
|
|
211
|
+
registration.rate_limited?.should == false
|
|
212
|
+
registration.protocols.should == []
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it "reads receive_ephemeral when set" do
|
|
216
|
+
build("receive_ephemeral" => true).receive_ephemeral?.should == true
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "loads from a YAML file" do
|
|
220
|
+
file = Tempfile.new(["registration", ".yaml"])
|
|
221
|
+
begin
|
|
222
|
+
file.write(YAML.dump(valid_data))
|
|
223
|
+
file.close
|
|
224
|
+
|
|
225
|
+
registration = Registration.load(file.path, homeserver: "http://synapse:8008", domain: "localhost")
|
|
226
|
+
registration.id.should == "agent"
|
|
227
|
+
registration.bot_mxid.should == "@agent:localhost"
|
|
228
|
+
ensure
|
|
229
|
+
file.unlink
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it "raises NotFoundError for a missing file" do
|
|
234
|
+
error =
|
|
235
|
+
begin
|
|
236
|
+
Registration.load("/nonexistent/registration.yaml", homeserver: "http://hs", domain: "d")
|
|
237
|
+
nil
|
|
238
|
+
rescue Async::Matrix::NotFoundError => e
|
|
239
|
+
e
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
error.class.should == Async::Matrix::NotFoundError
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
module Bridge
|
|
9
|
+
# In-memory idempotency store for appservice transaction IDs.
|
|
10
|
+
class TransactionStore
|
|
11
|
+
DEFAULT_CAPACITY = 1024
|
|
12
|
+
|
|
13
|
+
def initialize(capacity: DEFAULT_CAPACITY)
|
|
14
|
+
@seen = {}
|
|
15
|
+
@capacity = capacity
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def seen?(txn_id)
|
|
19
|
+
@seen.key?(txn_id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def mark_seen(txn_id)
|
|
23
|
+
if @seen.size >= @capacity
|
|
24
|
+
prune!
|
|
25
|
+
end
|
|
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
|
+
__END__
|
|
46
|
+
describe "Async::Matrix::Bridge::TransactionStore" do
|
|
47
|
+
it "tracks seen transaction IDs" do
|
|
48
|
+
store = Async::Matrix::Bridge::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::Bridge::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::Bridge::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::Bridge::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
|
|
@@ -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
|
+
require "async/matrix"
|
|
7
|
+
|
|
8
|
+
module Async
|
|
9
|
+
module Matrix
|
|
10
|
+
module Bridge
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Same eager glob as the core gem: source files under lib/async/matrix/bridge/
|
|
16
|
+
# do not self-require, they rely on this loader for ordering plus targeted
|
|
17
|
+
# require_relative for the few load-time cross-file dependencies.
|
|
18
|
+
Dir.glob("#{__dir__}/bridge/**/*.rb").sort.each do |path|
|
|
19
|
+
require path
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: async-matrix-bridge
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 2.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nathan Kidd
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-01 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: async
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.46'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.46'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: async-matrix
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: json_schemer
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.5'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.5'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: scampi
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: logger
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: lefthook
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '2.2'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '2.2'
|
|
96
|
+
description: 'The server side of a Matrix bridge or bot: a Rack application that authenticates
|
|
97
|
+
and deduplicates homeserver transactions, then hands each event to a block you pattern
|
|
98
|
+
match on, concurrently. Built on async-matrix and the Socketry async ecosystem.'
|
|
99
|
+
email:
|
|
100
|
+
- nathankidd@hey.com
|
|
101
|
+
executables: []
|
|
102
|
+
extensions: []
|
|
103
|
+
extra_rdoc_files: []
|
|
104
|
+
files:
|
|
105
|
+
- LICENSE
|
|
106
|
+
- README.md
|
|
107
|
+
- lib/async/matrix/bridge.rb
|
|
108
|
+
- lib/async/matrix/bridge/endpoint.rb
|
|
109
|
+
- lib/async/matrix/bridge/registration.rb
|
|
110
|
+
- lib/async/matrix/bridge/registration/schema/namespace_list.json
|
|
111
|
+
- lib/async/matrix/bridge/registration/schema/registration.json
|
|
112
|
+
- lib/async/matrix/bridge/transaction_store.rb
|
|
113
|
+
- lib/async/matrix/bridge/version.rb
|
|
114
|
+
homepage: https://github.com/general-intelligence-systems/async-matrix-bridge
|
|
115
|
+
licenses:
|
|
116
|
+
- Apache-2.0
|
|
117
|
+
metadata:
|
|
118
|
+
homepage_uri: https://github.com/general-intelligence-systems/async-matrix-bridge
|
|
119
|
+
source_code_uri: https://github.com/general-intelligence-systems/async-matrix-bridge
|
|
120
|
+
documentation_uri: https://github.com/general-intelligence-systems/async-matrix-bridge#readme
|
|
121
|
+
rubygems_mfa_required: 'true'
|
|
122
|
+
rdoc_options: []
|
|
123
|
+
require_paths:
|
|
124
|
+
- lib
|
|
125
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
126
|
+
requirements:
|
|
127
|
+
- - ">="
|
|
128
|
+
- !ruby/object:Gem::Version
|
|
129
|
+
version: '3.3'
|
|
130
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
131
|
+
requirements:
|
|
132
|
+
- - ">="
|
|
133
|
+
- !ruby/object:Gem::Version
|
|
134
|
+
version: '0'
|
|
135
|
+
requirements: []
|
|
136
|
+
rubygems_version: 3.7.2
|
|
137
|
+
specification_version: 4
|
|
138
|
+
summary: An async-native Matrix Application Service SDK for Ruby.
|
|
139
|
+
test_files: []
|