async-matrix 2.1.0 → 3.0.1

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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +8 -8
  3. data/README.md +33 -58
  4. data/ext/async_matrix_e2ee/Cargo.toml +7 -1
  5. data/lib/async/matrix/api/chain.rb +120 -94
  6. data/lib/async/matrix/api/concat.rb +28 -22
  7. data/lib/async/matrix/api/path_tree.rb +71 -44
  8. data/lib/async/matrix/api.rb +6 -1
  9. data/lib/async/matrix/app_service_client.rb +213 -0
  10. data/lib/async/matrix/auth_error.rb +20 -0
  11. data/lib/async/matrix/bad_json_error.rb +20 -0
  12. data/lib/async/matrix/client/encryption.rb +413 -0
  13. data/lib/async/matrix/client/rooms.rb +478 -0
  14. data/lib/async/matrix/client/sync.rb +508 -0
  15. data/lib/async/matrix/client.rb +217 -113
  16. data/lib/async/matrix/config/vivify.rb +107 -0
  17. data/lib/async/matrix/config.rb +184 -0
  18. data/lib/async/matrix/device_store.rb +1621 -0
  19. data/lib/async/matrix/double_puppet_client.rb +1 -1
  20. data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
  21. data/lib/async/matrix/error.rb +9 -36
  22. data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
  23. data/lib/async/matrix/homeserver_error.rb +20 -0
  24. data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
  25. data/lib/async/matrix/media_client.rb +46 -35
  26. data/lib/async/matrix/not_found_error.rb +20 -0
  27. data/lib/async/matrix/notifier.rb +7 -3
  28. data/lib/async/matrix/response_too_large_error.rb +20 -0
  29. data/lib/async/matrix/version.rb +1 -1
  30. data/lib/async/matrix.rb +16 -7
  31. data/lib/protocol/matrix/canonical_json.rb +200 -0
  32. data/lib/protocol/matrix/content.rb +106 -0
  33. data/lib/protocol/matrix/encrypted_message.rb +1018 -0
  34. data/lib/protocol/matrix/error.rb +49 -0
  35. data/lib/protocol/matrix/event.rb +208 -0
  36. data/lib/protocol/matrix/key_backup.rb +347 -0
  37. data/lib/protocol/matrix/keys.rb +381 -0
  38. data/lib/protocol/matrix/message_batch.rb +482 -0
  39. data/lib/protocol/matrix/schema/registry.rb +381 -0
  40. data/lib/protocol/matrix/schema/validation_error.rb +234 -0
  41. data/lib/protocol/matrix/schema.rb +170 -0
  42. data/lib/protocol/matrix/secret_storage.rb +535 -0
  43. data/lib/protocol/matrix/signing.rb +278 -0
  44. data/lib/protocol/matrix.rb +25 -0
  45. metadata +42 -135
  46. data/lib/async/discord/api/path_tree.rb +0 -127
  47. data/lib/async/discord/api.rb +0 -151
  48. data/lib/async/discord/client.rb +0 -283
  49. data/lib/async/discord/error.rb +0 -84
  50. data/lib/async/discord/gateway.rb +0 -359
  51. data/lib/async/discord.rb +0 -15
  52. data/lib/async/matrix/application_service/bot.rb +0 -232
  53. data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
  54. data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
  55. data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
  56. data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
  57. data/lib/async/matrix/application_service/config/schema/config.json +0 -61
  58. data/lib/async/matrix/application_service/config/schema/database.json +0 -38
  59. data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
  60. data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
  61. data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
  62. data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
  63. data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
  64. data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
  65. data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
  66. data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
  67. data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
  68. data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
  69. data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
  70. data/lib/async/matrix/application_service/config/vivify.rb +0 -109
  71. data/lib/async/matrix/application_service/config.rb +0 -225
  72. data/lib/async/matrix/application_service/dispatcher.rb +0 -185
  73. data/lib/async/matrix/application_service/event.rb +0 -285
  74. data/lib/async/matrix/application_service/server.rb +0 -430
  75. data/lib/async/matrix/application_service/transaction.rb +0 -66
  76. data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
  77. data/lib/async/matrix/application_service/transaction_store.rb +0 -80
  78. data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
  79. data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
  80. data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
  81. data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
  82. data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
  83. data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
  84. data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
  85. data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
  86. data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
  87. data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
  88. data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
  89. data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
  90. data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
  91. data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
  92. data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
  93. data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
  94. data/lib/async/matrix/bridge/discord/db.rb +0 -138
  95. data/lib/async/matrix/schema/registry.rb +0 -354
  96. data/lib/async/matrix/schema/validation_error.rb +0 -225
  97. data/lib/async/matrix/schema.rb +0 -170
