ruqqus 1.1.0 → 1.1.5

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