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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.github/dependabot.yml +7 -0
  3. data/.github/workflows/ci.yml +52 -0
  4. data/.gitignore +10 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +29 -0
  7. data/CONTRIBUTING.md +63 -0
  8. data/Gemfile +6 -0
  9. data/Guardfile +7 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +119 -0
  12. data/Rakefile +12 -0
  13. data/TODO.md +423 -0
  14. data/bin/console +127 -0
  15. data/bin/guard +2 -0
  16. data/bin/setup +8 -0
  17. data/docs/guides/.keep +0 -0
  18. data/docs/tutorials/creating-bots-with-redd.md +101 -0
  19. data/docs/tutorials/creating-webapps-with-redd.md +124 -0
  20. data/docs/tutorials/make-a-grammar-bot.md +5 -0
  21. data/docs/tutorials.md +7 -0
  22. data/lib/redd/api_client.rb +116 -0
  23. data/lib/redd/assist/delete_badly_scoring.rb +64 -0
  24. data/lib/redd/auth_strategies/auth_strategy.rb +68 -0
  25. data/lib/redd/auth_strategies/script.rb +35 -0
  26. data/lib/redd/auth_strategies/userless.rb +29 -0
  27. data/lib/redd/auth_strategies/web.rb +36 -0
  28. data/lib/redd/client.rb +91 -0
  29. data/lib/redd/errors.rb +65 -0
  30. data/lib/redd/middleware.rb +125 -0
  31. data/lib/redd/models/access.rb +54 -0
  32. data/lib/redd/models/comment.rb +229 -0
  33. data/lib/redd/models/front_page.rb +55 -0
  34. data/lib/redd/models/gildable.rb +13 -0
  35. data/lib/redd/models/inboxable.rb +33 -0
  36. data/lib/redd/models/listing.rb +52 -0
  37. data/lib/redd/models/live_thread.rb +133 -0
  38. data/lib/redd/models/live_update.rb +46 -0
  39. data/lib/redd/models/messageable.rb +20 -0
  40. data/lib/redd/models/mod_action.rb +59 -0
  41. data/lib/redd/models/model.rb +23 -0
  42. data/lib/redd/models/moderatable.rb +46 -0
  43. data/lib/redd/models/modmail.rb +61 -0
  44. data/lib/redd/models/modmail_conversation.rb +154 -0
  45. data/lib/redd/models/modmail_message.rb +35 -0
  46. data/lib/redd/models/more_comments.rb +96 -0
  47. data/lib/redd/models/multireddit.rb +104 -0
  48. data/lib/redd/models/paginated_listing.rb +124 -0
  49. data/lib/redd/models/postable.rb +83 -0
  50. data/lib/redd/models/private_message.rb +105 -0
  51. data/lib/redd/models/replyable.rb +16 -0
  52. data/lib/redd/models/reportable.rb +14 -0
  53. data/lib/redd/models/searchable.rb +35 -0
  54. data/lib/redd/models/self.rb +17 -0
  55. data/lib/redd/models/session.rb +198 -0
  56. data/lib/redd/models/submission.rb +405 -0
  57. data/lib/redd/models/subreddit.rb +670 -0
  58. data/lib/redd/models/trophy.rb +34 -0
  59. data/lib/redd/models/user.rb +239 -0
  60. data/lib/redd/models/wiki_page.rb +56 -0
  61. data/lib/redd/utilities/error_handler.rb +73 -0
  62. data/lib/redd/utilities/rate_limiter.rb +21 -0
  63. data/lib/redd/utilities/unmarshaller.rb +70 -0
  64. data/lib/redd/version.rb +5 -0
  65. data/lib/redd.rb +129 -0
  66. data/lib/spinels-redd.rb +3 -0
  67. data/logo.png +0 -0
  68. data/spinels-redd.gemspec +39 -0
  69. metadata +298 -0
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'model'
4
+
5
+ module Redd
6
+ module Models
7
+ # An object that represents a bunch of comments that need to be expanded.
8
+ class MoreComments < Model
9
+ # Expand the object's children into a listing of Comments and MoreComments.
10
+ # @param link [Submission] the submission the object belongs to
11
+ # @return [Listing<Comment, MoreComments>] the expanded children
12
+ def expand(link:)
13
+ expand_recursive(link: link, lookup: {})
14
+ end
15
+
16
+ # @return [Array<String>] an array representation of self
17
+ def to_a
18
+ read_attribute(:children)
19
+ end
20
+ alias to_ary to_a
21
+
22
+ # @!attribute [r] count
23
+ # @return [Integer] the comments under this object
24
+ property :count
25
+
26
+ # @!attribute [r] name
27
+ # @return [String] the object fullname
28
+ property :name
29
+
30
+ # @!attribute [r] id
31
+ # @return [String] the object id
32
+ property :id
33
+
34
+ # @!attribute [r] parent_id
35
+ # @return [String] the parent fullname
36
+ property :parent_id
37
+
38
+ # @!attribute [r] depth
39
+ # @return [Integer] the depth
40
+ property :depth
41
+
42
+ # @!attribute [r] children
43
+ # @return [Array<String>] the unexpanded comments
44
+ property :children
45
+
46
+ protected
47
+
48
+ # Keep expanding until all top-level MoreComments are converted to comments.
49
+ # @param link [Submission] the object's submission
50
+ # @param lookup [Hash] a hash of comments to add future replies to
51
+ # @return [Array<Comment, MoreComments>] the expanded comments or self if past depth
52
+ def expand_recursive(link:, lookup:) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
53
+ return [self] if depth == 0
54
+
55
+ expand_one(link: link).each_with_object([]) do |thing, coll|
56
+ target =
57
+ if thing.parent_id == read_attribute(:parent_id)
58
+ coll
59
+ elsif lookup.key?(thing.parent_id)
60
+ lookup[thing.parent_id].replies.children
61
+ end
62
+
63
+ if target.nil?
64
+ warn "expanding error: orphaned comment #{thing.name}"
65
+ next
66
+ end
67
+
68
+ if thing.is_a?(Comment)
69
+ # Add the comment to a lookup hash.
70
+ lookup[thing.name] = thing
71
+ # If the parent is not in the lookup hash, add it to the root listing.
72
+ target.push(thing)
73
+ elsif thing.is_a?(MoreComments) && thing.count > 0
74
+ if thing.parent_id == read_attribute(:parent_id)
75
+ ary = thing.expand_recursive(link: link, lookup: lookup, depth: depth - 1)
76
+ target.concat(ary)
77
+ else
78
+ target.push(thing)
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ # Expand the object's children into a listing of Comments and MoreComments.
87
+ # @param link [Submission] the submission the object belongs to
88
+ # @return [Listing<Comment, MoreComments>] the expanded children
89
+ def expand_one(link:)
90
+ params = { link_id: link.name, children: read_attribute(:children).join(',') }
91
+ params[:sort] = link.sort_order if link.sort_order
92
+ client.model(:post, '/api/morechildren', params)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'model'
4
+
5
+ module Redd
6
+ module Models
7
+ # A multi.
8
+ class Multireddit < Model
9
+ # Get the appropriate listing.
10
+ # @param sort [:hot, :new, :top, :controversial, :comments, :rising, :gilded] the type of
11
+ # listing
12
+ # @param params [Hash] a list of params to send with the request
13
+ # @option params [String] :after return results after the given fullname
14
+ # @option params [String] :before return results before the given fullname
15
+ # @option params [Integer] :count the number of items already seen in the listing
16
+ # @option params [1..100] :limit the maximum number of things to return
17
+ # @option params [:hour, :day, :week, :month, :year, :all] :time the time period to consider
18
+ # when sorting.
19
+ #
20
+ # @note The option :time only applies to the top and controversial sorts.
21
+ # @return [Listing<Submission>]
22
+ def listing(sort, **params)
23
+ params[:t] = params.delete(:time) if params.key?(:time)
24
+ client.model(:get, "#{read_attribute(:path)}#{sort}", params)
25
+ end
26
+
27
+ # @!method hot(**params)
28
+ # @!method new(**params)
29
+ # @!method top(**params)
30
+ # @!method controversial(**params)
31
+ # @!method comments(**params)
32
+ # @!method rising(**params)
33
+ # @!method gilded(**params)
34
+ #
35
+ # @see #listing
36
+ %i[hot new top controversial comments rising gilded].each do |sort|
37
+ define_method(sort) { |**params| listing(sort, **params) }
38
+ end
39
+
40
+ # @!attribute [r] can_edit?
41
+ # @return [Boolean] whether the user can edit the multireddit
42
+ property :can_edit?, from: :can_edit
43
+
44
+ # @!attribute [r] display_name
45
+ # @return [String] the multi's display name
46
+ property :display_name
47
+
48
+ # @!attribute [r] name
49
+ # @return [String] the multireddit name
50
+ property :name
51
+
52
+ # @!attribute [r] description_md
53
+ # @return [String] the markdown verion of the description
54
+ property :description_md
55
+
56
+ # @!attribute [r] description_html
57
+ # @return [String] the html-rendered description
58
+ property :description_html
59
+
60
+ # @!attribute [r] copied_from
61
+ # @return [Multireddit, nil] the multi this one was copied from
62
+ property :copied_from, with: ->(n) { Multireddit.new(client, path: n) if n }
63
+
64
+ # @!attribute [r] icon_url
65
+ # @return [String, nil] the icon url
66
+ property :icon_url
67
+
68
+ # @!attribute [r] subreddits
69
+ # @return [Array<Subreddit>] the subreddits in this multi
70
+ property :subreddits,
71
+ with: ->(a) { a.map { |n| Subreddit.new(client, display_name: n.fetch(:name)) } }
72
+
73
+ # @!attribute [r] created_at
74
+ # @return [Time] the creation time
75
+ property :created_at, from: :created_utc, with: ->(t) { Time.at(t) }
76
+
77
+ # @!attribute [r] key_color
78
+ # @return [String] a hex color
79
+ property :key_color
80
+
81
+ # @!attribute [r] visibility
82
+ # @return [String] the multi visibility, either "public" or "private"
83
+ property :visibility
84
+
85
+ # @!attribute [r] icon_name
86
+ # @return [String] the icon name
87
+ property :icon_name
88
+
89
+ # @!attribute [r] weighting_scheme
90
+ # @return [String]
91
+ property :weighting_scheme
92
+
93
+ # @!attribute [r] path
94
+ # @return [String] the multi path
95
+ property :path, :required
96
+
97
+ private
98
+
99
+ def lazer_reload
100
+ client.get("/api/multi#{read_attribute(:path)}").body[:data]
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'model'
4
+
5
+ module Redd
6
+ module Models
7
+ # An enumerable type that covers listings and expands forwards.
8
+ #
9
+ # If you want to use the #[] operator, you'll need to convert the object to an Array. This can
10
+ # be done with either {Enumerable#first} or {Enumerable#to_a}.
11
+ class PaginatedListing
12
+ include Enumerable
13
+
14
+ # A simple fixed-size ring buffer.
15
+ # @api private
16
+ class RingBuffer
17
+ def initialize(size)
18
+ @size = size
19
+ @backing_array = Array.new(size)
20
+ @pointer = 0
21
+ end
22
+
23
+ def include?(element)
24
+ @backing_array.include?(element)
25
+ end
26
+
27
+ def add(element)
28
+ @backing_array[@pointer] = element
29
+ @pointer = (@pointer + 1) % @size
30
+ end
31
+ end
32
+
33
+ # Create an expandable listing.
34
+ # @param client [APIClient] the caller to use for streams
35
+ # @param options [Hash]
36
+ # @option options [String] :before the listing's before parameter
37
+ # @option options [String] :after the listing's after parameter
38
+ # @option options [Integer] :limit the maximum number of items to fetch
39
+ # @yieldparam after [String] the fullname of the item to fetch after
40
+ # @yieldparam limit [Integer] the number of items to fetch (max 100)
41
+ # @yieldreturn [Listing] the listing to return
42
+ def initialize(client, **options, &block)
43
+ raise ArgumentError, 'block must be provided' unless block_given?
44
+
45
+ @client = client
46
+ @caller = block
47
+ @before = options[:before]
48
+ @after = options[:after]
49
+ @limit = options[:limit] || 1000
50
+ end
51
+
52
+ # Go forward through the listing.
53
+ # @yield [Model] the object returned in the listings
54
+ # @return [Enumerator] if a block wasn't provided
55
+ def each(&block)
56
+ return _each(&block) if block_given?
57
+
58
+ enum_for(:_each)
59
+ end
60
+
61
+ # Stream through the listing.
62
+ # @note If you iterate through the stream, you'll loop forever.
63
+ # This may or may not be desirable.
64
+ # @yield [Model] the object returned in the listings
65
+ # @return [Enumerator] if a block wasn't provided
66
+ def stream(&block)
67
+ return _stream(&block) if block_given?
68
+
69
+ enum_for(:_stream)
70
+ end
71
+
72
+ private
73
+
74
+ # Go backward through the listing.
75
+ # @yield [Object] the object returned in the listings
76
+ def _stream # rubocop:disable Metrics/MethodLength
77
+ buffer = RingBuffer.new(100)
78
+ remaining = @limit > 0 ? reverse_each.to_a : []
79
+
80
+ loop do
81
+ remaining.push(*fetch_prev_listing)
82
+ remaining.reverse_each do |o|
83
+ next if buffer.include?(o.id)
84
+
85
+ buffer.add(o.id)
86
+ yield o
87
+ end
88
+ remaining.clear
89
+ end
90
+ end
91
+
92
+ # Go forward through the listing.
93
+ # @yield [Object] the object returned in the listings
94
+ def _each(&block)
95
+ loop do
96
+ return if @limit == 0
97
+
98
+ remaining = fetch_next_listing
99
+ return if remaining.children.empty? # if the fetched listing is empty
100
+
101
+ remaining.each(&block)
102
+ return if remaining.after.nil? # if there's no link to the next item
103
+ end
104
+ end
105
+
106
+ # Fetch the next listing with @caller and update @after and @limit.
107
+ def fetch_next_listing
108
+ caller_limit = [@limit, 100].min
109
+ listing = @caller.call(after: @after, limit: caller_limit)
110
+ @after = listing.after
111
+ @limit -= caller_limit
112
+ listing
113
+ end
114
+
115
+ # Fetch the previous listing with @caller and update @before.
116
+ def fetch_prev_listing
117
+ # we're not using the user-provided because a stream isn't supposed to die
118
+ listing = @caller.call(before: @before, after: nil, limit: 100)
119
+ @before = listing.empty? ? nil : listing.first.name
120
+ listing
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Redd
4
+ module Models
5
+ # Methods for user-submitted content, i.e. Submissions and Comments.
6
+ module Postable
7
+ # Edit a thing.
8
+ # @param text [String] The new text.
9
+ # @return [self] the edited thing
10
+ def edit(text)
11
+ client.post('/api/editusertext', thing_id: read_attribute(:name), text: text)
12
+ self
13
+ end
14
+
15
+ # Delete the thing.
16
+ def delete
17
+ client.post('/api/del', id: read_attribute(:name))
18
+ end
19
+
20
+ # @return [Boolean] whether the item is probably deleted
21
+ def deleted?
22
+ read_attribute(:author).name == '[deleted]'
23
+ end
24
+
25
+ # Save a link or comment to the user's account.
26
+ # @param category [String] a category to save to
27
+ def save(category = nil)
28
+ params = { id: read_attribute(:name) }
29
+ params[:category] = category if category
30
+ client.post('/api/save', params)
31
+ end
32
+
33
+ # Remove the link or comment from the user's saved links.
34
+ def unsave
35
+ client.post('/api/unsave', id: read_attribute(:name))
36
+ end
37
+
38
+ # Hide a link from the user.
39
+ def hide
40
+ client.post('/api/hide', id: read_attribute(:name))
41
+ end
42
+
43
+ # Unhide a previously hidden link.
44
+ def unhide
45
+ client.post('/api/unhide', id: read_attribute(:name))
46
+ end
47
+
48
+ # Upvote the model.
49
+ def upvote
50
+ vote(1)
51
+ end
52
+
53
+ # Downvote the model.
54
+ def downvote
55
+ vote(-1)
56
+ end
57
+
58
+ # Clear any upvotes or downvotes on the model.
59
+ def undo_vote
60
+ vote(0)
61
+ end
62
+
63
+ # Send replies to this thing to the user's inbox.
64
+ def enable_inbox_replies
65
+ client.post('/api/sendreplies', id: read_attribute(:name), state: true)
66
+ end
67
+
68
+ # Stop sending replies to this thing to the user's inbox.
69
+ def disable_inbox_replies
70
+ client.post('/api/sendreplies', id: read_attribute(:name), state: false)
71
+ end
72
+
73
+ private
74
+
75
+ # Send a vote.
76
+ # @param direction [-1, 0, 1] the direction to vote in
77
+ def vote(direction)
78
+ fullname = read_attribute(:name)
79
+ client.post('/api/vote', id: fullname, dir: direction)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'model'
4
+ require_relative 'inboxable'
5
+ require_relative 'replyable'
6
+ require_relative 'reportable'
7
+
8
+ module Redd
9
+ module Models
10
+ # A private message
11
+ class PrivateMessage < Model
12
+ include Inboxable
13
+ include Replyable
14
+ include Reportable
15
+
16
+ # Delete the message from the user's inbox.
17
+ def delete
18
+ client.post('/api/del_msg', id: read_attribute(:name))
19
+ end
20
+
21
+ # Mute the author of the message.
22
+ def mute_author
23
+ client.post('/api/mute_message_author', id: read_attribute(:name))
24
+ end
25
+
26
+ # Unmute the author of the message.
27
+ def unmute_author
28
+ client.post('/api/unmute_message_author', id: read_attribute(:name))
29
+ end
30
+
31
+ # @!attribute [r] first_message
32
+ # @return [Integer] not sure what this does
33
+ property :first_message
34
+
35
+ # @!attribute [r] first_message_name
36
+ # @return [String] the fullname of the first message
37
+ property :first_message_name
38
+
39
+ # @!attribute [r] subreddit
40
+ # @return [Subreddit, nil] the subreddit that sent the message
41
+ property :subreddit, with: ->(s) { Subreddit.new(client, display_name: s) if s }
42
+
43
+ # @!attribute [r] replies
44
+ # @return [Listing<PrivateMessage>]
45
+ property :replies, with: ->(l) { Listing.new(client, l[:data]) if l.is_a?(Hash) }
46
+
47
+ # @!attribute [r] id
48
+ # @return [String] the message id
49
+ property :id
50
+
51
+ # @!attribute [r] subject
52
+ # @return [String] the message subject
53
+ property :subject
54
+
55
+ # @!attribute [r] was_comment?
56
+ # @return [Boolean]
57
+ property :was_comment?, from: :was_comment
58
+
59
+ # @!attribute [r] author
60
+ # @return [User] the message author
61
+ property :author, with: ->(n) { User.new(client, name: n) if n }
62
+
63
+ # @!attribute [r] num_comments
64
+ # @return [Integer] huh?
65
+ property :num_comments
66
+
67
+ # @!attribute [r] parent_id
68
+ # @return [String, nil] the parent id
69
+ property :parent_id
70
+
71
+ # @!attribute [r] subreddit_name_prefixed
72
+ # @return [String] the subreddit name, prefixed with "r/"
73
+ property :subreddit_name_prefixed
74
+
75
+ # @!attribute [r] new?
76
+ # @return [Boolean] whether the message is new
77
+ property :new?, from: :new
78
+
79
+ # @!attribute [r] body
80
+ # @return [String] the message body
81
+ property :body
82
+
83
+ # @!attribute [r] body_html
84
+ # @return [String] the html-rendered version of the body
85
+ property :body_html
86
+
87
+ # @!attribute [r] dest
88
+ # @return [String] the recipient of the message
89
+ # @todo maybe convert the object to Subreddit/User?
90
+ property :dest
91
+
92
+ # @!attribute [r] name
93
+ # @return [String] the message fullname
94
+ property :name
95
+
96
+ # @!attribute [r] created
97
+ # @return [Time] the time the message was created
98
+ property :created_utc, with: ->(t) { Time.at(t) }
99
+
100
+ # @!attribute [r] distinguished
101
+ # @return [String] the level the message is distinguished
102
+ property :distinguished
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Redd
4
+ module Models
5
+ # A model that can be commented on or replied to.
6
+ module Replyable
7
+ # Add a comment to a link, reply to a comment or reply to a message.
8
+ # @param text [String] the text to comment
9
+ # @return [Comment, PrivateMessage] The created reply.
10
+ def reply(text)
11
+ fullname = read_attribute(:name)
12
+ client.model(:post, '/api/comment', text: text, thing_id: fullname).first
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Redd
4
+ module Models
5
+ # Things that can be reported (except users).
6
+ module Reportable
7
+ # Report the object.
8
+ # @param reason [String] the report reason
9
+ def report(reason)
10
+ client.post('/api/report', thing_id: read_attribute(:name), reason: reason)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Redd
4
+ module Models
5
+ # Applied to {Session} for site-wide and {Subreddit} for subreddit-specific search.
6
+ module Searchable
7
+ # Search reddit.
8
+ # @see https://www.reddit.com/wiki/search
9
+ #
10
+ # @param query [String] the search query
11
+ # @param params [Hash] the search params
12
+ # @option params [:cloudsearch, :lucene, :plain] :syntax the query's syntax
13
+ # @option params [String] :after return results after the given fullname
14
+ # @option params [String] :before return results before the given fullname
15
+ # @option params [Integer] :count the number of items already seen in the listing
16
+ # @option params [1..100] :limit the maximum number of things to return
17
+ # @option params [:hour, :day, :week, :month, :year, :all] :time the time period to restrict
18
+ # search results by
19
+ # @option params [:relevance, :hot, :top, :new, :comments] :sort the sort order of results
20
+ # @option params [String] :restrict_to restrict by subreddit (prefer {Subreddit#search})
21
+ # @return [Listing<Comment, Submission>] the search results
22
+ def search(query, **params)
23
+ params[:q] = query
24
+ params[:t] = params.delete(:time) if params.key?(:time)
25
+ if params[:restrict_to]
26
+ subreddit = params.delete(:restrict_to)
27
+ params[:restrict_sr] = true
28
+ client.model(:get, "/r/#{subreddit}/search", params)
29
+ else
30
+ client.model(:get, '/search', params)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'user'
4
+
5
+ module Redd
6
+ module Models
7
+ # The user that the bot is running under.
8
+ class Self < User
9
+ private
10
+
11
+ def lazer_reload
12
+ fully_loaded!
13
+ client.get('/api/v1/me').body
14
+ end
15
+ end
16
+ end
17
+ end