@@ -22,7 +22,13 @@ module Async
22
22
  class PathTree
23
23
  SCHEMA_DIR = Pathname.new(File.expand_path("../../../../data/matrix-spec/api/client-server", __dir__))
24
24
 
25
- Node = Struct.new(:children, :wildcard, :methods, :operation_ids, keyword_init: true) do
25
+ Node = Struct.new(
26
+ :children,
27
+ :wildcard,
28
+ :methods,
29
+ :operation_ids,
30
+ keyword_init: true,
31
+ ) do
26
32
  def initialize(**)
27
33
  super
28
34
  self.children ||= {}
@@ -39,34 +45,46 @@ module Async
39
45
 
40
46
  # Load all OpenAPI schemas from data/ and build the tree.
41
47
  def self.load(schema_dir: SCHEMA_DIR)
42
- tree = new
43
- Pathname.glob(schema_dir / "*.yaml").each do |path|
44
- tree.load_schema(path)
48
+ new.tap do |tree|
49
+ Pathname.glob(schema_dir / "*.yaml").each do |path|
50
+ tree.load_schema(path)
51
+ end
45
52
  end
46
- tree
47
53
  end
48
54
 
49
55
  # Parse a single OpenAPI YAML file and insert its paths into the tree.
50
56
  def load_schema(path)
51
57
  doc = YAML.safe_load(File.read(path), permitted_classes: [Symbol], aliases: true)
52
- return unless doc.is_a?(Hash)
53
-
54
- base_path = extract_base_path(doc)
55
- paths = doc["paths"]
56
- return unless paths.is_a?(Hash)
57
-
58
- paths.each do |path_template, methods_hash|
59
- next unless methods_hash.is_a?(Hash)
60
-
61
- # Build full path: basePath + path_template
62
- full_path = "#{base_path}#{path_template.strip}"
63
- segments = full_path.split("/").reject(&:empty?)
64
-
65
- methods_hash.each do |method, operation|
66
- next unless %w[get post put delete patch head].include?(method)
67
- operation_id = operation.is_a?(Hash) ? operation["operationId"] : nil
68
- insert(segments, method, operation_id)
58
+ if doc.is_a?(Hash)
59
+ base_path = extract_base_path(doc)
60
+ paths = doc["paths"]
61
+ if paths.is_a?(Hash)
62
+ paths.each do |path_template, methods_hash|
63
+ unless methods_hash.is_a?(Hash)
64
+ next
65
+ end
66
+
67
+ # Build full path: basePath + path_template
68
+ full_path = "#{base_path}#{path_template.strip}"
69
+ segments = full_path.split("/").reject(&:empty?)
70
+
71
+ methods_hash.each do |method, operation|
72
+ unless %w[get post put delete patch head].include?(method)
73
+ next
74
+ end
75
+ if operation.is_a?(Hash)
76
+ operation_id = operation["operationId"]
77
+ else
78
+ operation_id = nil
79
+ end
80
+ insert(segments, method, operation_id)
81
+ end
82
+ end
83
+ else
84
+ nil
69
85
  end
86
+ else
87
+ nil
70
88
  end
71
89
  end
72
90
 
@@ -83,25 +101,30 @@ module Async
83
101
  node = node.children[segment]
84
102
  end
85
103
  end
86
- node.methods << method.downcase unless node.methods.include?(method.downcase)
87
- node.operation_ids[method.downcase] = operation_id if operation_id
104
+ unless node.methods.include?(method.downcase)
105
+ node.methods << method.downcase
106
+ end
107
+ if operation_id
108
+ node.operation_ids[method.downcase] = operation_id
109
+ end
88
110
  end
