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.
@@ -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,12 @@
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
+ VERSION = "2.0.0"
10
+ end
11
+ end
12
+ 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: []