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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/data/discord-api-spec/openapi.json +40404 -0
- data/lib/async/discord/api/path_tree.rb +130 -0
- data/lib/async/discord/api.rb +156 -0
- data/lib/async/discord/client.rb +286 -0
- data/lib/async/discord/error.rb +88 -0
- data/lib/async/discord/gateway.rb +362 -0
- data/lib/async/discord.rb +16 -0
- data/lib/async/matrix/api/chain.rb +9 -0
- data/lib/async/matrix/application_service/config/vivify.rb +3 -0
- data/lib/async/matrix/application_service/event.rb +9 -20
- data/lib/async/matrix/application_service/server.rb +2 -2
- data/lib/async/matrix/bridge/discord/db/connection.rb +143 -0
- data/lib/async/matrix/bridge/discord/db/file.rb +120 -0
- data/lib/async/matrix/bridge/discord/db/guild.rb +122 -0
- data/lib/async/matrix/bridge/discord/db/message.rb +162 -0
- data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +14 -0
- data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +14 -0
- data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +23 -0
- data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +19 -0
- data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +20 -0
- data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +19 -0
- data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +18 -0
- data/lib/async/matrix/bridge/discord/db/portal.rb +152 -0
- data/lib/async/matrix/bridge/discord/db/puppet.rb +130 -0
- data/lib/async/matrix/bridge/discord/db/reaction.rb +167 -0
- data/lib/async/matrix/bridge/discord/db/user.rb +114 -0
- data/lib/async/matrix/bridge/discord/db.rb +140 -0
- data/lib/async/matrix/double_puppet_client.rb +84 -0
- data/lib/async/matrix/schema.rb +2 -2
- data/lib/async/matrix/server.rb +1 -0
- data/lib/async/matrix/version.rb +1 -1
- data/lib/async/matrix.rb +2 -0
- 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
|