89
111
 
90
112
  # Match a concrete path (array of segments) against the trie.
91
113
  # Returns a result hash.
92
114
  def match(segments, method = nil)
93
- node = @root
94
- segments.each do |segment|
95
- if node.children.key?(segment)
96
- node = node.children[segment]
97
- elsif node.wildcard
98
- node = node.wildcard
99
- else
100
- return {valid: false, methods: [], operation_id: nil}
115
+ node = segments.inject(@root) do |current, segment|
116
+ if current.nil?
117
+ nil
118
+ elsif current.children.key?(segment)
119
+ current.children[segment]
120
+ elsif current.wildcard
121
+ current.wildcard
101
122
  end
102
123
  end
103
124
 
104
- if method
125
+ if node.nil?
126
+ {valid: false, methods: [], operation_id: nil}
127
+ elsif method
105
128
  method_down = method.downcase
106
129
  valid = node.methods.include?(method_down)
107
130
  {valid: valid, methods: node.methods, operation_id: node.operation_ids[method_down]}
@@ -112,17 +135,21 @@ module Async
112
135
 
113
136
  private
114
137
 
115
- def extract_base_path(doc)
116
- servers = doc["servers"]
117
- return "" unless servers.is_a?(Array) && servers.first.is_a?(Hash)
118
-
119
- server = servers.first
120
- variables = server["variables"] || {}
121
- base_path_var = variables["basePath"]
122
- return "" unless base_path_var.is_a?(Hash)
123
-
124
- base_path_var["default"] || ""
125
- end
138
+ def extract_base_path(doc)
139
+ servers = doc["servers"]
140
+ if servers.is_a?(Array) && servers.first.is_a?(Hash)
141
+ server = servers.first
142
+ variables = server["variables"] || {}
143
+ base_path_var = variables["basePath"]
144
+ if base_path_var.is_a?(Hash)
145
+ base_path_var["default"] || ""
146
+ else
147
+ ""
148
+ end
149
+ else
150
+ ""
151
+ end
152
+ end
126
153
  end
127
154
  end
128
155
  end
@@ -54,7 +54,12 @@ module Async
54
54
  if name.start_with?("to_")
55
55
  super
56
56
  else
57
- chain.__send__(name, *args, **kwargs, &block)
57
+ chain.__send__(
58
+ name,
59
+ *args,
60
+ **kwargs,
61
+ &block
62
+ )
58
63
  end
59
64
  end
60
65
 
