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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -1
- data/CONTRIBUTING.md +63 -0
- data/Guardfile +7 -0
- data/README.md +6 -5
- data/Rakefile +1 -1
- data/TODO.md +423 -0
- data/bin/console +91 -77
- data/bin/guard +2 -0
- data/lib/redd.rb +7 -5
- data/lib/redd/api_client.rb +2 -3
- data/lib/redd/auth_strategies/auth_strategy.rb +7 -2
- data/lib/redd/auth_strategies/script.rb +7 -0
- data/lib/redd/auth_strategies/userless.rb +7 -0
- data/lib/redd/auth_strategies/web.rb +6 -1
- data/lib/redd/client.rb +0 -3
- data/lib/redd/errors.rb +56 -0
- data/lib/redd/middleware.rb +10 -8
- data/lib/redd/models/access.rb +30 -18
- data/lib/redd/models/comment.rb +185 -27
- data/lib/redd/models/front_page.rb +16 -36
- data/lib/redd/models/gildable.rb +1 -1
- data/lib/redd/models/inboxable.rb +13 -3
- data/lib/redd/models/listing.rb +27 -6
- data/lib/redd/models/live_thread.rb +76 -23
- data/lib/redd/models/live_update.rb +46 -0
- data/lib/redd/models/messageable.rb +1 -1
- data/lib/redd/models/mod_action.rb +59 -0
- data/lib/redd/models/model.rb +23 -0
- data/lib/redd/models/moderatable.rb +6 -6
- data/lib/redd/models/modmail.rb +61 -0
- data/lib/redd/models/modmail_conversation.rb +154 -0
- data/lib/redd/models/modmail_message.rb +35 -0
- data/lib/redd/models/more_comments.rb +29 -5
- data/lib/redd/models/multireddit.rb +63 -20
- data/lib/redd/models/paginated_listing.rb +113 -0
- data/lib/redd/models/postable.rb +11 -13
- data/lib/redd/models/private_message.rb +78 -11
- data/lib/redd/models/replyable.rb +2 -2
- data/lib/redd/models/reportable.rb +14 -0
- data/lib/redd/models/searchable.rb +2 -2
- data/lib/redd/models/self.rb +17 -0
- data/lib/redd/models/session.rb +75 -31
- data/lib/redd/models/submission.rb +309 -56
- data/lib/redd/models/subreddit.rb +330 -103
- data/lib/redd/models/trophy.rb +34 -0
- data/lib/redd/models/user.rb +185 -46
- data/lib/redd/models/wiki_page.rb +37 -16
- data/lib/redd/utilities/error_handler.rb +13 -13
- data/lib/redd/utilities/unmarshaller.rb +7 -5
- data/lib/redd/version.rb +1 -1
- data/redd.gemspec +18 -15
- metadata +82 -16
- data/lib/redd/error.rb +0 -53
- data/lib/redd/models/basic_model.rb +0 -80
- data/lib/redd/models/lazy_model.rb +0 -75
- data/lib/redd/models/mod_mail.rb +0 -142
- data/lib/redd/utilities/stream.rb +0 -61
data/bin/console
CHANGED
@@ -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
|
-
|
10
|
-
|
11
|
-
attr_accessor :reddit
|
12
|
-
end
|
9
|
+
class Reddit
|
10
|
+
attr_accessor :session
|
13
11
|
end
|
12
|
+
reddit = Reddit.new
|
14
13
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
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
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
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
|
-
|
79
|
-
|
80
|
-
rescue Interrupt
|
81
|
-
|
82
|
-
|
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
|
-
|
99
|
+
reddit.instance_exec do
|
86
100
|
# Post a colourful welcome message
|
87
101
|
suggestions = [
|
88
102
|
# Session#me
|
89
|
-
'
|
103
|
+
'session.me.link_karma',
|
90
104
|
# Subreddit listings
|
91
|
-
"
|
105
|
+
"session.subreddit('pics').hot.first.title",
|
92
106
|
# User listings
|
93
|
-
'puts
|
107
|
+
'puts session.me.comments(sort: :top).first.body',
|
94
108
|
# Sending messages
|
95
|
-
"
|
109
|
+
"session.user('Mustermind').send_message(subject: 'Hi!', text: 'How are you?')",
|
96
110
|
# Subscribing to subreddits
|
97
|
-
"
|
111
|
+
"session.subreddit('EarthPorn').subscribe",
|
98
112
|
# Upvoting
|
99
|
-
'
|
113
|
+
'session.front_page.hot(time: :month).first.upvote',
|
100
114
|
# Add friend
|
101
|
-
"
|
115
|
+
"session.user('Mustermind').friend",
|
102
116
|
# List friends
|
103
|
-
'
|
117
|
+
'session.friends.each { |friend| puts friend.name };',
|
104
118
|
# Hiding / Duplicates
|
105
|
-
'
|
119
|
+
'session.front_page.hot.each { |l| l.hide if l.duplicates.count > 2 }'
|
106
120
|
]
|
107
|
-
puts "Hi \e[35m/u/#{
|
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)
|
data/bin/guard
ADDED
data/lib/redd.rb
CHANGED
@@ -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
|
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
|
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
|
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
|
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
|
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)
|
data/lib/redd/api_client.rb
CHANGED
@@ -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 && @
|
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
|
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(
|
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(
|
32
|
+
Models::Access.new(response.body.merge(refresh_token: token))
|
28
33
|
end
|
29
34
|
end
|
30
35
|
end
|
data/lib/redd/client.rb
CHANGED
@@ -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
|
data/lib/redd/errors.rb
ADDED
@@ -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
|
data/lib/redd/middleware.rb
CHANGED
@@ -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
|
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
|
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
|
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
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
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
|