async-matrix 1.0.0 → 1.1.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/discord-api-spec/openapi.json +40404 -0
  4. data/lib/async/discord/api/path_tree.rb +130 -0
  5. data/lib/async/discord/api.rb +156 -0
  6. data/lib/async/discord/client.rb +286 -0
  7. data/lib/async/discord/error.rb +88 -0
  8. data/lib/async/discord/gateway.rb +362 -0
  9. data/lib/async/discord.rb +16 -0
  10. data/lib/async/matrix/api/chain.rb +9 -0
  11. data/lib/async/matrix/application_service/config/vivify.rb +3 -0
  12. data/lib/async/matrix/application_service/event.rb +9 -20
  13. data/lib/async/matrix/application_service/server.rb +2 -2
  14. data/lib/async/matrix/bridge/discord/db/connection.rb +143 -0
  15. data/lib/async/matrix/bridge/discord/db/file.rb +120 -0
  16. data/lib/async/matrix/bridge/discord/db/guild.rb +122 -0
  17. data/lib/async/matrix/bridge/discord/db/message.rb +162 -0
  18. data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +14 -0
  19. data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +14 -0
  20. data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +23 -0
  21. data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +19 -0
  22. data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +20 -0
  23. data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +19 -0
  24. data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +18 -0
  25. data/lib/async/matrix/bridge/discord/db/portal.rb +152 -0
  26. data/lib/async/matrix/bridge/discord/db/puppet.rb +130 -0
  27. data/lib/async/matrix/bridge/discord/db/reaction.rb +167 -0
  28. data/lib/async/matrix/bridge/discord/db/user.rb +114 -0
  29. data/lib/async/matrix/bridge/discord/db.rb +140 -0
  30. data/lib/async/matrix/double_puppet_client.rb +84 -0
  31. data/lib/async/matrix/schema.rb +2 -2
  32. data/lib/async/matrix/server.rb +1 -0
  33. data/lib/async/matrix/version.rb +1 -1
  34. data/lib/async/matrix.rb +2 -0
  35. metadata +67 -1
