redd 0.8.8 → 0.9.0.pre.1

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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -1
  3. data/CONTRIBUTING.md +63 -0
  4. data/Guardfile +7 -0
  5. data/README.md +6 -5
  6. data/Rakefile +1 -1
  7. data/TODO.md +423 -0
  8. data/bin/console +91 -77
  9. data/bin/guard +2 -0
  10. data/lib/redd.rb +7 -5
  11. data/lib/redd/api_client.rb +2 -3
  12. data/lib/redd/auth_strategies/auth_strategy.rb +7 -2
  13. data/lib/redd/auth_strategies/script.rb +7 -0
  14. data/lib/redd/auth_strategies/userless.rb +7 -0
  15. data/lib/redd/auth_strategies/web.rb +6 -1
  16. data/lib/redd/client.rb +0 -3
  17. data/lib/redd/errors.rb +56 -0
  18. data/lib/redd/middleware.rb +10 -8
  19. data/lib/redd/models/access.rb +30 -18
  20. data/lib/redd/models/comment.rb +185 -27
  21. data/lib/redd/models/front_page.rb +16 -36
  22. data/lib/redd/models/gildable.rb +1 -1
  23. data/lib/redd/models/inboxable.rb +13 -3
  24. data/lib/redd/models/listing.rb +27 -6
  25. data/lib/redd/models/live_thread.rb +76 -23
  26. data/lib/redd/models/live_update.rb +46 -0
  27. data/lib/redd/models/messageable.rb +1 -1
  28. data/lib/redd/models/mod_action.rb +59 -0
  29. data/lib/redd/models/model.rb +23 -0
  30. data/lib/redd/models/moderatable.rb +6 -6
  31. data/lib/redd/models/modmail.rb +61 -0
  32. data/lib/redd/models/modmail_conversation.rb +154 -0
  33. data/lib/redd/models/modmail_message.rb +35 -0
  34. data/lib/redd/models/more_comments.rb +29 -5
  35. data/lib/redd/models/multireddit.rb +63 -20
  36. data/lib/redd/models/paginated_listing.rb +113 -0
  37. data/lib/redd/models/postable.rb +11 -13
  38. data/lib/redd/models/private_message.rb +78 -11
  39. data/lib/redd/models/replyable.rb +2 -2
  40. data/lib/redd/models/reportable.rb +14 -0
  41. data/lib/redd/models/searchable.rb +2 -2
  42. data/lib/redd/models/self.rb +17 -0
  43. data/lib/redd/models/session.rb +75 -31
  44. data/lib/redd/models/submission.rb +309 -56
  45. data/lib/redd/models/subreddit.rb +330 -103
  46. data/lib/redd/models/trophy.rb +34 -0
  47. data/lib/redd/models/user.rb +185 -46
  48. data/lib/redd/models/wiki_page.rb +37 -16
  49. data/lib/redd/utilities/error_handler.rb +13 -13
  50. data/lib/redd/utilities/unmarshaller.rb +7 -5
  51. data/lib/redd/version.rb +1 -1
  52. data/redd.gemspec +18 -15
  53. metadata +82 -16
  54. data/lib/redd/error.rb +0 -53
  55. data/lib/redd/models/basic_model.rb +0 -80
  56. data/lib/redd/models/lazy_model.rb +0 -75
  57. data/lib/redd/models/mod_mail.rb +0 -142
  58. data/lib/redd/utilities/stream.rb +0 -61
@@ -2,109 +2,123 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'webrick'
5
- require 'redd'
6
5
  require 'pry'
6
+ require_relative '../lib/redd'
7
7
 
8
8
  # The REPL session to initialize Pry in.
9
- module Reddit
10
- class << self
11
- attr_accessor :reddit
12
- end
9
+ class Reddit
10
+ attr_accessor :session
13
11
  end
12
+ reddit = Reddit.new
14
13
 
