spinels-redd 0.9.0
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 +7 -0
- data/.github/dependabot.yml +7 -0
- data/.github/workflows/ci.yml +52 -0
- data/.gitignore +10 -0
- data/.rspec +3 -0
- data/.rubocop.yml +29 -0
- data/CONTRIBUTING.md +63 -0
- data/Gemfile +6 -0
- data/Guardfile +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +119 -0
- data/Rakefile +12 -0
- data/TODO.md +423 -0
- data/bin/console +127 -0
- data/bin/guard +2 -0
- data/bin/setup +8 -0
- data/docs/guides/.keep +0 -0
- data/docs/tutorials/creating-bots-with-redd.md +101 -0
- data/docs/tutorials/creating-webapps-with-redd.md +124 -0
- data/docs/tutorials/make-a-grammar-bot.md +5 -0
- data/docs/tutorials.md +7 -0
- data/lib/redd/api_client.rb +116 -0
- data/lib/redd/assist/delete_badly_scoring.rb +64 -0
- data/lib/redd/auth_strategies/auth_strategy.rb +68 -0
- data/lib/redd/auth_strategies/script.rb +35 -0
- data/lib/redd/auth_strategies/userless.rb +29 -0
- data/lib/redd/auth_strategies/web.rb +36 -0
- data/lib/redd/client.rb +91 -0
- data/lib/redd/errors.rb +65 -0
- data/lib/redd/middleware.rb +125 -0
- data/lib/redd/models/access.rb +54 -0
- data/lib/redd/models/comment.rb +229 -0
- data/lib/redd/models/front_page.rb +55 -0
- data/lib/redd/models/gildable.rb +13 -0
- data/lib/redd/models/inboxable.rb +33 -0
- data/lib/redd/models/listing.rb +52 -0
- data/lib/redd/models/live_thread.rb +133 -0
- data/lib/redd/models/live_update.rb +46 -0
- data/lib/redd/models/messageable.rb +20 -0
- data/lib/redd/models/mod_action.rb +59 -0
- data/lib/redd/models/model.rb +23 -0
- data/lib/redd/models/moderatable.rb +46 -0
- 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 +96 -0
- data/lib/redd/models/multireddit.rb +104 -0
- data/lib/redd/models/paginated_listing.rb +124 -0
- data/lib/redd/models/postable.rb +83 -0
- data/lib/redd/models/private_message.rb +105 -0
- data/lib/redd/models/replyable.rb +16 -0
- data/lib/redd/models/reportable.rb +14 -0
- data/lib/redd/models/searchable.rb +35 -0
- data/lib/redd/models/self.rb +17 -0
- data/lib/redd/models/session.rb +198 -0
- data/lib/redd/models/submission.rb +405 -0
- data/lib/redd/models/subreddit.rb +670 -0
- data/lib/redd/models/trophy.rb +34 -0
- data/lib/redd/models/user.rb +239 -0
- data/lib/redd/models/wiki_page.rb +56 -0
- data/lib/redd/utilities/error_handler.rb +73 -0
- data/lib/redd/utilities/rate_limiter.rb +21 -0
- data/lib/redd/utilities/unmarshaller.rb +70 -0
- data/lib/redd/version.rb +5 -0
- data/lib/redd.rb +129 -0
- data/lib/spinels-redd.rb +3 -0
- data/logo.png +0 -0
- data/spinels-redd.gemspec +39 -0
- metadata +298 -0
@@ -0,0 +1,239 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'model'
|
4
|
+
require_relative 'messageable'
|
5
|
+
|
6
|
+
module Redd
|
7
|
+
module Models
|
8
|
+
# A reddit user.
|
9
|
+
class User < Model
|
10
|
+
include Messageable
|
11
|
+
|
12
|
+
# Get the appropriate listing.
|
13
|
+
# @param type [:overview, :submitted, :comments, :liked, :disliked, :hidden, :saved, :gilded]
|
14
|
+
# the type of listing to request
|
15
|
+
# @param options [Hash] a list of options to send with the request
|
16
|
+
# @option options [:hot, :new, :top, :controversial] :sort the order of the listing
|
17
|
+
# @option options [String] :after return results after the given fullname
|
18
|
+
# @option options [String] :before return results before the given fullname
|
19
|
+
# @option options [Integer] :count the number of items already seen in the listing
|
20
|
+
# @option options [1..100] :limit the maximum number of things to return
|
21
|
+
# @option options [:hour, :day, :week, :month, :year, :all] :time the time period to consider
|
22
|
+
# when sorting
|
23
|
+
# @option options [:given] :show whether to show the gildings given
|
24
|
+
#
|
25
|
+
# @note The option :time only applies to the top and controversial sorts.
|
26
|
+
# @return [Listing<Submission>]
|
27
|
+
def listing(type, **options)
|
28
|
+
options[:t] = options.delete(:time) if options.key?(:time)
|
29
|
+
PaginatedListing.new(client, **options) do |**req_opts|
|
30
|
+
client.model(:get, "/user/#{read_attribute(:name)}/#{type}.json", options.merge(req_opts))
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# @!method overview(**params)
|
35
|
+
# @!method submitted(**params)
|
36
|
+
# @!method comments(**params)
|
37
|
+
# @!method liked(**params)
|
38
|
+
# @!method disliked(**params)
|
39
|
+
# @!method hidden(**params)
|
40
|
+
# @!method saved(**params)
|
41
|
+
# @!method gilded(**params)
|
42
|
+
#
|
43
|
+
# @see #listing
|
44
|
+
%i[overview submitted comments liked disliked hidden saved gilded].each do |type|
|
45
|
+
define_method(type) { |**params| listing(type, **params) }
|
46
|
+
end
|
47
|
+
|
48
|
+
# Compose a message to the moderators of a subreddit.
|
49
|
+
#
|
50
|
+
# @param subject [String] the subject of the message
|
51
|
+
# @param text [String] the message text
|
52
|
+
# @param from [Subreddit, nil] the subreddit to send the message on behalf of
|
53
|
+
def send_message(subject:, text:, from: nil)
|
54
|
+
super(to: read_attribute(:name), subject: subject, text: text, from: from)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Block this user.
|
58
|
+
def block
|
59
|
+
client.post('/api/block_user', account_id: read_attribute(:id))
|
60
|
+
end
|
61
|
+
|
62
|
+
# @return [Array<Trophy>] this user's trophies
|
63
|
+
def trophies
|
64
|
+
client.get("/api/v1/user/#{read_attribute(:name)}/trophies")
|
65
|
+
.body[:data][:trophies]
|
66
|
+
.map { |t| client.unmarshal(t) }
|
67
|
+
end
|
68
|
+
|
69
|
+
# Unblock a previously blocked user.
|
70
|
+
# @param me [User] (optional) the person doing the unblocking
|
71
|
+
def unblock(me: nil) # rubocop:disable Naming/MethodParameterName
|
72
|
+
my_id = "t2_ #{me.is_a?(User ? user.id : client.get('/api/v1/me').body[:id])}"
|
73
|
+
# Talk about an unintuitive endpoint
|
74
|
+
client.post('/api/unfriend', container: my_id, name: read_attribute(:name), type: 'enemy')
|
75
|
+
end
|
76
|
+
|
77
|
+
# Add the user as a friend.
|
78
|
+
# @param note [String] a note for the friend
|
79
|
+
def friend(note = nil)
|
80
|
+
name = read_attribute(:name)
|
81
|
+
body = JSON.generate(note ? { name: name, note: note } : { name: name })
|
82
|
+
client.request(:put, "/api/v1/me/friends/#{name}", body: body)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Unfriend the user.
|
86
|
+
def unfriend
|
87
|
+
name = read_attribute(:name)
|
88
|
+
client.request(:delete, "/api/v1/me/friends/#{name}", raw: true, form: { id: name })
|
89
|
+
end
|
90
|
+
|
91
|
+
# Gift a redditor reddit gold.
|
92
|
+
# @param months [Integer] the number of months of gold to gift
|
93
|
+
def gift_gold(months: 1)
|
94
|
+
client.post("/api/v1/gold/give/#{read_attribute(:name)}", months: months)
|
95
|
+
end
|
96
|
+
|
97
|
+
# @!attribute [r] name
|
98
|
+
# @return [String] the user's username
|
99
|
+
property :name
|
100
|
+
|
101
|
+
# @!attribute [r] employee?
|
102
|
+
# @return [Boolean] whether the user is a reddit employee
|
103
|
+
property :employee?, from: :is_employee
|
104
|
+
|
105
|
+
# @!attribute [r] features
|
106
|
+
# @return [Hash] a hash of features
|
107
|
+
property :features
|
108
|
+
|
109
|
+
# @!attribute [r] friend?
|
110
|
+
# @return [Boolean] whether the user is your friend
|
111
|
+
property :friend?, from: :is_friend
|
112
|
+
|
113
|
+
# @!attribute [r] no_profanity?
|
114
|
+
# @return [Boolean] whether the user chooses to filter profanity
|
115
|
+
property :no_profanity?, from: :pref_no_profanity
|
116
|
+
|
117
|
+
# @!attribute [r] suspended?
|
118
|
+
# @return [Boolean] whether the user is suspended
|
119
|
+
property :suspended?, from: :is_suspended
|
120
|
+
|
121
|
+
# @!attribute [r] geopopular
|
122
|
+
# @return [String]
|
123
|
+
property :geopopular, from: :pref_geopopular
|
124
|
+
|
125
|
+
# @!attribute [r] subreddit
|
126
|
+
# @return [Subreddit] the user's personal "subreddit"
|
127
|
+
property :subreddit, with: ->(name) { Subreddit.new(client, display_name: name) if name }
|
128
|
+
|
129
|
+
# @!attribute [r] sponsor?
|
130
|
+
# @return [Boolean]
|
131
|
+
property :sponsor?, from: :is_sponsor
|
132
|
+
|
133
|
+
# @!attribute [r] gold_expiration
|
134
|
+
# @return [Time, nil] the time when the user's gold expires
|
135
|
+
property :gold_expiration, with: ->(epoch) { Time.at(epoch) if epoch }
|
136
|
+
|
137
|
+
# @!attribute [r] id
|
138
|
+
# @return [String] the user's base36 id
|
139
|
+
property :id
|
140
|
+
|
141
|
+
# @!attribute [r] profile_image
|
142
|
+
# @return [String] a link to the user's profile image
|
143
|
+
property :profile_image, from: :profile_img
|
144
|
+
|
145
|
+
# @!attribute [r] over_18?
|
146
|
+
# @return [Boolean] whether the user's profile is considered over 18.
|
147
|
+
property :over_18?, from: :profile_over_18
|
148
|
+
|
149
|
+
# @!attribute [r] suspension_expiration
|
150
|
+
# @return [Time, nil] the time when the user's suspension expires
|
151
|
+
property :suspension_expiration, from: :suspension_expiration_utc,
|
152
|
+
with: ->(epoch) { Time.at(epoch) if epoch }
|
153
|
+
|
154
|
+
# @!attribute [r] verified?
|
155
|
+
# @return [Boolean] whether the user is verified (?)
|
156
|
+
property :verified?, from: :verified
|
157
|
+
|
158
|
+
# @!attribute [r] new_modmail_exists?
|
159
|
+
# @return [Boolean] whether the user has mail in the new modmail
|
160
|
+
property :new_modmail_exists?, from: :new_modmail_exists
|
161
|
+
|
162
|
+
# @!attribute [r] over_18?
|
163
|
+
# @return [Boolean] whether the user has indicated they're over 18
|
164
|
+
property :over_18?, from: :over_18
|
165
|
+
|
166
|
+
# @!attribute [r] gold?
|
167
|
+
# @return [Boolean] whether the user currently has gold
|
168
|
+
property :gold?, from: :is_gold
|
169
|
+
|
170
|
+
# @!attribute [r] mod?
|
171
|
+
# @return [Boolean] whether the user is a moderator
|
172
|
+
property :mod?, from: :is_mod
|
173
|
+
|
174
|
+
# @!attribute [r] has_verified_email?
|
175
|
+
# @return [Boolean] whether the user's email has been verified
|
176
|
+
property :has_verified_email?, from: :has_verified_email
|
177
|
+
|
178
|
+
# @!attribute [r] has_mod_mail?
|
179
|
+
# @return [Boolean] whether the user has old-style mod mail
|
180
|
+
property :has_mod_mail?, from: :has_mod_mail
|
181
|
+
|
182
|
+
# @!attribute [r] hidden_from_robots?
|
183
|
+
# @return [Boolean] whether the user chose to hide from Google
|
184
|
+
property :hidden_from_robots?, from: :hide_from_robots
|
185
|
+
|
186
|
+
# @!attribute [r] link_karma
|
187
|
+
# @return [Integer] the user's link karma
|
188
|
+
property :link_karma
|
189
|
+
|
190
|
+
# @!attribute [r] inbox_count
|
191
|
+
# @return [Integer] the number of messages in the user's inbox
|
192
|
+
property :inbox_count
|
193
|
+
|
194
|
+
# @!attribute [r] show_top_karma_subreddits?
|
195
|
+
# @return [Boolean] whether top karma subreddits are shown on the user's page
|
196
|
+
property :show_top_karma_subreddits?, from: :pref_top_karma_subreddits
|
197
|
+
|
198
|
+
# @!attribute [r] has_mail?
|
199
|
+
# @return [Boolean] whether the user has new messages
|
200
|
+
property :has_mail?, from: :has_mail
|
201
|
+
|
202
|
+
# @!attribute [r] show_snoovatar?
|
203
|
+
# @return [Boolean] whether the user's snoovatar is shown
|
204
|
+
property :show_snoovatar?, from: :pref_show_snoovatar
|
205
|
+
|
206
|
+
# @!attribute [r] created_at
|
207
|
+
# @return [Time] the time the user signed up
|
208
|
+
property :created_at, from: :created_utc, with: ->(epoch) { Time.at(epoch) }
|
209
|
+
|
210
|
+
# @!attribute [r] gold_creddits
|
211
|
+
# @return [Integer] the number of gold creddits the user has
|
212
|
+
property :gold_creddits
|
213
|
+
|
214
|
+
# @!attribute [r] in_beta?
|
215
|
+
# @return [Boolean] whether the user is in beta
|
216
|
+
property :in_beta?, from: :in_beta
|
217
|
+
|
218
|
+
# @!attribute [r] comment_karma
|
219
|
+
# @return [Integer] the user's comment karma
|
220
|
+
property :comment_karma
|
221
|
+
|
222
|
+
# @!attribute [r] has_subscribed?
|
223
|
+
# @return [Boolean]
|
224
|
+
property :has_subscribed?, from: :has_subscribed
|
225
|
+
|
226
|
+
private
|
227
|
+
|
228
|
+
def lazer_reload
|
229
|
+
# return load_from_fullname if self[:id] && !self[:name]
|
230
|
+
fully_loaded!
|
231
|
+
client.get("/user/#{read_attribute(:name)}/about").body[:data]
|
232
|
+
end
|
233
|
+
|
234
|
+
def load_from_fullname
|
235
|
+
client.get('/api/user_data_by_account_ids', ids: read_attribute(:id)).body.values.first
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'model'
|
4
|
+
|
5
|
+
module Redd
|
6
|
+
module Models
|
7
|
+
# A reddit user.
|
8
|
+
class WikiPage < Model
|
9
|
+
# Edit the wiki page.
|
10
|
+
# @param content [String] the new wiki page contents
|
11
|
+
# @param reason [String, nil] an optional reason for editing the page
|
12
|
+
def edit(content, reason: nil)
|
13
|
+
params = { page: read_attribute(:title), content: content }
|
14
|
+
params[:reason] = reason if reason
|
15
|
+
client.post("/r/#{read_attribute(:subreddit).display_name}/api/wiki/edit", params)
|
16
|
+
end
|
17
|
+
|
18
|
+
# @!attribute [r] title
|
19
|
+
# @return [String] the page title
|
20
|
+
property :title, :required
|
21
|
+
|
22
|
+
# @!attribute [r] subreddit
|
23
|
+
# @return [Subreddit] the wiki page's (optional) subreddit
|
24
|
+
property :subreddit, :nil
|
25
|
+
|
26
|
+
# @!attribute [r] may_revise?
|
27
|
+
# @return [Boolean] not sure, whether you're allowed to edit the page?
|
28
|
+
property :may_revise?, from: :may_revise
|
29
|
+
|
30
|
+
# @!attribute [r] revision_date
|
31
|
+
# @return [Time] the time of the last revision
|
32
|
+
property :revision_date, with: ->(t) { Time.at(t) }
|
33
|
+
|
34
|
+
# @!attribute [r] content_md
|
35
|
+
# @return [String] the markdown version of the content
|
36
|
+
property :content_md
|
37
|
+
|
38
|
+
# @!attribute [r] content_html
|
39
|
+
# @return [String] the html version of the content
|
40
|
+
property :content_html
|
41
|
+
|
42
|
+
# @!attribute [r] revision_by
|
43
|
+
# @return [User] the user who made the last revision
|
44
|
+
property :revision_by, with: ->(res) { User.new(client, res[:data]) }
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def lazer_reload
|
49
|
+
fully_loaded!
|
50
|
+
path = "/wiki/#{read_attribute(:title)}"
|
51
|
+
path = "/r/#{read_attribute(:subreddit).display_name}#{path}" if exists_locally?(:subreddit)
|
52
|
+
client.get(path).body[:data]
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../errors'
|
4
|
+
|
5
|
+
module Redd
|
6
|
+
module Utilities
|
7
|
+
# Handles response errors in API responses.
|
8
|
+
class ErrorHandler
|
9
|
+
AUTH_HEADER = 'www-authenticate'
|
10
|
+
INVALID_TOKEN = 'invalid_token'
|
11
|
+
INSUFFICIENT_SCOPE = 'insufficient_scope'
|
12
|
+
|
13
|
+
HTTP_ERRORS = {
|
14
|
+
400 => Errors::BadRequest,
|
15
|
+
403 => Errors::Forbidden,
|
16
|
+
404 => Errors::NotFound,
|
17
|
+
429 => Errors::TooManyRequests,
|
18
|
+
500 => Errors::ServerError,
|
19
|
+
502 => Errors::ServerError,
|
20
|
+
503 => Errors::ServerError,
|
21
|
+
504 => Errors::ServerError
|
22
|
+
}
|
23
|
+
|
24
|
+
def check_error(res, raw:)
|
25
|
+
# Check for status code-based errors first and return it if we found one.
|
26
|
+
error = invalid_access_error(res) || insufficient_scope_error(res) || other_http_error(res)
|
27
|
+
return error if error || raw
|
28
|
+
|
29
|
+
# If there wasn't an status code error and we're allowed to look into the response, parse
|
30
|
+
# it and check for errors.
|
31
|
+
# TODO: deal with errors of type { fields:, explanation:, message:, reason: }
|
32
|
+
rate_limit_error(res) || other_api_error(res)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
# Deal with an error caused by having an expired or invalid access token.
|
38
|
+
def invalid_access_error(res)
|
39
|
+
return nil unless res.code == 401 && res.headers[AUTH_HEADER] &&
|
40
|
+
res.headers[AUTH_HEADER].include?(INVALID_TOKEN)
|
41
|
+
|
42
|
+
Errors::InvalidAccess.new(res)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Deal with an error caused by not having enough the correct scope
|
46
|
+
def insufficient_scope_error(res)
|
47
|
+
return nil unless res.code == 403 && res.headers[AUTH_HEADER] &&
|
48
|
+
res.headers[AUTH_HEADER].include?(INSUFFICIENT_SCOPE)
|
49
|
+
|
50
|
+
Errors::InsufficientScope.new(res)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Deal with an error signalled by the HTTP response code.
|
54
|
+
def other_http_error(res)
|
55
|
+
HTTP_ERRORS[res.code].new(res) if HTTP_ERRORS.key?(res.code)
|
56
|
+
end
|
57
|
+
|
58
|
+
def rate_limit_error(res)
|
59
|
+
return nil unless res.body.is_a?(Hash) && res.body[:json] && res.body[:json][:ratelimit]
|
60
|
+
|
61
|
+
Errors::RateLimitError.new(res)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Deal with those annoying errors that come with perfect 200 status codes.
|
65
|
+
def other_api_error(res)
|
66
|
+
return nil unless res.body.is_a?(Hash) && res.body[:json] && res.body[:json][:errors] &&
|
67
|
+
!res.body[:json][:errors].empty?
|
68
|
+
|
69
|
+
Errors::APIError.new(res)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Redd
|
4
|
+
module Utilities
|
5
|
+
# Manages rate limiting by sleeping.
|
6
|
+
class RateLimiter
|
7
|
+
def initialize(gap)
|
8
|
+
@gap = gap
|
9
|
+
@last_request_time = Time.now - gap
|
10
|
+
end
|
11
|
+
|
12
|
+
def after_limit
|
13
|
+
sleep_time = (@last_request_time + @gap) - Time.now
|
14
|
+
sleep(sleep_time) if sleep_time > 0.01
|
15
|
+
response = yield
|
16
|
+
@last_request_time += @gap
|
17
|
+
response
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Redd
|
4
|
+
module Utilities
|
5
|
+
# Unmarshals hashes into objects.
|
6
|
+
class Unmarshaller
|
7
|
+
# Contains the mapping from 'kind' strings to classes.
|
8
|
+
# TODO: UserList type!
|
9
|
+
MAPPING = {
|
10
|
+
'Listing' => Models::Listing,
|
11
|
+
't1' => Models::Comment,
|
12
|
+
't2' => Models::User,
|
13
|
+
't3' => Models::Submission,
|
14
|
+
't4' => Models::PrivateMessage,
|
15
|
+
't5' => Models::Subreddit,
|
16
|
+
't6' => Models::Trophy,
|
17
|
+
'more' => Models::MoreComments,
|
18
|
+
'wikipage' => Models::WikiPage,
|
19
|
+
'modaction' => Models::ModAction,
|
20
|
+
'LabeledMulti' => Models::Multireddit,
|
21
|
+
'LiveUpdate' => Models::LiveUpdate
|
22
|
+
}
|
23
|
+
|
24
|
+
def initialize(client)
|
25
|
+
@client = client
|
26
|
+
end
|
27
|
+
|
28
|
+
def unmarshal(res)
|
29
|
+
# I'm loving the hell out of this pattern.
|
30
|
+
model = js_listing(res) || js_model(res) || api_model(res)
|
31
|
+
raise "cannot unmarshal: #{res.inspect}" if model.nil?
|
32
|
+
|
33
|
+
model
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# Unmarshal frontent API-style listings
|
39
|
+
def js_listing(res)
|
40
|
+
# One day I'll get to deprecate Ruby 2.2 and jump into the world of Hash#dig.
|
41
|
+
return nil unless res[:json] && res[:json][:data] && res[:json][:data][:things]
|
42
|
+
|
43
|
+
Models::Listing.new(@client, children: res[:json][:data][:things])
|
44
|
+
end
|
45
|
+
|
46
|
+
# Unmarshal frontend API-style models.
|
47
|
+
def js_model(res)
|
48
|
+
# FIXME: deprecate this? this shouldn't be happening in the API, so this is better handled
|
49
|
+
# in the respective classes.
|
50
|
+
Models::Model.new(@client, res[:json][:data]) if res[:json] && res[:json][:data]
|
51
|
+
end
|
52
|
+
|
53
|
+
# Unmarshal API-provided listings.
|
54
|
+
def api_listing(res)
|
55
|
+
return nil unless res[:kind] == 'Listing'
|
56
|
+
|
57
|
+
attributes = res[:data]
|
58
|
+
attributes[:children].map! { |child| unmarshal(child) }
|
59
|
+
Models::Listing.new(@client, attributes)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Unmarshal API-provided model.
|
63
|
+
def api_model(res)
|
64
|
+
return nil unless MAPPING[res[:kind]]
|
65
|
+
|
66
|
+
MAPPING[res[:kind]].new(@client, res[:data])
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/redd/version.rb
ADDED
data/lib/redd.rb
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'uri'
|
4
|
+
|
5
|
+
# Redd Version
|
6
|
+
require_relative 'redd/version'
|
7
|
+
# Models
|
8
|
+
Dir[File.join(__dir__, 'redd', 'models', '*.rb')].sort.each { |f| require f }
|
9
|
+
# Authentication Clients
|
10
|
+
Dir[File.join(__dir__, 'redd', 'auth_strategies', '*.rb')].sort.each { |f| require f }
|
11
|
+
# Error Classes
|
12
|
+
require_relative 'redd/errors'
|
13
|
+
# Regular Client
|
14
|
+
require_relative 'redd/api_client'
|
15
|
+
# Assists
|
16
|
+
Dir[File.join(__dir__, 'redd', 'assist', '*.rb')].sort.each { |f| require f }
|
17
|
+
|
18
|
+
# Redd is a simple and intuitive API wrapper.
|
19
|
+
module Redd
|
20
|
+
class << self
|
21
|
+
# Based on the arguments you provide it, it guesses the appropriate authentication strategy.
|
22
|
+
# You can do this manually with:
|
23
|
+
#
|
24
|
+
# script = Redd::AuthStrategies::Script.new(**arguments)
|
25
|
+
# web = Redd::AuthStrategies::Web.new(**arguments)
|
26
|
+
# userless = Redd::AuthStrategies::Userless.new(**arguments)
|
27
|
+
#
|
28
|
+
# It then creates an {APIClient} with the auth strategy provided and calls authenticate on it:
|
29
|
+
#
|
30
|
+
# client = Redd::APIClient.new(script); client.authenticate(code)
|
31
|
+
# client = Redd::APIClient.new(web); client.authenticate
|
32
|
+
# client = Redd::APIClient.new(userless); client.authenticate
|
33
|
+
#
|
34
|
+
# Finally, it creates the {Models::Session} model, which is essentially a starting point for
|
35
|
+
# the user. But you can basically create any model with the client.
|
36
|
+
#
|
37
|
+
# session = Redd::Models::Session.new(client)
|
38
|
+
#
|
39
|
+
# user = Redd::Models::User.new(client, name: 'Mustermind')
|
40
|
+
# puts user.comment_karma
|
41
|
+
#
|
42
|
+
# If `auto_refresh` is `false` or if the access doesn't have an associated `expires_in`, you
|
43
|
+
# can manually refresh the token by calling:
|
44
|
+
#
|
45
|
+
# session.client.refresh
|
46
|
+
#
|
47
|
+
# Also, you can swap out the client's access any time.
|
48
|
+
#
|
49
|
+
# new_access = { access_token: '', refresh_token: '', expires_in: 1234 }
|
50
|
+
#
|
51
|
+
# session.client.access = Redd::Models::Access.new(script, new_access)
|
52
|
+
# session.client.access = Redd::Models::Access.new(web, new_access)
|
53
|
+
# session.client.access = Redd::Models::Access.new(userless, new_access)
|
54
|
+
#
|
55
|
+
# @see https://www.reddit.com/prefs/apps
|
56
|
+
# @param opts [Hash] the options to create the object with
|
57
|
+
# @option opts [String] :user_agent your app's *unique* and *descriptive* user agent
|
58
|
+
# @option opts [String] :client_id the client id of your app
|
59
|
+
# @option opts [String] :secret the app secret (for confidential types, i.e. *not* *installed*)
|
60
|
+
# @option opts [String] :username the username of your bot (only for *script*)
|
61
|
+
# @option opts [String] :password the plaintext password of your bot (only for *script*)
|
62
|
+
# @option opts [String] :redirect_uri the provided redirect URI (only for *web* and *installed*)
|
63
|
+
# @option opts [String] :code the code given by reddit (required for *web* and *installed*)
|
64
|
+
# @return [Models::Session] a fresh {Models::Session} for you to make requests with
|
65
|
+
def it(opts = {})
|
66
|
+
api_client = script(opts) || web(opts) || userless(opts)
|
67
|
+
raise "couldn't guess app type" unless api_client
|
68
|
+
|
69
|
+
Models::Session.new(api_client)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Create a url to send to users for authorization.
|
73
|
+
# @param response_type ['code', 'token'] the type of response from reddit
|
74
|
+
# @param state [String] a randomly generated token to avoid CSRF attacks.
|
75
|
+
# @param client_id [String] the client id of the app
|
76
|
+
# @param redirect_uri [String] the URI for reddit to redirect to after authorization
|
77
|
+
# @param scope [Array<String>] an array of scopes to request
|
78
|
+
# @param duration ['temporary', 'permanent'] the duration to request the code for (only applies
|
79
|
+
# when response_type is 'code')
|
80
|
+
# @return [String] the generated url
|
81
|
+
def url(client_id:, redirect_uri:, response_type: 'code', state: '', scope: ['identity'],
|
82
|
+
duration: 'temporary')
|
83
|
+
"https://www.reddit.com/api/v1/authorize?#{
|
84
|
+
URI.encode_www_form(
|
85
|
+
client_id: client_id,
|
86
|
+
redirect_uri: redirect_uri,
|
87
|
+
state: state,
|
88
|
+
scope: scope.join(','),
|
89
|
+
response_type: response_type,
|
90
|
+
duration: duration
|
91
|
+
)
|
92
|
+
}"
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def filter_auth(opts)
|
98
|
+
opts.select { |k| %i[client_id secret username password redirect_uri user_agent].include?(k) }
|
99
|
+
end
|
100
|
+
|
101
|
+
def filter_api(opts)
|
102
|
+
opts.select { |k| %i[user_agent limit_time max_retries auto_refresh].include?(k) }
|
103
|
+
end
|
104
|
+
|
105
|
+
def script(opts = {})
|
106
|
+
return unless %i[client_id secret username password].all? { |o| opts.include?(o) }
|
107
|
+
|
108
|
+
auth = AuthStrategies::Script.new(**filter_auth(opts))
|
109
|
+
api = APIClient.new(auth, **filter_api(opts))
|
110
|
+
api.tap(&:authenticate)
|
111
|
+
end
|
112
|
+
|
113
|
+
def web(opts = {})
|
114
|
+
return unless %i[client_id redirect_uri code].all? { |o| opts.include?(o) }
|
115
|
+
|
116
|
+
auth = AuthStrategies::Web.new(**filter_auth(opts))
|
117
|
+
api = APIClient.new(auth, **filter_api(opts))
|
118
|
+
api.tap { |c| c.authenticate(opts[:code]) }
|
119
|
+
end
|
120
|
+
|
121
|
+
def userless(opts = {})
|
122
|
+
return unless %i[client_id secret].all? { |o| opts.include?(o) }
|
123
|
+
|
124
|
+
auth = AuthStrategies::Userless.new(**filter_auth(opts))
|
125
|
+
api = APIClient.new(auth, **filter_api(opts))
|
126
|
+
api.tap(&:authenticate)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
data/lib/spinels-redd.rb
ADDED
data/logo.png
ADDED
Binary file
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'redd/version'
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength
|
8
|
+
spec.name = 'spinels-redd'
|
9
|
+
spec.version = Redd::VERSION
|
10
|
+
spec.authors = ['Avinash Dwarapu']
|
11
|
+
spec.email = ['avinash@dwarapu.me']
|
12
|
+
spec.summary = 'A batteries-included API wrapper for reddit.'
|
13
|
+
spec.homepage = 'https://github.com/spinels/redd'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
spec.required_ruby_version = '>= 2.6'
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
18
|
+
f.match(%r{^(test|spec|features)/})
|
19
|
+
end
|
20
|
+
spec.bindir = 'exe'
|
21
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
22
|
+
spec.require_paths = ['lib']
|
23
|
+
|
24
|
+
spec.add_dependency 'http', '>= 4.0', '< 6.0'
|
25
|
+
spec.add_dependency 'lazy_lazer', '~> 0.8.1'
|
26
|
+
|
27
|
+
spec.add_development_dependency 'bundler', '~> 2.0'
|
28
|
+
spec.add_development_dependency 'pry', '~> 0.10'
|
29
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
30
|
+
spec.add_development_dependency 'rubocop', '~> 1.26'
|
31
|
+
spec.add_development_dependency 'yard', '~> 0.9.9'
|
32
|
+
|
33
|
+
spec.add_development_dependency 'guard', '~> 2.14'
|
34
|
+
spec.add_development_dependency 'guard-rspec', '~> 4.7'
|
35
|
+
spec.add_development_dependency 'rspec', '~> 3.5'
|
36
|
+
spec.add_development_dependency 'simplecov', '~> 0.13'
|
37
|
+
spec.add_development_dependency 'vcr', '~> 6.1'
|
38
|
+
spec.add_development_dependency 'webmock', '~> 3.14'
|
39
|
+
end
|