redd 0.8.8 → 0.9.0.pre.1

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