ruqqus 0.1.0 → 1.1.3

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.
data/Rakefile CHANGED
@@ -1,10 +1,5 @@
1
1
  require "bundler/gem_tasks"
2
- require "rake/testtask"
3
2
 
4
- Rake::TestTask.new(:test) do |t|
5
- t.libs << "test"
6
- t.libs << "lib"
7
- t.test_files = FileList["test/**/*_test.rb"]
8
- end
3
+ task(:test) { true }
9
4
 
10
- task :default => :test
5
+ task default: :test
data/TODO.md ADDED
@@ -0,0 +1,25 @@
1
+ # TODO
2
+
3
+ A scratch pad for things to do and ideas to look into
4
+
5
+ * Create and centralize all API endpoints into `Routes` module
6
+ * Check for `json[:error]` in `http_get` and `http_post` for all calls?
7
+ * Create examples in documentation
8
+ * Update README with more examples
9
+ * Create wiki on GitHub
10
+ * Finish and cleanup and `ruqqus-oauth` app
11
+ * Embed comment/posts API
12
+
13
+ # Missing API features
14
+
15
+ Some API features that would be beneficial to have implemented
16
+
17
+ * Flagging
18
+ * Post deletion/edit (!)
19
+ * Notifications (!)
20
+ * NSFW/NSFW toggling
21
+ * Kicking/Yanking
22
+ * User Settings
23
+ * Guild Settings
24
+ * Guild join/leave
25
+ * Follow/Unfollow users
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'mechanize'
4
+ require 'tty-prompt'
5
+
6
+ #noinspection RubyResolve
7
+ class RuqqusOAuth
8
+
9
+ def initialize
10
+ prompt = TTY::Prompt.new
11
+ agent = Mechanize.new
12
+
13
+ prompt.warn('After inputting your client information and credentials of user')
14
+ prompt.warn('to authorize, and a code for token creation will be generated.')
15
+ puts
16
+ url = authorization_url(prompt)
17
+ login(agent, prompt)
18
+ prompt.say('Generating code...')
19
+ puts
20
+ code = generate_code(url, agent)
21
+ STDOUT << 'CODE: '
22
+ prompt.ok(code)
23
+ end
24
+
25
+ def authorization_url(prompt)
26
+ url = 'https://ruqqus.com/oauth/authorize'
27
+ url << "?client_id=#{ask_client_id(prompt)}"
28
+ url << "&redirect_uri=#{ask_client_redirect(prompt)}"
29
+ url << "&scope=#{ask_scopes(prompt)}"
30
+ url << "&state=#{SecureRandom.uuid.gsub('-', '')}"
31
+ url << '&permanent=true'
32
+ url
33
+ end
34
+
35
+ def generate_code(url, agent)
36
+ agent.get(url) do |page|
37
+ page.form_with(action: '/oauth/authorize').submit
38
+ /.*\?code=([a-zA-Z0-9_-]+)&?/.match(agent.history.last.uri.to_s)
39
+ return $1
40
+ end
41
+ end
42
+
43
+ def ask_client_id(prompt)
44
+ prompt.ask('Client ID: ') { |q| q.validate(/^[A-Za-z0-9_-]+$/, "Invalid client ID") }
45
+ end
46
+
47
+ def ask_client_secret(prompt)
48
+ prompt.ask('Client Secret: ') { |q| q.validate(/^[A-Za-z0-9_-]+$/, "Invalid client secret") }
49
+ end
50
+
51
+ def ask_client_redirect(prompt)
52
+ prompt.ask('Redirect URI: ', default: 'https://www.google.com') do |q|
53
+ proc = Proc.new { |v| URI.regexp =~ v && URI(v).scheme == 'https' }
54
+ q.validate(proc, 'Invalid HTTPS URI/scheme (must be HTTPS, not localhost)')
55
+ end
56
+ end
57
+
58
+ def ask_scopes(prompt)
59
+ msg = 'Select scopes the client is authorized to perform:'
60
+ choices = %i(identity create read update delete vote guildmaster)
61
+ scopes = prompt.multi_select(msg, choices, per_page: choices.size) do |q|
62
+ defaults = (1..choices.size).to_a
63
+ q.default(*defaults)
64
+ end
65
+ scopes.join(',')
66
+ end
67
+
68
+ def login(agent, prompt)
69
+ agent.get('https://ruqqus.com/login') do |page|
70
+ page.form_with(action: '/login') do |form|
71
+ form['username'] = prompt.ask('Username: ') do |q|
72
+ q.validate(/^[a-zA-Z0-9_]{5,25}$/, 'Invalid username')
73
+ end
74
+ form['password'] = prompt.mask("Password: ") do |q|
75
+ q.validate(/^.{8,100}$/, 'Invalid password')
76
+ end
77
+ puts 'Logging into Ruqqus...'
78
+ end.submit
79
+ end
80
+ end
81
+ end
82
+
83
+ begin
84
+ RuqqusOAuth.new
85
+ rescue Interrupt
86
+ puts
87
+ # Ignored
88
+ end
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
@@ -1,7 +1,278 @@
1
+ require 'base64'
2
+ require 'json'
3
+ require 'rbconfig'
4
+ require 'rest-client'
5
+ require 'securerandom'
6
+ require 'socket'
7
+
8
+ require_relative 'ruqqus/token'
9
+ require_relative 'ruqqus/routes'
10
+ require_relative 'ruqqus/client'
11
+ require_relative 'ruqqus/types'
1
12
  require_relative 'ruqqus/version'
