ruqqus 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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