async-matrix 2.1.0-aarch64-linux → 3.0.1-aarch64-linux

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -58
  3. data/lib/async/matrix/3.3/async_matrix_e2ee.so +0 -0
  4. data/lib/async/matrix/3.4/async_matrix_e2ee.so +0 -0
  5. data/lib/async/matrix/4.0/async_matrix_e2ee.so +0 -0
  6. data/lib/async/matrix/api/chain.rb +120 -94
  7. data/lib/async/matrix/api/concat.rb +28 -22
  8. data/lib/async/matrix/api/path_tree.rb +71 -44
  9. data/lib/async/matrix/api.rb +6 -1
  10. data/lib/async/matrix/app_service_client.rb +213 -0
  11. data/lib/async/matrix/auth_error.rb +20 -0
  12. data/lib/async/matrix/bad_json_error.rb +20 -0
  13. data/lib/async/matrix/client/encryption.rb +413 -0
  14. data/lib/async/matrix/client/rooms.rb +478 -0
  15. data/lib/async/matrix/client/sync.rb +508 -0
  16. data/lib/async/matrix/client.rb +217 -113
  17. data/lib/async/matrix/config/vivify.rb +107 -0
  18. data/lib/async/matrix/config.rb +184 -0
  19. data/lib/async/matrix/device_store.rb +1621 -0
  20. data/lib/async/matrix/double_puppet_client.rb +1 -1
  21. data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
  22. data/lib/async/matrix/error.rb +9 -36
  23. data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
  24. data/lib/async/matrix/homeserver_error.rb +20 -0
  25. data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
  26. data/lib/async/matrix/media_client.rb +46 -35
  27. data/lib/async/matrix/not_found_error.rb +20 -0
  28. data/lib/async/matrix/notifier.rb +7 -3
  29. data/lib/async/matrix/response_too_large_error.rb +20 -0
  30. data/lib/async/matrix/version.rb +1 -1
  31. data/lib/async/matrix.rb +16 -7
  32. data/lib/protocol/matrix/canonical_json.rb +200 -0
  33. data/lib/protocol/matrix/content.rb +106 -0
  34. data/lib/protocol/matrix/encrypted_message.rb +1018 -0
  35. data/lib/protocol/matrix/error.rb +49 -0
  36. data/lib/protocol/matrix/event.rb +208 -0
  37. data/lib/protocol/matrix/key_backup.rb +347 -0
  38. data/lib/protocol/matrix/keys.rb +381 -0
  39. data/lib/protocol/matrix/message_batch.rb +482 -0
  40. data/lib/protocol/matrix/schema/registry.rb +381 -0
  41. data/lib/protocol/matrix/schema/validation_error.rb +234 -0
  42. data/lib/protocol/matrix/schema.rb +170 -0
  43. data/lib/protocol/matrix/secret_storage.rb +535 -0
  44. data/lib/protocol/matrix/signing.rb +278 -0
  45. data/lib/protocol/matrix.rb +25 -0
  46. metadata +43 -136
  47. data/lib/async/discord/api/path_tree.rb +0 -127
  48. data/lib/async/discord/api.rb +0 -151
  49. data/lib/async/discord/client.rb +0 -283
  50. data/lib/async/discord/error.rb +0 -84
  51. data/lib/async/discord/gateway.rb +0 -359
  52. data/lib/async/discord.rb +0 -15
  53. data/lib/async/matrix/application_service/bot.rb +0 -232
  54. data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
  55. data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
  56. data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
  57. data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
  58. data/lib/async/matrix/application_service/config/schema/config.json +0 -61
  59. data/lib/async/matrix/application_service/config/schema/database.json +0 -38
  60. data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
  61. data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
  62. data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
  63. data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
  64. data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
  65. data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
  66. data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
  67. data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
  68. data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
  69. data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
  70. data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
  71. data/lib/async/matrix/application_service/config/vivify.rb +0 -109
  72. data/lib/async/matrix/application_service/config.rb +0 -225
  73. data/lib/async/matrix/application_service/dispatcher.rb +0 -185
  74. data/lib/async/matrix/application_service/event.rb +0 -285
  75. data/lib/async/matrix/application_service/server.rb +0 -430
  76. data/lib/async/matrix/application_service/transaction.rb +0 -66
  77. data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
  78. data/lib/async/matrix/application_service/transaction_store.rb +0 -80
  79. data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
  80. data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
  81. data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
  82. data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
  83. data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
  84. data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
  85. data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
  86. data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
  87. data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
  88. data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
  89. data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
  90. data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
  91. data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
  92. data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
  93. data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
  94. data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
  95. data/lib/async/matrix/bridge/discord/db.rb +0 -138
  96. data/lib/async/matrix/schema/registry.rb +0 -354
  97. data/lib/async/matrix/schema/validation_error.rb +0 -225
  98. data/lib/async/matrix/schema.rb +0 -170
