ruqqus 1.1.0 → 1.1.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -9,10 +9,18 @@ module Ruqqus
9
9
 
10
10
  ##
11
11
  # The user-agent the client identified itself as.
12
- USER_AGENT = "ruqqus-ruby/#{Ruqqus::VERSION} (efreed09@gmail.com)".freeze
12
+ USER_AGENT = "ruqqus-ruby/#{Ruqqus::VERSION}".freeze
13
13
 
14
14
  ##
15
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
16
24
  SCOPES = %i(identity create read update delete vote guildmaster).freeze
17
25
 
18
26
  ##
@@ -20,22 +28,40 @@ module Ruqqus
20
28
  DEFAULT_HEADERS = { 'User-Agent': USER_AGENT, 'Accept': 'application/json', 'Content-Type': 'application/json' }.freeze
21
29
 
22
30
  ##
23
- # @return [Token] the OAuth2 token that grants the client authentication.
24
- attr_reader :token
31
+ # @!attribute [rw] token
32
+ # @return [Token] the OAuth2 token that grants the client authentication.
25
33
 
26
34
  ##
27
35
  # @!attribute [r] identity
28
36
  # @return [User] the authenticated user this client is performing actions as.
29
37
 
30
38
  ##
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')
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
+ @token.refresh(client_id, client_secret)
36
56
  @session = nil
37
57
  end
38
58
 
59
+ attr_reader :token
60
+
61
+ def token=(token)
62
+ @token = token || raise(ArgumentError, 'token cannot be nil')
63
+ end
64
+
39
65
  # @!group Object Querying
40
66
 
41
67
  ##
@@ -45,7 +71,7 @@ module Ruqqus
45
71
  #
46
72
  # @return [User] the requested {User}.
47
73
  #
48
- # @raise [ArgumentError] when `username` is `nil` or value does match the {VALID_USERNAME} regular expression.
74
+ # @raise [ArgumentError] when `username` is `nil` or value does match the {Ruqqus::VALID_USERNAME} regular expression.
49
75
  # @raise [Error] thrown when user account does not exist.
50
76
  def user(username)
51
77
  raise(ArgumentError, 'username cannot be nil') unless username
@@ -60,7 +86,7 @@ module Ruqqus
60
86
  #
61
87
  # @return [Guild] the requested {Guild}.
62
88
  #
63
- # @raise [ArgumentError] when `guild_name` is `nil` or value does match the {VALID_GUILD} regular expression.
89
+ # @raise [ArgumentError] when `guild_name` is `nil` or value does match the {Ruqqus::VALID_GUILD} regular expression.
64
90
  # @raise [Error] thrown when guild does not exist.
65
91
  def guild(guild_name)
66
92
  raise(ArgumentError, 'guild_name cannot be nil') unless guild_name
@@ -75,7 +101,7 @@ module Ruqqus
75
101
  #
76
102
  # @return [Post] the requested {Post}.
77
103
  #
78
- # @raise [ArgumentError] when `post_id` is `nil` or value does match the {VALID_POST} regular expression.
104
+ # @raise [ArgumentError] when `post_id` is `nil` or value does match the {Ruqqus::VALID_POST} regular expression.
79
105
  # @raise [Error] thrown when a post with the specified ID does not exist.
80
106
  def post(post_id)
81
107
  raise(ArgumentError, 'post_id cannot be nil') unless post_id
@@ -90,7 +116,7 @@ module Ruqqus
90
116
  #
91
117
  # @return [Comment] the requested {Comment}.
92
118
  #
93
- # @raise [ArgumentError] when `comment_id` is `nil` or value does match the {VALID_POST} regular expression.
119
+ # @raise [ArgumentError] when `comment_id` is `nil` or value does match the {Ruqqus::VALID_POST} regular expression.
94
120
  # @raise [Error] when a comment with the specified ID does not exist.
95
121
  def comment(comment_id)
96
122
  raise(ArgumentError, 'comment_id cannot be nil') unless comment_id
@@ -129,10 +155,10 @@ module Ruqqus
129
155
  # @note This method is restricted to 6/minute, and will fail when that limit is exceeded.
