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