forked from shardlab/discordrb
-
Notifications
You must be signed in to change notification settings - Fork 0
/
api.rb
350 lines (296 loc) · 10.5 KB
/
api.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# frozen_string_literal: true
require 'rest-client'
require 'json'
require 'time'
require 'discordrb/errors'
# List of methods representing endpoints in Discord's API
module Discordrb::API
# The base URL of the Discord REST API.
APIBASE = 'https://discord.com/api/v8'
# The URL of Discord's CDN
CDN_URL = 'https://cdn.discordapp.com'
module_function
# @return [String] the currently used API base URL.
def api_base
@api_base || APIBASE
end
# Sets the API base URL to something.
def api_base=(value)
@api_base = value
end
# @return [String] the currently used CDN url
def cdn_url
@cdn_url || CDN_URL
end
# @return [String] the bot name, previously specified using {.bot_name=}.
def bot_name
@bot_name
end
# Sets the bot name to something. Used in {.user_agent}. For the bot's username, see {Profile#username=}.
def bot_name=(value)
@bot_name = value
end
# Changes the rate limit tracing behaviour. If rate limit tracing is on, a full backtrace will be logged on every RL
# hit.
# @param value [true, false] whether or not to enable rate limit tracing
def trace=(value)
@trace = value
end
# Generate a user agent identifying this requester as discordrb.
def user_agent
# This particular string is required by the Discord devs.
required = "DiscordBot (https://github.com/shardlab/discordrb, v#{Discordrb::VERSION})"
@bot_name ||= ''
"#{required} rest-client/#{RestClient::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION}p#{RUBY_PATCHLEVEL} discordrb/#{Discordrb::VERSION} #{@bot_name}"
end
# Resets all rate limit mutexes
def reset_mutexes
@mutexes = {}
@global_mutex = Mutex.new
end
# Wait a specified amount of time synchronised with the specified mutex.
def sync_wait(time, mutex)
mutex.synchronize { sleep time }
end
# Wait for a specified mutex to unlock and do nothing with it afterwards.
def mutex_wait(mutex)
mutex.lock
mutex.unlock
end
# Performs a RestClient request.
# @param type [Symbol] The type of HTTP request to use.
# @param attributes [Array] The attributes for the request.
def raw_request(type, attributes)
RestClient.send(type, *attributes)
rescue RestClient::Forbidden => e
# HACK: for #request, dynamically inject restclient's response into NoPermission - this allows us to rate limit
noprm = Discordrb::Errors::NoPermission.new
noprm.define_singleton_method(:_rc_response) { e.response }
raise noprm, "The bot doesn't have the required permission to do this!"
rescue RestClient::BadGateway
Discordrb::LOGGER.warn('Got a 502 while sending a request! Not a big deal, retrying the request')
retry
end
# Make an API request, including rate limit handling.
def request(key, major_parameter, type, *attributes)
# Add a custom user agent
attributes.last[:user_agent] = user_agent if attributes.last.is_a? Hash
# The most recent Discord rate limit requirements require the support of major parameters, where a particular route
# and major parameter combination (*not* the HTTP method) uniquely identifies a RL bucket.
key = [key, major_parameter].freeze
begin
mutex = @mutexes[key] ||= Mutex.new
# Lock and unlock, i.e. wait for the mutex to unlock and don't do anything with it afterwards
mutex_wait(mutex)
# If the global mutex happens to be locked right now, wait for that as well.
mutex_wait(@global_mutex) if @global_mutex.locked?
response = nil
begin
response = raw_request(type, attributes)
rescue RestClient::Exception => e
response = e.response
if response.body && !e.is_a?(RestClient::TooManyRequests)
data = JSON.parse(response.body)
err_klass = Discordrb::Errors.error_class_for(data['code'] || 0)
e = err_klass.new(data['message'], data['errors'])
Discordrb::LOGGER.error(e.full_message)
end
raise e
rescue Discordrb::Errors::NoPermission => e
if e.respond_to?(:_rc_response)
response = e._rc_response
else
Discordrb::LOGGER.warn("NoPermission doesn't respond_to? _rc_response!")
end
raise e
ensure
if response
handle_preemptive_rl(response.headers, mutex, key) if response.headers[:x_ratelimit_remaining] == '0' && !mutex.locked?
else
Discordrb::LOGGER.ratelimit('Response was nil before trying to preemptively rate limit!')
end
end
rescue RestClient::TooManyRequests => e
# If the 429 is from the global RL, then we have to use the global mutex instead.
mutex = @global_mutex if e.response.headers[:x_ratelimit_global] == 'true'
unless mutex.locked?
response = JSON.parse(e.response)
wait_seconds = response['retry_after'] ? response['retry_after'].to_f : e.response.headers[:retry_after].to_i
Discordrb::LOGGER.ratelimit("Locking RL mutex (key: #{key}) for #{wait_seconds} seconds due to Discord rate limiting")
trace("429 #{key.join(' ')}")
# Wait the required time synchronized by the mutex (so other incoming requests have to wait) but only do it if
# the mutex isn't locked already so it will only ever wait once
sync_wait(wait_seconds, mutex)
end
retry
end
response
end
# Handles pre-emptive rate limiting by waiting the given mutex by the difference of the Date header to the
# X-Ratelimit-Reset header, thus making sure we don't get 429'd in any subsequent requests.
def handle_preemptive_rl(headers, mutex, key)
Discordrb::LOGGER.ratelimit "RL bucket depletion detected! Date: #{headers[:date]} Reset: #{headers[:x_ratelimit_reset]}"
delta = headers[:x_ratelimit_reset_after].to_f
Discordrb::LOGGER.warn("Locking RL mutex (key: #{key}) for #{delta} seconds pre-emptively")
sync_wait(delta, mutex)
end
# Perform rate limit tracing. All this method does is log the current backtrace to the console with the `:ratelimit`
# level.
# @param reason [String] the reason to include with the backtrace.
def trace(reason)
unless @trace
Discordrb::LOGGER.debug("trace was called with reason #{reason}, but tracing is not enabled")
return
end
Discordrb::LOGGER.ratelimit("Trace (#{reason}):")
caller.each do |str|
Discordrb::LOGGER.ratelimit(" #{str}")
end
end
# Make an icon URL from server and icon IDs
def icon_url(server_id, icon_id, format = 'webp')
"#{cdn_url}/icons/#{server_id}/#{icon_id}.#{format}"
end
# Make an icon URL from application and icon IDs
def app_icon_url(app_id, icon_id, format = 'webp')
"#{cdn_url}/app-icons/#{app_id}/#{icon_id}.#{format}"
end
# Make a widget picture URL from server ID
def widget_url(server_id, style = 'shield')
"#{api_base}/guilds/#{server_id}/widget.png?style=#{style}"
end
# Make a splash URL from server and splash IDs
def splash_url(server_id, splash_id, format = 'webp')
"#{cdn_url}/splashes/#{server_id}/#{splash_id}.#{format}"
end
# Make a banner URL from server and banner IDs
def banner_url(server_id, banner_id, format = 'webp')
"#{cdn_url}/banners/#{server_id}/#{banner_id}.#{format}"
end
# Make an emoji icon URL from emoji ID
def emoji_icon_url(emoji_id, format = 'webp')
"#{cdn_url}/emojis/#{emoji_id}.#{format}"
end
# Make an asset URL from application and asset IDs
def asset_url(application_id, asset_id, format = 'webp')
"#{cdn_url}/app-assets/#{application_id}/#{asset_id}.#{format}"
end
# Make an achievement icon URL from application ID, achievement ID, and icon hash
def achievement_icon_url(application_id, achievement_id, icon_hash, format = 'webp')
"#{cdn_url}/app-assets/#{application_id}/achievements/#{achievement_id}/icons/#{icon_hash}.#{format}"
end
# Login to the server
def login(email, password)
request(
:auth_login,
nil,
:post,
"#{api_base}/auth/login",
email: email,
password: password
)
end
# Logout from the server
def logout(token)
request(
:auth_logout,
nil,
:post,
"#{api_base}/auth/logout",
nil,
Authorization: token
)
end
# Create an OAuth application
def create_oauth_application(token, name, redirect_uris)
request(
:oauth2_applications,
nil,
:post,
"#{api_base}/oauth2/applications",
{ name: name, redirect_uris: redirect_uris }.to_json,
Authorization: token,
content_type: :json
)
end
# Change an OAuth application's properties
def update_oauth_application(token, name, redirect_uris, description = '', icon = nil)
request(
:oauth2_applications,
nil,
:put,
"#{api_base}/oauth2/applications",
{ name: name, redirect_uris: redirect_uris, description: description, icon: icon }.to_json,
Authorization: token,
content_type: :json
)
end
# Get the bot's OAuth application's information
def oauth_application(token)
request(
:oauth2_applications_me,
nil,
:get,
"#{api_base}/oauth2/applications/@me",
Authorization: token
)
end
# Acknowledge that a message has been received
# The last acknowledged message will be sent in the ready packet,
# so this is an easy way to catch up on messages
def acknowledge_message(token, channel_id, message_id)
request(
:channels_cid_messages_mid_ack,
nil, # This endpoint is unavailable for bot accounts and thus isn't subject to its rate limit requirements.
:post,
"#{api_base}/channels/#{channel_id}/messages/#{message_id}/ack",
nil,
Authorization: token
)
end
# Get the gateway to be used
def gateway(token)
request(
:gateway,
nil,
:get,
"#{api_base}/gateway",
Authorization: token
)
end
# Get the gateway to be used, with additional information for sharding and
# session start limits
def gateway_bot(token)
request(
:gateway_bot,
nil,
:get,
"#{api_base}/gateway/bot",
Authorization: token
)
end
# Validate a token (this request will fail if the token is invalid)
def validate_token(token)
request(
:auth_login,
nil,
:post,
"#{api_base}/auth/login",
{}.to_json,
Authorization: token,
content_type: :json
)
end
# Get a list of available voice regions
def voice_regions(token)
request(
:voice_regions,
nil,
:get,
"#{api_base}/voice/regions",
Authorization: token,
content_type: :json
)
end
end
Discordrb::API.reset_mutexes