130
156
  def comment_reply(body, comment)
131
157
  if comment.is_a?(Comment)
132
- comment_submit(comment.full_name, comment.post_id, body)
158
+ comment_submit(comment.fullname, comment.post_id, body)
133
159
  else
134
160
  comment = self.comment(comment.to_s)
135
- comment_submit(comment.full_name, comment.post_id, body)
161
+ comment_submit(comment.fullname, comment.post_id, body)
136
162
  end
137
163
  end
138
164
 
@@ -148,6 +174,18 @@ module Ruqqus
148
174
  http_post(url).empty? rescue false
149
175
  end
150
176
 
177
+ ##
178
+ # Deletes an existing post.
179
+ #
180
+ # @param post [Comment,String] a {Post} instance, or the unique ID of the post to delete.
181
+ #
182
+ # @return [Boolean] `true` if deletion completed without error, otherwise `false`.
183
+ def post_delete(post)
184
+ id = post.is_a?(Post) ? post.id : post.sub(/^t3_/, '')
185
+ url = "#{Routes::API_BASE}/delete_post/#{id}"
186
+ http_post(url).empty? rescue false
187
+ end
188
+
151
189
  # @!endgroup Commenting
152
190
 
153
191
  # @!group Posting
@@ -306,6 +344,54 @@ module Ruqqus
306
344
  self
307
345
  end
308
346
 
347
+ ##
348
+ # Enumerates through each comment in a guild, yielding each to a block.
349
+ #
350
+ # @param guild [Guild,String] a {Guild} instance, or the name of the guild to query.
351
+ # @yieldparam [Comment] yields a {Comment} to the block.
352
+ #
353
+ # @return [self]
354
+ # @raise [LocalJumpError] when a block is not supplied to the method.
355
+ def each_guild_comment(guild)
356
+ raise(LocalJumpError, 'block required') unless block_given?
357
+ name = guild.to_s
358
+ raise(ArgumentError, 'invalid guild name') unless Ruqqus::VALID_GUILD.match?(name)
359
+
360
+ page = 1
361
+ loop do
362
+ params = { page: page }
363
+ json = http_get("#{Routes::GUILD}#{name}/comments", headers(params: params))
364
+ break if json[:error]
365
+
366
+ json[:data].each { |hash| yield Comment.from_json(hash) }
367
+ break if json[:data].size < 25
368
+ page += 1
369
+ end
370
+
371
+ self
372
+ end
373
+
374
+ ##
375
+ # Enumerates through each comment in a guild, yielding each to a block.
376
+ #
377
+ # @param post [Post,String] a {Post} instance, or the unique ID of the post to query.
378
+ # @yieldparam [Comment] yields a {Comment} to the block.
379
+ #
380
+ # @return [self]
381
+ # @raise [LocalJumpError] when a block is not supplied to the method.
382
+ # @note This method is very inefficient, as it the underlying API does not yet implement it, therefore each comment
383
+ # in the entire guild must be searched through.
384
+ def each_post_comment(post)
385
+ # TODO: This is extremely inefficient, but will have to do until it gets implemented in the API
386
+ raise(LocalJumpError, 'block required') unless block_given?
387
+ post = self.post(post) unless post.is_a?(Post)
388
+ each_guild_comment(post.guild_name) do |comment|
389
+ next unless comment.post_id == post.id
390
+ yield comment
391
+ end
392
+ self
393
+ end
394
+
309
395
  ##
310
396
  # Enumerates through every post on Ruqqus, yielding each post to a block.
311
397
  #
@@ -358,10 +444,25 @@ module Ruqqus
358
444
 
359
445
  # @!endgroup Object Enumeration
360
446
 
447
+ ##
448
+ # @return [User] the authenticated user this client is performing actions as.
361
449
  def identity
362
450
  @me ||= User.from_json(http_get(Routes::IDENTITY))
363
451
  end
364
452
 
