ruqqus 1.0.0 → 1.1.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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/item_base'
4
- require_relative 'ruqqus/comment'
5
- require_relative 'ruqqus/guild'
6
- require_relative 'ruqqus/post'
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
- # Retrieves the {User} with the specified username.
45
- #
46
- # @param username [String] the username of the Ruqqus account to retrieve.
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
- # @return [User] the requested {User}.
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
- # @raise [ArgumentError] when `username` is `nil` or value does match the {VALID_USERNAME} regular expression.
51
- # @raise [Error] thrown when user account does not exist.
52
- def self.user(username)
53
- raise(ArgumentError, 'username cannot be nil') unless username
54
- raise(ArgumentError, 'invalid username') unless VALID_USERNAME.match?(username)
55
- api_get("#{HOME}/api/v1/user/#{username}", User)
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
- # Retrieves the {Guild} with the specified name.
88
+ # Helper function to automate uploading images to Imgur anonymously and returning the direct image link.
60
89
  #
61
- # @param guild_name [String] the name of the Ruqqus guild to retrieve.
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 [Guild] the requested {Guild}.
64
- #
65
- # @raise [ArgumentError] when `guild_name` is `nil` or value does match the {VALID_GUILD} regular expression.
66
- # @raise [Error] thrown when guild does not exist.
67
- def self.guild(guild_name)
68
- raise(ArgumentError, 'guild_name cannot be nil') unless guild_name
69
- raise(ArgumentError, 'invalid guild name') unless VALID_POST.match?(guild_name)
70
- api_get("#{HOME}/api/v1/guild/#{guild_name}", Guild)
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
- # Retrieves the {Post} with the specified name.
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
- # @return [Post] the requested {Post}.
116
+ # @param guild_name [String] the name of a guild to query.
79
117
  #
80
- # @raise [ArgumentError] when `post_id` is `nil` or value does match the {VALID_POST} regular expression.
81
- # @raise [Error] thrown when a post with the specified ID does not exist.
82
- def self.post(post_id)
83
- raise(ArgumentError, 'post_id cannot be nil') unless post_id
84
- raise(ArgumentError, 'invalid post ID') unless VALID_POST.match?(post_id)
85
- api_get("#{HOME}/api/v1/post/#{post_id}", Post)
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
- # Retrieves the {Comment} with the specified name.
131
+ # Checks if the specified username is available to be created.
90
132
  #
91
- # @param comment_id [String] the ID of the comment to retrieve.
133
+ # @param username [String] the name of a user to query.
92
134
  #
93
- # @return [Comment] the requested {Comment}.
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
- # @raise [ArgumentError] when `comment_id` is `nil` or value does match the {VALID_POST} regular expression.
96
- # @raise [Error] when a comment with the specified ID does not exist.
97
- def self.comment(comment_id)
98
- raise(ArgumentError, 'comment_id cannot be nil') unless comment_id
99
- raise(ArgumentError, 'invalid comment ID') unless VALID_POST.match?(comment_id)
100
- api_get("#{HOME}/api/v1/comment/#{comment_id}", Comment)
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
- # @api private
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 route [String] the full API route to the GET method.
108
- # @param klass [Class] a Class instance that is inherited from {ItemBase}.
185
+ # @param [String] the URL to open.
109
186
  #
110
- # @return [Object] an instance of the specified class.
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
- # @raise [Error] thrown when the requested item is not found.
113
- # @raise [ArgumentError] when the specified class is not inherited from {ItemBase}.
114
- def self.api_get(route, klass)
115
- raise(ArgumentError, 'klass is not a child class of Ruqqus::ItemBase') unless klass < ItemBase
116
- #noinspection RubyResolve
117
- begin
118
- response = RestClient.get(route)
119
- klass.from_json(response.body)
120
- rescue RestClient::BadRequest
121
- raise(Error, 'invalid search parameters, object with specified criteria does not exist')
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;">&nbsp;</p>
270
+ #{message}
271
+ <p style="text-align: center;">&nbsp;&nbsp;</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