@@ -0,0 +1,130 @@
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 "bundler/setup"
7
+ require "json"
8
+ require "pathname"
9
+ require "uri"
10
+ require "async/discord"
11
+ require "async/matrix"
12
+
13
+ module Async
14
+ module Discord
15
+ module Api
16
+ # Loads the Discord HTTP API OpenAPI spec and builds a PathTree trie
17
+ # of all valid endpoints. Reuses the core Node/insert/match logic from
18
+ # Async::Matrix::Api::PathTree.
19
+ #
20
+ # The Discord spec is a single JSON file (OpenAPI 3.1.0) with the server
21
+ # URL https://discord.com/api/v10, yielding prefix segments ["api", "v10"].
22
+ #
23
+ # tree = PathTree.load
24
+ # tree.match(%w[api v10 channels 123 messages], "POST")
25
+ # # => { valid: true, operation_id: "create_message", methods: ["post"] }
26
+ #
27
+ class PathTree < Async::Matrix::Api::PathTree
28
+ SCHEMA_PATH = Pathname.new(File.expand_path("../../../../data/discord-api-spec/openapi.json", __dir__)).freeze
29
+
30
+ def self.load(schema_path: SCHEMA_PATH)
31
+ tree = new
32
+ tree.load_json_schema(schema_path)
33
+ tree
34
+ end
35
+
36
+ # Parse the Discord OpenAPI JSON file and insert all paths into the trie.
37
+ def load_json_schema(path)
38
+ doc = JSON.parse(File.read(path))
39
+
40
+ base_path = extract_json_base_path(doc)
41
+ paths = doc["paths"]
42
+ return unless paths.is_a?(Hash)
43
+
44
+ paths.each do |path_template, methods_hash|
45
+ next unless methods_hash.is_a?(Hash)
46
+
47
+ full_path = "#{base_path}#{path_template.strip}"
48
+ segments = full_path.split("/").reject(&:empty?)
49
+
50
+ methods_hash.each do |method, operation|
51
+ # Skip path-level parameters (Discord spec puts these alongside methods)
52
+ next if method == "parameters"
53
+ next unless %w[get post put delete patch head].include?(method)
54
+ operation_id = operation.is_a?(Hash) ? operation["operationId"] : nil
55
+ insert(segments, method, operation_id)
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def extract_json_base_path(doc)
63
+ servers = doc["servers"]
64
+ return "" unless servers.is_a?(Array) && servers.first.is_a?(Hash)
65
+
66
+ url = servers.first["url"]
67
+ return "" unless url
68
+
69
+ URI.parse(url).path
70
+ rescue URI::InvalidURIError
71
+ ""
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ test do
79
+ describe "Async::Discord::Api::PathTree" do
80
+ it "loads from the Discord OpenAPI spec" do
81
+ tree = Async::Discord::Api::PathTree.load
82
+
83
+ # channels/{id}/messages should exist
84
+ result = tree.match(%w[api v10 channels 123456 messages], "POST")
85
+ result[:valid].should == true
86
+ result[:operation_id].should == "create_message"
87
+
88
+ # guilds/{id} should exist
89
+ result = tree.match(%w[api v10 guilds 789 channels], "GET")
90
+ result[:valid].should == true
91
+
92
+ # users/@me should exist
93
+ result = tree.match(%w[api v10 users @me], "GET")
94
+ result[:valid].should == true
95
+ end
96
+
97
+ it "rejects unknown paths" do
98
+ tree = Async::Discord::Api::PathTree.load
99
+ result = tree.match(%w[api v10 totallyFake], "GET")
100
+ result[:valid].should == false
101
+ end
102
+
103
+ it "rejects wrong HTTP method" do
104
+ tree = Async::Discord::Api::PathTree.load
105
+ # GET on a POST-only endpoint
106
+ result = tree.match(%w[api v10 channels 123 messages], "DELETE")
107
+ # DELETE is not valid on /channels/{id}/messages (only GET and POST)
108
+ result[:valid].should == false
109
+ end
110
+
111
+ it "supports PATCH method (Discord uses it heavily)" do
112
+ tree = Async::Discord::Api::PathTree.load
113
+ # PATCH /channels/{id} should exist
114
+ result = tree.match(%w[api v10 channels 123], "PATCH")
115
+ result[:valid].should == true
116
+ end
117
+
118
+ it "inherits from Async::Matrix::Api::PathTree" do
119
+ Async::Discord::Api::PathTree.ancestors.should.include Async::Matrix::Api::PathTree
120
+ end
121
+
122
+ it "supports manual insert and match" do
123
+ tree = Async::Discord::Api::PathTree.new
124
+ tree.insert(%w[api v10 test {id} action], "post", "testAction")
125
+ result = tree.match(%w[api v10 test 12345 action], "POST")
126
+ result[:valid].should == true
127
+ result[:operation_id].should == "testAction"
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,156 @@
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 "bundler/setup"
7
+ require "async/discord"
8
+ require "async/matrix"
9
+
10
+ module Async
11
+ module Discord
12
+ # Runtime-generated Discord HTTP API built from the official OpenAPI spec.
13
+ #
14
+ # Loads the OpenAPI 3.1.0 JSON file from data/discord-api-spec/openapi.json
15
+ # at require-time and builds a PathTree trie of all valid endpoints. API calls
16
+ # are constructed via method chains (reusing Async::Matrix::Api::Chain),
17
+ # validated against the tree, and terminated by .get(), .post(), .put(),
18
+ # .patch(), or .delete().
19
+ #
20
+ # Usage:
21
+ # client.api.channels("123456").messages.post(content: "hello")
22
+ # client.api.guilds("789").members.get(limit: 100)
23
+ # client.api.users("@me").get
24
+ #
25
+ module Api
26
+ # The shared PathTree instance, loaded once from the bundled spec.
27
+ def self.path_tree
28
+ @path_tree ||= PathTree.load
29
+ end
30
+
31
+ # Reset the cached path tree (useful for testing or reloading).
32
+ def self.reset!
33
+ @path_tree = nil
34
+ end
35
+
36
+ # Gateway produces Chain instances bound to a specific Discord Client.
37
+ # Each call to a method on the Gateway starts a new chain.
38
+ class Gateway
39
+ def initialize(client, prefix: %w[api v10])
40
+ @client = client
41
+ @prefix = prefix
42
+ end
43
+
44
+ # Start a fresh chain. Every method call on the gateway creates
45
+ # a new chain so chains are never reused.
46
+ def chain
47
+ Async::Matrix::Api::Chain.new(
48
+ client: @client,
49
+ path_tree: Api.path_tree,
50
+ prefix: @prefix
51
+ )
52
+ end
53
+
54
+ # Forward all unknown methods to a fresh chain, starting the path.
55
+ def method_missing(name, *args, **kwargs, &block)
56
+ if name.start_with?("to_")
57
+ super
58
+ else
59
+ chain.__send__(name, *args, **kwargs, &block)
60
+ end
61
+ end
62
+
63
+ def respond_to_missing?(name, include_private = false)
64
+ !name.start_with?("to_") || super
65
+ end
66
+
67
+ def inspect
68
+ "#<#{self.class} prefix=/#{@prefix.join("/")}>"
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ test do
76
+ describe "Async::Discord::Api::Gateway" do
77
+ # Stub client that records calls instead of making HTTP requests.
78
+ StubDiscordClient = Struct.new(:calls) do
79
+ def initialize
80
+ super([])
81
+ end
82
+
83
+ def get(path, max_retries: nil)
84
+ calls << [:get, path, {max_retries: max_retries}]
85
+ {"stub" => true}
86
+ end
87
+
88
+ def post(path, body = {}, max_retries: nil)
89
+ calls << [:post, path, body, {max_retries: max_retries}]
90
+ {"stub" => true}
91
+ end
92
+
93
+ def put(path, body = {}, max_retries: nil)
94
+ calls << [:put, path, body, {max_retries: max_retries}]
95
+ {"stub" => true}
96
+ end
97
+
98
+ def request(method, path, body = nil, max_retries: nil)
99
+ calls << [method.downcase.to_sym, path, body, {max_retries: max_retries}]
100
+ {"stub" => true}
101
+ end
102
+
103
+ # Chain checks for media_client on binary routes; Discord doesn't need it
104
+ # but we provide a stub to avoid NoMethodError.
105
+ def media_client
106
+ nil
107
+ end
108
+ end
109
+
110
+ def make_gateway
111
+ client = StubDiscordClient.new
112
+ gateway = Async::Discord::Api::Gateway.new(client)
113
+ [gateway, client]
114
+ end
115
+
116
+ it "GET /users/@me" do
117
+ gw, client = make_gateway
118
+ gw.users("@me").get
119
+ client.calls.last[0].should == :get
120
+ client.calls.last[1].should == "/api/v10/users/%40me"
121
+ end
122
+
123
+ it "POST /channels/{id}/messages" do
124
+ gw, client = make_gateway
125
+ gw.channels("123456").messages.post(content: "hello world")
126
+ client.calls.last[0].should == :post
127
+ client.calls.last[1].should == "/api/v10/channels/123456/messages"
128
+ client.calls.last[2].should == {content: "hello world"}
129
+ end
130
+
131
+ it "GET /guilds/{id}/channels" do
132
+ gw, client = make_gateway
133
+ gw.guilds("789").channels.get
134
+ client.calls.last[0].should == :get
135
+ client.calls.last[1].should == "/api/v10/guilds/789/channels"
136
+ end
137
+
138
+ it "PATCH /channels/{id} via request" do
139
+ gw, client = make_gateway
140
+ gw.channels("123456").patch(name: "new-name")
141
+ client.calls.last[0].should == :patch
142
+ end
143
+
144
+ it "each gateway method call starts a fresh chain" do
145
+ gw, client = make_gateway
146
+ gw.users("@me").get
147
+ gw.channels("123").messages.post(content: "hi")
148
+ client.calls.length.should == 2
149
+ end
150
+
151
+ it "raises InvalidEndpointError for unknown path" do
152
+ gw, _ = make_gateway
153
+ lambda { gw.totally.bogus.endpoint.get }.should.raise Async::Matrix::InvalidEndpointError
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,286 @@
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 "bundler/setup"
7
+ require "async/http/internet"
8
+ require "async/discord"
9
+ require "json"
10
+ require "erb"
11
+ require "console"
12
+ require "time"
13
+
14
+ module Async
15
+ module Discord
16
+ # Async HTTP client for the Discord REST API.
17
+ #
18
+ # Authenticated with a Bot token. All methods are fiber-safe and run
19
+ # naturally inside Falcon's async reactor.
20
+ #
21
+ # client = Async::Discord::Client.new(token: "MTk...")
22
+ # client.api.channels("123").messages.post(content: "hello")
23
+ # client.api.users("@me").get
24
+ #
25
+ class Client
26
+ DEFAULT_BASE_URL = "https://discord.com"
27
+
28
+ # Retry defaults
29
+ DEFAULT_MAX_RETRIES = 3
30
+ DEFAULT_RETRY_BASE = 0.5
31
+ DEFAULT_MAX_RETRY_DELAY = 30
32
+
33
+ # Status codes eligible for retry
34
+ RATE_LIMIT_STATUS = 429
35
+ GATEWAY_ERROR_STATUSES = [502, 503, 504].freeze
36
+
37
+ # Response size limits (bytes)
38
+ DEFAULT_RESPONSE_SIZE_LIMIT = 50 * 1024 * 1024 # 50 MiB
39
+ DEFAULT_ERROR_RESPONSE_SIZE_LIMIT = 512 * 1024 # 512 KiB
40
+
41
+ attr_reader :token
42
+
43
+ def initialize(token:, base_url: DEFAULT_BASE_URL,
44
+ max_retries: DEFAULT_MAX_RETRIES,
45
+ retry_base_delay: DEFAULT_RETRY_BASE,
46
+ max_retry_delay: DEFAULT_MAX_RETRY_DELAY,
47
+ response_size_limit: DEFAULT_RESPONSE_SIZE_LIMIT,
48
+ error_response_size_limit: DEFAULT_ERROR_RESPONSE_SIZE_LIMIT)
49
+ @token = token
50
+ @base = base_url
51
+ @max_retries = max_retries
52
+ @retry_base_delay = retry_base_delay
53
+ @max_retry_delay = max_retry_delay
54
+ @response_size_limit = response_size_limit
55
+ @error_response_size_limit = error_response_size_limit
56
+ @headers = [
57
+ ["authorization", "Bot #{token}"],
58
+ ["content-type", "application/json"],
59
+ ["user-agent", "AsyncDiscord (https://github.com/general-intelligence-systems/async-matrix, 1.0)"]
60
+ ]
61
+ end
62
+
63
+ # ── Full API (runtime-generated from OpenAPI spec) ────────
64
+
65
+ # Returns a Gateway that provides method-chained access to every
66
+ # Discord HTTP API endpoint. Chains are validated against the official
67
+ # OpenAPI path tree and terminated by .get(), .post(), .put(),
68
+ # .patch(), or .delete().
69
+ #
70
+ # client.api.channels("123").messages.post(content: "hello")
71
+ # client.api.guilds("789").get
72
+ # client.api.users("@me").get
73
+ #
74
+ def api
75
+ Api::Gateway.new(self)
76
+ end
77
+
78
+ # ── Low-level HTTP ────────────────────────────────────────
79
+
80
+ def get(path, max_retries: nil)
81
+ request("GET", path, nil, max_retries: max_retries)
82
+ end
83
+
84
+ def post(path, body = {}, max_retries: nil)
85
+ request("POST", path, body, max_retries: max_retries)
86
+ end
87
+
88
+ def put(path, body = {}, max_retries: nil)
89
+ request("PUT", path, body, max_retries: max_retries)
90
+ end
91
+
92
+ def close
93
+ @internet&.close
94
+ @internet = nil
95
+ end
96
+
97
+ # General-purpose request method supporting any HTTP method.
98
+ def request(method, path, body = nil, max_retries: nil)
99
+ url = "#{@base}#{path}"
100
+ json_body = body ? JSON.generate(body) : nil
101
+ effective_max_retries = max_retries || @max_retries
102
+
103
+ Console.debug(self) { "#{method} #{path}" }
104
+
105
+ attempt = 0
106
+ loop do
107
+ response = internet.call(method, url, @headers, json_body)
108
+ status = response.status
109
+
110
+ if (200..299).cover?(status)
111
+ payload = read_limited(response, @response_size_limit)
112
+ return payload && !payload.empty? ? JSON.parse(payload) : {}
113
+ end
114
+
115
+ attempt += 1
116
+
117
+ if attempt <= effective_max_retries && retryable_status?(status)
118
+ delay = compute_retry_delay(status, response, attempt)
119
+ Console.warn(self) {
120
+ "#{method} #{path} returned #{status}, retry #{attempt}/#{effective_max_retries} in #{delay.round(2)}s"
121
+ }
122
+ response.close if response.respond_to?(:close)
123
+ sleep(delay)
124
+ next
125
+ end
126
+
127
+ payload = read_limited(response, @error_response_size_limit)
128
+ parsed = begin; JSON.parse(payload || "{}"); rescue; {} end
129
+ discord_code = parsed["code"]
130
+ discord_msg = parsed["message"] || payload.to_s[0..200]
131
+
132
+ Console.error(self) { "Discord API #{status}: #{discord_code} — #{discord_msg}" }
133
+
134
+ error_class = case status
135
+ when 401 then AuthError
136
+ when 429 then RateLimitError
137
+ when 400..499 then ApiError
138
+ else ServerError
139
+ end
140
+
141
+ raise error_class.new(
142
+ discord_code.to_s,
143
+ discord_msg,
144
+ status: status
145
+ )
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def internet
152
+ @internet ||= Async::HTTP::Internet.new
153
+ end
154
+
155
+ # ── Response size limiting ──────────────────────────────
156
+
157
+ def read_limited(response, limit)
158
+ body = response.body
159
+ return nil unless body
160
+
161
+ if body.respond_to?(:length) && body.length && body.length > limit
162
+ body.close
163
+ raise ResponseTooLargeError.new(
164
+ "TOO_LARGE",
165
+ "Response Content-Length #{body.length} bytes exceeds limit of #{limit} bytes"
166
+ )
167
+ end
168
+
169
+ buffer = String.new(encoding: Encoding::BINARY)
170
+ body.each do |chunk|
171
+ buffer << chunk
172
+ if buffer.bytesize > limit
173
+ body.close
174
+ raise ResponseTooLargeError.new(
175
+ "TOO_LARGE",
176
+ "Response body exceeds limit of #{limit} bytes"
177
+ )
178
+ end
179
+ end
180
+ buffer.empty? ? nil : buffer
181
+ end
182
+
183
+ # ── Retry logic ─────────────────────────────────────────
184
+
185
+ def retryable_status?(status)
186
+ status == RATE_LIMIT_STATUS || GATEWAY_ERROR_STATUSES.include?(status)
187
+ end
188
+
189
+ # Discord sends Retry-After as seconds (float) in the JSON body on 429,
190
+ # and also as X-RateLimit-Reset-After header. We check both.
191
+ def compute_retry_delay(status, response, attempt)
192
+ if status == RATE_LIMIT_STATUS
193
+ server_delay = parse_rate_limit_delay(response)
194
+ delay = server_delay || exponential_delay(attempt)
195
+ [delay, @max_retry_delay].min
196
+ else
197
+ calculated = exponential_delay(attempt)
198
+ rand(0.0..[calculated, @max_retry_delay].min)
199
+ end
200
+ end
201
+
202
+ def exponential_delay(attempt)
203
+ @retry_base_delay * (2 ** (attempt - 1))
204
+ end
205
+
206
+ # Parse Discord rate limit delay. Checks:
207
+ # 1. X-RateLimit-Reset-After header (seconds as float)
208
+ # 2. Retry-After header (seconds as integer)
209
+ def parse_rate_limit_delay(response)
210
+ reset_after = response.headers["x-ratelimit-reset-after"]
211
+ return reset_after.to_f if reset_after
212
+
213
+ retry_after = response.headers["retry-after"]
214
+ return retry_after.to_f if retry_after && retry_after.strip.match?(/\A[\d.]+\z/)
215
+
216
+ nil
217
+ end
218
+
219
+ def encode(value)
220
+ ERB::Util.url_encode(value)
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+ test do
227
+ describe "Async::Discord::Client" do
228
+ it "sets authorization header with Bot prefix" do
229
+ client = Async::Discord::Client.new(token: "test_token_123")
230
+ headers = client.instance_variable_get(:@headers)
231
+ auth = headers.find { |k, _| k == "authorization" }
232
+ auth[1].should == "Bot test_token_123"
233
+ end
234
+
235
+ it "stores the token" do
236
+ client = Async::Discord::Client.new(token: "my_token")
237
+ client.token.should == "my_token"
238
+ end
239
+
240
+ it "defaults base URL to discord.com" do
241
+ client = Async::Discord::Client.new(token: "tok")
242
+ client.instance_variable_get(:@base).should == "https://discord.com"
243
+ end
244
+
245
+ it "accepts custom base URL" do
246
+ client = Async::Discord::Client.new(token: "tok", base_url: "http://localhost:9999")
247
+ client.instance_variable_get(:@base).should == "http://localhost:9999"
248
+ end
249
+
250
+ it "responds to HTTP methods" do
251
+ client = Async::Discord::Client.new(token: "tok")
252
+ client.should.respond_to :get
253
+ client.should.respond_to :post
254
+ client.should.respond_to :put
255
+ client.should.respond_to :request
256
+ client.should.respond_to :close
257
+ end
258
+
259
+ it "responds to api" do
260
+ client = Async::Discord::Client.new(token: "tok")
261
+ client.should.respond_to :api
262
+ end
263
+
264
+ it "returns a Discord Api::Gateway from api" do
265
+ client = Async::Discord::Client.new(token: "tok")
266
+ client.api.should.be.kind_of Async::Discord::Api::Gateway
267
+ end
268
+
269
+ it "accepts custom retry configuration" do
270
+ client = Async::Discord::Client.new(
271
+ token: "tok",
272
+ max_retries: 5,
273
+ retry_base_delay: 1.0,
274
+ max_retry_delay: 60
275
+ )
276
+ client.instance_variable_get(:@max_retries).should == 5
277
+ client.instance_variable_get(:@retry_base_delay).should == 1.0
278
+ client.instance_variable_get(:@max_retry_delay).should == 60
279
+ end
280
+
281
+ it "can be closed without error" do
282
+ client = Async::Discord::Client.new(token: "tok")
283
+ lambda { client.close }.should.not.raise
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,88 @@
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 "bundler/setup"
7
+ require "async/discord"
8
+
9
+ module Async
10
+ module Discord
11
+ # Base error for all Discord API errors.
12
+ class Error < StandardError
13
+ attr_reader :code, :status
14
+
15
+ def initialize(code, message, status: nil)
16
+ @code = code
17
+ @status = status
18
+ super(message)
19
+ end
20
+ end
21
+
22
+ # Raised when authentication fails (401).
23
+ class AuthError < Error; end
24
+
25
+ # Raised on non-retryable Discord API errors (4xx).
26
+ class ApiError < Error; end
27
+
28
+ # Raised when rate-limited and retries are exhausted.
29
+ class RateLimitError < Error; end
30
+
31
+ # Raised on server errors after retries exhausted (5xx).
32
+ class ServerError < Error; end
33
+
34
+ # Raised when a response body exceeds the configured size limit.
35
+ class ResponseTooLargeError < Error; end
36
+
37
+ # Raised when the WebSocket gateway connection fails.
38
+ class GatewayError < Error; end
39
+ end
40
+ end
41
+
42
+ test do
43
+ describe "Async::Discord::Error" do
44
+ it "stores code and message" do
45
+ err = Async::Discord::Error.new("DISCORD_ERROR", "something broke")
46
+ err.code.should == "DISCORD_ERROR"
47
+ err.message.should == "something broke"
48
+ end
49
+
50
+ it "stores optional status" do
51
+ err = Async::Discord::Error.new("ERR", "fail", status: 400)
52
+ err.status.should == 400
53
+ end
54
+
55
+ it "defaults status to nil" do
56
+ err = Async::Discord::Error.new("ERR", "fail")
57
+ err.status.should.be.nil
58
+ end
59
+
60
+ it "is a StandardError" do
61
+ Async::Discord::Error.new("ERR", "fail").should.be.kind_of StandardError
62
+ end
63
+ end
64
+
65
+ it "AuthError inherits from Error" do
66
+ Async::Discord::AuthError.new("AUTH", "bad token").should.be.kind_of Async::Discord::Error
67
+ end
68
+
69
+ it "ApiError inherits from Error" do
70
+ Async::Discord::ApiError.new("API", "bad request").should.be.kind_of Async::Discord::Error
71
+ end
72
+
73
+ it "RateLimitError inherits from Error" do
74
+ Async::Discord::RateLimitError.new("RATE", "slow down").should.be.kind_of Async::Discord::Error
75
+ end
76
+
77
+ it "ServerError inherits from Error" do
78
+ Async::Discord::ServerError.new("SERVER", "500").should.be.kind_of Async::Discord::Error
79
+ end
80
+
81
+ it "ResponseTooLargeError inherits from Error" do
82
+ Async::Discord::ResponseTooLargeError.new("LARGE", "too big").should.be.kind_of Async::Discord::Error
83
+ end
84
+
85
+ it "GatewayError inherits from Error" do
86
+ Async::Discord::GatewayError.new("GW", "disconnected").should.be.kind_of Async::Discord::Error
87
+ end
88
+ end