453
+ ##
454
+ # @overload token_refreshed(&block)
455
+ # Sets a callback to be invoked when the token is refreshed, and a new access token is assigned.
456
+ # @yieldparam token [Token] yields the newly refreshed {Token} to the block.
457
+ #
458
+ # @overload token_refreshed
459
+ # When called without a block, clears any callback that was previously assigned.
460
+ #
461
+ # @return [void]
462
+ def token_refreshed(&block)
463
+ @refreshed = block_given? ? block : nil
464
+ end
465
+
365
466
  private
366
467
 
367
468
  ##
@@ -444,7 +545,7 @@ module Ruqqus
444
545
  # @return [Hash] the response deserialized into a JSON hash.
445
546
  # @see http_post
446
547
  def http_get(uri, header = nil)
447
- @token.refresh if @token && @token.expired?
548
+ refresh_token
448
549
  header ||= headers
449
550
  response = RestClient.get(uri.chomp('/'), header)
450
551
  @session = response.cookies['session_ruqqus'] if response.cookies['session_ruqqus']
@@ -463,12 +564,21 @@ module Ruqqus
463
564
  # @return [Hash] the response deserialized into a JSON hash.
464
565
  # @see http_get
465
566
  def http_post(uri, params = {}, header = nil)
466
- @token.refresh if @token && @token.expired?
567
+ refresh_token
467
568
  header ||= headers
468
569
  response = RestClient.post(uri.chomp('/'), params, header)
469
570
  @session = response.cookies['session_ruqqus'] if response.cookies['session_ruqqus']
470
571
  raise(Ruqqus::Error, 'HTTP request failed') if response.code < 200 || response.code >= 300
471
572
  JSON.parse(response, symbolize_names: response.body)
472
573
  end
574
+
575
+ ##
576
+ # @api private
577
+ # Checks if token is expired, and refreshes if so, calling the {#token_refreshed} block as if defined.
578
+ def refresh_token
579
+ return unless @token.need_refresh?
580
+ @token.refresh(@client_id, @client_secret)
581
+ @refreshed&.call(@token)
582
+ end
473
583
  end
474
584
  end
@@ -4,6 +4,10 @@ module Ruqqus
4
4
  # Represents a Ruqqus [OAuth2](https://oauth.net/2/) access token.
5
5
  class Token
6
6
 
7
+ ##
8
+ # The minimum number of seconds that can remain before the token refreshes itself.
9
+ REFRESH_THRESHOLD = 60
10
+
7
11
  ##
8
12
  # @!attribute [r] access_token
9
13
  # @return [String] the access token value.
@@ -37,15 +41,11 @@ module Ruqqus
37
41
  headers = { 'User-Agent': Client::USER_AGENT, 'Accept': 'application/json', 'Content-Type': 'application/json' }
38
42
  params = { code: code, client_id: client_id, client_secret: client_secret, grant_type: 'code', permanent: persist }
39
43
  resp = RestClient.post('https://ruqqus.com/oauth/grant', params, headers )
40
-
41
- @client_id = client_id
42
- @client_secret = client_secret
43
44
  @data = JSON.parse(resp.body, symbolize_names: true)
44
45
 
45
46
  raise(Ruqqus::Error, 'failed to grant access for token') if @data[:oauth_error]
46
47
  end
47
48
 
48
-
49
49
  def access_token
50
50
  @data[:access_token]
51
51
  end
@@ -70,32 +70,15 @@ module Ruqqus
70
70
  # Refreshes the access token and resets its time of expiration.
71
71
  #
72
72
  # @return [void]
73
- def refresh
73
+ def refresh(client_id, client_secret)
74
74
  headers = { 'User-Agent': Client::USER_AGENT, Authorization: "Bearer #{access_token}" }
75
- params = { client_id: @client_id, client_secret: @client_secret, refresh_token: refresh_token, grant_type: 'refresh' }
75
+ params = { client_id: client_id, client_secret: client_secret, refresh_token: refresh_token, grant_type: 'refresh' }
76
76
  resp = RestClient.post('https://ruqqus.com/oauth/grant', params, headers )
77
77
 
78
78
  data = JSON.parse(resp.body, symbolize_names: true)
79
79
  raise(Ruqqus::Error, 'failed to refresh authentication token') unless resp.code == 200 || data[:oauth_error]