2
13
 
14
+ ##
15
+ # Top-level namespace of the Ruqqus gem.
3
16
  module Ruqqus
4
17
 
5
- class Error < StandardError; end
18
+ ##
19
+ # The base Ruqqus URL.
20
+ HOME = 'https://ruqqus.com'.freeze
21
+
22
+ ##
23
+ # A regular expression used for username validation.
24
+ VALID_USERNAME = /^[a-zA-Z0-9_]{5,25}$/.freeze
25
+
26
+ ##
27
+ # A regular expression used for password validation.
28
+ VALID_PASSWORD= /^.{8,100}$/.freeze
29
+
30
+ ##
31
+ # A regular expression used for guild name validation.
32
+ VALID_GUILD = /^[a-zA-Z0-9][a-zA-Z0-9_]{2,24}$/.freeze
33
+
34
+ ##
35
+ # A regular expression used for post/comment ID validation.
36
+ VALID_POST = /[A-Za-z0-9]+/.freeze
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
+
46
+ ##
47
+ # Generic error class for exceptions specific to the Ruqqus API.
48
+ class Error < StandardError
49
+ end
50
+
51
+ ##
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.
57
+ #
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.
60
+ #
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
85
+ end
86
+
87
+ ##
88
+ # Helper function to automate uploading images to Imgur anonymously and returning the direct image link.
89
+ #
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
95
+ #
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]
111
+ end
112
+
113
+ ##
114
+ # Checks if the specified guild name is available to be created.
115
+ #
116
+ # @param guild_name [String] the name of a guild to query.
117
+ #
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
128
+ end
129
+
130
+ ##
131
+ # Checks if the specified username is available to be created.
132
+ #
133
+ # @param username [String] the name of a user to query.
134
+ #
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.
148
+ #
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
179
+ end
180
+
181
+
182
+ ##
183
+ # Opens a URL in the system's default web browser, using the appropriate command for the host platform.
184
+ #
185
+ # @param [String] the URL to open.
186
+ #
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.
203
+ #
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 }
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]
242
+ end
243
+
244
+ private
6
245
 
