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.
@@ -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