80
80
  @data.merge!(data)
81
- @refreshed&.call(self)
82
- end
83
-
84
- ##
85
- # Sets a callback block that will be invoked when the token is refresh. This can be used to automate saving the
86
- # token after its gets updated.
87
- #
88
- # @yieldparam token [Token] yields the token to the block.
89
- #
90
- # @return [self]
91
- #
92
- # @example Auto-save updated token
93
- # token = Token.load_json('./token.json')
94
- # token.on_refresh { |t| t.save_json('./token.json') }
95
- def on_refresh(&block)
96
- raise(LocalJumpError, "block required") unless block_given?
97
- @refreshed = block
98
- self
81
+ sleep(1) # TODO: Test. Get internment 401 error when token needs refreshed
99
82
  end
100
83
 
101
84
  ##
@@ -104,10 +87,16 @@ module Ruqqus
104
87
  expires <= Time.now
105
88
  end
106
89
 
90
+ ##
91
+ # @return [Boolean] `true` if remaining lifetime is within the {REFRESH_THRESHOLD}, otherwise `false`.
92
+ def need_refresh?
93
+ (expires - Time.now) < REFRESH_THRESHOLD
94
+ end
95
+
107
96
  ##
108
97
  # @return [String] the object as a JSON-formatted string.
109
- def to_json
110
- { client_id: @client_id, client_secret: @client_secret, data: @data }.to_json
98
+ def to_json(*_unused_)
99
+ @data.to_json
111
100
  end
112
101
 
113
102
  ##
@@ -133,15 +122,13 @@ module Ruqqus
133
122
  ##
134
123
  # Loads the object from a JSON-formatted string.
135
124
  #
136
- # @param json [String] a JSON string representing the object.
125
+ # @param json [String,Hash] a JSON string representing the object, or the parsed Hash of the JSON (symbol keys).
137
126
  #
138
127
  # @return [Object] the loaded object.
139
128
  def self.from_json(payload)
140
- data = JSON.parse(payload, symbolize_names: true)
129
+ data = payload.is_a?(Hash) ? payload: JSON.parse(payload, symbolize_names: true)
141
130
  token = allocate
142
- token.instance_variable_set(:@client_id, data[:client_id])
143
- token.instance_variable_set(:@client_secret, data[:client_secret])
144
- token.instance_variable_set(:@data, data[:data])
131
+ token.instance_variable_set(:@data, data)
145
132
  token
146
133
  end
147
134
  end
@@ -6,31 +6,37 @@ module Ruqqus
6
6
  class Comment < Submission
7
7
 
8
8
  ##
9
- # @return [Integer] the level of "nesting" in the comment tree, starting at `1` when in direct reply to the post.
10
- def level
11
- @data[:level]
12
- end
9
+ # @!attribute [r] level
10
+ # @return [Integer] the level of "nesting" in the comment tree, starting at `1` when in direct reply to the post.
13
11
 
14
12
  ##
15
- # @return [String] the unique ID of the parent for this comment.
16
- def parent_id
17
- @data[:parent]
18
- end
13
+ # @!attribute parent_id
14
+ # @return [String] the unique ID of the parent for this comment.
15
+
16
+ ##
17
+ # @!attribute [r] post_id
18
+ # @return [String] the ID of the post this comment belongs to.
19
19
 
20
20
  ##
21
- # @return [Boolean] `true` if {#parent_id} refers to a comment, otherwise `false` if a post.
21
+ # @return [Boolean] `true` if the comment's parent is comment, otherwise `false` if it is a post.
22
22
  def parent_comment?
23
23
  level > 1
24
24
  end
25
25
 
26
26
  ##
27
- # @return [Boolean] `true` if {#parent_id} refers to a post, otherwise `false` if a comment.
27
+ # @return [Boolean] `true` if the comment's parent is post, otherwise `false` if it is a comment.
28
28
  def parent_post?
29
29
  level == 1
30
30
  end
31
31
 
32
- ##
33
- # @return [String] the ID of the post this comment belongs to.
32
+ def level
33
+ @data[:level]
34
+ end
35
+
36
+ def parent_id
37
+ @data[:parent]
38
+ end
39
+
34
40
  def post_id