7
- end
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,563 @@
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
+ SCOPES = %i(identity create read update delete vote guildmaster).freeze
17
+
18
+ ##
19
+ # A set of HTTP headers that will be included with every request.
20
+ DEFAULT_HEADERS = { 'User-Agent': USER_AGENT, 'Accept': 'application/json', 'Content-Type': 'application/json' }.freeze
21
+
22
+ ##
23
+ # @!attribute [rw] token
24
+ # @return [Token] the OAuth2 token that grants the client authentication.
25
+
26
+ ##
27
+ # @!attribute [r] identity
28
+ # @return [User] the authenticated user this client is performing actions as.
29
+
30
+ ##
31
+ # @overload initialize(client_id, client_secret, token)
32
+ # Creates a new instance of the {Client} class with an existing token for authorization.
33
+ # @param client_id [String] the client ID of your of your application, issued after registration on Ruqqus.
34
+ # @param client_secret [String] the client secret of your of your application, issued after registration on Ruqqus.
35
+ # @param token [Token] a valid access token that has previously been granted access for the client.
36
+ #
37
+ # @overload initialize(client_id, client_secret, code)
38
+ # Creates a new instance of the {Client} class with an existing token for authorization.
39
+ # @param client_id [String] the client ID of your of your application, issued after registration on Ruqqus.
40
+ # @param client_secret [String] the client secret of your of your application, issued after registration on Ruqqus.
41
+ # @param code [String] a the code from the Oauth2 redirect to create a new {Token} and grant access to it.
42
+ def initialize(client_id, client_secret, token)
43
+ @client_id = client_id || raise(ArgumentError, 'client ID cannot be nil')
44
+ @client_secret = client_secret || raise(ArgumentError, 'client secret cannot be nil')
45
+
46
+ @token = token.is_a?(Token) ? token : Token.new(client_id, client_secret, token.to_s)
47
+ @session = nil
48
+ end
49
+
50
+ attr_reader :token
51
+
52
+ def token=(token)
53
+ @token = token || raise(ArgumentError, 'token cannot be nil')
54
+ end
55
+
56
+ # @!group Object Querying
57
+
58
+ ##
59
+ # Retrieves the {User} with the specified username.
60
+ #
61
+ # @param username [String] the username of the Ruqqus account to retrieve.
62
+ #
63
+ # @return [User] the requested {User}.
64
+ #
65
+ # @raise [ArgumentError] when `username` is `nil` or value does match the {Ruqqus::VALID_USERNAME} regular expression.
66
+ # @raise [Error] thrown when user account does not exist.
67
+ def user(username)
68
+ raise(ArgumentError, 'username cannot be nil') unless username
69
+ raise(ArgumentError, 'invalid username') unless VALID_USERNAME.match?(username)
70
+ User.from_json(http_get("#{Routes::USER}#{username}"))
71
+ end
72
+
73
+ ##
74
+ # Retrieves the {Guild} with the specified name.
75
+ #
76
+ # @param guild_name [String] the name of the Ruqqus guild to retrieve.
77
+ #
78
+ # @return [Guild] the requested {Guild}.
79
+ #
80
+ # @raise [ArgumentError] when `guild_name` is `nil` or value does match the {Ruqqus::VALID_GUILD} regular expression.
81
+ # @raise [Error] thrown when guild does not exist.
82
+ def guild(guild_name)
83
+ raise(ArgumentError, 'guild_name cannot be nil') unless guild_name
84
+ raise(ArgumentError, 'invalid guild name') unless VALID_GUILD.match?(guild_name)
85
+ Guild.from_json(http_get("#{Routes::GUILD}#{guild_name}"))
86
+ end
87
+
88
+ ##
89
+ # Retrieves the {Post} with the specified name.
90
+ #
91
+ # @param post_id [String] the ID of the post to retrieve.
92
+ #
93
+ # @return [Post] the requested {Post}.
94
+ #
95
+ # @raise [ArgumentError] when `post_id` is `nil` or value does match the {Ruqqus::VALID_POST} regular expression.
96
+ # @raise [Error] thrown when a post with the specified ID does not exist.
97
+ def post(post_id)
98
+ raise(ArgumentError, 'post_id cannot be nil') unless post_id
99
+ raise(ArgumentError, 'invalid post ID') unless VALID_POST.match?(post_id)
100
+ Post.from_json(http_get("#{Routes::POST}#{post_id}"))
101
+ end
102
+
103
+ ##
104
+ # Retrieves the {Comment} with the specified name.
105
+ #
106
+ # @param comment_id [String] the ID of the comment to retrieve.
107
+ #
108
+ # @return [Comment] the requested {Comment}.
109
+ #
110
+ # @raise [ArgumentError] when `comment_id` is `nil` or value does match the {Ruqqus::VALID_POST} regular expression.
111
+ # @raise [Error] when a comment with the specified ID does not exist.
112
+ def comment(comment_id)
113
+ raise(ArgumentError, 'comment_id cannot be nil') unless comment_id
114
+ raise(ArgumentError, 'invalid comment ID') unless VALID_POST.match?(comment_id)
115
+ Comment.from_json(http_get("#{Routes::COMMENT}#{comment_id}"))
116
+ end
117
+
118
+ # @!endgroup Object Querying
119
+
120
+ # @!group Commenting
121
+
122
+ ##
123
+ # Submits a new comment on a post.
124
+ #
125
+ # @param body [String] the text content of the post (supports Markdown)
126
+ # @param post [Post,String] a {Post} instance or the unique ID of a post.
127
+ # @param comment [Comment,String] a {Comment} with the post to reply under, or `nil` to reply directly to the post.
128
+ #
129
+ # @return [Comment?] the comment that was submitted, or `nil` if an error occurred.
130
+ #
131
+ # @note This method is restricted to 6/minute, and will fail when that limit is exceeded.
132
+ def comment_create(body, post, comment = nil)
133
+ pid = post.to_s
134
+ parent = comment ? 't3_' + comment.to_s : 't2_' + pid
135
+ comment_submit(parent, pid, body)
136
+ end
137
+
138
+ ##
139
+ # Submits a new comment on a post.
140
+ #
141
+ # @param body [String] the text content of the comment (supports Markdown)
142
+ # @param comment [Comment,String] a {Comment} instance or the unique ID of a comment.
143
+ #
144
+ # @return [Comment?] the comment that was submitted, or `nil` if an error occurred.
145
+ #
146
+ # @note This method is restricted to 6/minute, and will fail when that limit is exceeded.
147
+ def comment_reply(body, comment)
148
+ if comment.is_a?(Comment)
149
+ comment_submit(comment.fullname, comment.post_id, body)
150
+ else
151
+ comment = self.comment(comment.to_s)
152
+ comment_submit(comment.fullname, comment.post_id, body)
153
+ end
154
+ end
155
+
156
+ ##
157
+ # Deletes an existing comment.
158
+ #
159
+ # @param comment [Comment,String] a {Comment} instance, or the unique ID of the comment to delete.
160
+ #
161
+ # @return [Boolean] `true` if deletion completed without error, otherwise `false`.
162
+ def comment_delete(comment)
163
+ id = comment.is_a?(Comment) ? comment.id : comment.sub(/^t3_/, '')
164
+ url = "#{Routes::API_BASE}/delete/comment/#{id}"
165
+ http_post(url).empty? rescue false
166
+ end
167
+
168
+ # @!endgroup Commenting
169
+
170
+ # @!group Posting
171
+
172
+ ##
173
+ # Creates a new post on Ruqqus as the current user.
174
+ #
175
+ # @param guild [Guild,String] a {Guild} instance or the name of the guild to post to.
176
+ # @param title [String] the title of the post to create.
177
+ # @param body [String?] the text body of the post, which can be `nil` if supplying URL or image upload.
178
+ # @param opts [Hash] The options hash to specify a link or image to upload.
179
+ # @option opts [String] :image (nil) the path to an image file to upload.
180
+ # @option opts [String] :url (nil) a URL to share with the post.
181
+ # @option opts [String] :imgur_client (nil) an Imgur client ID to automatically share images via Imgur instead of
182
+ # direct upload.
183
+ #
184
+ # @return [Post?] the newly created {Post} instance, or `nil` if an error occurred.
185
+ # @note This method is restricted to 6/minute, and will fail when that limit is exceeded.
186
+ def post_create(guild, title, body = nil, **opts)
187
+ name = guild.is_a?(Guild) ? guild.name : guild.strip.sub(/^\+/, '')
188
+ raise(ArgumentError, 'invalid guild name') unless Ruqqus::VALID_GUILD.match?(name)
189
+ raise(ArgumentError, 'title cannot be nil or empty') unless title && !title.empty?
190
+ params = { title: title, board: name, body: body }
191
+
192
+ if opts[:image]
193
+ if opts[:imgur_client]
194
+ params[:url] = Ruqqus.imgur_upload(opts[:imgur_client], opts[:image])
195
+ else
196
+ params[:file] = File.new(opts[:image])
197
+ end
198
+ elsif opts[:url]
199
+ raise(ArgumentError, 'invalid URI') unless URI.regexp =~ opts[:url]
200
+ params[:url] = opts[:url]
201
+ end
202
+
203
+ if [params[:body], params[:image], params[:url]].none?
204
+ raise(ArgumentError, 'text body cannot be nil or empty without URL or image') if body.nil? || body.empty?
205
+ end
206
+ Post.from_json(http_post(Routes::SUBMIT, params)) rescue nil
207
+ end
208
+
209
+ # @!endgroup Posting
210
+
211
+ # @!group Voting
212
+
213
+ ##
214
+ # Places a vote on a post.
215
+ #
216
+ # @param post [Post,String] a {Post} instance, or the unique ID of a post.
217
+ # @param value [Integer] the vote value to place, either `-1`, `0`, or `1`.
218
+ #
219
+ # @return [Boolean] `true` if vote was placed successfully, otherwise `false`.
220
+ def vote_post(post, value = 1)
221
+ submit_vote(post.to_s, value, 'https://ruqqus.com/api/v1/vote/post/')
222
+ end
223
+
224
+ ##
225
+ # Places a vote on a comment.
226
+ #
227
+ # @param comment [Comment,String] a {Comment} instance, or the unique ID of a comment.
228
+ # @param value [Integer] the vote value to place, either `-1`, `0`, or `1`.
229
+ #
230
+ # @return [Boolean] `true` if vote was placed successfully, otherwise `false`.
231
+ def vote_comment(comment, value = 1)
232
+ submit_vote(comment.to_s, value, 'https://ruqqus.com/api/v1/vote/comment/')
233
+ end
234
+
235
+ # @!endgroup Voting
236
+
237
+ # @!group Object Enumeration
238
+
239
+ ##
240
+ # Enumerates through each post of a user, yielding each to a block.
241
+ #
242
+ # @param user [User,String] a {User} instance or the name of the account to query.
243
+ # @yieldparam post [Post] yields a {Post} to the block.
244
+ # @return [self]
245
+ # @raise [LocalJumpError] when a block is not supplied to the method.
246
+ # @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
247
+ # these intervals is an expected behavior.
248
+ def each_user_post(user)
249
+ raise(LocalJumpError, 'block required') unless block_given?
250
+ each_submission(user, Post, 'listing') { |obj| yield obj }
251
+ end
252
+
253
+ ##
254
+ # Enumerates through each comment of a user, yielding each to a block.
255
+ #
256
+ # @param user [User,String] a {User} instance or the name of the account to query.
257
+ # @yieldparam comment [Comment] yields a {Comment} to the block.
258
+ # @return [self]
259
+ # @raise [LocalJumpError] when a block is not supplied to the method.
260
+ # @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
261
+ # these intervals is an expected behavior.
262
+ def each_user_comment(user)
263
+ raise(LocalJumpError, 'block required') unless block_given?
264
+ each_submission(user, Comment, 'comments') { |obj| yield obj }
265
+ end
266
+
267
+ ##
268
+ # Enumerates through each post in the specified guild, and yields each one to a block.
269
+ #
270
+ # @param sort [Symbol] a symbol to determine the sorting method, valid values include `:trending`, `:subs`, `:new`.
271
+ # @yieldparam guild [Guild] yields a {Guild} to the block.
272
+ # @return [self]
273
+ # @raise [LocalJumpError] when a block is not supplied to the method.
274
+ # @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
275
+ # these intervals is an expected behavior.
276
+ def each_guild(sort = :subs)
277
+ raise(LocalJumpError, 'block required') unless block_given?
278
+
279
+ page = 1
280
+ loop do
281
+ params = { sort: sort, page: page }
282
+ json = http_get(Routes::GUILDS, headers(params: params))
283
+ break if json[:error]
284
+ json[:data].each { |hash| yield Guild.from_json(hash) }
285
+ break if json[:data].size < 25
286
+ page += 1
287
+ end
288
+ self
289
+ end
290
+
291
+ ##
292
+ # Enumerates through each post in a guild, yielding each to a block.
293
+ #
294
+ # @param guild [Guild,String] a {Guild} instance, or the name of the guild to query.
295
+ # @param opts [Hash] the options hash.
296
+ # @option opts [Symbol] :sort (:new) Valid: `:new`, `:top`, `:hot`, `:activity`, `:disputed`
297
+ # @option opts [Symbol] :filter (:all) Valid: `:all`, `:day`, `:week`, `:month`, `:year`
298
+ #
299
+ # @yieldparam post [Post] yields a {Post} to the block.
300
+ # @return [self]
301
+ # @raise [LocalJumpError] when a block is not supplied to the method.
302
+ # @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
303
+ # these intervals is an expected behavior.
304
+ def each_guild_post(guild, **opts)
305
+ raise(LocalJumpError, 'block required') unless block_given?
306
+ name = guild.to_s
307
+ raise(ArgumentError, 'invalid guild name') unless Ruqqus::VALID_GUILD.match?(name)
308
+
309
+ sort = opts[:sort] || :new
310
+ filter = opts[:filter] || :all
311
+
312
+ page = 1
313
+ loop do
314
+ params = { page: page, sort: sort, t: filter }
315
+ json = http_get("#{Routes::GUILD}#{name}/listing", headers(params: params))
316
+ break if json[:error]
317
+
318
+ json[:data].each { |hash| yield Post.from_json(hash) }
319
+ break if json[:data].size < 25
320
+ page += 1
321
+ end
322
+
323
+ self
324
+ end
325
+
326
+ ##
327
+ # Enumerates through each comment in a guild, yielding each to a block.
328
+ #
329
+ # @param guild [Guild,String] a {Guild} instance, or the name of the guild to query.
330
+ # @yieldparam [Comment] yields a {Comment} to the block.
331
+ #
332
+ # @return [self]
333
+ # @raise [LocalJumpError] when a block is not supplied to the method.
334
+ def each_guild_comment(guild)
335
+ raise(LocalJumpError, 'block required') unless block_given?
336
+ name = guild.to_s
337
+ raise(ArgumentError, 'invalid guild name') unless Ruqqus::VALID_GUILD.match?(name)
338
+
339
+ page = 1
340
+ loop do
341
+ params = { page: page }
342
+ json = http_get("#{Routes::GUILD}#{name}/comments", headers(params: params))
343
+ break if json[:error]
344
+
345
+ json[:data].each { |hash| yield Comment.from_json(hash) }
346
+ break if json[:data].size < 25
347
+ page += 1
348
+ end
349
+
350
+ self
351
+ end
352
+
353
+ ##
354
+ # Enumerates through each comment in a guild, yielding each to a block.
355
+ #
356
+ # @param post [Post,String] a {Post} instance, or the unique ID of the post to query.
357
+ # @yieldparam [Comment] yields a {Comment} to the block.
358
+ #
359
+ # @return [self]
360
+ # @raise [LocalJumpError] when a block is not supplied to the method.
361
+ # @note This method is very inefficient, as it the underlying API does not yet implement it, therefore each comment
362
+ # in the entire guild must be searched through.
363
+ def each_post_comment(post)
364
+ # TODO: This is extremely inefficient, but will have to do until it gets implemented in the API
365
+ raise(LocalJumpError, 'block required') unless block_given?
366
+ post = self.post(post) unless post.is_a?(Post)
367
+ each_guild_comment(post.guild_name) do |comment|
368
+ next unless comment.post_id == post.id
369
+ yield comment
370
+ end
371
+ self
372
+ end
373
+
374
+ ##
375
+ # Enumerates through every post on Ruqqus, yielding each post to a block.
376
+ #
377
+ # @param opts [Hash] the options hash.
378
+ # @option opts [Symbol] :sort (:new) Valid: `:new`, `:top`, `:hot`, `:activity`, `:disputed`
379
+ # @option opts [Symbol] :filter (:all) Valid: `:all`, `:day`, `:week`, `:month`, `:year`
380
+ #
381
+ # @yieldparam post [Post] yields a post to the block.
382
+ # @return [self]
383
+ # @raise [LocalJumpError] when a block is not supplied to the method.
384
+ # @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
385
+ # these intervals is an expected behavior.
386
+ def each_post(**opts)
387
+ raise(LocalJumpError, 'block required') unless block_given?
388
+
389
+ sort = opts[:sort] || :new
390
+ filter = opts[:filter] || :all
391
+
392
+ page = 1
393
+ loop do
394
+ params = { page: page, sort: sort, t: filter }
395
+ json = http_get(Routes::ALL_LISTINGS, headers(params: params))
396
+ break if json[:error]
397
+ json[:data].each { |hash| yield Post.from_json(hash) }
398
+ break if json[:data].size < 25
399
+ page += 1
400
+ end
401
+ self
402
+ end
403
+
404
+ ##
405
+ # Enumerates through every post on the "front page", yielding each post to a block.
406
+ #
407
+ # @yieldparam post [Post] yields a {Post} to the block.
408
+ #
409
+ # @return [self]
410
+ # @note The front page uses a unique algorithm that is essentially "hot", but for guilds the user is subscribed to.
411
+ def each_home_post
412
+ raise(LocalJumpError, 'block required') unless block_given?
413
+ page = 1
414
+ loop do
415
+ json = http_get(Routes::FRONT_PAGE, headers(params: { page: page }))
416
+ break if json[:error]
417
+ json[:data].each { |hash| yield Post.from_json(hash) }
418
+ break if json[:data].size < 25
419
+ page += 1
420
+ end
421
+ self
422
+ end
423
+
424
+ # @!endgroup Object Enumeration
425
+
426
+ ##
427
+ # @return [User] the authenticated user this client is performing actions as.
428
+ def identity
429
+ @me ||= User.from_json(http_get(Routes::IDENTITY))
430
+ end
431
+
432
+ ##
433
+ # @overload token_refreshed(&block)
434
+ # Sets a callback to be invoked when the token is refreshed, and a new access token is assigned.
435
+ # @yieldparam token [Token] yields the newly refreshed {Token} to the block.
436
+ #
437
+ # @overload token_refreshed
438
+ # When called without a block, clears any callback that was previously assigned.
439
+ #
440
+ # @return [void]
441
+ def token_refreshed(&block)
442
+ @refreshed = block_given? ? block : nil
443
+ end
444
+
445
+ private
446
+
447
+ ##
448
+ # @api private
449
+ # Places a vote on a comment or post.
450
+ #
451
+ # @param id [String] the ID of a post or comment.
452
+ # @param value [Integer] the vote to place, between -1 and 1.
453
+ # @param route [String] the endpoint of the vote method to invoke.
454
+ #
455
+ # @return [Boolean] `true` if vote was placed successfully, otherwise `false`.
456
+ def submit_vote(id, value, route)
457
+ raise(Ruqqus::Error, 'invalid ID') unless Ruqqus::VALID_POST.match?(id)
458
+ amount = [-1, [1, value.to_i].min].max
459
+ !!http_post("#{route}#{id}/#{amount}")[:error] rescue false
460
+ end
461
+
462
+ ##
463
+ # @api private
464
+ # Retrieves the HTTP headers for API calls.
465
+ #
466
+ # @param opts [Hash] the options hash to include any additional parameters.
467
+ #
468
+ # @return [Hash<Symbol, Sting>] a hash containing the header parameters.
469
+ def headers(**opts)
470
+ hash = DEFAULT_HEADERS.merge({ Authorization: "#{@token.type} #{@token.access_token}" })
471
+ opts[:cookies] = { session: @session } if @session
472
+ hash.merge(opts)
473
+ end
474
+
475
+ ##
476
+ # @api private
477
+ # Submits a new comment.
478
+ #
479
+ # @param parent [String] the full name of a post or comment to reply under. (i.e. `t2_`, `t3_`, etc.)
480
+ # @param pid [String] the unique ID of the parent post to comment within.
481
+ # @param body [String] the text body of the comment.
482
+ #
483
+ # @return [Comment] the newly submitted comment.
484
+ def comment_submit(parent, pid, body)
485
+ raise(ArgumentError, 'body cannot be nil or empty') unless body && !body.empty?
486
+ params = { submission: pid, parent_fullname: parent, body: body }
487
+ Comment.from_json(http_post(Routes::COMMENT, params)) rescue nil
488
+ end
489
+
490
+ ##
491
+ # @api private
492
+ # Enumerates over each page of posts/comments for a user, and returns the deserialized objects.
493
+ #
494
+ # @param user [User,String] a {User} instance or the name of the account to query.
495
+ # @param klass [Class] the type of object to return, must implement `.from_json`.
496
+ # @param route [String] the final API route for the endpoint, either `"listing"` or "comments"`
497
+ #
498
+ # @return [self]
499
+ def each_submission(user, klass, route)
500
+
501
+ username = user.is_a?(User) ? user.username : user.to_s
502
+ raise(Ruqqus::Error, 'invalid username') unless VALID_USERNAME.match?(username)
503
+
504
+ page = 1
505
+ loop do
506
+ url = "#{Routes::USER}#{username}/#{route}"
507
+ json = http_get(url, headers(params: { page: page }))
508
+ break if json[:error]
509
+
510
+ json[:data].each { |hash| yield klass.from_json(hash) }
511
+ break if json[:data].size < 25
512
+ page += 1
513
+ end
514
+ self
515
+ end
516
+
517
+ ##
518
+ # @api private
519
+ # Creates and sends a GET request and returns the response as a JSON hash.
520
+ #
521
+ # @param uri [String] the endpoint to invoke.
522
+ # @param header [Hash] a set of headers to send, or `nil` to use the default headers.
523
+ #
524
+ # @return [Hash] the response deserialized into a JSON hash.
525
+ # @see http_post
526
+ def http_get(uri, header = nil)
527
+ refresh_token
528
+ header ||= headers
529
+ response = RestClient.get(uri.chomp('/'), header)
530
+ @session = response.cookies['session_ruqqus'] if response.cookies['session_ruqqus']
531
+ raise(Ruqqus::Error, 'HTTP request failed') if response.code < 200 || response.code >= 300
532
+ JSON.parse(response, symbolize_names: response.body)
533
+ end
534
+
535
+ ##
536
+ # @api private
537
+ # Creates and sends a POST request and returns the response as a JSON hash.
538
+ #
539
+ # @param uri [String] the endpoint to invoke.
540
+ # @param params [Hash] a hash of parameters that will be sent with the request.
541
+ # @param header [Hash] a set of headers to send, or `nil` to use the default headers.
542
+ #
543
+ # @return [Hash] the response deserialized into a JSON hash.
544
+ # @see http_get
545
+ def http_post(uri, params = {}, header = nil)
546
+ refresh_token
547
+ header ||= headers
548
+ response = RestClient.post(uri.chomp('/'), params, header)
549
+ @session = response.cookies['session_ruqqus'] if response.cookies['session_ruqqus']
550
+ raise(Ruqqus::Error, 'HTTP request failed') if response.code < 200 || response.code >= 300
551
+ JSON.parse(response, symbolize_names: response.body)
552
+ end
553
+
554
+ ##
555
+ # @api private
556
+ # Checks if token is expired, and refreshes if so, calling the {#token_refreshed} block as if defined.
557
+ def refresh_token
558
+ return unless @token.expired?
559
+ @token.refresh(@client_id, @client_secret)
560
+ @refreshed&.call(@token)
561
+ end
562
+ end
563
+ end