@@ -0,0 +1,381 @@
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 "yaml"
7
+ require "json"
8
+ require "json_schemer"
9
+ require "pathname"
10
+
11
+ module Protocol
12
+ module Matrix
13
+ module Schema
14
+ # Loads and indexes Matrix event schemas from the official matrix-org/matrix-spec
15
+ # YAML files bundled in data/matrix-spec/event-schemas/schema/.
16
+ #
17
+ # Schemas are loaded lazily on first access and cached for the lifetime of the
18
+ # process. Each schema is a JSONSchemer::Schema instance that resolves relative
19
+ # $ref paths (e.g. core-event-schema/room_event.yaml) via a file-based resolver.
20
+ #
21
+ # registry = Protocol::Matrix::Schema::Registry.instance
22
+ # registry["m.room.message"] # => JSONSchemer::Schema
23
+ # registry.variant("m.room.message", "m.text") # => JSONSchemer::Schema
24
+ # registry.event_types # => ["m.accepted_terms", ...]
25
+ #
26
+ class Registry
27
+ # Custom format validators for Matrix-specific format hints.
28
+ MATRIX_FORMATS = {
29
+ "mx-user-id" => ->(value, _schema) { value.is_a?(String) && value.match?(/\A@[^:]+:.+\z/) },
30
+ "mx-room-id" => ->(value, _schema) { value.is_a?(String) && value.match?(/\A![^:]+:.+\z/) },
31
+ "mx-event-id" => ->(value, _schema) { value.is_a?(String) && value.match?(/\A\$/) },
32
+ "mx-room-alias" => ->(value, _schema) { value.is_a?(String) && value.match?(/\A#[^:]+:.+\z/) },
33
+ "mx-server-name" => ->(value, _schema) { value.is_a?(String) && !value.empty? },
34
+ "mx-unpadded-base64" => ->(value, _schema) { value.is_a?(String) && value.match?(/\A[A-Za-z0-9+\/]*\z/) },
35
+ }.freeze
36
+
37
+ GEM_ROOT = File.expand_path("../../../..", __dir__)
38
+ SCHEMA_DIR = File.join(
39
+ GEM_ROOT,
40
+ "data",
41
+ "matrix-spec",
42
+ "event-schemas",
43
+ "schema",
44
+ )
45
+
46
+ @instance = nil
47
+
48
+ class << self
49
+ # Returns the singleton Registry instance, creating it on first access.
50
+ def instance
51
+ @instance ||= new
52
+ end
53
+
54
+ # Reset the singleton (useful for testing).
55
+ def reset!
56
+ @instance = nil
57
+ end
58
+ end
59
+
60
+ def initialize
61
+ @schemas = {}
62
+ @variants = {}
63
+ @loaded = false
64
+ end
65
+
66
+ # Look up a schema by Matrix event type (e.g. "m.room.message").
67
+ # Returns a JSONSchemer::Schema or nil if no schema exists for that type.
68
+ def [](event_type)
69
+ unless @loaded
70
+ load_schemas!
71
+ end
72
+ @schemas[event_type]
73
+ end
74
+
75
+ # Look up a variant schema (e.g. variant("m.room.message", "m.text")).
76
+ # Returns a JSONSchemer::Schema or nil.
77
+ def variant(event_type, subtype)
78
+ unless @loaded
79
+ load_schemas!
80
+ end
81
+ @variants[[event_type, subtype]]
82
+ end
83
+
84
+ # All known base event types (excludes variant subtypes).
85
+ # @return [Array<String>] sorted event type strings
86
+ def event_types
87
+ unless @loaded
88
+ load_schemas!
89
+ end
90
+ @schemas.keys.sort
91
+ end
92
+
93
+ # All known variant keys as [event_type, subtype] pairs.
94
+ # @return [Array<Array(String, String)>]
95
+ def variant_types
96
+ unless @loaded
97
+ load_schemas!
98
+ end
99
+ @variants.keys.sort
100
+ end
101
+
102
+ # Total number of schemas loaded (base + variants).
103
+ def size
104
+ unless @loaded
105
+ load_schemas!
106
+ end
107
+ @schemas.size + @variants.size
108
+ end
109
+
110
+ # Validate an event hash against its schema.
111
+ # Returns an array of error hashes (empty if valid).
112
+ # If no schema exists for the event type, returns empty (lenient).
113
+ def validate(event_hash)
114
+ event_type = event_hash["type"]
115
+ if event_type
116
+ base = self[event_type]
117
+ if base
118
+ errors = base.validate(event_hash).to_a
119
+ variant_key = detect_variant_key(event_type, event_hash)
120
+ if variant_key
121
+ variant_schema = variant(event_type, variant_key)
122
+ if variant_schema
123
+ errors.concat(variant_schema.validate(event_hash).to_a)
124
+ end
125
+ end
126
+ errors
127
+ else
128
+ []
129
+ end
130
+ else
131
+ []
132
+ end
133
+ end
134
+
135
+ # Boolean validation.
136
+ def valid?(event_hash)
137
+ validate(event_hash).empty?
138
+ end
139
+
140
+ # Content properties defined by the schema for a given event type.
141
+ # Returns an array of property name strings, or empty array if unknown.
142
+ def content_properties(event_type)
143
+ schema = self[event_type]
144
+ if schema
145
+ extract_content_properties(schema.value)
146
+ else
147
+ []
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ # Ref resolver that loads YAML/JSON files from disk.
154
+ # json_schemer calls this with a URI for each $ref it encounters.
155
+ def ref_resolver
156
+ @ref_resolver ||= proc do |uri|
157
+ path = uri.path
158
+ if path && File.exist?(path)
159
+ content = File.read(path)
160
+ if path.end_with?(".yaml", ".yml")
161
+ YAML.safe_load(content, permitted_classes: [Symbol])
162
+ else
163
+ JSON.parse(content)
164
+ end
165
+ else
166
+ raise JSONSchemer::UnknownRef, uri.to_s
167
+ end
168
+ end
169
+ end
170
+
171
+ # Build a JSONSchemer::Schema from a parsed YAML hash, anchored at the
172
+ # given file path so relative $ref paths resolve correctly.
173
+ def build_schemer(parsed, file_path)
174
+ uri = URI("file://#{File.expand_path(file_path)}")
175
+ JSONSchemer.schema(
176
+ parsed,
177
+ base_uri: uri,
178
+ ref_resolver: ref_resolver,
179
+ insert_property_defaults: false,
180
+ formats: MATRIX_FORMATS,
181
+ )
182
+ end
183
+
184
+ # Scan the schema directory and load all event schemas.
185
+ def load_schemas!
186
+ if @loaded
187
+ nil
188
+ else
189
+ Dir.glob(File.join(SCHEMA_DIR, "*.yaml")).each do |path|
190
+ load_schema_file(path)
191
+ end
192
+ Dir.glob(File.join(SCHEMA_DIR, "*.json")).each do |path|
193
+ load_schema_file(path)
194
+ end
195
+ @loaded = true
196
+ end
197
+ end
198
+
199
+ def load_schema_file(path)
200
+ filename = File.basename(path, File.extname(path))
201
+ content = File.read(path)
202
+ if path.end_with?(".json")
203
+ parsed = JSON.parse(content)
204
+ else
205
+ parsed = YAML.safe_load(content, permitted_classes: [Symbol])
206
+ end
207
+
208
+ schemer = build_schemer(parsed, path)
209
+
210
+ # Detect event type from schema: properties.type.enum[0]
211
+ declared_type = parsed.dig("properties", "type", "enum")&.first
212
+
213
+ if filename.include?("$")
214
+ # Variant schema: "m.room.message$m.text" -> base="m.room.message", sub="m.text"
215
+ parts = filename.split("$", 2)
216
+ base_type = declared_type || parts[0]
217
+ subtype = parts[1]
218
+ @variants[[base_type, subtype]] = schemer
219
+ elsif declared_type
220
+ @schemas[declared_type] = schemer
221
+ else
222
+ # Schemas without a type enum (like core-event-schema/) are skipped
223
+ # from the index -- they're only used as $ref targets.
224
+ end
225
+ rescue => e
226
+ # Don't let a single broken schema prevent loading the rest
227
+ warn "async-matrix: failed to load schema #{path}: #{e.message}"
228
+ end
229
+
230
+ # Detect the variant subtype key from an event hash.
231
+ # For m.room.message, the variant is the msgtype.
232
+ # For m.room.encrypted, the variant is the algorithm.
233
+ # For m.key.verification.start, the variant is the method.
234
+ def detect_variant_key(event_type, event_hash)
235
+ content = event_hash["content"]
236
+ if content.is_a?(Hash)
237
+ case event_type
238
+ when "m.room.message"
239
+ content["msgtype"]
240
+ when "m.room.encrypted"
241
+ content["algorithm"]
242
+ when "m.key.verification.start"
243
+ content["method"]
244
+ end
245
+ else
246
+ nil
247
+ end
248
+ end
249
+
250
+ # Extract content property names from a schema's parsed YAML.
251
+ def extract_content_properties(schema_hash)
252
+ props = schema_hash.dig("properties", "content", "properties")
253
+ if props.is_a?(Hash)
254
+ props.keys
255
+ else
256
+ []
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+
264
+ __END__
265
+ describe "Protocol::Matrix::Schema::Registry" do
266
+ it "loads schemas from disk" do
267
+ Protocol::Matrix::Schema::Registry.instance.size.should.be > 0
268
+ end
269
+
270
+ it "has known event types" do
271
+ types = Protocol::Matrix::Schema::Registry.instance.event_types
272
+ types.should.include "m.room.message"
273
+ types.should.include "m.room.member"
274
+ types.should.include "m.room.create"
275
+ types.should.include "m.reaction"
276
+ end
277
+
278
+ it "returns a JSONSchemer::Schema for a known type" do
279
+ schema = Protocol::Matrix::Schema::Registry.instance["m.room.message"]
280
+ schema.should.not.be.nil
281
+ schema.should.be.kind_of JSONSchemer::Schema
282
+ end
283
+
284
+ it "returns nil for an unknown type" do
285
+ Protocol::Matrix::Schema::Registry.instance["m.fake.event"].should.be.nil
286
+ end
287
+
288
+ it "has variant schemas" do
289
+ Protocol::Matrix::Schema::Registry.instance.variant_types.should.not.be.empty
290
+ end
291
+
292
+ it "resolves variant schemas" do
293
+ schema = Protocol::Matrix::Schema::Registry.instance.variant("m.room.message", "m.text")
294
+ schema.should.not.be.nil
295
+ schema.should.be.kind_of JSONSchemer::Schema
296
+ end
297
+
298
+ it "validates a correct m.room.message event" do
299
+ event = {
300
+ "type" => "m.room.message",
301
+ "content" => {"msgtype" => "m.text", "body" => "hello"},
302
+ "event_id" => "$abc123",
303
+ "sender" => "@alice:example.org",
304
+ "origin_server_ts" => 1234567890,
305
+ "room_id" => "!room:example.org"
306
+ }
307
+ Protocol::Matrix::Schema::Registry.instance.valid?(event).should == true
308
+ end
309
+
310
+ it "rejects m.room.message missing body" do
311
+ event = {
312
+ "type" => "m.room.message",
313
+ "content" => {"msgtype" => "m.text"},
314
+ "event_id" => "$abc123",
315
+ "sender" => "@alice:example.org",
316
+ "origin_server_ts" => 1234567890,
317
+ "room_id" => "!room:example.org"
318
+ }
319
+ Protocol::Matrix::Schema::Registry.instance.valid?(event).should == false
320
+ end
321
+
322
+ it "is lenient with unknown event types" do
323
+ event = {
324
+ "type" => "m.room.other",
325
+ "content" => {"msgtype" => "m.text", "body" => "hi"},
326
+ "event_id" => "$abc123",
327
+ "sender" => "@alice:example.org",
328
+ "origin_server_ts" => 1234567890,
329
+ "room_id" => "!room:example.org"
330
+ }
331
+ Protocol::Matrix::Schema::Registry.instance.valid?(event).should == true
332
+ end
333
+
334
+ it "validates m.room.member with correct membership" do
335
+ event = {
336
+ "type" => "m.room.member",
337
+ "content" => {"membership" => "join"},
338
+ "state_key" => "@alice:example.org",
339
+ "event_id" => "$abc123",
340
+ "sender" => "@alice:example.org",
341
+ "origin_server_ts" => 1234567890,
342
+ "room_id" => "!room:example.org"
343
+ }
344
+ Protocol::Matrix::Schema::Registry.instance.valid?(event).should == true
345
+ end
346
+
347
+ it "rejects m.room.member with invalid membership" do
348
+ event = {
349
+ "type" => "m.room.member",
350
+ "content" => {"membership" => "invalid_state"},
351
+ "state_key" => "@alice:example.org",
352
+ "event_id" => "$abc123",
353
+ "sender" => "@alice:example.org",
354
+ "origin_server_ts" => 1234567890,
355
+ "room_id" => "!room:example.org"
356
+ }
357
+ Protocol::Matrix::Schema::Registry.instance.valid?(event).should == false
358
+ end
359
+
360
+ it "returns content properties for known types" do
361
+ props = Protocol::Matrix::Schema::Registry.instance.content_properties("m.room.message")
362
+ props.should.include "msgtype"
363
+ props.should.include "body"
364
+ end
365
+
366
+ it "returns empty content properties for unknown types" do
367
+ Protocol::Matrix::Schema::Registry.instance.content_properties("m.fake.type").should == []
368
+ end
369
+
370
+ it "performs variant validation for m.room.message with msgtype" do
371
+ event = {
372
+ "type" => "m.room.message",
373
+ "content" => {"msgtype" => "m.text", "body" => "hello"},
374
+ "event_id" => "$abc123",
375
+ "sender" => "@alice:example.org",
376
+ "origin_server_ts" => 1234567890,
377
+ "room_id" => "!room:example.org"
378
+ }
379
+ Protocol::Matrix::Schema::Registry.instance.validate(event).should == []
380
+ end
381
+ end
@@ -0,0 +1,234 @@
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 "../error"
7
+
8
+ module Protocol
9
+ module Matrix
10
+ module Schema
11
+ # Raised by Event#valid! when data fails schema validation.
12
+ #
13
+ # Produces detailed, human-readable error messages that identify the exact
14
+ # key path and offending value.
15
+ #
16
+ # Schema validation failed for m.room.member event $abc123:
17
+ # - content.membership = "invalid" -- must be one of: ["invite", "join", "knock", "leave", "ban"]
18
+ # - sender is required but missing
19
+ #
20
+ # Carries an errcode, which the base supports: M_BAD_JSON is what a
21
+ # homeserver answers for a payload that fails its schema.
22
+ class ValidationError < Protocol::Matrix::Error
23
+ attr_reader :errors, :event_type, :event_id
24
+
25
+ # @param errors [Array<Hash>] raw JSONSchemer error hashes
26
+ # @param event_type [String, nil] Matrix event type (e.g. "m.room.member")
27
+ # @param event_id [String, nil] Matrix event ID (e.g. "$abc123")
28
+ def initialize(errors, event_type: nil, event_id: nil)
29
+ @errors = errors
30
+ @event_type = event_type
31
+ @event_id = event_id
32
+ super("M_BAD_JSON", build_message)
33
+ end
34
+
35
+ private
36
+
37
+ def build_message
38
+ lines = [header_line]
39
+ @errors.each { |e| lines.concat(Array(format_error(e))) }
40
+ lines.join("\n")
41
+ end
42
+
43
+ def header_line
44
+ header = "Schema validation failed"
45
+ if @event_type
46
+ header += " for #{@event_type}"
47
+ end
48
+ if @event_id
49
+ header += " event #{@event_id}"
50
+ end
51
+ header + ":"
52
+ end
53
+
54
+ def format_error(error)
55
+ path = pointer_to_dot(error["data_pointer"])
56
+ type = error["type"]
57
+ data = error["data"]
58
+
59
+ case type
60
+ when "required"
61
+ format_required(error, path)
62
+ when "string", "integer", "number", "boolean", "array", "object", "null"
63
+ " - #{path} = #{truncate(data.inspect)} -- expected #{type}, got #{data.class}"
64
+ when "minimum"
65
+ " - #{path} = #{truncate(data.inspect)} -- must be >= #{error.dig("schema", "minimum")}"
66
+ when "maximum"
67
+ " - #{path} = #{truncate(data.inspect)} -- must be <= #{error.dig("schema", "maximum")}"
68
+ when "enum"
69
+ allowed = error.dig("schema", "enum")
70
+ " - #{path} = #{truncate(data.inspect)} -- must be one of: #{truncate(allowed.inspect)}"
71
+ when "pattern"
72
+ pattern = error.dig("schema", "pattern")
73
+ " - #{path} = #{truncate(data.inspect)} -- does not match pattern: #{pattern}"
74
+ when "format"
75
+ fmt = error.dig("schema", "format")
76
+ " - #{path} = #{truncate(data.inspect)} -- invalid #{fmt} format"
77
+ when "minLength"
78
+ " - #{path} = #{truncate(data.inspect)} -- length must be >= #{error.dig("schema", "minLength")}"
79
+ when "maxLength"
80
+ " - #{path} = #{truncate(data.inspect)} -- length must be <= #{error.dig("schema", "maxLength")}"
81
+ when "minItems"
82
+ " - #{path} -- array must have >= #{error.dig("schema", "minItems")} items"
83
+ when "maxItems"
84
+ " - #{path} -- array must have <= #{error.dig("schema", "maxItems")} items"
85
+ when "uniqueItems"
86
+ " - #{path} -- array items must be unique"
87
+ when "const"
88
+ expected = error.dig("schema", "const")
89
+ " - #{path} = #{truncate(data.inspect)} -- must be #{truncate(expected.inspect)}"
90
+ when "additionalProperties"
91
+ " - #{path}: #{error["error"] || "has additional properties that are not allowed"}"
92
+ else
93
+ msg = error["error"] || type
94
+ " - #{path}: #{msg}"
95
+ end
96
+ end
97
+
98
+ def format_required(error, path)
99
+ missing = error.dig("details", "missing_keys") || []
100
+ if missing.empty?
101
+ " - #{path}: #{error["error"] || "required"}"
102
+ else
103
+ missing.map { |key| " - #{join_path(path, key)} is required but missing" }
104
+ end
105
+ end
106
+
107
+ def pointer_to_dot(pointer)
108
+ if pointer.nil? || pointer.empty?
109
+ "root"
110
+ else
111
+ pointer.delete_prefix("/").gsub("/", ".")
112
+ end
113
+ end
114
+
115
+ def join_path(base, key)
116
+ base == "root" ? key.to_s : "#{base}.#{key}"
117
+ end
118
+
119
+ def truncate(str, max: 60)
120
+ str.length > max ? "#{str[0...max]}..." : str
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ __END__
128
+ describe "Protocol::Matrix::Schema::ValidationError" do
129
+ def error_hash(overrides = {})
130
+ {
131
+ "data" => nil,
132
+ "data_pointer" => "",
133
+ "schema" => {},
134
+ "schema_pointer" => "",
135
+ "root_schema" => {},
136
+ "type" => "unknown",
137
+ "error" => "something went wrong"
138
+ }.merge(overrides)
139
+ end
140
+
141
+ it "includes event type in header" do
142
+ err = Protocol::Matrix::Schema::ValidationError.new(
143
+ [error_hash],
144
+ event_type: "m.room.message"
145
+ )
146
+ err.message.should.include "Schema validation failed for m.room.message"
147
+ end
148
+
149
+ it "includes event ID in header" do
150
+ err = Protocol::Matrix::Schema::ValidationError.new(
151
+ [error_hash],
152
+ event_type: "m.room.member",
153
+ event_id: "$abc123"
154
+ )
155
+ err.message.should.include "m.room.member event $abc123"
156
+ end
157
+
158
+ it "formats type mismatch errors" do
159
+ err = Protocol::Matrix::Schema::ValidationError.new([
160
+ error_hash(
161
+ "data_pointer" => "/content/body",
162
+ "type" => "string",
163
+ "data" => 42
164
+ )
165
+ ])
166
+ err.message.should.include "content.body = 42 -- expected string, got Integer"
167
+ end
168
+
169
+ it "formats required errors with missing keys" do
170
+ err = Protocol::Matrix::Schema::ValidationError.new([
171
+ error_hash(
172
+ "data_pointer" => "/content",
173
+ "type" => "required",
174
+ "details" => {"missing_keys" => ["msgtype", "body"]}
175
+ )
176
+ ])
177
+ err.message.should.include "content.msgtype is required but missing"
178
+ err.message.should.include "content.body is required but missing"
179
+ end
180
+
181
+ it "formats enum errors" do
182
+ err = Protocol::Matrix::Schema::ValidationError.new([
183
+ error_hash(
184
+ "data_pointer" => "/content/membership",
185
+ "type" => "enum",
186
+ "data" => "invalid",
187
+ "schema" => {"enum" => %w[invite join knock leave ban]}
188
+ )
189
+ ])
190
+ err.message.should.include 'content.membership = "invalid" -- must be one of:'
191
+ err.message.should.include "invite"
192
+ end
193
+
194
+ it "formats pattern errors" do
195
+ err = Protocol::Matrix::Schema::ValidationError.new([
196
+ error_hash(
197
+ "data_pointer" => "/state_key",
198
+ "type" => "pattern",
199
+ "data" => "bad",
200
+ "schema" => {"pattern" => "^@"}
201
+ )
202
+ ])
203
+ err.message.should.include 'state_key = "bad" -- does not match pattern: ^@'
204
+ end
205
+
206
+ it "formats format errors" do
207
+ err = Protocol::Matrix::Schema::ValidationError.new([
208
+ error_hash(
209
+ "data_pointer" => "/content/avatar_url",
210
+ "type" => "format",
211
+ "data" => "not-a-uri",
212
+ "schema" => {"format" => "uri"}
213
+ )
214
+ ])
215
+ err.message.should.include "invalid uri format"
216
+ end
217
+
218
+ it "truncates long values" do
219
+ err = Protocol::Matrix::Schema::ValidationError.new([
220
+ error_hash(
221
+ "data_pointer" => "/content/body",
222
+ "type" => "integer",
223
+ "data" => "a" * 200
224
+ )
225
+ ])
226
+ err.message.should.include "..."
227
+ end
228
+
229
+ it "exposes the raw errors array" do
230
+ raw = [error_hash]
231
+ err = Protocol::Matrix::Schema::ValidationError.new(raw)
232
+ err.errors.should.equal raw
233
+ end
234
+ end