vox 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/deploy_docs.yml +39 -0
- data/.github/workflows/rspec.yml +29 -0
- data/.github/workflows/rubocop.yml +24 -0
- data/.github/workflows/yard.yml +32 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +22 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +0 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/LICENSE.md +25 -0
- data/README.md +44 -0
- data/Rakefile +14 -0
- data/lib/vox.rb +10 -0
- data/lib/vox/http/client.rb +146 -0
- data/lib/vox/http/error.rb +56 -0
- data/lib/vox/http/middleware.rb +14 -0
- data/lib/vox/http/middleware/log_formatter.rb +62 -0
- data/lib/vox/http/middleware/rate_limiter.rb +206 -0
- data/lib/vox/http/route.rb +59 -0
- data/lib/vox/http/routes.rb +22 -0
- data/lib/vox/http/routes/audit_log.rb +71 -0
- data/lib/vox/http/routes/channel.rb +425 -0
- data/lib/vox/http/routes/emoji.rb +87 -0
- data/lib/vox/http/routes/guild.rb +292 -0
- data/lib/vox/http/routes/invite.rb +37 -0
- data/lib/vox/http/routes/user.rb +114 -0
- data/lib/vox/http/routes/voice.rb +22 -0
- data/lib/vox/http/routes/webhook.rb +177 -0
- data/lib/vox/http/upload_io.rb +23 -0
- data/lib/vox/http/util.rb +33 -0
- data/lib/vox/version.rb +6 -0
- data/vox.gemspec +42 -0
- metadata +234 -0
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vox
|
4
|
+
module HTTP
|
5
|
+
# Standard API errors for bad requests
|
6
|
+
class Error < Vox::Error
|
7
|
+
# @return [Hash<Symbol, Object>] The response object
|
8
|
+
attr_reader :data
|
9
|
+
|
10
|
+
# @return [String, nil] The trace identifier this error originated from.
|
11
|
+
attr_reader :trace
|
12
|
+
|
13
|
+
def initialize(data, req_id = nil)
|
14
|
+
@data = data
|
15
|
+
@trace = req_id
|
16
|
+
super(data[:message])
|
17
|
+
end
|
18
|
+
|
19
|
+
# Status Code 400
|
20
|
+
class BadRequest < self
|
21
|
+
end
|
22
|
+
|
23
|
+
# Status Code 401
|
24
|
+
class Unauthorized < self
|
25
|
+
end
|
26
|
+
|
27
|
+
# Status Code 403
|
28
|
+
class Forbidden < self
|
29
|
+
end
|
30
|
+
|
31
|
+
# Status Code 404
|
32
|
+
class NotFound < self
|
33
|
+
end
|
34
|
+
|
35
|
+
# Status Code 405
|
36
|
+
class MethodNotAllowed < self
|
37
|
+
end
|
38
|
+
|
39
|
+
# Status Code 429
|
40
|
+
class TooManyRequests < self
|
41
|
+
end
|
42
|
+
|
43
|
+
# Status Code 502
|
44
|
+
class GatewayUnavailable < self
|
45
|
+
end
|
46
|
+
|
47
|
+
# Status Code 5XX
|
48
|
+
class ServerError < StandardError
|
49
|
+
def initialize(req_id)
|
50
|
+
@trace = req_id
|
51
|
+
super('Internal Server Error')
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'logging'
|
5
|
+
|
6
|
+
require 'vox/http/middleware/rate_limiter'
|
7
|
+
require 'vox/http/middleware/log_formatter'
|
8
|
+
|
9
|
+
log = Logging.logger['Vox::HTTP']
|
10
|
+
|
11
|
+
log.debug { 'Registering rate_limiter middleware' }
|
12
|
+
Faraday::Middleware.register_middleware(
|
13
|
+
vox_ratelimiter: Vox::HTTP::Middleware::RateLimiter
|
14
|
+
)
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'faraday/logging/formatter'
|
5
|
+
|
6
|
+
module Vox
|
7
|
+
module HTTP
|
8
|
+
# Collection of middleware used to process requests internally.
|
9
|
+
module Middleware
|
10
|
+
# @!visibility private
|
11
|
+
class LogFormatter < Faraday::Logging::Formatter
|
12
|
+
# Request processing
|
13
|
+
def request(env)
|
14
|
+
req_id = env.request.context[:trace]
|
15
|
+
|
16
|
+
log_request_info(env, req_id)
|
17
|
+
# Debug just logs the response with the response body
|
18
|
+
log_request_debug(env, req_id) if env.request_headers['Content-Type'] == 'application/json'
|
19
|
+
debug { "{#{req_id}} [OUT] #{env.request_headers}" }
|
20
|
+
end
|
21
|
+
|
22
|
+
# Info level logging for requests. Logs the HTTP verb, the url path, query string arguments, and
|
23
|
+
# request body size.
|
24
|
+
def log_request_info(env, req_id)
|
25
|
+
query_string = "?#{env.url.query}" if env.url.query
|
26
|
+
size = env.body.respond_to?(:size) ? env.body.size : env.request_headers['Content-Length']
|
27
|
+
info { "{#{req_id}} [OUT] #{env.method} #{env.url.path}#{query_string} (#{size || 0})" }
|
28
|
+
end
|
29
|
+
|
30
|
+
# Debug level logging for requests. Displays the request body.
|
31
|
+
def log_request_debug(env, req_id)
|
32
|
+
debug { "{#{req_id}} [OUT] #{env.body}" }
|
33
|
+
end
|
34
|
+
|
35
|
+
# Response processing
|
36
|
+
def response(env)
|
37
|
+
resp = env.response
|
38
|
+
req_id = env.request.context[:trace]
|
39
|
+
|
40
|
+
log_response_error(env, resp, req_id) unless resp.success?
|
41
|
+
log_response_info(env, resp, req_id)
|
42
|
+
log_response_debug(env, resp, req_id) unless resp.body.empty?
|
43
|
+
end
|
44
|
+
|
45
|
+
# Error level logging for responses. Logs status code, url path, and response body.
|
46
|
+
def log_response_error(env, resp, req_id)
|
47
|
+
error { "{#{req_id}} [IN] #{resp.status} #{env.url.path} #{resp.body}" }
|
48
|
+
end
|
49
|
+
|
50
|
+
# Info level logging for responses. Logs status code, url path, and body size.
|
51
|
+
def log_response_info(env, resp, req_id)
|
52
|
+
info { "{#{req_id}} [IN] #{resp.status} #{env.url.path} (#{resp.body&.size || 0})" }
|
53
|
+
end
|
54
|
+
|
55
|
+
# Debug level logging for responses. Logs the response body.
|
56
|
+
def log_response_debug(_env, resp, req_id)
|
57
|
+
debug { "{#{req_id}} [IN] #{resp.body}" }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,206 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vox
|
4
|
+
module HTTP
|
5
|
+
# @!visibility private
|
6
|
+
# A bucket used for HTTP rate limiting
|
7
|
+
class Bucket
|
8
|
+
# @!attribute [r] limit
|
9
|
+
# @return [Integer]
|
10
|
+
attr_reader :limit
|
11
|
+
|
12
|
+
# @!attribute [r] remaining
|
13
|
+
# @return [Integer]
|
14
|
+
attr_reader :remaining
|
15
|
+
|
16
|
+
# @!attribute [r] reset_time
|
17
|
+
# @return [Time]
|
18
|
+
attr_reader :reset_time
|
19
|
+
|
20
|
+
def initialize(limit, remaining, reset_time)
|
21
|
+
update(limit, remaining, reset_time)
|
22
|
+
|
23
|
+
@mutex = Mutex.new
|
24
|
+
end
|
25
|
+
|
26
|
+
# @param limit [Integer]
|
27
|
+
# @param remaining [Integer]
|
28
|
+
# @param reset_time [Time]
|
29
|
+
def update(limit, remaining, reset_time)
|
30
|
+
@limit = limit
|
31
|
+
@remaining = remaining
|
32
|
+
@reset_time = reset_time
|
33
|
+
end
|
34
|
+
|
35
|
+
# @return [true, false]
|
36
|
+
def will_limit?
|
37
|
+
(@remaining - 1).negative? && Time.now <= @reset_time
|
38
|
+
end
|
39
|
+
|
40
|
+
# Lock and unlock this mutex (prevents access during reset)
|
41
|
+
def wait_until_available
|
42
|
+
return unless locked?
|
43
|
+
|
44
|
+
@mutex.synchronize {}
|
45
|
+
end
|
46
|
+
|
47
|
+
# Lock the mutex for a given duration. Used for cooldown periods
|
48
|
+
def lock_for(duration)
|
49
|
+
@mutex.synchronize { sleep duration }
|
50
|
+
end
|
51
|
+
|
52
|
+
# Lock the mutex until the bucket resets
|
53
|
+
def lock_until_reset
|
54
|
+
time_remaining = @reset_time - Time.now
|
55
|
+
|
56
|
+
raise 'Cannot sleep for negative duration.' if time_remaining.negative?
|
57
|
+
|
58
|
+
lock_for(time_remaining) unless locked?
|
59
|
+
end
|
60
|
+
|
61
|
+
# @return [true, false]
|
62
|
+
def locked?
|
63
|
+
@mutex.locked?
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# @!visibility private
|
68
|
+
# A rate limiting class used for our {Client}
|
69
|
+
class LimitTable
|
70
|
+
def initialize
|
71
|
+
@bucket_key_map = {}
|
72
|
+
@bucket_id_map = {}
|
73
|
+
@key_to_id = {}
|
74
|
+
end
|
75
|
+
|
76
|
+
# Index a bucket based on the route key
|
77
|
+
def get_from_key(key)
|
78
|
+
@bucket_key_map[key]
|
79
|
+
end
|
80
|
+
|
81
|
+
# Index a bucket based on server side bucket id
|
82
|
+
def get_from_id(id)
|
83
|
+
@bucket_id_map[id]
|
84
|
+
end
|
85
|
+
|
86
|
+
# Get a bucket from a rl_key if it exists
|
87
|
+
def id_from_key(key)
|
88
|
+
@key_to_id[key]
|
89
|
+
end
|
90
|
+
|
91
|
+
# Update a rate limit bucket from response headers
|
92
|
+
def update_from_headers(key, headers, req_id = nil)
|
93
|
+
limit = headers['x-ratelimit-limit']&.to_i
|
94
|
+
remaining = headers['x-ratelimit-remaining']&.to_i
|
95
|
+
bucket_id = headers['x-ratelimit-bucket']
|
96
|
+
reset_after = headers['x-ratelimit-reset-after']&.to_f
|
97
|
+
retry_after = headers['retry-after']&.to_f
|
98
|
+
|
99
|
+
if limit && remaining && reset_after && bucket_id
|
100
|
+
reset = if retry_after
|
101
|
+
retry_after / 1000
|
102
|
+
else
|
103
|
+
reset_after
|
104
|
+
end
|
105
|
+
update(key, bucket_id, limit, remaining, Time.now + reset)
|
106
|
+
elsif retry_after
|
107
|
+
update(key, bucket_id, 0, 0, Time.now + (retry_after / 1000))
|
108
|
+
else
|
109
|
+
LOGGER.debug { "{#{req_id}} Unable to set RL for #{key}" }
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Update a rate limit bucket
|
114
|
+
def update(key, bucket_id, limit, remaining, reset_time)
|
115
|
+
bucket = @bucket_id_map[bucket_id]
|
116
|
+
if bucket
|
117
|
+
bucket.update(limit, remaining, reset_time)
|
118
|
+
@bucket_key_map[key] = bucket_id
|
119
|
+
else
|
120
|
+
bucket = Bucket.new(limit, remaining, reset_time)
|
121
|
+
@bucket_key_map[key] = bucket
|
122
|
+
if bucket_id
|
123
|
+
@bucket_id_map[bucket_id] = bucket
|
124
|
+
@key_to_id[key] = bucket_id
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# @!visibility private
|
130
|
+
LOGGER = Logging.logger[self]
|
131
|
+
end
|
132
|
+
|
133
|
+
module Middleware
|
134
|
+
# Faraday middleware to handle ratelimiting based on
|
135
|
+
# bucket ids and the rate limit key provided by {Client#request}
|
136
|
+
# in the request context
|
137
|
+
class RateLimiter < Faraday::Middleware
|
138
|
+
def initialize(app, **_options)
|
139
|
+
super(app)
|
140
|
+
@limit_table = LimitTable.new
|
141
|
+
@mutex_table = Hash.new { |hash, key| hash[key] = Mutex.new }
|
142
|
+
end
|
143
|
+
|
144
|
+
# Request handler
|
145
|
+
def call(env)
|
146
|
+
rl_key = env.request.context[:rl_key]
|
147
|
+
req_id = env.request.context[:trace]
|
148
|
+
mutex = @mutex_table[rl_key]
|
149
|
+
|
150
|
+
mutex.synchronize do
|
151
|
+
rl_wait(rl_key, req_id)
|
152
|
+
rl_wait(:global, req_id)
|
153
|
+
@app.call(env).on_complete do |environ|
|
154
|
+
on_complete(environ, req_id)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# Handler for response data
|
160
|
+
def on_complete(env, req_id)
|
161
|
+
resp = env.response
|
162
|
+
|
163
|
+
if resp.status == 429 && resp.headers['x-ratelimit-global']
|
164
|
+
@limit_table.update_from_headers(:global, resp.headers, req_id)
|
165
|
+
Thread.new { @limit_table.get_from_key(:global).lock_until_reset }
|
166
|
+
LOGGER.error { "{#{req_id}}} Global ratelimit hit" }
|
167
|
+
end
|
168
|
+
|
169
|
+
update_from_headers(env)
|
170
|
+
end
|
171
|
+
|
172
|
+
private
|
173
|
+
|
174
|
+
# Lock a rate limit mutex preemptively if the next request would deplete the bucket.
|
175
|
+
def rl_wait(key, trace)
|
176
|
+
bucket_id = @limit_table.id_from_key(key)
|
177
|
+
bucket = if bucket_id
|
178
|
+
@limit_table.get_from_id(bucket_id)
|
179
|
+
else
|
180
|
+
@limit_table.get_from_key(key)
|
181
|
+
end
|
182
|
+
return if bucket.nil?
|
183
|
+
|
184
|
+
bucket.wait_until_available
|
185
|
+
return unless bucket.will_limit?
|
186
|
+
|
187
|
+
LOGGER.info do
|
188
|
+
duration = bucket.reset_time - Time.now
|
189
|
+
"{#{trace}} [RL] Locking #{key} for #{duration.truncate(3)} seconds"
|
190
|
+
end
|
191
|
+
|
192
|
+
bucket.lock_until_reset
|
193
|
+
end
|
194
|
+
|
195
|
+
def update_from_headers(env)
|
196
|
+
rl_key = env.request.context[:rl_key]
|
197
|
+
req_id = env.request.context[:trace]
|
198
|
+
@limit_table.update_from_headers(rl_key, env.response.headers, req_id)
|
199
|
+
end
|
200
|
+
|
201
|
+
# @!visibility private
|
202
|
+
LOGGER = Logging.logger[Vox::HTTP]
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vox
|
4
|
+
module HTTP
|
5
|
+
# Route that contains information about a request path, intended
|
6
|
+
# for use with {HTTP::Client#request}.
|
7
|
+
class Route
|
8
|
+
# Major parameters that are significant when forming a rate limit key.
|
9
|
+
MAJOR_PARAMS = %i[guild_id channel_id webhook_id].freeze
|
10
|
+
|
11
|
+
# @return [Symbol, String] HTTP verb to be used when accessing the API
|
12
|
+
# path.
|
13
|
+
attr_reader :verb
|
14
|
+
|
15
|
+
# @return [String] Unformatted API path, using Kernel.format
|
16
|
+
# syntax referencing keys in {params}.
|
17
|
+
attr_reader :key
|
18
|
+
|
19
|
+
# @return [String] String that defines an endpoint based on HTTP verb,
|
20
|
+
# API path, and major parameter if any.
|
21
|
+
attr_reader :rl_key
|
22
|
+
|
23
|
+
# @return [Hash] Parameters that are passed to be used when formatting
|
24
|
+
# the API path.
|
25
|
+
attr_reader :params
|
26
|
+
|
27
|
+
# @param verb [#to_sym] The HTTP verb to be used when accessing the API path.
|
28
|
+
# @param key [String] The unformatted route using Kernel.format syntax to
|
29
|
+
# incorporate the data provided in `params`.
|
30
|
+
# @param params [Hash<String, #to_s>] Parameters passed when formatting `key`.
|
31
|
+
def initialize(verb, key, **params)
|
32
|
+
@verb = verb.downcase.to_sym
|
33
|
+
@key = key
|
34
|
+
@params = params
|
35
|
+
@rl_key = "#{@verb}:#{@key}:#{major_param}"
|
36
|
+
end
|
37
|
+
|
38
|
+
# Format the route with the given params
|
39
|
+
# @return [String] Formatted API path.
|
40
|
+
def format
|
41
|
+
return @key if @params.empty?
|
42
|
+
|
43
|
+
Kernel.format(@key, @params) if @params.any?
|
44
|
+
end
|
45
|
+
|
46
|
+
# @return [String, Integer, nil] The major param value of the route key if any
|
47
|
+
def major_param
|
48
|
+
params.slice(*MAJOR_PARAMS).values.first
|
49
|
+
end
|
50
|
+
|
51
|
+
# Compare a {Route} or {Route} like object (responds to `#verb`, `#key`, and `#params`).
|
52
|
+
# @param other [Route]
|
53
|
+
# @return [true, false]
|
54
|
+
def ==(other)
|
55
|
+
@verb == other.verb && @key == other.key && @params == other.params
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'vox/http/routes/audit_log'
|
4
|
+
require 'vox/http/routes/channel'
|
5
|
+
require 'vox/http/routes/emoji'
|
6
|
+
require 'vox/http/routes/guild'
|
7
|
+
require 'vox/http/routes/invite'
|
8
|
+
require 'vox/http/routes/user'
|
9
|
+
require 'vox/http/routes/voice'
|
10
|
+
require 'vox/http/routes/webhook'
|
11
|
+
|
12
|
+
module Vox
|
13
|
+
module HTTP
|
14
|
+
# Module that contains all route containers.
|
15
|
+
module Routes
|
16
|
+
# Include all route containers if this module is included
|
17
|
+
def self.included(klass)
|
18
|
+
[AuditLog, Channel, Emoji, Guild, Invite, User, Voice, Webhook].each { |m| klass.include m }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'vox/http/route'
|
4
|
+
require 'vox/http/util'
|
5
|
+
|
6
|
+
module Vox
|
7
|
+
module HTTP
|
8
|
+
module Routes
|
9
|
+
# HTTP methods for accessing information about a {Guild}'s {AuditLog}
|
10
|
+
module AuditLog
|
11
|
+
include Util
|
12
|
+
|
13
|
+
# TODO: Is this the best place for these?
|
14
|
+
EVENTS = {
|
15
|
+
GUILD_UPDATE: 1,
|
16
|
+
CHANNEL_CREATE: 10,
|
17
|
+
CHANNEL_UPDATE: 11,
|
18
|
+
CHANNEL_DELETE: 12,
|
19
|
+
CHANNEL_OVERWRITE_CREATE: 13,
|
20
|
+
CHANNEL_OVERWRITE_UPDATE: 14,
|
21
|
+
CHANNEL_OVERWRITE_DELETE: 15,
|
22
|
+
MEMBER_KICK: 20,
|
23
|
+
MEMBER_PRUNE: 21,
|
24
|
+
MEMBER_BAN_ADD: 22,
|
25
|
+
MEMBER_BAN_REMOVE: 23,
|
26
|
+
MEMBER_UPDATE: 24,
|
27
|
+
MEMBER_ROLE_UPDATE: 25,
|
28
|
+
MEMBER_MOVE: 26,
|
29
|
+
MEMBER_DISCONNECT: 27,
|
30
|
+
BOT_ADD: 28,
|
31
|
+
ROLE_CREATE: 30,
|
32
|
+
ROLE_UPDATE: 31,
|
33
|
+
ROLE_DELETE: 32,
|
34
|
+
INVITE_CREATE: 40,
|
35
|
+
INVITE_UPDATE: 41,
|
36
|
+
INVITE_DELETE: 42,
|
37
|
+
WEBHOOK_CREATE: 50,
|
38
|
+
WEBHOOK_UPDATE: 51,
|
39
|
+
WEBHOOK_DELETE: 52,
|
40
|
+
EMOJI_CREATE: 60,
|
41
|
+
EMOJI_UPDATE: 61,
|
42
|
+
EMOJI_DELETE: 62,
|
43
|
+
MESSAGE_DELETE: 72,
|
44
|
+
MESSAGE_BULK_DELETE: 73,
|
45
|
+
MESSAGE_PIN: 74,
|
46
|
+
MESSAGE_UNPIN: 75,
|
47
|
+
INTEGRATION_CREATE: 80,
|
48
|
+
INTEGRATION_UPDATE: 81,
|
49
|
+
INTEGRATION_DELETE: 82
|
50
|
+
}.freeze
|
51
|
+
|
52
|
+
# Fetch a guild's audit log. [View on Discord's docs](https://discord.com/developers/docs/resources/audit-log#get-guild-audit-log)
|
53
|
+
# @param guild_id [String, Integer] The ID of the guild to fetch audit log entries from.
|
54
|
+
# @param user_id [String, Integer] The ID of the user to filter events for.
|
55
|
+
# @param action_type [Symbol, Integer] The name of the audit log event to filter for. Either a key from {EVENTS}
|
56
|
+
# or the corresponding integer value.
|
57
|
+
# @param before [String, Integer] The ID of the audit log entry to fetch before chronologically.
|
58
|
+
# @param limit [Integer] The maximum amount of entries to return. Defaults to 50 if no value is supplied.
|
59
|
+
# Maximum of 100, minimum of 1.
|
60
|
+
# @return [Hash<:audit_log_entries, Array<Object>>]
|
61
|
+
# @vox.permissions VIEW_AUDIT_LOG
|
62
|
+
# @vox.api_docs https://discord.com/developers/docs/resources/audit-log#get-guild-audit-log
|
63
|
+
def get_guild_audit_log(guild_id, user_id: :undef, action_type: :undef, before: :undef, limit: :undef)
|
64
|
+
route = HTTP::Route.new(:GET, '/guilds/%{guild_id}/audit-logs', guild_id: guild_id)
|
65
|
+
query_params = filter_undef({ user_id: user_id, action_type: action_type, before: before, limit: limit })
|
66
|
+
request(route, query: query_params)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|