ruqqus 1.0.0 → 1.1.0

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.
@@ -0,0 +1,474 @@
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} (efreed09@gmail.com)".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
+ # @return [Token] the OAuth2 token that grants the client authentication.
24
+ attr_reader :token
25
+
26
+ ##
27
+ # @!attribute [r] identity
28
+ # @return [User] the authenticated user this client is performing actions as.
29
+
30
+ ##
31
+ # Creates a new instance of the {Client} class.
32
+ #
33
+ # @param token [Token] a valid access token to authorize the client.
34
+ def initialize(token)
35
+ @token = token || raise(ArgumentError, 'token cannot be nil')
36
+ @session = nil
37
+ end
38
+
39
+ # @!group Object Querying
40
+
41
+ ##
42
+ # Retrieves the {User} with the specified username.
43
+ #
44
+ # @param username [String] the username of the Ruqqus account to retrieve.
45
+ #
46
+ # @return [User] the requested {User}.
47
+ #
48
+ # @raise [ArgumentError] when `username` is `nil` or value does match the {VALID_USERNAME} regular expression.
49
+ # @raise [Error] thrown when user account does not exist.
50
+ def user(username)
51
+ raise(ArgumentError, 'username cannot be nil') unless username
52
+ raise(ArgumentError, 'invalid username') unless VALID_USERNAME.match?(username)
53
+ User.from_json(http_get("#{Routes::USER}#{username}"))
54
+ end
55
+
56
+ ##
57
+ # Retrieves the {Guild} with the specified name.
58
+ #
59
+ # @param guild_name [String] the name of the Ruqqus guild to retrieve.
60
+ #
61
+ # @return [Guild] the requested {Guild}.
62
+ #
63
+ # @raise [ArgumentError] when `guild_name` is `nil` or value does match the {VALID_GUILD} regular expression.
64
+ # @raise [Error] thrown when guild does not exist.
65
+ def guild(guild_name)
66
+ raise(ArgumentError, 'guild_name cannot be nil') unless guild_name
67
+ raise(ArgumentError, 'invalid guild name') unless VALID_GUILD.match?(guild_name)
68
+ Guild.from_json(http_get("#{Routes::GUILD}#{guild_name}"))
69
+ end
70
+
71
+ ##
72
+ # Retrieves the {Post} with the specified name.
73
+ #
74
+ # @param post_id [String] the ID of the post to retrieve.
75
+ #
76
+ # @return [Post] the requested {Post}.
77
+ #
78
+ # @raise [ArgumentError] when `post_id` is `nil` or value does match the {VALID_POST} regular expression.
79
+ # @raise [Error] thrown when a post with the specified ID does not exist.
80
+ def post(post_id)
81
+ raise(ArgumentError, 'post_id cannot be nil') unless post_id
82
+ raise(ArgumentError, 'invalid post ID') unless VALID_POST.match?(post_id)
83
+ Post.from_json(http_get("#{Routes::POST}#{post_id}"))
84
+ end
85
+
86
+ ##
87
+ # Retrieves the {Comment} with the specified name.
88
+ #
89
+ # @param comment_id [String] the ID of the comment to retrieve.
90
+ #
91
+ # @return [Comment] the requested {Comment}.
92
+ #
93
+ # @raise [ArgumentError] when `comment_id` is `nil` or value does match the {VALID_POST} regular expression.
94
+ # @raise [Error] when a comment with the specified ID does not exist.
95
+ def comment(comment_id)
96
+ raise(ArgumentError, 'comment_id cannot be nil') unless comment_id
97
+ raise(ArgumentError, 'invalid comment ID') unless VALID_POST.match?(comment_id)
98
+ Comment.from_json(http_get("#{Routes::COMMENT}#{comment_id}"))
99
+ end
100
+
101
+ # @!endgroup Object Querying
102
+
103
+ # @!group Commenting
104
+
105
+ ##
106
+ # Submits a new comment on a post.
107
+ #
108
+ # @param body [String] the text content of the post (supports Markdown)
109
+ # @param post [Post,String] a {Post} instance or the unique ID of a post.
110
+ # @param comment [Comment,String] a {Comment} with the post to reply under, or `nil` to reply directly to the post.
111
+ #
112
+ # @return [Comment?] the comment that was submitted, or `nil` if an error occurred.
113
+ #
114
+ # @note This method is restricted to 6/minute, and will fail when that limit is exceeded.
115
+ def comment_create(body, post, comment = nil)
116
+ pid = post.to_s
117
+ parent = comment ? 't3_' + comment.to_s : 't2_' + pid
118
+ comment_submit(parent, pid, body)
119
+ end
120
+
121
+ ##
122
+ # Submits a new comment on a post.
123
+ #
124
+ # @param body [String] the text content of the comment (supports Markdown)
125
+ # @param comment [Comment,String] a {Comment} instance or the unique ID of a comment.
126
+ #
127
+ # @return [Comment?] the comment that was submitted, or `nil` if an error occurred.
128
+ #
129
+ # @note This method is restricted to 6/minute, and will fail when that limit is exceeded.
130
+ def comment_reply(body, comment)
131
+ if comment.is_a?(Comment)
132
+ comment_submit(comment.full_name, comment.post_id, body)
133
+ else
134
+ comment = self.comment(comment.to_s)
135
+ comment_submit(comment.full_name, comment.post_id, body)
136
+ end
137
+ end
138
+
139
+ ##
140
+ # Deletes an existing comment.
141
+ #
142
+ # @param comment [Comment,String] a {Comment} instance, or the unique ID of the comment to delete.
143
+ #
144
+ # @return [Boolean] `true` if deletion completed without error, otherwise `false`.
145
+ def comment_delete(comment)
146
+ id = comment.is_a?(Comment) ? comment.id : comment.sub(/^t3_/, '')
147
+ url = "#{Routes::API_BASE}/delete/comment/#{id}"
148
+ http_post(url).empty? rescue false
149
+ end
150
+
151
+ # @!endgroup Commenting
152
+
153
+ # @!group Posting
154
+
155
+ ##
156
+ # Creates a new post on Ruqqus as the current user.
157
+ #
158
+ # @param guild [Guild,String] a {Guild} instance or the name of the guild to post to.
159
+ # @param title [String] the title of the post to create.
160
+ # @param body [String?] the text body of the post, which can be `nil` if supplying URL or image upload.
161
+ # @param opts [Hash] The options hash to specify a link or image to upload.
162
+ # @option opts [String] :image (nil) the path to an image file to upload.
163
+ # @option opts [String] :url (nil) a URL to share with the post.
164
+ # @option opts [String] :imgur_client (nil) an Imgur client ID to automatically share images via Imgur instead of
165
+ # direct upload.
166
+ #
167
+ # @return [Post?] the newly created {Post} instance, or `nil` if an error occurred.
168
+ # @note This method is restricted to 6/minute, and will fail when that limit is exceeded.
169
+ def post_create(guild, title, body = nil, **opts)
170
+ name = guild.is_a?(Guild) ? guild.name : guild.strip.sub(/^\+/, '')
171
+ raise(ArgumentError, 'invalid guild name') unless Ruqqus::VALID_GUILD.match?(name)
172
+ raise(ArgumentError, 'title cannot be nil or empty') unless title && !title.empty?
173
+ params = { title: title, board: name, body: body }
174
+
175
+ if opts[:image]
176
+ if opts[:imgur_client]
177
+ params[:url] = Ruqqus.imgur_upload(opts[:imgur_client], opts[:image])
178
+ else
179
+ params[:file] = File.new(opts[:image])
180
+ end
181
+ elsif opts[:url]
182
+ raise(ArgumentError, 'invalid URI') unless URI.regexp =~ opts[:url]
183
+ params[:url] = opts[:url]
184
+ end
185
+
186
+ if [params[:body], params[:image], params[:url]].none?
187
+ raise(ArgumentError, 'text body cannot be nil or empty without URL or image') if body.nil? || body.empty?
188
+ end
189
+ Post.from_json(http_post(Routes::SUBMIT, params)) rescue nil
190
+ end
191
+
192
+ # @!endgroup Posting
193
+
194
+ # @!group Voting
195
+
196
+ ##
197
+ # Places a vote on a post.
198
+ #
199
+ # @param post [Post,String] a {Post} instance, or the unique ID of a post.
200
+ # @param value [Integer] the vote value to place, either `-1`, `0`, or `1`.
201
+ #
202
+ # @return [Boolean] `true` if vote was placed successfully, otherwise `false`.
203
+ def vote_post(post, value = 1)
204
+ submit_vote(post.to_s, value, 'https://ruqqus.com/api/v1/vote/post/')
205
+ end
206
+
207
+ ##
208
+ # Places a vote on a comment.
209
+ #
210
+ # @param comment [Comment,String] a {Comment} instance, or the unique ID of a comment.
211
+ # @param value [Integer] the vote value to place, either `-1`, `0`, or `1`.
212
+ #
213
+ # @return [Boolean] `true` if vote was placed successfully, otherwise `false`.
214
+ def vote_comment(comment, value = 1)
215
+ submit_vote(comment.to_s, value, 'https://ruqqus.com/api/v1/vote/comment/')
216
+ end
217
+
218
+ # @!endgroup Voting
219
+
220
+ # @!group Object Enumeration
221
+
222
+ ##
223
+ # Enumerates through each post of a user, yielding each to a block.
224
+ #
225
+ # @param user [User,String] a {User} instance or the name of the account to query.
226
+ # @yieldparam post [Post] yields a {Post} to the block.
227
+ # @return [self]
228
+ # @raise [LocalJumpError] when a block is not supplied to the method.
229
+ # @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
230
+ # these intervals is an expected behavior.
231
+ def each_user_post(user)
232
+ raise(LocalJumpError, 'block required') unless block_given?
233
+ each_submission(user, Post, 'listing') { |obj| yield obj }
234
+ end
235
+
236
+ ##
237
+ # Enumerates through each comment of a user, yielding each to a block.
238
+ #
239
+ # @param user [User,String] a {User} instance or the name of the account to query.
240
+ # @yieldparam comment [Comment] yields a {Comment} to the block.
241
+ # @return [self]
242
+ # @raise [LocalJumpError] when a block is not supplied to the method.
243
+ # @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
244
+ # these intervals is an expected behavior.
245
+ def each_user_comment(user)
246
+ raise(LocalJumpError, 'block required') unless block_given?
247
+ each_submission(user, Comment, 'comments') { |obj| yield obj }
248
+ end
249
+
250
+ ##
251
+ # Enumerates through each post in the specified guild, and yields each one to a block.
252
+ #
253
+ # @param sort [Symbol] a symbol to determine the sorting method, valid values include `:trending`, `:subs`, `:new`.
254
+ # @yieldparam guild [Guild] yields a {Guild} to the block.
255
+ # @return [self]
256
+ # @raise [LocalJumpError] when a block is not supplied to the method.
257
+ # @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
258
+ # these intervals is an expected behavior.
259
+ def each_guild(sort = :subs)
260
+ raise(LocalJumpError, 'block required') unless block_given?
261
+
262
+ page = 1
263
+ loop do
264
+ params = { sort: sort, page: page }
265
+ json = http_get(Routes::GUILDS, headers(params: params))
266
+ break if json[:error]
267
+ json[:data].each { |hash| yield Guild.from_json(hash) }
268
+ break if json[:data].size < 25
269
+ page += 1
270
+ end
271
+ self
272
+ end
273
+
274
+ ##
275
+ # Enumerates through each post in a guild, yielding each to a block.
276
+ #
277
+ # @param guild [Guild,String] a {Guild} instance, or the name of the guild to query.
278
+ # @param opts [Hash] the options hash.
279
+ # @option opts [Symbol] :sort (:new) Valid: `:new`, `:top`, `:hot`, `:activity`, `:disputed`
280
+ # @option opts [Symbol] :filter (:all) Valid: `:all`, `:day`, `:week`, `:month`, `:year`
281
+ #
282
+ # @yieldparam post [Post] yields a {Post} to the block.
283
+ # @return [self]
284
+ # @raise [LocalJumpError] when a block is not supplied to the method.
285
+ # @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
286
+ # these intervals is an expected behavior.
287
+ def each_guild_post(guild, **opts)
288
+ raise(LocalJumpError, 'block required') unless block_given?
289
+ name = guild.to_s
290
+ raise(ArgumentError, 'invalid guild name') unless Ruqqus::VALID_GUILD.match?(name)
291
+
292
+ sort = opts[:sort] || :new
293
+ filter = opts[:filter] || :all
294
+
295
+ page = 1
296
+ loop do
297
+ params = { page: page, sort: sort, t: filter }
298
+ json = http_get("#{Routes::GUILD}#{name}/listing", headers(params: params))
299
+ break if json[:error]
300
+
301
+ json[:data].each { |hash| yield Post.from_json(hash) }
302
+ break if json[:data].size < 25
303
+ page += 1
304
+ end
305
+
306
+ self
307
+ end
308
+
309
+ ##
310
+ # Enumerates through every post on Ruqqus, yielding each post to a block.
311
+ #
312
+ # @param opts [Hash] the options hash.
313
+ # @option opts [Symbol] :sort (:new) Valid: `:new`, `:top`, `:hot`, `:activity`, `:disputed`
314
+ # @option opts [Symbol] :filter (:all) Valid: `:all`, `:day`, `:week`, `:month`, `:year`
315
+ #
316
+ # @yieldparam post [Post] yields a post to the block.
317
+ # @return [self]
318
+ # @raise [LocalJumpError] when a block is not supplied to the method.
319
+ # @note An API invocation is required for every 25 items that are yielded to the block, so observing brief pauses at
320
+ # these intervals is an expected behavior.
321
+ def each_post(**opts)
322
+ raise(LocalJumpError, 'block required') unless block_given?
323
+
324
+ sort = opts[:sort] || :new
325
+ filter = opts[:filter] || :all
326
+
327
+ page = 1
328
+ loop do
329
+ params = { page: page, sort: sort, t: filter }
330
+ json = http_get(Routes::ALL_LISTINGS, headers(params: params))
331
+ break if json[:error]
332
+ json[:data].each { |hash| yield Post.from_json(hash) }
333
+ break if json[:data].size < 25
334
+ page += 1
335
+ end
336
+ self
337
+ end
338
+
339
+ ##
340
+ # Enumerates through every post on the "front page", yielding each post to a block.
341
+ #
342
+ # @yieldparam post [Post] yields a {Post} to the block.
343
+ #
344
+ # @return [self]
345
+ # @note The front page uses a unique algorithm that is essentially "hot", but for guilds the user is subscribed to.
346
+ def each_home_post
347
+ raise(LocalJumpError, 'block required') unless block_given?
348
+ page = 1
349
+ loop do
350
+ json = http_get(Routes::FRONT_PAGE, headers(params: { page: page }))
351
+ break if json[:error]
352
+ json[:data].each { |hash| yield Post.from_json(hash) }
353
+ break if json[:data].size < 25
354
+ page += 1
355
+ end
356
+ self
357
+ end
358
+
359
+ # @!endgroup Object Enumeration
360
+
361
+ def identity
362
+ @me ||= User.from_json(http_get(Routes::IDENTITY))
363
+ end
364
+
365
+ private
366
+
367
+ ##
368
+ # @api private
369
+ # Places a vote on a comment or post.
370
+ #
371
+ # @param id [String] the ID of a post or comment.
372
+ # @param value [Integer] the vote to place, between -1 and 1.
373
+ # @param route [String] the endpoint of the vote method to invoke.
374
+ #
375
+ # @return [Boolean] `true` if vote was placed successfully, otherwise `false`.
376
+ def submit_vote(id, value, route)
377
+ raise(Ruqqus::Error, 'invalid ID') unless Ruqqus::VALID_POST.match?(id)
378
+ amount = [-1, [1, value.to_i].min].max
379
+ !!http_post("#{route}#{id}/#{amount}")[:error] rescue false
380
+ end
381
+
382
+ ##
383
+ # @api private
384
+ # Retrieves the HTTP headers for API calls.
385
+ #
386
+ # @param opts [Hash] the options hash to include any additional parameters.
387
+ #
388
+ # @return [Hash<Symbol, Sting>] a hash containing the header parameters.
389
+ def headers(**opts)
390
+ hash = DEFAULT_HEADERS.merge({ Authorization: "#{@token.type} #{@token.access_token}" })
391
+ opts[:cookies] = { session: @session } if @session
392
+ hash.merge(opts)
393
+ end
394
+
395
+ ##
396
+ # @api private
397
+ # Submits a new comment.
398
+ #
399
+ # @param parent [String] the full name of a post or comment to reply under. (i.e. `t2_`, `t3_`, etc.)
400
+ # @param pid [String] the unique ID of the parent post to comment within.
401
+ # @param body [String] the text body of the comment.
402
+ #
403
+ # @return [Comment] the newly submitted comment.
404
+ def comment_submit(parent, pid, body)
405
+ raise(ArgumentError, 'body cannot be nil or empty') unless body && !body.empty?
406
+ params = { submission: pid, parent_fullname: parent, body: body }
407
+ Comment.from_json(http_post(Routes::COMMENT, params)) rescue nil
408
+ end
409
+
410
+ ##
411
+ # @api private
412
+ # Enumerates over each page of posts/comments for a user, and returns the deserialized objects.
413
+ #
414
+ # @param user [User,String] a {User} instance or the name of the account to query.
415
+ # @param klass [Class] the type of object to return, must implement `.from_json`.
416
+ # @param route [String] the final API route for the endpoint, either `"listing"` or "comments"`
417
+ #
418
+ # @return [self]
419
+ def each_submission(user, klass, route)
420
+
421
+ username = user.is_a?(User) ? user.username : user.to_s
422
+ raise(Ruqqus::Error, 'invalid username') unless VALID_USERNAME.match?(username)
423
+
424
+ page = 1
425
+ loop do
426
+ url = "#{Routes::USER}#{username}/#{route}"
427
+ json = http_get(url, headers(params: { page: page }))
428
+ break if json[:error]
429
+
430
+ json[:data].each { |hash| yield klass.from_json(hash) }
431
+ break if json[:data].size < 25
432
+ page += 1
433
+ end
434
+ self
435
+ end
436
+
437
+ ##
438
+ # @api private
439
+ # Creates and sends a GET request and returns the response as a JSON hash.
440
+ #
441
+ # @param uri [String] the endpoint to invoke.
442
+ # @param header [Hash] a set of headers to send, or `nil` to use the default headers.
443
+ #
444
+ # @return [Hash] the response deserialized into a JSON hash.
445
+ # @see http_post
446
+ def http_get(uri, header = nil)
447
+ @token.refresh if @token && @token.expired?
448
+ header ||= headers
449
+ response = RestClient.get(uri.chomp('/'), header)
450
+ @session = response.cookies['session_ruqqus'] if response.cookies['session_ruqqus']
451
+ raise(Ruqqus::Error, 'HTTP request failed') if response.code < 200 || response.code >= 300
452
+ JSON.parse(response, symbolize_names: response.body)
453
+ end
454
+
455
+ ##
456
+ # @api private
457
+ # Creates and sends a POST request and returns the response as a JSON hash.
458
+ #
459
+ # @param uri [String] the endpoint to invoke.
460
+ # @param params [Hash] a hash of parameters that will be sent with the request.
461
+ # @param header [Hash] a set of headers to send, or `nil` to use the default headers.
462
+ #
463
+ # @return [Hash] the response deserialized into a JSON hash.
464
+ # @see http_get
465
+ def http_post(uri, params = {}, header = nil)
466
+ @token.refresh if @token && @token.expired?
467
+ header ||= headers
468
+ response = RestClient.post(uri.chomp('/'), params, header)
469
+ @session = response.cookies['session_ruqqus'] if response.cookies['session_ruqqus']
470
+ raise(Ruqqus::Error, 'HTTP request failed') if response.code < 200 || response.code >= 300
471
+ JSON.parse(response, symbolize_names: response.body)
472
+ end
473
+ end
474
+ end