ruqqus 1.0.0 → 1.1.4
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/.gitignore +5 -1
- data/CHANGELOG.md +48 -1
- data/Gemfile +1 -1
- data/README.md +160 -23
- data/Rakefile +1 -2
- data/TODO.md +25 -0
- data/exe/ruqqus-oauth +98 -0
- data/lib/ruqqus.rb +217 -64
- data/lib/ruqqus/client.rb +571 -0
- data/lib/ruqqus/routes.rb +68 -0
- data/lib/ruqqus/token.rb +124 -0
- data/lib/ruqqus/types.rb +11 -0
- data/lib/ruqqus/{badge.rb → types/badge.rb} +0 -0
- data/lib/ruqqus/types/comment.rb +44 -0
- data/lib/ruqqus/{guild.rb → types/guild.rb} +51 -33
- data/lib/ruqqus/{item_base.rb → types/item_base.rb} +25 -15
- data/lib/ruqqus/types/post.rb +70 -0
- data/lib/ruqqus/{submission.rb → types/submission.rb} +66 -59
- data/lib/ruqqus/{title.rb → types/title.rb} +0 -0
- data/lib/ruqqus/types/user.rb +118 -0
- data/lib/ruqqus/version.rb +7 -3
- data/ruqqus.gemspec +6 -2
- metadata +50 -16
- data/lib/ruqqus/comment.rb +0 -61
- data/lib/ruqqus/post.rb +0 -85
- data/lib/ruqqus/user.rb +0 -96
data/lib/ruqqus.rb
CHANGED
@@ -1,10 +1,14 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'json'
|
3
|
+
require 'rbconfig'
|
1
4
|
require 'rest-client'
|
5
|
+
require 'securerandom'
|
6
|
+
require 'socket'
|
2
7
|
|
3
|
-
require_relative 'ruqqus/
|
4
|
-
require_relative 'ruqqus/
|
5
|
-
require_relative 'ruqqus/
|
6
|
-
require_relative 'ruqqus/
|
7
|
-
require_relative 'ruqqus/user'
|
8
|
+
require_relative 'ruqqus/token'
|
9
|
+
require_relative 'ruqqus/routes'
|
10
|
+
require_relative 'ruqqus/client'
|
11
|
+
require_relative 'ruqqus/types'
|
8
12
|
require_relative 'ruqqus/version'
|
9
13
|
|
10
14
|
##
|
@@ -15,10 +19,6 @@ module Ruqqus
|
|
15
19
|
# The base Ruqqus URL.
|
16
20
|
HOME = 'https://ruqqus.com'.freeze
|
17
21
|
|
18
|
-
##
|
19
|
-
# The Ruqqus API version.
|
20
|
-
API_VERSION = 1
|
21
|
-
|
22
22
|
##
|
23
23
|
# A regular expression used for username validation.
|
24
24
|
VALID_USERNAME = /^[a-zA-Z0-9_]{5,25}$/.freeze
|
@@ -35,91 +35,244 @@ module Ruqqus
|
|
35
35
|
# A regular expression used for post/comment ID validation.
|
36
36
|
VALID_POST = /[A-Za-z0-9]+/.freeze
|
37
37
|
|
38
|
+
##
|
39
|
+
# Captures the ID of a post from a Ruqqus URL
|
40
|
+
POST_REGEX = /\/post\/([A-Za-z0-9]+)\/?.*/.freeze
|
41
|
+
|
42
|
+
##
|
43
|
+
# Captures the ID of a comment from a Ruqqus URL
|
44
|
+
COMMENT_REGEX = /\/post\/.+\/.+\/([A-Za-z0-9]+)\/?/.freeze
|
45
|
+
|
38
46
|
##
|
39
47
|
# Generic error class for exceptions specific to the Ruqqus API.
|
40
48
|
class Error < StandardError
|
41
49
|
end
|
42
50
|
|
43
51
|
##
|
44
|
-
#
|
45
|
-
#
|
46
|
-
|
52
|
+
# @!attribute self.proxy [rw]
|
53
|
+
# @return [URI?] the URI of the proxy server in use, or `nil` if none has been set.
|
54
|
+
|
55
|
+
##
|
56
|
+
# Obtains a list of URIs of free proxy servers that can be used to route network traffic through.
|
47
57
|
#
|
48
|
-
# @
|
58
|
+
# @param anon [Symbol] anonymity filter for the servers to return, either `:transparent`, `:anonymous`, or `:elite`.
|
59
|
+
# @param country [String,Symbol] country filter for servers to return, an ISO-3166 two digit county code.
|
49
60
|
#
|
50
|
-
# @
|
51
|
-
# @
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
61
|
+
# @return [Array<URI>] an array of proxy URIs that match the input filters.
|
62
|
+
# @note These proxies are free, keep that in mind. They are refreshed frequently, can go down unexpectedly, be slow,
|
63
|
+
# and other manners of inconvenience that can be expected with free services.
|
64
|
+
# @see https://www.nationsonline.org/oneworld/country_code_list.htm
|
65
|
+
def self.proxy_list(anon: :elite, country: nil)
|
66
|
+
raise(ArgumentError, 'invalid anonymity value') unless %i(transparent anonymous elite).include?(anon.to_sym)
|
67
|
+
|
68
|
+
url = "https://www.proxy-list.download/api/v1/get?type=https&anon=#{anon}"
|
69
|
+
url << "&country=#{country}" if country
|
70
|
+
|
71
|
+
RestClient.get(url) do |resp|
|
72
|
+
break if resp.code != 200
|
73
|
+
return resp.body.split.map { |proxy| URI.parse("https://#{proxy}") }
|
74
|
+
end
|
75
|
+
Array.new
|
76
|
+
end
|
77
|
+
|
78
|
+
def self.proxy
|
79
|
+
RestClient.proxy
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.proxy=(uri)
|
83
|
+
raise(TypeError, "#{uri} is not a URI") if uri && !uri.is_a?(URI)
|
84
|
+
RestClient.proxy = uri
|
56
85
|
end
|
57
86
|
|
58
87
|
##
|
59
|
-
#
|
88
|
+
# Helper function to automate uploading images to Imgur anonymously and returning the direct image link.
|
60
89
|
#
|
61
|
-
# @param
|
90
|
+
# @param client_id [String] an Imgur client ID
|
91
|
+
# @param image_path [String] the path to an image file.
|
92
|
+
# @param opts [Hash] the options hash.
|
93
|
+
# @option opts [String] :title a title to set on the Imgur post
|
94
|
+
# @option opts [String] :description a description to set on the Imgur post
|
62
95
|
#
|
63
|
-
# @return [
|
64
|
-
#
|
65
|
-
# @
|
66
|
-
# @
|
67
|
-
def self.
|
68
|
-
|
69
|
-
raise(
|
70
|
-
|
96
|
+
# @return [String] the direct image link from Imgur.
|
97
|
+
# @note To obtain a free Imgur client ID, visit https://api.imgur.com/oauth2/addclient
|
98
|
+
# @note No authentication is required for anonymous image upload, though rate limiting (very generous) applies.
|
99
|
+
# @see Client.post_create
|
100
|
+
def self.imgur_upload(client_id, image_path, **opts)
|
101
|
+
#noinspection RubyResolve
|
102
|
+
raise(Errno::ENOENT, image_path) unless File.exist?(image_path)
|
103
|
+
raise(ArgumentError, 'client_id cannot be nil or empty') if client_id.nil? || client_id.empty?
|
104
|
+
|
105
|
+
header = { 'Content-Type': 'application/json', 'Authorization': "Client-ID #{client_id}" }
|
106
|
+
params = { image: File.new(image_path), type: 'file', title: opts[:title], description: opts[:description] }
|
107
|
+
|
108
|
+
response = RestClient.post('https://api.imgur.com/3/upload', params, header)
|
109
|
+
json = JSON.parse(response.body, symbolize_names: true)
|
110
|
+
json[:data][:link]
|
71
111
|
end
|
72
112
|
|
73
113
|
##
|
74
|
-
#
|
75
|
-
#
|
76
|
-
# @param post_id [String] the ID of the post to retrieve.
|
114
|
+
# Checks if the specified guild name is available to be created.
|
77
115
|
#
|
78
|
-
# @
|
116
|
+
# @param guild_name [String] the name of a guild to query.
|
79
117
|
#
|
80
|
-
# @
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
118
|
+
# @return [Boolean] `true` is name is available, otherwise `false` if it has been reserved or is in use.
|
119
|
+
def self.guild_available?(guild_name)
|
120
|
+
begin
|
121
|
+
response = RestClient.get("#{Routes::GUILD_AVAILABLE}#{guild_name}")
|
122
|
+
json = JSON.parse(response.body, symbolize_names: true)
|
123
|
+
return json[:available]
|
124
|
+
rescue
|
125
|
+
puts 'err'
|
126
|
+
return false
|
127
|
+
end
|
86
128
|
end
|
87
129
|
|
88
130
|
##
|
89
|
-
#
|
131
|
+
# Checks if the specified username is available to be created.
|
90
132
|
#
|
91
|
-
# @param
|
133
|
+
# @param username [String] the name of a user to query.
|
92
134
|
#
|
93
|
-
# @return [
|
135
|
+
# @return [Boolean] `true` is name is available, otherwise `false` if it has been reserved or is in use.
|
136
|
+
def self.username_available?(username)
|
137
|
+
begin
|
138
|
+
response = RestClient.get("#{Routes::USERNAME_AVAILABLE}#{username}")
|
139
|
+
json = JSON.parse(response.body)
|
140
|
+
return json[username]
|
141
|
+
rescue
|
142
|
+
return false
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
##
|
147
|
+
# Generates a URL for the user to navigate to that will allow them to authorize an application.
|
94
148
|
#
|
95
|
-
# @
|
96
|
-
# @
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
149
|
+
# @param client_id [String] the unique ID of the approved client to authorize.
|
150
|
+
# @param redirect [String] the redirect URL where the client sends the OAuth authorization code.
|
151
|
+
# @param scopes [Array<Symbol>] a collection of values indicating the permissions the application is requesting from
|
152
|
+
# the user. See {Ruqqus::Client::SCOPES} for valid values.
|
153
|
+
# @param permanent [Boolean] `true` if authorization should persist until user explicitly revokes it,
|
154
|
+
# otherwise `false`.
|
155
|
+
# @param csrf [String] a token to authenticate and prevent a cross-site request forgery (CSRF) attack, or `nil` if
|
156
|
+
# you do not plan to validate the presence of the cookie in the redirection.
|
157
|
+
#
|
158
|
+
# @see https://ruqqus.com/settings/apps
|
159
|
+
# @see https://owasp.org/www-community/attacks/csrf
|
160
|
+
def self.authorize_url(client_id, redirect, scopes, permanent = true, csrf = nil)
|
161
|
+
|
162
|
+
raise(ArgumentError, 'invalid redirect URI') unless URI.regexp =~ redirect
|
163
|
+
raise(ArgumentError, 'scopes cannot be empty') unless scopes && !scopes.empty?
|
164
|
+
|
165
|
+
scopes = scopes.map(&:to_sym)
|
166
|
+
raise(ArgumentError, "invalid scopes specified") unless scopes.all? { |s| Client::SCOPES.include?(s) }
|
167
|
+
if scopes.any? { |s| [:create, :update, :guildmaster].include?(s) } && !scopes.include?(:identity)
|
168
|
+
# Add identity permission if missing, which is obviously required for a few other permissions
|
169
|
+
scopes << :identity
|
170
|
+
end
|
171
|
+
|
172
|
+
url = 'https://ruqqus.com/oauth/authorize'
|
173
|
+
url << "?client_id=#{client_id || raise(ArgumentError, 'client ID cannot be nil')}"
|
174
|
+
url << "&redirect_uri=#{redirect}"
|
175
|
+
url << "&scope=#{scopes.join(',')}"
|
176
|
+
url << "&state=#{csrf || Base64.encode64(SecureRandom.uuid).chomp}"
|
177
|
+
url << "&permanent=#{permanent}"
|
178
|
+
url
|
101
179
|
end
|
102
180
|
|
181
|
+
|
103
182
|
##
|
104
|
-
#
|
105
|
-
# Calls the GET method at the specified API route, and returns the deserializes JSON response as an object.
|
183
|
+
# Opens a URL in the system's default web browser, using the appropriate command for the host platform.
|
106
184
|
#
|
107
|
-
# @param
|
108
|
-
# @param klass [Class] a Class instance that is inherited from {ItemBase}.
|
185
|
+
# @param [String] the URL to open.
|
109
186
|
#
|
110
|
-
# @return [
|
187
|
+
# @return [void]
|
188
|
+
def self.open_browser(url)
|
189
|
+
|
190
|
+
cmd = case RbConfig::CONFIG['host_os']
|
191
|
+
when /mswin|mingw|cygwin/ then "start \"\"#{url}\"\""
|
192
|
+
when /darwin/ then "open '#{url}'"
|
193
|
+
when /linux|bsd/ then "xdg-open '#{url}'"
|
194
|
+
else raise(Ruqqus::Error, 'unable to determine how to open URL for current platform')
|
195
|
+
end
|
196
|
+
|
197
|
+
system(cmd)
|
198
|
+
end
|
199
|
+
|
200
|
+
##
|
201
|
+
# If using a `localhost` address for your application's OAuth redirect, this method can be used to open a socket and
|
202
|
+
# listen for a request, returning the authorization code once it arrives.
|
111
203
|
#
|
112
|
-
# @
|
113
|
-
# @
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
204
|
+
# @param port [Integer] the port to listen on.
|
205
|
+
# @param timeout [Numeric] sets the number of seconds to wait before cancelling and returning `nil`.
|
206
|
+
#
|
207
|
+
# @return [String?] the authorization code, `nil` if an error occurred.
|
208
|
+
# @note This method is blocking, and will *not* return until a connection is made and data is received on the
|
209
|
+
# specified port, or the timeout is reached.
|
210
|
+
def self.wait_for_code(port, timeout = 30)
|
211
|
+
|
212
|
+
thread = Thread.new do
|
213
|
+
sleep(timeout)
|
214
|
+
TCPSocket.open('localhost', port) { |s| s.puts }
|
122
215
|
end
|
216
|
+
|
217
|
+
params = {}
|
218
|
+
TCPServer.open('localhost', port) do |server|
|
219
|
+
|
220
|
+
session = server.accept
|
221
|
+
request = session.gets
|
222
|
+
match = /^GET [\/?]+(.*) HTTP.*/.match(request)
|
223
|
+
|
224
|
+
Thread.kill(thread)
|
225
|
+
return nil unless match
|
226
|
+
|
227
|
+
$1.split('&').each do |str|
|
228
|
+
key, value = str.split('=')
|
229
|
+
next unless key && value
|
230
|
+
params[key.to_sym] = value
|
231
|
+
end
|
232
|
+
|
233
|
+
session.puts "HTTP/1.1 200\r\n"
|
234
|
+
session.puts "Content-Type: text/html\r\n"
|
235
|
+
session.puts "\r\n"
|
236
|
+
session.puts create_response(!!params[:code])
|
237
|
+
|
238
|
+
session.close
|
239
|
+
end
|
240
|
+
|
241
|
+
params[:code]
|
123
242
|
end
|
124
|
-
end
|
125
243
|
|
244
|
+
private
|
245
|
+
|
246
|
+
##
|
247
|
+
# @return [String] a generic confirmation page to display in the user's browser after confirming application access.
|
248
|
+
def self.create_response(success)
|
249
|
+
args = success ? ['#339966', 'Authorization Confirmed'] : ['#ff0000', 'Authorization Failed']
|
250
|
+
format ='<h1 style="text-align: center;"><span style="color: %s;"><strong>%s</strong></span></h1>'
|
251
|
+
message = sprintf(format, *args)
|
252
|
+
<<-EOS
|
253
|
+
<html>
|
254
|
+
<head>
|
255
|
+
<style>
|
256
|
+
.center {
|
257
|
+
margin: 0;
|
258
|
+
position: absolute;
|
259
|
+
top: 50%;
|
260
|
+
left: 50%;
|
261
|
+
-ms-transform: translate(-50%, -50%);
|
262
|
+
transform: translate(-50%, -50%);
|
263
|
+
}
|
264
|
+
</style>
|
265
|
+
</head>
|
266
|
+
<body>
|
267
|
+
<div class="center">
|
268
|
+
<div><img src="https://raw.githubusercontent.com/ruqqus/ruqqus/master/ruqqus/assets/images/logo/ruqqus_text_logo.png" alt="" width="365" height="92" /></div>
|
269
|
+
<p style="text-align: center;"> </p>
|
270
|
+
#{message}
|
271
|
+
<p style="text-align: center;"> </p>
|
272
|
+
<p style="text-align: center;"><span style="color: #808080;">You can safely close the tab/browser and return to the application.</span></p>
|
273
|
+
</div>
|
274
|
+
</body>
|
275
|
+
</html>
|
276
|
+
EOS
|
277
|
+
end
|
278
|
+
end
|
@@ -0,0 +1,571 @@
|
|
1
|
+
require_relative 'version'
|
2
|
+
|
3
|
+
module Ruqqus
|
4
|
+
|
5
|
+
##
|
6
|
+
# Implements interacting with the Ruqqus API as a user, such as login, posting, account management, etc.
|
7
|
+
#noinspection RubyTooManyMethodsInspection
|
8
|
+
class Client
|
9
|
+
|
10
|
+
##
|
11
|
+
# The user-agent the client identified itself as.
|
12
|
+
USER_AGENT = "ruqqus-ruby/#{Ruqqus::VERSION}".freeze
|
13
|
+
|
14
|
+
##
|
15
|
+
# A collection of valid scopes that can be authorized.
|
16
|
+
#
|
17
|
+
# * `:identity` - See your username.
|
18
|
+
# * `:create` - Save posts and comments as you
|
19
|
+
# * `:read` - View Ruqqus as you, including private or restricted content
|
20
|
+
# * `:update` - Edit your posts and comments
|
21
|
+
# * `:delete` - Delete your posts and comments
|
22
|
+
# * `:vote` - Cast votes as you
|
23
|
+
# * `:guildmaster` - Perform Guildmaster actions
|
24
|
+
SCOPES = %i(identity create read update delete vote guildmaster).freeze
|
25
|
+
|
26
|
+
##
|
27
|
+
# A set of HTTP headers that will be included with every request.
|
28
|
+
DEFAULT_HEADERS = { 'User-Agent': USER_AGENT, 'Accept': 'application/json', 'Content-Type': 'application/json' }.freeze
|
29
|
+
|
30
|
+
##
|
31
|
+
# @!attribute [rw] token
|
32
|
+
# @return [Token] the OAuth2 token that grants the client authentication.
|
33
|
+
|
34
|
+
##
|
35
|
+
# @!attribute [r] identity
|
36
|
+
# @return [User] the authenticated user this client is performing actions as.
|
37
|
+
|
38
|
+
##
|
39
|
+
# @overload initialize(client_id, client_secret, token)
|
40
|
+
# Creates a new instance of the {Client} class with an existing token for authorization.
|
41
|
+
# @param client_id [String] the client ID of your of your application, issued after registration on Ruqqus.
|
42
|
+
# @param client_secret [String] the client secret of your of your application, issued after registration on Ruqqus.
|
43
|
+
# @param token [Token] a valid access token that has previously been granted access for the client.
|
44
|
+
#
|
45
|
+
# @overload initialize(client_id, client_secret, code)
|
46
|
+
# Creates a new instance of the {Client} class with an existing token for authorization.
|
47
|
+
# @param client_id [String] the client ID of your of your application, issued after registration on Ruqqus.
|
48
|
+
# @param client_secret [String] the client secret of your of your application, issued after registration on Ruqqus.
|
49
|
+
# @param code [String] a the code from the Oauth2 redirect to create a new {Token} and grant access to it.
|
50
|
+
def initialize(client_id, client_secret, token)
|
51
|
+
@client_id = client_id || raise(ArgumentError, 'client ID cannot be nil')
|
52
|
+
@client_secret = client_secret || raise(ArgumentError, 'client secret cannot be nil')
|
53
|
+
|
54
|
+
@token = token.is_a?(Token) ? token : Token.new(client_id, client_secret, token.to_s)
|
55
|
+
@session = nil
|
56
|
+
end
|
57
|
+
|
58
|
+
attr_reader :token
|
59
|
+
|
60
|
+
def token=(token)
|
61
|
+
@token = token || raise(ArgumentError, 'token cannot be nil')
|
62
|
+
end
|
63
|
+
|
64
|
+
# @!group Object Querying
|
65
|
+
|
66
|
+
##
|
67
|
+
# Retrieves the {User} with the specified username.
|
68
|
+
#
|
69
|
+
# @param username [String] the username of the Ruqqus account to retrieve.
|
70
|
+
#
|
71
|
+
# @return [User] the requested {User}.
|
72
|
+
#
|
73
|
+
# @raise [ArgumentError] when `username` is `nil` or value does match the {Ruqqus::VALID_USERNAME} regular expression.
|
74
|
+
# @raise [Error] thrown when user account does not exist.
|
75
|
+
def user(username)
|
76
|
+
raise(ArgumentError, 'username cannot be nil') unless username
|
77
|
+
raise(ArgumentError, 'invalid username') unless VALID_USERNAME.match?(username)
|
78
|
+
User.from_json(http_get("#{Routes::USER}#{username}"))
|
79
|
+
end
|
80
|
+
|
81
|
+
##
|
82
|
+
# Retrieves the {Guild} with the specified name.
|
83
|
+
#
|
84
|
+
# @param guild_name [String] the name of the Ruqqus guild to retrieve.
|
85
|
+
#
|
86
|
+
# @return [Guild] the requested {Guild}.
|
87
|
+
#
|
88
|
+
# @raise [ArgumentError] when `guild_name` is `nil` or value does match the {Ruqqus::VALID_GUILD} regular expression.
|
89
|
+
# @raise [Error] thrown when guild does not exist.
|
90
|
+
def guild(guild_name)
|
91
|
+
raise(ArgumentError, 'guild_name cannot be nil') unless guild_name
|
92
|
+
raise(ArgumentError, 'invalid guild name') unless VALID_GUILD.match?(guild_name)
|
93
|
+
Guild.from_json(http_get("#{Routes::GUILD}#{guild_name}"))
|
94
|
+
end
|
95
|
+
|
96
|
+
##
|
97
|
+
# Retrieves the {Post} with the specified name.
|
98
|
+
#
|
99
|
+
# @param post_id [String] the ID of the post to retrieve.
|
100
|
+
#
|
101
|
+
# @return [Post] the requested {Post}.
|
102
|
+
#
|
103
|
+
# @raise [ArgumentError] when `post_id` is `nil` or value does match the {Ruqqus::VALID_POST} regular expression.
|
104
|
+
# @raise [Error] thrown when a post with the specified ID does not exist.
|
105
|
+
def post(post_id)
|
106
|
+
raise(ArgumentError, 'post_id cannot be nil') unless post_id
|
107
|
+
raise(ArgumentError, 'invalid post ID') unless VALID_POST.match?(post_id)
|
108
|
+
Post.from_json(http_get("#{Routes::POST}#{post_id}"))
|
109
|
+
end
|
110
|
+
|
111
|
+
##
|
112
|
+
# Retrieves the {Comment} with the specified name.
|
113
|
+
#
|
114
|
+
# @param comment_id [String] the ID of the comment to retrieve.
|
115
|
+
#
|
116
|
+
# @return [Comment] the requested {Comment}.
|
117
|
+
#
|
118
|
+
# @raise [ArgumentError] when `comment_id` is `nil` or value does match the {Ruqqus::VALID_POST} regular expression.
|
119
|
+
# @raise [Error] when a comment with the specified ID does not exist.
|
120
|
+
def comment(comment_id)
|
121
|
+
raise(ArgumentError, 'comment_id cannot be nil') unless comment_id
|
122
|
+
raise(ArgumentError, 'invalid comment ID') unless VALID_POST.match?(comment_id)
|
123
|
+
Comment.from_json(http_get("#{Routes::COMMENT}#{comment_id}"))
|
124
|
+
end
|
125
|
+
|
126
|
+
# @!endgroup Object Querying
|
127
|
+
|
128
|
+
# @!group Commenting
|
129
|
+
|
130
|
+
##
|
131
|
+
# Submits a new comment on a post.
|
132
|
+
#
|
133
|
+
# @param body [String] the text content of the post (supports Markdown)
|
134
|
+
# @param post [Post,String] a {Post} instance or the unique ID of a post.
|
135
|
+
# @param comment [Comment,String] a {Comment} with the post to reply under, or `nil` to reply directly to the post.
|
136
|
+
#
|
137
|
+
# @return [Comment?] the comment that was submitted, or `nil` if an error occurred.
|
138
|
+
#
|
139
|
+
# @note This method is restricted to 6/minute, and will fail when that limit is exceeded.
|
140
|
+
def comment_create(body, post, comment = nil)
|
141
|
+
pid = post.to_s
|
142
|
+
parent = comment ? 't3_' + comment.to_s : 't2_' + pid
|
143
|
+
comment_submit(parent, pid, body)
|
144
|
+
end
|
145
|
+
|
146
|
+
##
|
147
|
+
# Submits a new comment on a post.
|
148
|
+
#
|
149
|
+
# @param body [String] the text content of the comment (supports Markdown)
|
150
|
+
# @param comment [Comment,String] a {Comment} instance or the unique ID of a comment.
|
151
|
+
#
|
152
|
+
# @return [Comment?] the comment that was submitted, or `nil` if an error occurred.
|
153
|
+
#
|
154
|
+
# @note This method is restricted to 6/minute, and will fail when that limit is exceeded.
|
155
|
+
def comment_reply(body, comment)
|
156
|
+
if comment.is_a?(Comment)
|
157
|
+
comment_submit(comment.fullname, comment.post_id, body)
|
158
|
+
else
|
159
|
+
comment = self.comment(comment.to_s)
|
160
|
+
comment_submit(comment.fullname, comment.post_id, body)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
##
|
165
|
+
# Deletes an existing comment.
|
166
|
+
#
|
167
|
+
# @param comment [Comment,String] a {Comment} instance, or the unique ID of the comment to delete.
|
168
|
+
#
|
169
|
+
# @return [Boolean] `true` if deletion completed without error, otherwise `false`.
|
170
|
+
def comment_delete(comment)
|
171
|
+
id = comment.is_a?(Comment) ? comment.id : comment.sub(/^t3_/, '')
|
172
|
+
url = "#{Routes::API_BASE}/delete/comment/#{id}"
|
173
|
+
http_post(url).empty? rescue false
|
174
|
+
end
|
175
|
+
|
176
|
+
# @!endgroup Commenting
|
177
|
+
|
178
|
+
# @!group Posting
|
179
|
+
|
180
|
+
##
|
181
|
+
# Creates a new post on Ruqqus as the current user.
|
182
|
+
#
|
183
|
+
# @param guild [Guild,String] a {Guild} instance or the name of the guild to post to.
|
184
|
+
# @param title [String] the title of the post to create.
|
185
|
+
# @param body [String?] the text body of the post, which can be `nil` if supplying URL or image upload.
|
186
|
+
# @param opts [Hash] The options hash to specify a link or image to upload.
|
187
|
+
# @option opts [String] :image (nil) the path to an image file to upload.
|
188
|
+
# @option opts [String] :url (nil) a URL to share with the post.
|
189
|
+
# @option opts [String] :imgur_client (nil) an Imgur client ID to automatically share images via Imgur instead of
|
190
|
+
# direct upload.
|
191
|
+
#
|
192
|
+
# @return [Post?] the newly created {Post} instance, or `nil` if an error occurred.
|
193
|
+
# @note This method is restricted to 6/minute, and will fail when that limit is exceeded.
|
194
|
+
def post_create(guild, title, body = nil, **opts)
|
195
|
+
name = guild.is_a?(Guild) ? guild.name : guild.strip.sub(/^\+/, '')
|
196
|
+
raise(ArgumentError, 'invalid guild name') unless Ruqqus::VALID_GUILD.match?(name)
|
197
|
+
raise(ArgumentError, 'title cannot be nil or empty') unless title && !title.empty?
|
198
|
+
params = { title: title, board: name, body: body }
|
199
|
+
|
200
|
+
if opts[:image]
|
201
|
+
if opts[:imgur_client]
|
202
|
+
params[:url] = Ruqqus.imgur_upload(opts[:imgur_client], opts[:image])
|
203
|
+
else
|
204
|
+
params[:file] = File.new(opts[:image])
|
205
|
+
end
|
206
|
+
elsif opts[:url]
|
207
|
+
raise(ArgumentError, 'invalid URI') unless URI.regexp =~ opts[:url]
|
208
|
+
params[:url] = opts[:url]
|
209
|
+
end
|
210
|
+
|
211
|
+
if [params[:body], params[:image], params[:url]].none?
|
212
|
+
raise(ArgumentError, 'text body cannot be nil or empty without URL or image') if body.nil? || body.empty?
|
213
|
+
end
|
214
|
+
Post.from_json(http_post(Routes::SUBMIT, params)) rescue nil
|
215
|
+
end
|
216
|
+
|
217
|
+
# @!endgroup Posting
|
218
|
+
|
219
|
+
# @!group Voting
|
220
|
+
|
221
|
+
##
|
222
|
+
# Places a vote on a post.
|
223
|
+
#
|
224
|
+
# @param post [Post,String] a {Post} instance, or the unique ID of a post.
|
225
|
+
# @param value [Integer] the vote value to place, either `-1`, `0`, or `1`.
|
226
|
+
#
|
227
|
+
# @return [Boolean] `true` if vote was placed successfully, otherwise `false`.
|
228
|
+
def vote_post(post, value = 1)
|
229
|
+
submit_vote(post.to_s, value, 'https://ruqqus.com/api/v1/vote/post/')
|
230
|
+
end
|
231
|
+
|
232
|
+
##
|
233
|
+
# Places a vote on a comment.
|
234
|
+
#
|
235
|
+
# @param comment [Comment,String] a {Comment} instance, or the unique ID of a comment.
|
236
|
+
# @param value [Integer] the vote value to place, either `-1`, `0`, or `1`.
|
237
|
+
#
|
238
|
+
# @return [Boolean] `true` if vote was placed successfully, otherwise `false`.
|
239
|
+
def vote_comment(comment, value = 1)
|
240
|
+
submit_vote(comment.to_s, value, 'https://ruqqus.com/api/v1/vote/comment/')
|
241
|
+
end
|
242
|
+
|
243
|
+
# @!endgroup Voting
|
244
|
+
|
245
|
+
# @!group Object Enumeration
|
246
|
+
|
247
|
+
##
|
248
|
+
# Enumerates through each post of a user, yielding each to a block.
|
249
|
+
#
|
250
|
+
# @param user [User,String] a {User} instance or the name of the account to query.
|
251
|
+
# @yieldparam post [Post] yields a {Post} to the block.
|
252
|
+
# @return [self]
|
253
|
+
# @raise [LocalJumpError] when a block is not supplied to the method.
|
254
|
+
# @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
|
255
|
+
# these intervals is an expected behavior.
|
256
|
+
def each_user_post(user)
|
257
|
+
raise(LocalJumpError, 'block required') unless block_given?
|
258
|
+
each_submission(user, Post, 'listing') { |obj| yield obj }
|
259
|
+
end
|
260
|
+
|
261
|
+
##
|
262
|
+
# Enumerates through each comment of a user, yielding each to a block.
|
263
|
+
#
|
264
|
+
# @param user [User,String] a {User} instance or the name of the account to query.
|
265
|
+
# @yieldparam comment [Comment] yields a {Comment} to the block.
|
266
|
+
# @return [self]
|
267
|
+
# @raise [LocalJumpError] when a block is not supplied to the method.
|
268
|
+
# @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
|
269
|
+
# these intervals is an expected behavior.
|
270
|
+
def each_user_comment(user)
|
271
|
+
raise(LocalJumpError, 'block required') unless block_given?
|
272
|
+
each_submission(user, Comment, 'comments') { |obj| yield obj }
|
273
|
+
end
|
274
|
+
|
275
|
+
##
|
276
|
+
# Enumerates through each post in the specified guild, and yields each one to a block.
|
277
|
+
#
|
278
|
+
# @param sort [Symbol] a symbol to determine the sorting method, valid values include `:trending`, `:subs`, `:new`.
|
279
|
+
# @yieldparam guild [Guild] yields a {Guild} to the block.
|
280
|
+
# @return [self]
|
281
|
+
# @raise [LocalJumpError] when a block is not supplied to the method.
|
282
|
+
# @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
|
283
|
+
# these intervals is an expected behavior.
|
284
|
+
def each_guild(sort = :subs)
|
285
|
+
raise(LocalJumpError, 'block required') unless block_given?
|
286
|
+
|
287
|
+
page = 1
|
288
|
+
loop do
|
289
|
+
params = { sort: sort, page: page }
|
290
|
+
json = http_get(Routes::GUILDS, headers(params: params))
|
291
|
+
break if json[:error]
|
292
|
+
json[:data].each { |hash| yield Guild.from_json(hash) }
|
293
|
+
break if json[:data].size < 25
|
294
|
+
page += 1
|
295
|
+
end
|
296
|
+
self
|
297
|
+
end
|
298
|
+
|
299
|
+
##
|
300
|
+
# Enumerates through each post in a guild, yielding each to a block.
|
301
|
+
#
|
302
|
+
# @param guild [Guild,String] a {Guild} instance, or the name of the guild to query.
|
303
|
+
# @param opts [Hash] the options hash.
|
304
|
+
# @option opts [Symbol] :sort (:new) Valid: `:new`, `:top`, `:hot`, `:activity`, `:disputed`
|
305
|
+
# @option opts [Symbol] :filter (:all) Valid: `:all`, `:day`, `:week`, `:month`, `:year`
|
306
|
+
#
|
307
|
+
# @yieldparam post [Post] yields a {Post} to the block.
|
308
|
+
# @return [self]
|
309
|
+
# @raise [LocalJumpError] when a block is not supplied to the method.
|
310
|
+
# @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
|
311
|
+
# these intervals is an expected behavior.
|
312
|
+
def each_guild_post(guild, **opts)
|
313
|
+
raise(LocalJumpError, 'block required') unless block_given?
|
314
|
+
name = guild.to_s
|
315
|
+
raise(ArgumentError, 'invalid guild name') unless Ruqqus::VALID_GUILD.match?(name)
|
316
|
+
|
317
|
+
sort = opts[:sort] || :new
|
318
|
+
filter = opts[:filter] || :all
|
319
|
+
|
320
|
+
page = 1
|
321
|
+
loop do
|
322
|
+
params = { page: page, sort: sort, t: filter }
|
323
|
+
json = http_get("#{Routes::GUILD}#{name}/listing", headers(params: params))
|
324
|
+
break if json[:error]
|
325
|
+
|
326
|
+
json[:data].each { |hash| yield Post.from_json(hash) }
|
327
|
+
break if json[:data].size < 25
|
328
|
+
page += 1
|
329
|
+
end
|
330
|
+
|
331
|
+
self
|
332
|
+
end
|
333
|
+
|
334
|
+
##
|
335
|
+
# Enumerates through each comment in a guild, yielding each to a block.
|
336
|
+
#
|
337
|
+
# @param guild [Guild,String] a {Guild} instance, or the name of the guild to query.
|
338
|
+
# @yieldparam [Comment] yields a {Comment} to the block.
|
339
|
+
#
|
340
|
+
# @return [self]
|
341
|
+
# @raise [LocalJumpError] when a block is not supplied to the method.
|
342
|
+
def each_guild_comment(guild)
|
343
|
+
raise(LocalJumpError, 'block required') unless block_given?
|
344
|
+
name = guild.to_s
|
345
|
+
raise(ArgumentError, 'invalid guild name') unless Ruqqus::VALID_GUILD.match?(name)
|
346
|
+
|
347
|
+
page = 1
|
348
|
+
loop do
|
349
|
+
params = { page: page }
|
350
|
+
json = http_get("#{Routes::GUILD}#{name}/comments", headers(params: params))
|
351
|
+
break if json[:error]
|
352
|
+
|
353
|
+
json[:data].each { |hash| yield Comment.from_json(hash) }
|
354
|
+
break if json[:data].size < 25
|
355
|
+
page += 1
|
356
|
+
end
|
357
|
+
|
358
|
+
self
|
359
|
+
end
|
360
|
+
|
361
|
+
##
|
362
|
+
# Enumerates through each comment in a guild, yielding each to a block.
|
363
|
+
#
|
364
|
+
# @param post [Post,String] a {Post} instance, or the unique ID of the post to query.
|
365
|
+
# @yieldparam [Comment] yields a {Comment} to the block.
|
366
|
+
#
|
367
|
+
# @return [self]
|
368
|
+
# @raise [LocalJumpError] when a block is not supplied to the method.
|
369
|
+
# @note This method is very inefficient, as it the underlying API does not yet implement it, therefore each comment
|
370
|
+
# in the entire guild must be searched through.
|
371
|
+
def each_post_comment(post)
|
372
|
+
# TODO: This is extremely inefficient, but will have to do until it gets implemented in the API
|
373
|
+
raise(LocalJumpError, 'block required') unless block_given?
|
374
|
+
post = self.post(post) unless post.is_a?(Post)
|
375
|
+
each_guild_comment(post.guild_name) do |comment|
|
376
|
+
next unless comment.post_id == post.id
|
377
|
+
yield comment
|
378
|
+
end
|
379
|
+
self
|
380
|
+
end
|
381
|
+
|
382
|
+
##
|
383
|
+
# Enumerates through every post on Ruqqus, yielding each post to a block.
|
384
|
+
#
|
385
|
+
# @param opts [Hash] the options hash.
|
386
|
+
# @option opts [Symbol] :sort (:new) Valid: `:new`, `:top`, `:hot`, `:activity`, `:disputed`
|
387
|
+
# @option opts [Symbol] :filter (:all) Valid: `:all`, `:day`, `:week`, `:month`, `:year`
|
388
|
+
#
|
389
|
+
# @yieldparam post [Post] yields a post to the block.
|
390
|
+
# @return [self]
|
391
|
+
# @raise [LocalJumpError] when a block is not supplied to the method.
|
392
|
+
# @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
|
393
|
+
# these intervals is an expected behavior.
|
394
|
+
def each_post(**opts)
|
395
|
+
raise(LocalJumpError, 'block required') unless block_given?
|
396
|
+
|
397
|
+
sort = opts[:sort] || :new
|
398
|
+
filter = opts[:filter] || :all
|
399
|
+
|
400
|
+
page = 1
|
401
|
+
loop do
|
402
|
+
params = { page: page, sort: sort, t: filter }
|
403
|
+
json = http_get(Routes::ALL_LISTINGS, headers(params: params))
|
404
|
+
break if json[:error]
|
405
|
+
json[:data].each { |hash| yield Post.from_json(hash) }
|
406
|
+
break if json[:data].size < 25
|
407
|
+
page += 1
|
408
|
+
end
|
409
|
+
self
|
410
|
+
end
|
411
|
+
|
412
|
+
##
|
413
|
+
# Enumerates through every post on the "front page", yielding each post to a block.
|
414
|
+
#
|
415
|
+
# @yieldparam post [Post] yields a {Post} to the block.
|
416
|
+
#
|
417
|
+
# @return [self]
|
418
|
+
# @note The front page uses a unique algorithm that is essentially "hot", but for guilds the user is subscribed to.
|
419
|
+
def each_home_post
|
420
|
+
raise(LocalJumpError, 'block required') unless block_given?
|
421
|
+
page = 1
|
422
|
+
loop do
|
423
|
+
json = http_get(Routes::FRONT_PAGE, headers(params: { page: page }))
|
424
|
+
break if json[:error]
|
425
|
+
json[:data].each { |hash| yield Post.from_json(hash) }
|
426
|
+
break if json[:data].size < 25
|
427
|
+
page += 1
|
428
|
+
end
|
429
|
+
self
|
430
|
+
end
|
431
|
+
|
432
|
+
# @!endgroup Object Enumeration
|
433
|
+
|
434
|
+
##
|
435
|
+
# @return [User] the authenticated user this client is performing actions as.
|
436
|
+
def identity
|
437
|
+
@me ||= User.from_json(http_get(Routes::IDENTITY))
|
438
|
+
end
|
439
|
+
|
440
|
+
##
|
441
|
+
# @overload token_refreshed(&block)
|
442
|
+
# Sets a callback to be invoked when the token is refreshed, and a new access token is assigned.
|
443
|
+
# @yieldparam token [Token] yields the newly refreshed {Token} to the block.
|
444
|
+
#
|
445
|
+
# @overload token_refreshed
|
446
|
+
# When called without a block, clears any callback that was previously assigned.
|
447
|
+
#
|
448
|
+
# @return [void]
|
449
|
+
def token_refreshed(&block)
|
450
|
+
@refreshed = block_given? ? block : nil
|
451
|
+
end
|
452
|
+
|
453
|
+
private
|
454
|
+
|
455
|
+
##
|
456
|
+
# @api private
|
457
|
+
# Places a vote on a comment or post.
|
458
|
+
#
|
459
|
+
# @param id [String] the ID of a post or comment.
|
460
|
+
# @param value [Integer] the vote to place, between -1 and 1.
|
461
|
+
# @param route [String] the endpoint of the vote method to invoke.
|
462
|
+
#
|
463
|
+
# @return [Boolean] `true` if vote was placed successfully, otherwise `false`.
|
464
|
+
def submit_vote(id, value, route)
|
465
|
+
raise(Ruqqus::Error, 'invalid ID') unless Ruqqus::VALID_POST.match?(id)
|
466
|
+
amount = [-1, [1, value.to_i].min].max
|
467
|
+
!!http_post("#{route}#{id}/#{amount}")[:error] rescue false
|
468
|
+
end
|
469
|
+
|
470
|
+
##
|
471
|
+
# @api private
|
472
|
+
# Retrieves the HTTP headers for API calls.
|
473
|
+
#
|
474
|
+
# @param opts [Hash] the options hash to include any additional parameters.
|
475
|
+
#
|
476
|
+
# @return [Hash<Symbol, Sting>] a hash containing the header parameters.
|
477
|
+
def headers(**opts)
|
478
|
+
hash = DEFAULT_HEADERS.merge({ Authorization: "#{@token.type} #{@token.access_token}" })
|
479
|
+
opts[:cookies] = { session: @session } if @session
|
480
|
+
hash.merge(opts)
|
481
|
+
end
|
482
|
+
|
483
|
+
##
|
484
|
+
# @api private
|
485
|
+
# Submits a new comment.
|
486
|
+
#
|
487
|
+
# @param parent [String] the full name of a post or comment to reply under. (i.e. `t2_`, `t3_`, etc.)
|
488
|
+
# @param pid [String] the unique ID of the parent post to comment within.
|
489
|
+
# @param body [String] the text body of the comment.
|
490
|
+
#
|
491
|
+
# @return [Comment] the newly submitted comment.
|
492
|
+
def comment_submit(parent, pid, body)
|
493
|
+
raise(ArgumentError, 'body cannot be nil or empty') unless body && !body.empty?
|
494
|
+
params = { submission: pid, parent_fullname: parent, body: body }
|
495
|
+
Comment.from_json(http_post(Routes::COMMENT, params)) rescue nil
|
496
|
+
end
|
497
|
+
|
498
|
+
##
|
499
|
+
# @api private
|
500
|
+
# Enumerates over each page of posts/comments for a user, and returns the deserialized objects.
|
501
|
+
#
|
502
|
+
# @param user [User,String] a {User} instance or the name of the account to query.
|
503
|
+
# @param klass [Class] the type of object to return, must implement `.from_json`.
|
504
|
+
# @param route [String] the final API route for the endpoint, either `"listing"` or "comments"`
|
505
|
+
#
|
506
|
+
# @return [self]
|
507
|
+
def each_submission(user, klass, route)
|
508
|
+
|
509
|
+
username = user.is_a?(User) ? user.username : user.to_s
|
510
|
+
raise(Ruqqus::Error, 'invalid username') unless VALID_USERNAME.match?(username)
|
511
|
+
|
512
|
+
page = 1
|
513
|
+
loop do
|
514
|
+
url = "#{Routes::USER}#{username}/#{route}"
|
515
|
+
json = http_get(url, headers(params: { page: page }))
|
516
|
+
break if json[:error]
|
517
|
+
|
518
|
+
json[:data].each { |hash| yield klass.from_json(hash) }
|
519
|
+
break if json[:data].size < 25
|
520
|
+
page += 1
|
521
|
+
end
|
522
|
+
self
|
523
|
+
end
|
524
|
+
|
525
|
+
##
|
526
|
+
# @api private
|
527
|
+
# Creates and sends a GET request and returns the response as a JSON hash.
|
528
|
+
#
|
529
|
+
# @param uri [String] the endpoint to invoke.
|
530
|
+
# @param header [Hash] a set of headers to send, or `nil` to use the default headers.
|
531
|
+
#
|
532
|
+
# @return [Hash] the response deserialized into a JSON hash.
|
533
|
+
# @see http_post
|
534
|
+
def http_get(uri, header = nil)
|
535
|
+
refresh_token
|
536
|
+
header ||= headers
|
537
|
+
response = RestClient.get(uri.chomp('/'), header)
|
538
|
+
@session = response.cookies['session_ruqqus'] if response.cookies['session_ruqqus']
|
539
|
+
raise(Ruqqus::Error, 'HTTP request failed') if response.code < 200 || response.code >= 300
|
540
|
+
JSON.parse(response, symbolize_names: response.body)
|
541
|
+
end
|
542
|
+
|
543
|
+
##
|
544
|
+
# @api private
|
545
|
+
# Creates and sends a POST request and returns the response as a JSON hash.
|
546
|
+
#
|
547
|
+
# @param uri [String] the endpoint to invoke.
|
548
|
+
# @param params [Hash] a hash of parameters that will be sent with the request.
|
549
|
+
# @param header [Hash] a set of headers to send, or `nil` to use the default headers.
|
550
|
+
#
|
551
|
+
# @return [Hash] the response deserialized into a JSON hash.
|
552
|
+
# @see http_get
|
553
|
+
def http_post(uri, params = {}, header = nil)
|
554
|
+
refresh_token
|
555
|
+
header ||= headers
|
556
|
+
response = RestClient.post(uri.chomp('/'), params, header)
|
557
|
+
@session = response.cookies['session_ruqqus'] if response.cookies['session_ruqqus']
|
558
|
+
raise(Ruqqus::Error, 'HTTP request failed') if response.code < 200 || response.code >= 300
|
559
|
+
JSON.parse(response, symbolize_names: response.body)
|
560
|
+
end
|
561
|
+
|
562
|
+
##
|
563
|
+
# @api private
|
564
|
+
# Checks if token is expired, and refreshes if so, calling the {#token_refreshed} block as if defined.
|
565
|
+
def refresh_token
|
566
|
+
return unless @token.expired?
|
567
|
+
@token.refresh(@client_id, @client_secret)
|
568
|
+
@refreshed&.call(@token)
|
569
|
+
end
|
570
|
+
end
|
571
|
+
end
|