@@ -0,0 +1,213 @@
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_relative "client"
7
+
8
+ module Async
9
+ module Matrix
10
+ # A Client that acts AS one of the appservice's users, on one of that user's
11
+ # devices.
12
+ #
13
+ # client = AppServiceClient.new(config, user_id: "@ada:example.org", device_id: "ABCDEFGHIJ")
14
+ # client.upload_keys(device_keys: ...) # uploaded for Ada's device, not the bot's
15
+ #
16
+ # This is double puppeting, and it is the officially intended mechanism
17
+ # rather than a trick. Identity assertion -- `?user_id=` -- has been in the
18
+ # application service spec for years; [MSC4326] added `?device_id=` beside
19
+ # it and is MERGED, so the plain parameter names below are stable spec, not
20
+ # the unstable `org.matrix.msc3202.device_id` an older implementation would
21
+ # have sent.
22
+ #
23
+ # WHY NOT A TOKEN. Bridges used to get a per-user access token by calling
24
+ # /login with `m.login.application_service` ([MSC2778]), and that route is
25
+ # gone on a homeserver fronted by OAuth2 -- it answers
26
+ # M_APPSERVICE_LOGIN_UNSUPPORTED. [MSC4190], which is also merged, replaced
27
+ # it: an appservice creates devices directly (`PUT /devices/{deviceId}`) and
28
+ # then acts as them with these two parameters. No token is ever issued, so
29
+ # none can expire or need refreshing.
30
+ #
31
+ # IT STILL WORKS BEHIND MAS. Synapse checks an as_token against its own
32
+ # appservice registry before it introspects anything at the authentication
33
+ # service, so masquerading is unaffected by next-generation auth.
34
+ #
35
+ # THE USER MUST BE IN THE REGISTRATION'S NAMESPACE. The homeserver refuses
36
+ # otherwise -- which is why a double-puppet registration claims a wide user
37
+ # namespace non-exclusively rather than naming individuals.
38
+ #
39
+ # [MSC4326]: https://github.com/matrix-org/matrix-spec-proposals/pull/4326
40
+ # [MSC4190]: https://github.com/matrix-org/matrix-spec-proposals/pull/4190
41
+ # [MSC2778]: https://github.com/matrix-org/matrix-spec-proposals/pull/2778
42
+ class AppServiceClient < Client
43
+ # @parameter user_id [String] the user to act as. Must match the
44
+ # registration's user namespace.
45
+ # @parameter device_id [String] the device to act as.
46
+ #
47
+ # WITHOUT IT THE REQUEST HAS NO DEVICE, and an appservice request with
48
+ # no device cannot upload one-time keys, claim keys or send to-device
49
+ # messages -- every call encryption is made of. It is optional only
50
+ # because the device has to be CREATED before it can be acted as, and
51
+ # that one call is made without it.
52
+ def initialize(config, user_id:, device_id: nil, **options)
53
+ super(config, **options)
54
+
55
+ @user_id = user_id
56
+ @device_id = device_id
57
+ end
58
+
59
+ attr_reader :user_id, :device_id
60
+
61
+ # NAMED ON EVERY REQUEST, not only the ones that obviously need it. An
62
+ # appservice request that omits them is not an error: it silently acts as
63
+ # the registration's sender_localpart, with no device, which is far worse
64
+ # than a failure because it succeeds.
65
+ def default_query
66
+ {user_id: @user_id}.tap do |query|
67
+ if @device_id
68
+ query[:device_id] = @device_id
69
+ end
70
+ end
71
+ end
72
+
73
+ # A sibling client for another user or device, sharing this one's config
74
+ # and retry policy.
75
+ #
76
+ # One appservice commonly acts for many users, and the alternative --
77
+ # mutating user_id on a single client -- races itself the moment two
78
+ # fibers use it, which is exactly what an async bridge does.
79
+ def as(user_id:, device_id: nil)
80
+ self.class.new(@config, user_id: user_id, device_id: device_id)
81
+ end
82
+
83
+ # The same user, now on a device: what you call once the device exists.
84
+ def with_device(device_id)
85
+ as(user_id: @user_id, device_id: device_id)
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ __END__
92
+ describe "Async::Matrix::AppServiceClient" do
93
+ def config
94
+ Async::Matrix::Config.new({
95
+ "homeserver" => {"address" => "http://synapse:8008", "domain" => "example.org"},
96
+ "appservice" => {"as_token" => "as", "hs_token" => "hs", "bot" => {"username" => "bot"}},
97
+ })
98
+ end
99
+
100
+ def recording_client(user_id: "@ada:example.org", device_id: "ABCDEFGHIJ")
101
+ Async::Matrix::Api.reset!
102
+
103
+ client = Async::Matrix::AppServiceClient.new(config, user_id: user_id, device_id: device_id)
104
+ calls = []
105
+ client.define_singleton_method(:calls) { calls }
106
+ client.define_singleton_method(:internet) do
107
+ internet = Object.new
108
+ internet.define_singleton_method(:call) do |method, url, _headers, _body|
109
+ calls << [method, url]
110
+
111
+ # Shaped like what Client#read_limited consumes: a body that yields
112
+ # chunks and reports its own length.
113
+ body = Object.new
114
+ body.define_singleton_method(:length) { 2 }
115
+ body.define_singleton_method(:each) { |&block| block.call("{}") }
116
+ body.define_singleton_method(:close) { nil }
117
+
118
+ response = Object.new
119
+ response.define_singleton_method(:status) { 200 }
120
+ response.define_singleton_method(:body) { body }
121
+ response.define_singleton_method(:close) { nil }
122
+ response
123
+ end
124
+ internet
125
+ end
126
+ client
127
+ end
128
+
129
+ def last_url(client) = client.calls.last[1]
130
+
131
+ it "asserts the user and device on every request" do
132
+ client = recording_client
133
+ client.whoami
134
+
135
+ last_url(client).should ==
136
+ "http://synapse:8008/_matrix/client/v3/account/whoami" \
137
+ "?user_id=%40ada%3Aexample.org&device_id=ABCDEFGHIJ"
138
+ end
139
+
140
+ # Not only on the calls that obviously need it: an appservice request that
141
+ # omits them silently acts as the sender_localpart, with no device.
142
+ it "asserts them through the api chain too" do
143
+ client = recording_client
144
+ client.joined_members(room_id: "!ops:example.org")
145
+
146
+ last_url(client).should.be.include? "user_id=%40ada%3Aexample.org"
147
+ last_url(client).should.be.include? "device_id=ABCDEFGHIJ"
148
+ end
149
+
150
+ # The chain appends its own query first, so this is the case that catches a
151
+ # naive "?" concatenation.
152
+ it "appends to a path that already carries a query" do
153
+ client = recording_client
154
+ client.messages(room_id: "!ops:example.org", limit: 10)
155
+
156
+ last_url(client).should.be.include? "?dir=b&limit=10&"
157
+ last_url(client).should.be.include? "user_id=%40ada%3Aexample.org"
158
+ last_url(client).count("?").should == 1
159
+ end
160
+
161
+ # The device has to be created before it can be acted as, and that one call
162
+ # is made without a device.
163
+ it "omits the device when it has none" do
164
+ client = recording_client(device_id: nil)
165
+ client.whoami
166
+
167
+ last_url(client).should.be.include? "user_id=%40ada%3Aexample.org"
168
+ last_url(client).should.not.be.include? "device_id"
169
+ end
170
+
171
+ # Overwriting it would mean acting as somebody other than the caller asked
172
+ # for, which is worse than a failure.
173
+ it "lets a caller's own parameter win" do
174
+ client = recording_client
175
+ client.get("/_matrix/client/v3/account/whoami?user_id=%40bob%3Aexample.org")
176
+
177
+ last_url(client).should.be.include? "user_id=%40bob%3Aexample.org"
178
+ last_url(client).scan("user_id=").length.should == 1
179
+ end
180
+
181
+ it "encodes the punctuation in a user id" do
182
+ client = recording_client(user_id: "@ada:example.org")
183
+ client.whoami
184
+
185
+ last_url(client).should.be.include? "%40ada%3Aexample.org"
186
+ end
187
+
188
+ # One appservice acts for many users; mutating a shared client would race
189
+ # itself the moment two fibers used it.
190
+ it "makes a sibling client for another user" do
191
+ client = recording_client
192
+ other = client.as(user_id: "@bob:example.org", device_id: "OTHERDEV")
193
+
194
+ other.user_id.should == "@bob:example.org"
195
+ other.device_id.should == "OTHERDEV"
196
+ client.user_id.should == "@ada:example.org"
197
+ other.config.should.be == client.config
198
+ end
199
+
200
+ it "makes a client for the same user on a device" do
201
+ client = recording_client(device_id: nil)
202
+ with_device = client.with_device("NEWDEVICE")
203
+
204
+ with_device.user_id.should == "@ada:example.org"
205
+ with_device.device_id.should == "NEWDEVICE"
206
+ end
207
+
208
+ # An ordinary client asserts nothing: a user's own token already says who
209
+ # the request is for.
210
+ it "is the only client that asserts anything" do
211
+ Async::Matrix::Client.new(config).default_query.should == {}
212
+ end
213
+ 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 AuthError < Error; end
14
+ end
15
+ end
16
+
17
+ __END__
18
+ it "AuthError inherits from Error" do
19
+ Async::Matrix::AuthError.new("M_FORBIDDEN", "denied").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 BadJsonError < Error; end
14
+ end
15
+ end
16
+
17
+ __END__
18
+ it "BadJsonError inherits from Error" do
19
+ Async::Matrix::BadJsonError.new("M_BAD_JSON", "invalid").should.be.kind_of Async::Matrix::Error
20
+ end