35
41
  @data[:post]
36
42
  end
@@ -5,28 +5,44 @@ module Ruqqus
5
5
  class Guild < ItemBase
6
6
 
7
7
  ##
8
- # @return [String] the name of the guild.
9
- def name
10
- @data[:name]
11
- end
8
+ # @!attribute [r] name
9
+ # @return [String] the name of the guild.
12
10
 
13
11
  ##
14
- # @return [Integer] the number of members subscribed to the guild.
15
- def member_count
16
- @data[:subscriber_count]&.to_i || 0
17
- end
12
+ # @!attribute [r] member_count
13
+ # @return [Integer] the number of members subscribed to the guild.
18
14
 
19
15
  ##
20
- # @return [Integer] the number of guild masters who moderate this guild.
21
- def gm_count
22
- @data[:mods_count]&.to_i || 0
23
- end
16
+ # @!attribute [r] fullname
17
+ # @return [String] the full ID of the guild.
24
18
 
25
19
  ##
26
- # @return [String] the full ID of the guild.
27
- def full_name
28
- @data[:fullname]
29
- end
20
+ # @!attribute [r] guildmaster_count
21
+ # @return [Integer] the number of guild masters who moderate this guild.
22
+
23
+ ##
24
+ # @!attribute [r] profile_url
25
+ # @return [String] the URL for the profile image associated with the guild.
26
+
27
+ ##
28
+ # @!attribute [r] color
29
+ # @return [String] the accent color used for the guild, in HTML format.
30
+
31
+ ##
32
+ # @!attribute [r] description
33
+ # @return [String] the description of the guild.
34
+
35
+ ##
36
+ # @!attribute [r] description_html
37
+ # @return [String] the description of the guild in HTML format.
38
+
39
+ ##
40
+ # @!attribute [r] banner_url
41
+ # @return [String] the URL for the banner image associated with the guild.
42
+
43
+ ##
44
+ # @!attribute [r] guildmasters
45
+ # @return [Array<User>] an array of {User} instances of the moderators for this guild.
30
46
 
31
47
  ##
32
48
  # @return [Boolean] `true` if the guild contains adult content and flagged as NSFW, otherwise `false`.
@@ -47,39 +63,50 @@ module Ruqqus
47
63
  end
48
64
 
49
65
  ##
50
- # @return [String] the description of the guild.
51
- def description
52
- @data[:description]
66
+ # @return [String] the string representation of the object.
67
+ def to_s
68
+ @data[:name] || inspect
53
69
  end
54
70
 
55
- ##
56
- # @return [String] the description of the guild in HTML format.
57
- def description_html
58
- @data[:description_html]
71
+ def description
72
+ @data[:description]
59
73
  end
60
74
 
61
- ##
62
- # @return [String] the URL for the banner image associated with the guild.
63
75
  def banner_url
64
76
  @data[:banner_url]
65
77
  end
66
78
 
67
- ##
68
- # @return [String] the URL for the profile image associated with the guild.
79
+ def description_html
80
+ @data[:description_html]
81
+ end
82
+
69
83
  def profile_url
70
84
  @data[:profile_url]
71
85
  end
72
86
 
73
- ##
74
- # @return [String] the accent color used for the guild, in HTML format.
75
87
  def color
76
88
  @data[:color]
77
89
  end
78
90
 
79
- ##
80
- # @return [String] the string representation of the object.
81
- def to_s
82
- @data[:name] || inspect
91
+ def name
92
+ @data[:name]
93
+ end
94
+
95
+ def member_count
96
+ @data[:subscriber_count]&.to_i || 0
97
+ end
98
+
99
+ def guildmaster_count
100
+ @data[:mods_count]&.to_i || 0
101
+ end
102
+
103
+ def fullname
104
+ @data[:fullname]
105
+ end
106
+
107
+ def guildmasters
108
+ return Array.new unless @data[:guildmasters]
109
+ @data[:guildmasters].map { |gm| User.from_json(gm) }
83
110
  end
84
111
  end
85
112
  end