15
- server = WEBrick::HTTPServer.new(
16
- Port: 8000,
17
- BindAddress: '0.0.0.0',
18
- Logger: WEBrick::Log.new(File.open(File::NULL, 'w')),
19
- AccessLog: []
20
- )
14
+ redd_file_path = File.join(Dir.home, '.redd.key')
15
+ if File.exist?(redd_file_path)
16
+ auth = Redd::AuthStrategies::Web.new(
17
+ client_id: 'P4txR-D6TzF8cg',
18
+ redirect_uri: 'http://localhost:8000/redirect'
19
+ )
20
+ access = Redd::Models::Access.new(refresh_token: File.read(redd_file_path))
21
+ client = Redd::APIClient.new(auth, user_agent: "Ruby:Redd-Quickstart:v#{Redd::VERSION} (by /u/Mustermind)")
22
+ client.access = auth.refresh(access)
23
+ reddit.session = Redd::Models::Session.new(client)
24
+ else
25
+ server = WEBrick::HTTPServer.new(
26
+ Port: 8000,
27
+ BindAddress: '0.0.0.0',
28
+ Logger: WEBrick::Log.new(File.open(File::NULL, 'w')),
29
+ AccessLog: []
30
+ )
21
31
 
22
- server.mount_proc '/' do |_, res|
23
- res.body = <<-EOS
24
- <!doctype html>
25
- <title>Redd Quickstart</title>
26
- <style>
27
- html, body { margin: 0; height: 100vh; }
28
- .wrapper { padding-top: 30vh; text-align: center; font-family: sans-serif; }
29
- #btn { background-color: #3D9970; margin: 5px; border-radius: 5px; padding: 10px; color: #fff; text-decoration: none; }
30
- </style>
31
- <div class="wrapper">
32
- <h1>redd // quickstart</h1>
33
- <a href="/authenticate" target="_blank" id="btn">Start</a>
34
- <span>a new session in your terminal?</span>
35
- </div>
36
- EOS
37
- end
32
+ server.mount_proc '/' do |_, res|
33
+ res.body = <<-HTML
34
+ <!doctype html>
35
+ <title>Redd Quickstart</title>
36
+ <style>
37
+ html, body { margin: 0; height: 100vh; }
38
+ .wrapper { padding-top: 30vh; text-align: center; font-family: sans-serif; }
39
+ #btn { background-color: #3D9970; margin: 5px; border-radius: 5px; padding: 10px; color: #fff; text-decoration: none; }
40
+ </style>
41
+ <div class="wrapper">
42
+ <h1>redd // quickstart</h1>
43
+ <a href="/authenticate" target="_blank" id="btn">Start</a>
44
+ <span>a new session in your terminal?</span>
45
+ </div>
46
+ HTML
47
+ end
38
48
 
