snoobert/reddit/subreddit.go
Vartan Benohanian d128a7c4f7 Remove "before" field from Response
Listing responses only ever contain a non-empty "before" field when the
"count" parameter is provided, which is only useful for the HTML
website, not really needed for API clients

Signed-off-by: Vartan Benohanian <vartanbeno@gmail.com>
2020-09-29 14:19:32 -04:00

1183 lines
39 KiB
Go

package reddit
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/google/go-querystring/query"
)
// SubredditService handles communication with the subreddit
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_subreddits
type SubredditService struct {
client *Client
}
type rootSubredditNames struct {
Names []string `json:"names,omitempty"`
}
// Relationship holds information about a relationship (friend/blocked).
type Relationship struct {
ID string `json:"rel_id,omitempty"`
User string `json:"name,omitempty"`
UserID string `json:"id,omitempty"`
Created *Timestamp `json:"date,omitempty"`
}
// Moderator is a user who moderates a subreddit.
type Moderator struct {
*Relationship
Permissions []string `json:"mod_permissions"`
}
// Ban represents a banned relationship.
type Ban struct {
*Relationship
// nil means the ban is permanent
DaysLeft *int `json:"days_left"`
Note string `json:"note,omitempty"`
}
// SubredditRule is a rule in the subreddit.
type SubredditRule struct {
// One of: comment, link (i.e. post), or all (i.e. both comment and link).
Kind string `json:"kind,omitempty"`
// Short description of the rule.
Name string `json:"short_name,omitempty"`
// The reason that will appear when a thing is reported in violation to this rule.
ViolationReason string `json:"violation_reason,omitempty"`
Description string `json:"description,omitempty"`
Priority int `json:"priority"`
Created *Timestamp `json:"created_utc,omitempty"`
}
// SubredditRuleCreateRequest represents a request to add a subreddit rule.
type SubredditRuleCreateRequest struct {
// One of: comment, link (i.e. post) or all (i.e. both).
Kind string `url:"kind"`
// Short description of the rule. No longer than 100 characters.
Name string `url:"short_name"`
// The reason that will appear when a thing is reported in violation to this rule.
// If this is empty, Reddit will set its value to Name by default.
// No longer than 100 characters.
ViolationReason string `url:"violation_reason,omitempty"`
// Optional. No longer than 500 characters.
Description string `url:"description,omitempty"`
}
func (r *SubredditRuleCreateRequest) validate() error {
if r == nil {
return errors.New("*SubredditRuleCreateRequest: cannot be nil")
}
switch r.Kind {
case "comment", "link", "all":
// intentionally left blank
default:
return errors.New("(*SubredditRuleCreateRequest).Kind: must be one of: comment, link, all")
}
if r.Name == "" || len(r.Name) > 100 {
return errors.New("(*SubredditRuleCreateRequest).Name: must be between 1-100 characters")
}
if len(r.ViolationReason) > 100 {
return errors.New("(*SubredditRuleCreateRequest).ViolationReason: cannot be longer than 100 characters")
}
if len(r.Description) > 500 {
return errors.New("(*SubredditRuleCreateRequest).Description: cannot be longer than 500 characters")
}
return nil
}
// SubredditTrafficStats hold information about subreddit traffic.
type SubredditTrafficStats struct {
// Traffic data is returned in the form of day, hour, and month.
// Start is a timestamp indicating the start of the category, i.e.
// start of the day for day, start of the hour for hour, and start of the month for month.
Start *Timestamp `json:"start"`
UniqueViews int `json:"unique_views"`
TotalViews int `json:"total_views"`
// This is only available for "day" traffic, not hour and month.
// Therefore, it is always 0 by default for hour and month.
Subscribers int `json:"subscribers"`
}
// SubredditImage is an image part of the image set of a subreddit.
type SubredditImage struct {
Name string `json:"name"`
Link string `json:"link"`
URL string `json:"url"`
}
// SubredditStyleSheet contains the subreddit's styling information.
type SubredditStyleSheet struct {
SubredditID string `json:"subreddit_id"`
Images []*SubredditImage `json:"images"`
StyleSheet string `json:"stylesheet"`
}
// UnmarshalJSON implements the json.Unmarshaler interface.
func (s *SubredditTrafficStats) UnmarshalJSON(b []byte) error {
var data [4]int
err := json.Unmarshal(b, &data)
if err != nil {
return err
}
timestampByteValue, err := json.Marshal(data[0])
if err != nil {
return err
}
timestamp := new(Timestamp)
err = timestamp.UnmarshalJSON(timestampByteValue)
if err != nil {
return err
}
s.Start = timestamp
s.UniqueViews = data[1]
s.TotalViews = data[2]
s.Subscribers = data[3]
return nil
}
// SubredditSettings are a subreddit's settings.
type SubredditSettings struct {
// The id of the subreddit.
ID string `url:"-" json:"subreddit_id,omitempty"`
// One of: public, restricted, private, gold_restricted, archived, employees_only, gold_only, user.
Type *string `url:"type,omitempty" json:"subreddit_type,omitempty"`
// A valid IETF language tag (underscore separated).
Language *string `url:"lang,omitempty" json:"language,omitempty"`
// No longer than 100 characters.
Title *string `url:"title,omitempty" json:"title,omitempty"`
// Raw markdown text. No longer than 500 characters.
Description *string `url:"public_description,omitempty" json:"public_description,omitempty"`
// Raw markdown text. No longer than 10240 characters.
Sidebar *string `url:"description,omitempty" json:"description,omitempty"`
// Raw markdown text. No longer than 1024 characters.
SubmissionText *string `url:"submit_text,omitempty" json:"submit_text,omitempty"`
// Raw markdown text. No longer than 5000 characters.
WelcomeMessage *string `url:"welcome_message_text,omitempty" json:"welcome_message_text,omitempty"`
WelcomeMessageEnabled *bool `url:"welcome_message_enabled,omitempty" json:"welcome_message_enabled,omitempty"`
AllowCrossposts *bool `url:"allow_post_crossposts,omitempty" json:"allow_post_crossposts,omitempty"`
AllowChatPosts *bool `url:"allow_chat_post_creation,omitempty" json:"allow_chat_post_creation,omitempty"`
AllowPollPosts *bool `url:"allow_polls,omitempty" json:"allow_polls,omitempty"`
AllowFreeFormReports *bool `url:"free_form_reports,omitempty" json:"free_form_reports,omitempty"`
AllowOriginalContent *bool `url:"original_content_tag_enabled,omitempty" json:"original_content_tag_enabled,omitempty"`
// Allow image uploads and links to image hosting sites.
AllowImages *bool `url:"allow_images,omitempty" json:"allow_images,omitempty"`
AllowMultipleImagesPerPost *bool `url:"allow_galleries,omitempty" json:"allow_galleries,omitempty"`
ExcludeSitewideBannedUsersContent *bool `url:"exclude_banned_modqueue,omitempty" json:"exclude_banned_modqueue,omitempty"`
// An integer from 0 to 3.
CrowdControlChalLevel *int `url:"crowd_control_chat_level,omitempty" json:"crowd_control_chat_level,omitempty"`
// Mark all posts in this subreddit as Original Content (OC) on the desktop redesign.
AllOriginalContent *bool `url:"all_original_content,omitempty" json:"all_original_content,omitempty"`
// One of: none (recommended), confidence, top, new, controversial, old, random, qa, live.
SuggestedCommentSort *string `url:"suggested_comment_sort,omitempty" json:"suggested_comment_sort,omitempty"`
// No longer than 60 characters.
SubmitLinkPostLabel *string `url:"submit_link_label,omitempty" json:"submit_link_label,omitempty"`
// No longer than 60 characters.
SubmitTextPostLabel *string `url:"submit_text_label,omitempty" json:"submit_text_label,omitempty"`
// One of: any, link, self.
PostType *string `url:"link_type,omitempty" json:"content_options,omitempty"`
// One of: low (disable most filtering), high (standard), all (filter everything, requiring mod approval).
SpamFilterStrengthLinkPosts *string `url:"spam_links,omitempty" json:"spam_links,omitempty"`
// One of: low (disable most filtering), high (standard), all (filter everything, requiring mod approval).
SpamFilterStrengthTextPosts *string `url:"spam_selfposts,omitempty" json:"spam_selfposts,omitempty"`
// One of: low (disable most filtering), high (standard), all (filter everything, requiring mod approval).
SpamFilterStrengthComments *string `url:"spam_comments,omitempty" json:"spam_comments,omitempty"`
ShowContentThumbnails *bool `url:"show_media,omitempty" json:"show_media,omitempty"`
ExpandMediaPreviewsOnCommentsPages *bool `url:"show_media_preview,omitempty" json:"show_media_preview,omitempty"`
CollapseDeletedComments *bool `url:"collapse_deleted_comments,omitempty" json:"collapse_deleted_comments,omitempty"`
// An integer between 0 and 1440.
MinutesToHideCommentScores *int `url:"comment_score_hide_mins,omitempty" json:"comment_score_hide_mins,omitempty"`
// Enable marking posts as containing spoilers.
SpoilersEnabled *bool `url:"spoilers_enabled,omitempty" json:"spoilers_enabled,omitempty"`
// If there's an image header set, hovering the mouse over it will display this text.
HeaderMouseoverText *string `url:"header-title,omitempty" json:"header_hover_text,omitempty"`
// 6-digit rgb hex colour, e.g. #AABBCC.
// Thematic colour for the subreddit on mobile.
MobileColour *string `url:"key_color,omitempty" json:"key_color,omitempty"`
// Can only be set to true if subreddit type is gold_only.
HideAds *bool `url:"hide_ads,omitempty" json:"hide_ads,omitempty"`
// Require viewers to be over 18 years old.
NSFW *bool `url:"over_18,omitempty" json:"over_18,omitempty"`
// Show up in high-traffic feeds: Allow your community to be in r/all, r/popular, and trending lists where it can be
// seen by the general Reddit population.
AllowDiscoveryInHighTrafficFeeds *bool `url:"allow_top,omitempty" json:"default_set,omitempty"`
// Get recommended to individual redditors. Let Reddit recommend your community to people who have similar interests.
AllowDiscoveryByIndividualUsers *bool `url:"allow_discovery,omitempty" json:"allow_discovery,omitempty"`
// One of:
// - disabled: wiki is disabled for everyone except mods.
// - modonly: only mods, approved wiki contributors, or those on a page's edit list may edit.
// - anyone: anyone who can submit to the subreddit may edit.
WikiMode *string `url:"wikimode,omitempty" json:"wikimode,omitempty"`
// Account age (in days) required to create and edit wiki pages.
WikiMinimumAccountAge *int `url:"wiki_edit_age,omitempty" json:"wiki_edit_age,omitempty"`
// Subreddit karma required to create and edit wiki pages.
WikiMinimumKarma *int `url:"wiki_edit_karma,omitempty" json:"wiki_edit_karma,omitempty"`
}
// SubredditPostRequirements is a list of moderator-designed requirements to post to a subreddit.
type SubredditPostRequirements struct {
// Sentence or two on how to successfully post to the subreddit.
Guidelines string `json:"guidelines_text"`
GuidelinesDisplayPolicy string `json:"guidelines_display_policy"`
TitleMinLength int `json:"title_text_min_length"`
TitleMaxLength int `json:"title_text_max_length"`
BodyMinLength int `json:"body_text_min_length"`
BodyMaxLength int `json:"body_text_max_length"`
// Do not allow any of these words in the title.
TitleBlacklistedStrings []string `json:"title_blacklisted_strings"`
// Do not allow any of these words in the body.
BodyBlacklistedStrings []string `json:"body_blacklisted_strings"`
// Require at least one of these words in the title.
TitleRequiredStrings []string `json:"title_required_strings"`
// Require at least one of these words in the body.
BodyRequiredStrings []string `json:"body_required_strings"`
// Block link posts with these domains.
DomainBlacklist []string `json:"domain_blacklist"`
// Only allow link posts with these domains.
DomainWhitelist []string `json:"domain_whitelist"`
// One of: required, notAllowed, none.
// Meaning that a selftext post's body is required, not allowed, or optional, respectively.
BodyRestrictionPolicy string `json:"body_restriction_policy"`
LinkRestrictionPolicy string `json:"link_restriction_policy"`
GalleryMinItems int `json:"gallery_min_items"`
GalleryMaxItems int `json:"gallery_max_items"`
GalleryCaptionsRequirement string `json:"gallery_captions_requirement"`
GalleryURLsRequirement string `json:"gallery_urls_requirement"`
// Prevent users from posting a link that was already posted to your community within x days of the original.
LinkRepostAge int `json:"link_repost_age"`
FlairRequired bool `json:"is_flair_required"`
TitleRegexes []string `json:"title_regexes"`
BodyRegexes []string `json:"body_regexes"`
}
func (s *SubredditService) getPosts(ctx context.Context, sort string, subreddit string, opts interface{}) ([]*Post, *Response, error) {
path := sort
if subreddit != "" {
path = fmt.Sprintf("r/%s/%s", subreddit, sort)
}
l, resp, err := s.client.getListing(ctx, path, opts)
if err != nil {
return nil, resp, err
}
return l.Posts(), resp, nil
}
// HotPosts returns the hottest posts from the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If none are defined, it returns the ones from your subscribed subreddits.
// To search through all, just specify "all".
// To search through all and filter out subreddits, provide "all-name1-name2".
// Note: when looking for hot posts in a subreddit, it will include the stickied
// posts (if any) PLUS posts from the limit parameter (25 by default).
func (s *SubredditService) HotPosts(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, *Response, error) {
return s.getPosts(ctx, "hot", subreddit, opts)
}
// NewPosts returns the newest posts from the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If none are defined, it returns the ones from your subscribed subreddits.
// To search through all, just specify "all".
// To search through all and filter out subreddits, provide "all-name1-name2".
func (s *SubredditService) NewPosts(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, *Response, error) {
return s.getPosts(ctx, "new", subreddit, opts)
}
// RisingPosts returns the rising posts from the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If none are defined, it returns the ones from your subscribed subreddits.
// To search through all, just specify "all".
// To search through all and filter out subreddits, provide "all-name1-name2".
func (s *SubredditService) RisingPosts(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, *Response, error) {
return s.getPosts(ctx, "rising", subreddit, opts)
}
// ControversialPosts returns the most controversial posts from the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If none are defined, it returns the ones from your subscribed subreddits.
// To search through all, just specify "all".
// To search through all and filter out subreddits, provide "all-name1-name2".
func (s *SubredditService) ControversialPosts(ctx context.Context, subreddit string, opts *ListPostOptions) ([]*Post, *Response, error) {
return s.getPosts(ctx, "controversial", subreddit, opts)
}
// TopPosts returns the top posts from the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If none are defined, it returns the ones from your subscribed subreddits.
// To search through all, just specify "all".
// To search through all and filter out subreddits, provide "all-name1-name2".
func (s *SubredditService) TopPosts(ctx context.Context, subreddit string, opts *ListPostOptions) ([]*Post, *Response, error) {
return s.getPosts(ctx, "top", subreddit, opts)
}
// Get a subreddit by name.
func (s *SubredditService) Get(ctx context.Context, name string) (*Subreddit, *Response, error) {
if name == "" {
return nil, nil, errors.New("name: cannot be empty")
}
path := fmt.Sprintf("r/%s/about", name)
t, resp, err := s.client.getThing(ctx, path, nil)
if err != nil {
return nil, resp, err
}
sr, _ := t.Subreddit()
return sr, resp, nil
}
// Popular returns popular subreddits.
func (s *SubredditService) Popular(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) {
return s.getSubreddits(ctx, "subreddits/popular", opts)
}
// New returns new subreddits.
func (s *SubredditService) New(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) {
return s.getSubreddits(ctx, "subreddits/new", opts)
}
// Gold returns gold subreddits (i.e. only accessible to users with gold).
// It seems like it returns an empty list if you don't have gold.
func (s *SubredditService) Gold(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) {
return s.getSubreddits(ctx, "subreddits/gold", opts)
}
// Default returns default subreddits.
func (s *SubredditService) Default(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) {
return s.getSubreddits(ctx, "subreddits/default", opts)
}
// Subscribed returns the list of subreddits you are subscribed to.
func (s *SubredditService) Subscribed(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) {
return s.getSubreddits(ctx, "subreddits/mine/subscriber", opts)
}
// Approved returns the list of subreddits you are an approved user in.
func (s *SubredditService) Approved(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) {
return s.getSubreddits(ctx, "subreddits/mine/contributor", opts)
}
// Moderated returns the list of subreddits you are a moderator of.
func (s *SubredditService) Moderated(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) {
return s.getSubreddits(ctx, "subreddits/mine/moderator", opts)
}
// GetSticky1 returns the first stickied post on a subreddit (if it exists).
func (s *SubredditService) GetSticky1(ctx context.Context, subreddit string) (*PostAndComments, *Response, error) {
return s.getSticky(ctx, subreddit, 1)
}
// GetSticky2 returns the second stickied post on a subreddit (if it exists).
func (s *SubredditService) GetSticky2(ctx context.Context, subreddit string) (*PostAndComments, *Response, error) {
return s.getSticky(ctx, subreddit, 2)
}
func (s *SubredditService) handleSubscription(ctx context.Context, form url.Values) (*Response, error) {
path := "api/subscribe"
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Subscribe subscribes to subreddits based on their names.
func (s *SubredditService) Subscribe(ctx context.Context, subreddits ...string) (*Response, error) {
form := url.Values{}
form.Set("action", "sub")
form.Set("sr_name", strings.Join(subreddits, ","))
return s.handleSubscription(ctx, form)
}
// SubscribeByID subscribes to subreddits based on their id.
func (s *SubredditService) SubscribeByID(ctx context.Context, ids ...string) (*Response, error) {
form := url.Values{}
form.Set("action", "sub")
form.Set("sr", strings.Join(ids, ","))
return s.handleSubscription(ctx, form)
}
// Unsubscribe unsubscribes from subreddits based on their names.
func (s *SubredditService) Unsubscribe(ctx context.Context, subreddits ...string) (*Response, error) {
form := url.Values{}
form.Set("action", "unsub")
form.Set("sr_name", strings.Join(subreddits, ","))
return s.handleSubscription(ctx, form)
}
// UnsubscribeByID unsubscribes from subreddits based on their id.
func (s *SubredditService) UnsubscribeByID(ctx context.Context, ids ...string) (*Response, error) {
form := url.Values{}
form.Set("action", "unsub")
form.Set("sr", strings.Join(ids, ","))
return s.handleSubscription(ctx, form)
}
// Favorite the subreddit.
func (s *SubredditService) Favorite(ctx context.Context, subreddit string) (*Response, error) {
path := "api/favorite"
form := url.Values{}
form.Set("sr_name", subreddit)
form.Set("make_favorite", "true")
form.Set("api_type", "json")
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Unfavorite the subreddit.
func (s *SubredditService) Unfavorite(ctx context.Context, subreddit string) (*Response, error) {
path := "api/favorite"
form := url.Values{}
form.Set("sr_name", subreddit)
form.Set("make_favorite", "false")
form.Set("api_type", "json")
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Search for subreddits.
func (s *SubredditService) Search(ctx context.Context, query string, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) {
path := fmt.Sprintf("subreddits/search?q=%s", query)
l, resp, err := s.client.getListing(ctx, path, opts)
if err != nil {
return nil, resp, err
}
return l.Subreddits(), resp, nil
}
// SearchNames searches for subreddits with names beginning with the query provided.
func (s *SubredditService) SearchNames(ctx context.Context, query string) ([]string, *Response, error) {
path := fmt.Sprintf("api/search_reddit_names?query=%s", query)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(rootSubredditNames)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Names, resp, nil
}
// SearchPosts searches for posts in the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If no subreddit is provided, the search is run against r/all.
func (s *SubredditService) SearchPosts(ctx context.Context, query string, subreddit string, opts *ListPostSearchOptions) ([]*Post, *Response, error) {
if subreddit == "" {
subreddit = "all"
}
path := fmt.Sprintf("r/%s/search", subreddit)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
notAll := !strings.EqualFold(subreddit, "all")
params := struct {
Query string `url:"q"`
RestrictSubreddits bool `url:"restrict_sr,omitempty"`
}{query, notAll}
t, resp, err := s.client.getThing(ctx, path, params)
if err != nil {
return nil, resp, err
}
l, _ := t.Listing()
return l.Posts(), resp, nil
}
func (s *SubredditService) getSubreddits(ctx context.Context, path string, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) {
l, resp, err := s.client.getListing(ctx, path, opts)
if err != nil {
return nil, resp, err
}
return l.Subreddits(), resp, nil
}
// getSticky returns one of the 2 stickied posts of the subreddit (if they exist).
// Num should be equal to 1 or 2, depending on which one you want.
func (s *SubredditService) getSticky(ctx context.Context, subreddit string, num int) (*PostAndComments, *Response, error) {
params := struct {
Num int `url:"num"`
}{num}
path := fmt.Sprintf("r/%s/about/sticky", subreddit)
path, err := addOptions(path, params)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(PostAndComments)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// todo: sr_detail's NSFW indicator is over_18 instead of over18
func (s *SubredditService) random(ctx context.Context, nsfw bool) (*Subreddit, *Response, error) {
path := "r/random"
if nsfw {
path = "r/randnsfw"
}
params := struct {
ExpandSubreddit bool `url:"sr_detail"`
Limit int `url:"limit,omitempty"`
}{true, 1}
path, err := addOptions(path, params)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(struct {
Data struct {
Children [1]struct {
Data struct {
Subreddit *Subreddit `json:"sr_detail"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
subreddit := root.Data.Children[0].Data.Subreddit
return subreddit, resp, nil
}
// Random returns a random SFW subreddit.
func (s *SubredditService) Random(ctx context.Context) (*Subreddit, *Response, error) {
return s.random(ctx, false)
}
// RandomNSFW returns a random NSFW subreddit.
func (s *SubredditService) RandomNSFW(ctx context.Context) (*Subreddit, *Response, error) {
return s.random(ctx, true)
}
// SubmissionText gets the submission text for the subreddit.
// This text is set by the subreddit moderators and intended to be displayed on the submission form.
func (s *SubredditService) SubmissionText(ctx context.Context, name string) (string, *Response, error) {
if name == "" {
return "", nil, errors.New("name: cannot be empty")
}
path := fmt.Sprintf("r/%s/api/submit_text", name)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return "", nil, err
}
root := new(struct {
Text string `json:"submit_text"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return "", resp, err
}
return root.Text, resp, err
}
// Banned gets banned users from the subreddit.
func (s *SubredditService) Banned(ctx context.Context, subreddit string, opts *ListOptions) ([]*Ban, *Response, error) {
path := fmt.Sprintf("r/%s/about/banned", subreddit)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(struct {
Data struct {
Bans []*Ban `json:"children"`
After string `json:"after"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
resp.After = root.Data.After
return root.Data.Bans, resp, nil
}
// Muted gets muted users from the subreddit.
func (s *SubredditService) Muted(ctx context.Context, subreddit string, opts *ListOptions) ([]*Relationship, *Response, error) {
path := fmt.Sprintf("r/%s/about/muted", subreddit)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(struct {
Data struct {
Relationships []*Relationship `json:"children"`
After string `json:"after"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
resp.After = root.Data.After
return root.Data.Relationships, resp, nil
}
// WikiBanned gets banned users from the subreddit.
func (s *SubredditService) WikiBanned(ctx context.Context, subreddit string, opts *ListOptions) ([]*Ban, *Response, error) {
path := fmt.Sprintf("r/%s/about/wikibanned", subreddit)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(struct {
Data struct {
Bans []*Ban `json:"children"`
After string `json:"after"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
resp.After = root.Data.After
return root.Data.Bans, resp, nil
}
// Contributors gets contributors (also known as approved users) from the subreddit.
func (s *SubredditService) Contributors(ctx context.Context, subreddit string, opts *ListOptions) ([]*Relationship, *Response, error) {
path := fmt.Sprintf("r/%s/about/contributors", subreddit)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(struct {
Data struct {
Relationships []*Relationship `json:"children"`
After string `json:"after"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
resp.After = root.Data.After
return root.Data.Relationships, resp, nil
}
// WikiContributors gets contributors of the wiki from the subreddit.
func (s *SubredditService) WikiContributors(ctx context.Context, subreddit string, opts *ListOptions) ([]*Relationship, *Response, error) {
path := fmt.Sprintf("r/%s/about/wikicontributors", subreddit)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(struct {
Data struct {
Relationships []*Relationship `json:"children"`
After string `json:"after"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
resp.After = root.Data.After
return root.Data.Relationships, resp, nil
}
// Moderators gets the moderators of the subreddit.
func (s *SubredditService) Moderators(ctx context.Context, subreddit string) ([]*Moderator, *Response, error) {
path := fmt.Sprintf("r/%s/about/moderators", subreddit)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(struct {
Data struct {
Moderators []*Moderator `json:"children"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Data.Moderators, resp, nil
}
// Rules gets the rules of the subreddit.
func (s *SubredditService) Rules(ctx context.Context, subreddit string) ([]*SubredditRule, *Response, error) {
path := fmt.Sprintf("r/%s/about/rules", subreddit)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(struct {
Rules []*SubredditRule `json:"rules"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Rules, resp, nil
}
// CreateRule adds a rule to the subreddit.
func (s *SubredditService) CreateRule(ctx context.Context, subreddit string, request *SubredditRuleCreateRequest) (*Response, error) {
err := request.validate()
if err != nil {
return nil, err
}
form, err := query.Values(request)
if err != nil {
return nil, err
}
form.Set("api_type", "json")
path := fmt.Sprintf("r/%s/api/add_subreddit_rule", subreddit)
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Traffic gets the traffic data of the subreddit.
// It returns traffic data by day, hour, and month, respectively.
func (s *SubredditService) Traffic(ctx context.Context, subreddit string) ([]*SubredditTrafficStats, []*SubredditTrafficStats, []*SubredditTrafficStats, *Response, error) {
path := fmt.Sprintf("r/%s/about/traffic", subreddit)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, nil, nil, err
}
root := new(struct {
Day []*SubredditTrafficStats `json:"day"`
Hour []*SubredditTrafficStats `json:"hour"`
Month []*SubredditTrafficStats `json:"month"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, nil, nil, resp, err
}
return root.Day, root.Hour, root.Month, resp, nil
}
// StyleSheet returns the subreddit's style sheet, as well as some information about images.
func (s *SubredditService) StyleSheet(ctx context.Context, subreddit string) (*SubredditStyleSheet, *Response, error) {
path := fmt.Sprintf("r/%s/about/stylesheet", subreddit)
t, resp, err := s.client.getThing(ctx, path, nil)
if err != nil {
return nil, resp, err
}
styleSheet, _ := t.StyleSheet()
return styleSheet, resp, nil
}
// StyleSheetRaw returns the subreddit's style sheet with all comments and newlines stripped.
func (s *SubredditService) StyleSheetRaw(ctx context.Context, subreddit string) (string, *Response, error) {
path := fmt.Sprintf("r/%s/stylesheet", subreddit)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return "", nil, err
}
buf := new(bytes.Buffer)
resp, err := s.client.Do(ctx, req, buf)
if err != nil {
return "", resp, err
}
return buf.String(), resp, nil
}
// UpdateStyleSheet updates the style sheet of the subreddit.
// Providing a reason is optional.
func (s *SubredditService) UpdateStyleSheet(ctx context.Context, subreddit, styleSheet, reason string) (*Response, error) {
path := fmt.Sprintf("r/%s/api/subreddit_stylesheet", subreddit)
form := url.Values{}
form.Set("api_type", "json")
form.Set("op", "save")
form.Set("stylesheet_contents", styleSheet)
if reason != "" {
form.Set("reason", reason)
}
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// RemoveImage removes an image from the subreddit's custom image set.
// The call succeeds even if the named image does not exist.
func (s *SubredditService) RemoveImage(ctx context.Context, subreddit, imageName string) (*Response, error) {
path := fmt.Sprintf("r/%s/api/delete_sr_img", subreddit)
form := url.Values{}
form.Set("api_type", "json")
form.Set("img_name", imageName)
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// RemoveHeader removes the subreddit's current header image.
// The call succeeds even if there's no header image.
func (s *SubredditService) RemoveHeader(ctx context.Context, subreddit string) (*Response, error) {
path := fmt.Sprintf("r/%s/api/delete_sr_header", subreddit)
form := url.Values{}
form.Set("api_type", "json")
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// RemoveMobileHeader removes the subreddit's current mobile header.
// The call succeeds even if there's no mobile header.
func (s *SubredditService) RemoveMobileHeader(ctx context.Context, subreddit string) (*Response, error) {
path := fmt.Sprintf("r/%s/api/delete_sr_banner", subreddit)
form := url.Values{}
form.Set("api_type", "json")
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// RemoveMobileIcon removes the subreddit's current mobile icon.
// The call succeeds even if there's no mobile icon.
func (s *SubredditService) RemoveMobileIcon(ctx context.Context, subreddit string) (*Response, error) {
path := fmt.Sprintf("r/%s/api/delete_sr_icon", subreddit)
form := url.Values{}
form.Set("api_type", "json")
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
func (s *SubredditService) uploadImage(ctx context.Context, subreddit, imagePath, imageType, imageName string) (string, *Response, error) {
file, err := os.Open(imagePath)
if err != nil {
return "", nil, err
}
defer file.Close()
form := url.Values{}
form.Set("upload_type", imageType)
form.Set("name", imageName)
form.Set("img_type", "png")
ext := filepath.Ext(file.Name())
if strings.EqualFold(ext, ".jpg") {
form.Set("img_type", "jpg")
}
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
for k := range form {
writer.WriteField(k, form.Get(k))
}
part, err := writer.CreateFormFile("file", file.Name())
if err != nil {
return "", nil, err
}
_, err = io.Copy(part, file)
if err != nil {
return "", nil, err
}
err = writer.Close()
if err != nil {
return "", nil, err
}
path := fmt.Sprintf("r/%s/api/upload_sr_img", subreddit)
u, err := s.client.BaseURL.Parse(path)
if err != nil {
return "", nil, err
}
req, err := http.NewRequest(http.MethodPost, u.String(), body)
if err != nil {
return "", nil, err
}
req.Header.Set(headerContentType, writer.FormDataContentType())
root := new(struct {
Errors []string `json:"errors"`
ErrorValues []string `json:"errors_values"`
ImageSource string `json:"img_src"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return "", resp, err
}
if len(root.ErrorValues) > 0 {
err = fmt.Errorf("could not upload image: %s", strings.Join(root.ErrorValues, "; "))
return "", resp, err
}
return root.ImageSource, resp, nil
}
// UploadImage uploads an image to the subreddit.
// If an image with the image name already exists, it it replaced.
// A successful call returns a link to the uploaded image.
func (s *SubredditService) UploadImage(ctx context.Context, subreddit, imagePath, imageName string) (string, *Response, error) {
return s.uploadImage(ctx, subreddit, imagePath, "img", imageName)
}
// UploadHeader uploads an image to be user as the subreddit's header image.
// A successful call returns a link to the uploaded image.
func (s *SubredditService) UploadHeader(ctx context.Context, subreddit, imagePath, imageName string) (string, *Response, error) {
return s.uploadImage(ctx, subreddit, imagePath, "header", imageName)
}
// UploadMobileHeader uploads an image to be user as the subreddit's mobile header image.
// A successful call returns a link to the uploaded image.
func (s *SubredditService) UploadMobileHeader(ctx context.Context, subreddit, imagePath, imageName string) (string, *Response, error) {
return s.uploadImage(ctx, subreddit, imagePath, "banner", imageName)
}
// UploadMobileIcon uploads an image to be user as the subreddit's mobile icon.
// A successful call returns a link to the uploaded image.
func (s *SubredditService) UploadMobileIcon(ctx context.Context, subreddit, imagePath, imageName string) (string, *Response, error) {
return s.uploadImage(ctx, subreddit, imagePath, "icon", imageName)
}
// Create a subreddit.
func (s *SubredditService) Create(ctx context.Context, name string, request *SubredditSettings) (*Response, error) {
if request == nil {
return nil, errors.New("*SubredditSettings: cannot be nil")
}
form, err := query.Values(request)
if err != nil {
return nil, err
}
form.Set("name", name)
form.Set("api_type", "json")
path := "api/site_admin"
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Edit a subreddit.
// This endpoint expects all values of the request to be provided.
// To make this easier, it might be useful to get the subreddit's current settings via GetSettings(),
// and use that as a starting point. It even returns the subreddit's ID, which is needed for this.
func (s *SubredditService) Edit(ctx context.Context, subredditID string, request *SubredditSettings) (*Response, error) {
if request == nil {
return nil, errors.New("*SubredditSettings: cannot be nil")
}
form, err := query.Values(request)
if err != nil {
return nil, err
}
form.Set("sr", subredditID)
form.Set("api_type", "json")
path := "api/site_admin"
req, err := s.client.NewRequest(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// GetSettings gets the settings of a subreddit.
func (s *SubredditService) GetSettings(ctx context.Context, subreddit string) (*SubredditSettings, *Response, error) {
path := fmt.Sprintf("r/%s/about/edit", subreddit)
t, resp, err := s.client.getThing(ctx, path, nil)
if err != nil {
return nil, resp, err
}
settings, _ := t.SubredditSettings()
return settings, resp, nil
}
// PostRequirements returns the subreddit's moderator-designed requirements to post to it.
// Clients may use the values returned by this method to pre-validate submissions to the subreddit.
func (s *SubredditService) PostRequirements(ctx context.Context, subreddit string) (*SubredditPostRequirements, *Response, error) {
path := fmt.Sprintf("api/v1/%s/post_requirements", subreddit)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(SubredditPostRequirements)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}