diff --git a/reddit/subreddit.go b/reddit/subreddit.go index 9cad451..6e7d3f1 100644 --- a/reddit/subreddit.go +++ b/reddit/subreddit.go @@ -2,11 +2,14 @@ package reddit import ( "context" + "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" + + "github.com/google/go-querystring/query" ) // SubredditService handles communication with the subreddit @@ -43,6 +46,100 @@ type Ban struct { 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"` +} + +// 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 +} + // todo: interface{}, seriously? func (s *SubredditService) getPosts(ctx context.Context, sort string, subreddit string, opts interface{}) ([]*Post, *Response, error) { path := sort @@ -654,3 +751,68 @@ func (s *SubredditService) Moderators(ctx context.Context, subreddit string) ([] 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 +} diff --git a/reddit/subreddit_test.go b/reddit/subreddit_test.go index f610472..9e024b1 100644 --- a/reddit/subreddit_test.go +++ b/reddit/subreddit_test.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "testing" "time" @@ -274,6 +275,47 @@ var expectedModerators = []*Moderator{ }, } +var expectedRules = []*SubredditRule{ + { + Kind: "link", + Name: "Read the Rules Before Posting", + ViolationReason: "Read the Rules Before Posting", + Description: "https://www.reddit.com/r/Fitness/wiki/rules", + Priority: 0, + Created: &Timestamp{time.Date(2019, 5, 22, 5, 32, 58, 0, time.UTC)}, + }, + { + Kind: "link", + Name: "Read the Wiki Before Posting", + ViolationReason: "Read the Wiki Before Posting", + Description: "https://thefitness.wiki", + Priority: 1, + Created: &Timestamp{time.Date(2019, 11, 9, 7, 56, 33, 0, time.UTC)}, + }, +} + +var expectedDayTraffic = []*SubredditTrafficStats{ + {&Timestamp{time.Date(2020, 9, 13, 0, 0, 0, 0, time.UTC)}, 0, 0, 0}, + {&Timestamp{time.Date(2020, 9, 12, 0, 0, 0, 0, time.UTC)}, 1, 12, 0}, + {&Timestamp{time.Date(2020, 9, 11, 0, 0, 0, 0, time.UTC)}, 5, 85, 0}, + {&Timestamp{time.Date(2020, 9, 10, 0, 0, 0, 0, time.UTC)}, 4, 20, 0}, + {&Timestamp{time.Date(2020, 9, 9, 0, 0, 0, 0, time.UTC)}, 2, 64, 0}, + {&Timestamp{time.Date(2020, 9, 8, 0, 0, 0, 0, time.UTC)}, 2, 95, 0}, + {&Timestamp{time.Date(2020, 9, 7, 0, 0, 0, 0, time.UTC)}, 3, 41, 0}, +} + +var expectedHourTraffic = []*SubredditTrafficStats{ + {&Timestamp{time.Date(2020, 9, 12, 20, 0, 0, 0, time.UTC)}, 1, 12, 0}, + {&Timestamp{time.Date(2020, 9, 11, 3, 0, 0, 0, time.UTC)}, 4, 57, 0}, + {&Timestamp{time.Date(2020, 9, 11, 2, 0, 0, 0, time.UTC)}, 4, 28, 0}, +} + +var expectedMonthTraffic = []*SubredditTrafficStats{ + {&Timestamp{time.Date(2020, 9, 1, 0, 0, 0, 0, time.UTC)}, 7, 481, 0}, + {&Timestamp{time.Date(2020, 8, 1, 0, 0, 0, 0, time.UTC)}, 5, 346, 0}, + {&Timestamp{time.Date(2020, 7, 1, 0, 0, 0, 0, time.UTC)}, 4, 264, 0}, +} + func TestSubredditService_HotPosts(t *testing.T) { client, mux, teardown := setup() defer teardown() @@ -1021,3 +1063,95 @@ func TestSubredditService_Moderators(t *testing.T) { require.NoError(t, err) require.Equal(t, expectedModerators, moderators) } + +func TestSubredditService_Rules(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + blob, err := readFileContents("../testdata/subreddit/rules.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/rules", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + rules, _, err := client.Subreddit.Rules(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedRules, rules) +} + +func TestSubredditService_CreateRule(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/r/testsubreddit/api/add_subreddit_rule", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("kind", "all") + form.Set("short_name", "testname") + form.Set("violation_reason", "testreason") + form.Set("description", "testdescription") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.CreateRule(ctx, "testsubreddit", &SubredditRuleCreateRequest{ + Kind: "all", + Name: "testname", + ViolationReason: "testreason", + Description: "testdescription", + }) + require.NoError(t, err) +} + +func TestSubredditService_CreateRule_Error(t *testing.T) { + client, _, teardown := setup() + defer teardown() + + _, err := client.Subreddit.CreateRule(ctx, "testsubreddit", nil) + require.EqualError(t, err, "*SubredditRuleCreateRequest: cannot be nil") + + _, err = client.Subreddit.CreateRule(ctx, "testsubreddit", &SubredditRuleCreateRequest{Kind: "invalid"}) + require.EqualError(t, err, "(*SubredditRuleCreateRequest).Kind: must be one of: comment, link, all") + + _, err = client.Subreddit.CreateRule(ctx, "testsubreddit", &SubredditRuleCreateRequest{Kind: "all", Name: ""}) + require.EqualError(t, err, "(*SubredditRuleCreateRequest).Name: must be between 1-100 characters") + + _, err = client.Subreddit.CreateRule(ctx, "testsubreddit", &SubredditRuleCreateRequest{ + Kind: "all", + Name: "testname", + ViolationReason: strings.Repeat("x", 101), + }) + require.EqualError(t, err, "(*SubredditRuleCreateRequest).ViolationReason: cannot be longer than 100 characters") + + _, err = client.Subreddit.CreateRule(ctx, "testsubreddit", &SubredditRuleCreateRequest{ + Kind: "all", + Name: "testname", + Description: strings.Repeat("x", 501), + }) + require.EqualError(t, err, "(*SubredditRuleCreateRequest).Description: cannot be longer than 500 characters") +} + +func TestSubredditService_Traffic(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + blob, err := readFileContents("../testdata/subreddit/traffic.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/traffic", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + dayTraffic, hourTraffic, monthTraffic, _, err := client.Subreddit.Traffic(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedDayTraffic, dayTraffic) + require.Equal(t, expectedHourTraffic, hourTraffic) + require.Equal(t, expectedMonthTraffic, monthTraffic) +} diff --git a/testdata/subreddit/rules.json b/testdata/subreddit/rules.json new file mode 100644 index 0000000..ee6cb39 --- /dev/null +++ b/testdata/subreddit/rules.json @@ -0,0 +1,158 @@ +{ + "rules": [ + { + "kind": "link", + "description": "https://www.reddit.com/r/Fitness/wiki/rules", + "short_name": "Read the Rules Before Posting", + "violation_reason": "Read the Rules Before Posting", + "created_utc": 1558503178.0, + "priority": 0, + "description_html": "<!-- SC_OFF --><div class=\"md\"><p><a href=\"https://www.reddit.com/r/Fitness/wiki/rules\">https://www.reddit.com/r/Fitness/wiki/rules</a></p>\n</div><!-- SC_ON -->" + }, + { + "kind": "link", + "description": "https://thefitness.wiki", + "short_name": "Read the Wiki Before Posting", + "violation_reason": "Read the Wiki Before Posting", + "created_utc": 1573286193.0, + "priority": 1, + "description_html": "<!-- SC_OFF --><div class=\"md\"><p><a href=\"https://thefitness.wiki\">https://thefitness.wiki</a></p>\n</div><!-- SC_ON -->" + } + ], + "site_rules": [ + "Spam", + "Personal and confidential information", + "Threatening, harassing, or inciting violence" + ], + "site_rules_flow": [ + { + "reasonTextToShow": "This is spam", + "reasonText": "This is spam" + }, + { + "reasonTextToShow": "This is misinformation", + "reasonText": "This is misinformation" + }, + { + "nextStepHeader": "In what way?", + "reasonTextToShow": "This is abusive or harassing", + "nextStepReasons": [ + { + "nextStepHeader": "Who is the harassment targeted at?", + "reasonTextToShow": "It's targeted harassment", + "nextStepReasons": [ + { + "reasonTextToShow": "At me", + "reasonText": "It's targeted harassment at me" + }, + { + "reasonTextToShow": "At someone else", + "reasonText": "It's targeted harassment at someone else" + } + ], + "reasonText": "" + }, + { + "nextStepHeader": "Who is the threat directed at?", + "reasonTextToShow": "It threatens violence or physical harm", + "nextStepReasons": [ + { + "reasonTextToShow": "At me", + "reasonText": "It threatens violence or physical harm at me" + }, + { + "reasonTextToShow": "At someone else", + "reasonText": "It threatens violence or physical harm at someone else" + } + ], + "reasonText": "" + }, + { + "reasonTextToShow": "It's promoting hate based on identity or vulnerability", + "reasonText": "It's promoting hate based on identity or vulnerability" + }, + { + "reasonTextToShow": "It's rude, vulgar or offensive", + "reasonText": "It's rude, vulgar or offensive" + }, + { + "reasonTextToShow": "It's abusing the report button", + "canWriteNotes": true, + "isAbuseOfReportButton": true, + "notesInputTitle": "Additional information (optional)", + "reasonText": "It's abusing the report button" + } + ], + "reasonText": "" + }, + { + "nextStepHeader": "What issue?", + "reasonTextToShow": "Other issues", + "nextStepReasons": [ + { + "complaintButtonText": "File a complaint", + "complaintUrl": "https://www.reddit.com/api/report_redirect?thing=%25%28thing%29s&reason_code=COPYRIGHT", + "complaintPageTitle": "File a complaint?", + "reasonText": "It infringes my copyright", + "reasonTextToShow": "It infringes my copyright", + "fileComplaint": true, + "complaintPrompt": "If you think content on Reddit violates your intellectual property, please file a complaint at the link below:" + }, + { + "complaintButtonText": "File a complaint", + "complaintUrl": "https://www.reddit.com/api/report_redirect?thing=%25%28thing%29s&reason_code=TRADEMARK", + "complaintPageTitle": "File a complaint?", + "reasonText": "It infringes my trademark rights", + "reasonTextToShow": "It infringes my trademark rights", + "fileComplaint": true, + "complaintPrompt": "If you think content on Reddit violates your intellectual property, please file a complaint at the link below:" + }, + { + "reasonTextToShow": "It's personal and confidential information", + "reasonText": "It's personal and confidential information" + }, + { + "reasonTextToShow": "It's sexual or suggestive content involving minors", + "reasonText": "It's sexual or suggestive content involving minors" + }, + { + "nextStepHeader": "Do you appear in the image?", + "reasonTextToShow": "It's involuntary pornography", + "nextStepReasons": [ + { + "reasonTextToShow": "I appear in the image", + "reasonText": "It's involuntary pornography and i appear in it" + }, + { + "reasonTextToShow": "I do not appear in the image", + "reasonText": "It's involuntary pornography and i do not appear in it" + } + ], + "reasonText": "" + }, + { + "reasonTextToShow": "It's a transaction for prohibited goods or services", + "reasonText": "It's a transaction for prohibited goods or services" + }, + { + "complaintButtonText": "File a complaint", + "complaintUrl": "https://www.reddit.com/api/report_redirect?thing=%25%28thing%29s&reason_code=NETZDG", + "complaintPageTitle": "File a complaint?", + "reasonText": "Report this content under NetzDG", + "reasonTextToShow": "Report this content under NetzDG", + "fileComplaint": true, + "complaintPrompt": "This reporting procedure is only available for people in Germany. If you are in Germany and would like to report this content under the German Netzwerkdurchsetzungsgesetz (NetzDG) law you may file a complaint by clicking the link below." + }, + { + "usernamesInputTitle": "Username", + "reasonTextToShow": "Someone is considering suicide or serious self-harm.", + "canSpecifyUsernames": true, + "reasonText": "Someone is considering suicide or serious self-harm.", + "requestCrisisSupport": true, + "oneUsername": true + } + ], + "reasonText": "" + } + ] +} diff --git a/testdata/subreddit/traffic.json b/testdata/subreddit/traffic.json new file mode 100644 index 0000000..27027e9 --- /dev/null +++ b/testdata/subreddit/traffic.json @@ -0,0 +1,21 @@ +{ + "day": [ + [1599955200, 0, 0, 0], + [1599868800, 1, 12, 0], + [1599782400, 5, 85, 0], + [1599696000, 4, 20, 0], + [1599609600, 2, 64, 0], + [1599523200, 2, 95, 0], + [1599436800, 3, 41, 0] + ], + "hour": [ + [1599940800, 1, 12], + [1599793200, 4, 57], + [1599789600, 4, 28] + ], + "month": [ + [1598918400, 7, 481], + [1596240000, 5, 346], + [1593561600, 4, 264] + ] +}