39
- server.mount_proc '/authenticate' do |_, res|
40
- res.set_redirect(
41
- WEBrick::HTTPStatus[302],
42
- Redd.url(
43
- client_id: 'P4txR-D6TzF8cg',
44
- response_type: 'code',
45
- state: '0',
46
- redirect_uri: 'http://localhost:8000/redirect',
47
- duration: 'permanent',
48
- scope: %w(account creddits edit flair history identity livemanage modconfig modcontributors
49
- modflair modlog modmail modothers modposts modself modwiki mysubreddits
50
- privatemessages read report save submit subscribe vote wikiedit wikiread)
49
+ server.mount_proc '/authenticate' do |_, res|
50
+ res.set_redirect(
51
+ WEBrick::HTTPStatus[302],
52
+ Redd.url(
53
+ client_id: 'P4txR-D6TzF8cg',
54
+ response_type: 'code',
55
+ state: '0',
56
+ redirect_uri: 'http://localhost:8000/redirect',
57
+ duration: 'permanent',
58
+ scope: %w[account creddits edit flair history identity livemanage modconfig modcontributors
59
+ modflair modlog modmail modothers modposts modself modwiki mysubreddits
60
+ privatemessages read report save submit subscribe vote wikiedit wikiread]
61
+ )
51
62
  )
52
- )
53
- end
63
+ end
54
64
 
55
- server.mount_proc '/redirect' do |req, res|
56
- err = req.query['error']
57
- should_exit = err.nil? || err == 'access_denied'
58
- res.body = <<-EOS
59
- <!doctype html>
60
- <title>Done!</title>
61
- #{should_exit ? '<script>window.close();</script>' : "<p>Uh oh, there was an error: #{err}</p>"}
62
- EOS
65
+ server.mount_proc '/redirect' do |req, res|
66
+ err = req.query['error']
67
+ should_exit = err.nil? || err == 'access_denied'
68
+ res.body = <<-HTML
69
+ <!doctype html>
70
+ <title>Done!</title>
71
+ #{should_exit ? '<script>window.close();</script>' : "<p>Uh oh, there was an error: #{err}</p>"}
72
+ HTML
63
73
 
64
- unless err
65
- Reddit.reddit = Redd.it(
66
- user_agent: "Ruby:Redd-Quickstart:v#{Redd::VERSION} (by unknown)",
67
- client_id: 'P4txR-D6TzF8cg',
68
- redirect_uri: 'http://localhost:8000/redirect',
69
- code: req.query['code'],
70
- auto_refresh: true
71
- )
72
- server.stop
74
+ unless err
75
+ server.stop
76
+ reddit.session = Redd.it(
77
+ user_agent: "Ruby:Redd-Quickstart:v#{Redd::VERSION} (by /u/Mustermind)",
78
+ client_id: 'P4txR-D6TzF8cg',
79
+ redirect_uri: 'http://localhost:8000/redirect',
80
+ code: req.query['code'],
81
+ auto_refresh: true
82
+ )
83
+ File.open(redd_file_path, 'w') do |f|
84
+ f.write(reddit.session.client.access.refresh_token)
85
+ end
86
+ end
73
87
  end
74
- end
75
88
 
76
- # Get the server going and shut it all down if user hits Ctrl-C
77
- begin
78
- puts "Listening at \e[34mhttp://localhost:8000\e[0m..."
79
- server.start
80
- rescue Interrupt
81
- server.shutdown
82
- exit
89
+ # Get the server going and shut it all down if user hits Ctrl-C
90
+ begin
91
+ puts "Listening at \e[34mhttp://localhost:8000\e[0m..."
92
+ server.start
93
+ rescue Interrupt
94
+ server.shutdown
95
+ exit
96
+ end
83
97
  end
84
98
 
85
- Reddit.instance_exec do
99
+ reddit.instance_exec do
86
100
  # Post a colourful welcome message
87
101
  suggestions = [
88
102
  # Session#me
89
- 'reddit.me.link_karma',
103
+ 'session.me.link_karma',
90
104
  # Subreddit listings
91
- "reddit.subreddit('pics').hot.first.title",
105
+ "session.subreddit('pics').hot.first.title",
92
106
  # User listings
93
- 'puts reddit.me.comments(sort: :top).first.body',
107
+ 'puts session.me.comments(sort: :top).first.body',
94
108
  # Sending messages
95
- "reddit.user('Mustermind').send_message(subject: 'Hi!', text: 'How are you?')",
109
+ "session.user('Mustermind').send_message(subject: 'Hi!', text: 'How are you?')",
96
110
  # Subscribing to subreddits
97
- "reddit.subreddit('EarthPorn').subscribe",
111
+ "session.subreddit('EarthPorn').subscribe",
98
112
  # Upvoting
99
- 'reddit.front_page.hot(time: :month).first.upvote',
113
+ 'session.front_page.hot(time: :month).first.upvote',
100
114
  # Add friend
101
- "reddit.user('Mustermind').friend",
115
+ "session.user('Mustermind').friend",
102
116
  # List friends
103
- 'reddit.friends.each { |friend| puts friend.name };',
117
+ 'session.friends.each { |friend| puts friend.name };',
104
118
  # Hiding / Duplicates
105
- 'reddit.front_page.hot.each { |l| l.hide if l.duplicates.count > 2 }'
119
+ 'session.front_page.hot.each { |l| l.hide if l.duplicates.count > 2 }'
106
120
  ]
107
- puts "Hi \e[35m/u/#{reddit.me.name}\e[0m! Try `\e[34m#{suggestions.sample}\e[0m`."
121
+ puts "Hi \e[35m/u/#{session.me.name}\e[0m! Try `\e[34m#{suggestions.sample}\e[0m`."
108
122
 
109
123
  # Load Pry
110
124
  Pry.start(self)
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ bundle exec guard
@@ -8,6 +8,8 @@ require_relative 'redd/version'
8
8
  Dir[File.join(__dir__, 'redd', 'models', '*.rb')].each { |f| require f }
9
9
  # Authentication Clients
10
10
  Dir[File.join(__dir__, 'redd', 'auth_strategies', '*.rb')].each { |f| require f }
11
+ # Error Classes
12
+ require_relative 'redd/errors'
11
13
  # Regular Client
12
14
  require_relative 'redd/api_client'
13
15
 
@@ -88,29 +90,29 @@ module Redd
88
90
  private
89
91
 
90
92
  def filter_auth(opts)
91
- opts.select { |k| %i(client_id secret username password redirect_uri user_agent).include?(k) }
93
+ opts.select { |k| %i[client_id secret username password redirect_uri user_agent].include?(k) }
92
94
  end
93
95
 
94
96
  def filter_api(opts)
95
- opts.select { |k| %i(user_agent limit_time max_retries auto_refresh).include?(k) }
97
+ opts.select { |k| %i[user_agent limit_time max_retries auto_refresh].include?(k) }
96
98
  end
97
99
 
98
100
  def script(opts = {})
99
- return unless %i(client_id secret username password).all? { |o| opts.include?(o) }
101
+ return unless %i[client_id secret username password].all? { |o| opts.include?(o) }
100
102
  auth = AuthStrategies::Script.new(filter_auth(opts))
101
103
  api = APIClient.new(auth, **filter_api(opts))
102
104
  api.tap(&:authenticate)
103
105
  end
104
106
 
105
107
  def web(opts = {})
106
- return unless %i(client_id redirect_uri code).all? { |o| opts.include?(o) }
108
+ return unless %i[client_id redirect_uri code].all? { |o| opts.include?(o) }
107
109
  auth = AuthStrategies::Web.new(**filter_auth(opts))
108
110
  api = APIClient.new(auth, **filter_api(opts))
109
111
  api.tap { |c| c.authenticate(opts[:code]) }
110
112
  end
111
113
 
112
114
  def userless(opts = {})
113
- return unless %i(client_id secret).all? { |o| opts.include?(o) }
115
+ return unless %i[client_id secret].all? { |o| opts.include?(o) }
114
116
  auth = AuthStrategies::Userless.new(filter_auth(opts))
115
117
  api = APIClient.new(auth, **filter_api(opts))
116
118
  api.tap(&:authenticate)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'client'
4
- require_relative 'error'
5
4
  require_relative 'utilities/error_handler'
6
5
  require_relative 'utilities/rate_limiter'
7
6
  require_relative 'utilities/unmarshaller'
@@ -87,12 +86,12 @@ module Redd
87
86
  # If access is nil, panic
88
87
  raise 'client access is nil, try calling #authenticate' if @access.nil?
89
88
  # Refresh access if auto_refresh is enabled
90
- refresh if @auto_refresh && @access.permanent? && @access.expired?
89
+ refresh if @access.expired? && @auto_refresh && @auth && @auth.refreshable?(@access)
91
90
  end
92
91
 
93
92
  def handle_retryable_errors
94
93
  response = yield
95
- rescue Redd::ServerError, HTTP::TimeoutError => e
94
+ rescue Errors::ServerError, HTTP::TimeoutError => e
96
95
  # FIXME: maybe only retry GET requests, for obvious reasons?
97
96
  @failures += 1
98
97
  raise e if @failures > @max_retries
@@ -25,6 +25,11 @@ module Redd
25
25
  raise 'abstract method: this strategy cannot authenticate with reddit'
26
26
  end
27
27
 
28
+ # @return [Boolean] whether the access object can be refreshed
29
+ def refreshable?(_access)
30
+ false
31
+ end
32
+
28
33
  # @abstract Refresh the authentication and return the refreshed access
29
34
  # @param _access [Access, String] the access to refresh
30
35
  # @return [Access] the new access
@@ -54,8 +59,8 @@ module Redd
54
59
 
55
60
  def request_access(grant_type, options = {})
56
61
  response = post('/api/v1/access_token', { grant_type: grant_type }.merge(options))
57
- raise AuthenticationError.new(response) if response.body.key?(:error)
58
- Models::Access.new(self, response.body)
62
+ raise Errors::AuthenticationError.new(response) if response.body.key?(:error)
63
+ Models::Access.new(response.body)
59
64
  end
60
65
  end
61
66
  end
@@ -18,6 +18,13 @@ module Redd
18
18
  request_access('password', username: @username, password: @password)
19
19
  end
20
20
 
21
+ # Since the access isn't used for refreshing, the strategy is inherently
22
+ # refreshable.
23
+ # @return [true]
24
+ def refreshable?(_access)
25
+ true
26
+ end
27
+
21
28
  # Refresh the authentication and return the refreshed access
22
29
  # @return [Access] the new access
23
30
  def refresh(_)
@@ -12,6 +12,13 @@ module Redd
12
12
  request_access('client_credentials')
13
13
  end
14
14
 
15
+ # Since the access isn't used for refreshing, the strategy is inherently
16
+ # refreshable.
17
+ # @return [true]
18
+ def refreshable?(_access)
19
+ true
20
+ end
21
+
15
22
  # Refresh the authentication and return the refreshed access
16
23
  # @return [Access] the new access
17
24
  def refresh(_)
@@ -18,13 +18,18 @@ module Redd
18
18
  request_access('authorization_code', code: code, redirect_uri: @redirect_uri)
19
19
  end
20
20
 
21
+ # @return [Boolean] whether the access has a refresh token
22
+ def refreshable?(access)
23
+ access.permanent?
24
+ end
25
+
21
26
  # Refresh the authentication and return a new refreshed access
22
27
  # @return [Access] the new access
23
28
  def refresh(access)
24
29
  token = access.is_a?(String) ? refresh_token : access.refresh_token
25
30
  response = post('/api/v1/access_token', grant_type: 'refresh_token', refresh_token: token)
26
31
  # When refreshed, the response doesn't include an access token, so we have to add it.
27
- Models::Access.new(self, response.body.merge(refresh_token: token))
32
+ Models::Access.new(response.body.merge(refresh_token: token))
28
33
  end
29
34
  end
30
35
  end
@@ -33,9 +33,6 @@ module Redd
33
33
  # @option options [Hash] :body the direct body contents
34
34
  # @return [Response] the response
35
35
  def request(verb, path, options = {})
36
- # Uncomment if desperate
37
- # puts "#{verb} #{path}: #{options}"
38
-
39
36
  response = connection.request(verb, path, **options)
40
37
  Response.new(response.status.code, response.headers, response.body.to_s)
41
38
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Redd
4
+ # Namespace for Redd errors.
5
+ module Errors
6
+ # An error raised by {Redd::Middleware} when there was an error returned by reddit.
7
+ class TokenRetrievalError < StandardError; end
8
+
9
+ # An error with the API.
10
+ class APIError < StandardError
11
+ attr_reader :response, :name
12
+
13
+ def initialize(response)
14
+ @response = response
15
+ @name, message = response.body[:json][:errors][0]
16
+ super(message)
17
+ end
18
+ end
19
+
20
+ # Represents an error from reddit returned in a response.
21
+ class ResponseError < StandardError
22
+ attr_accessor :response
23
+
24
+ def initialize(response)
25
+ super(response.raw_body.length <= 80 ? response.raw_body : "#{response.raw_body[0..80]}...")
26
+ @response = response
27
+ end
28
+ end
29
+
30
+ # An error returned by AuthStrategy.
31
+ # @note A common cause of this error is not having a bot account registered as a developer on
32
+ # the app.
33
+ class AuthenticationError < ResponseError; end
34
+
35
+ # An error with Redd, probably (let me know!)
36
+ class BadRequest < ResponseError; end
37
+
38
+ # Whatever it is, you're not allowed to do it.
39
+ class Forbidden < ResponseError; end
40
+
41
+ # You don't have the correct scope to do this.
42
+ class InsufficientScope < ResponseError; end
43
+
44
+ # The access object supplied was invalid.
45
+ class InvalidAccess < ResponseError; end
46
+
47
+ # Returned when reddit raises a 404 error.
48
+ class NotFound < ResponseError; end
49
+
50
+ # Too many requests and not enough rate limiting.
51
+ class TooManyRequests < ResponseError; end
52
+
53
+ # An unknown error on reddit's end. Usually fixed with a retry.
54
+ class ServerError < ResponseError; end
55
+ end
56
+ end
@@ -22,7 +22,7 @@ module Redd
22
22
  # redirects a user to reddit
23
23
  def initialize(app, opts = {})
24
24
  @app = app
25
- strategy_opts = opts.select { |k| %i(user_agent client_id secret redirect_uri).include?(k) }
25
+ strategy_opts = opts.select { |k| %i[user_agent client_id secret redirect_uri].include?(k) }
26
26
  @strategy = Redd::AuthStrategies::Web.new(strategy_opts)
27
27
 
28
28
  @user_agent = opts.fetch(:user_agent, "Redd:Web Application:v#{Redd::VERSION} (by unknown)")
@@ -95,7 +95,7 @@ module Redd
95
95
  message = nil
96
96
  message = 'invalid_state' if @request.GET['state'] != @request.session[:redd_state]
97
97
  message = @request.GET['error'] if @request.GET['error']
98
- raise Redd::TokenRetrievalError, message if message
98
+ raise Errors::TokenRetrievalError, message if message
99
99
  end
100
100
 
101
101
  # Store the access token and other details in the user's browser, assigning any errors to
@@ -106,17 +106,19 @@ module Redd
106
106
  # Try to get a code (the rescue block will also prevent crazy crashes)
107
107
  access = @strategy.authenticate(@request.GET['code'])
108
108
  @request.session[:redd_session] = access.to_h
109
- rescue Redd::TokenRetrievalError, Redd::ResponseError => error
109
+ rescue Errors::TokenRetrievalError, Errors::ResponseError => error
110
110
  @request.env['redd.error'] = error
111
111
  end
112
112
 
113
113
  # Return a {Redd::Models::Session} based on the hash saved into the browser's session.
114
114
  def parse_session
115
- client = Redd::APIClient.new(
116
- @strategy,
117
- user_agent: @user_agent, limit_time: 0, auto_refresh: @auto_refresh
118
- )
119
- client.access = Redd::Models::Access.new(@strategy, @request.session[:redd_session])
115
+ parsed_session = @request.session[:redd_session]
116
+ .each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
117
+ client = Redd::APIClient.new(@strategy,
118
+ user_agent: @user_agent,
119
+ limit_time: 0,
120
+ auto_refresh: @auto_refresh)
121
+ client.access = Redd::Models::Access.new(@strategy, parsed_session)
120
122
  Redd::Models::Session.new(client)
121
123
  end
122
124
  end