diff --git a/reddit/emoji.go b/reddit/emoji.go index f01a99c..b926925 100644 --- a/reddit/emoji.go +++ b/reddit/emoji.go @@ -45,6 +45,7 @@ type EmojiCreateOrUpdateRequest struct { } func (r *EmojiCreateOrUpdateRequest) validate() error { + // todo if r == nil { ... } if r.Name == "" { return errors.New("name: cannot be empty") } diff --git a/reddit/live-thread.go b/reddit/live-thread.go index e3032bc..9d0f18a 100644 --- a/reddit/live-thread.go +++ b/reddit/live-thread.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "net/url" + "reflect" + "strings" "github.com/google/go-querystring/query" ) @@ -40,13 +42,13 @@ type LiveThread struct { NSFW bool `json:"nsfw"` } -// LiveThreadCreateRequest represents a request to create a live thread. -type LiveThreadCreateRequest struct { +// LiveThreadCreateOrUpdateRequest represents a request to create/update a live thread. +type LiveThreadCreateOrUpdateRequest struct { // No longer than 120 characters. - Title string `url:"title"` + Title string `url:"title,omitempty"` Description string `url:"description,omitempty"` Resources string `url:"resources,omitempty"` - NSFW bool `url:"nsfw,omitempty"` + NSFW *bool `url:"nsfw,omitempty"` } // LiveThreadContributor is a user that can contribute to a live thread. @@ -122,6 +124,50 @@ func (c *LiveThreadContributors) unmarshalMany(b []byte) error { return nil } +// LiveThreadPermissions are the different permissions contributors have or don't have for a live thread. +// Read about them here: https://mods.reddithelp.com/hc/en-us/articles/360009381491-User-Management-moderators-and-permissions +type LiveThreadPermissions struct { + All bool `permission:"all"` + Close bool `permission:"close"` + Discussions bool `permission:"discussions"` + Edit bool `permission:"edit"` + Manage bool `permission:"manage"` + Settings bool `permission:"settings"` + Update bool `permission:"update"` +} + +func (p *LiveThreadPermissions) String() (s string) { + if p == nil { + return "+all" + } + + t := reflect.TypeOf(*p) + v := reflect.ValueOf(*p) + + for i := 0; i < t.NumField(); i++ { + if v.Field(i).Kind() != reflect.Bool { + continue + } + + permission := t.Field(i).Tag.Get("permission") + permitted := v.Field(i).Bool() + + if permitted { + s += "+" + } else { + s += "-" + } + + s += permission + + if i != t.NumField()-1 { + s += "," + } + } + + return +} + // Get information about a live thread. func (s *LiveThreadService) Get(ctx context.Context, id string) (*LiveThread, *Response, error) { path := fmt.Sprintf("live/%s/about", id) @@ -140,10 +186,23 @@ func (s *LiveThreadService) Get(ctx context.Context, id string) (*LiveThread, *R return t, resp, nil } +// GetMultiple gets information about multiple live threads. +func (s *LiveThreadService) GetMultiple(ctx context.Context, ids ...string) ([]*LiveThread, *Response, error) { + if len(ids) == 0 { + return nil, nil, errors.New("must provide at least 1 id") + } + path := fmt.Sprintf("api/live/by_id/%s", strings.Join(ids, ",")) + l, resp, err := s.client.getListing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + return l.LiveThreads(), resp, nil +} + // Create a live thread and get its id. -func (s *LiveThreadService) Create(ctx context.Context, request *LiveThreadCreateRequest) (string, *Response, error) { +func (s *LiveThreadService) Create(ctx context.Context, request *LiveThreadCreateOrUpdateRequest) (string, *Response, error) { if request == nil { - return "", nil, errors.New("*LiveThreadCreateRequest: cannot be nil") + return "", nil, errors.New("*LiveThreadCreateOrUpdateRequest: cannot be nil") } form, err := query.Values(request) @@ -173,6 +232,47 @@ func (s *LiveThreadService) Create(ctx context.Context, request *LiveThreadCreat return root.JSON.Data.ID, resp, nil } +// Close the thread permanently, disallowing future updates. +func (s *LiveThreadService) Close(ctx context.Context, id string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + + path := fmt.Sprintf("api/live/%s/close_thread", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Configure the thread. +// Requires the "settings" permission. +func (s *LiveThreadService) Configure(ctx context.Context, id string, request *LiveThreadCreateOrUpdateRequest) (*Response, error) { + if request == nil { + return nil, errors.New("*LiveThreadCreateOrUpdateRequest: cannot be nil") + } + + form, err := query.Values(request) + if err != nil { + return nil, err + } + form.Set("api_type", "json") + + path := fmt.Sprintf("api/live/%s/edit", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + // Contributors gets a list of users that are contributors to the live thread. // If you are a contributor and you have the "manage" permission (to manage contributors), you // also get a list of invited contributors that haven't yet accepted/refused their invitation. @@ -220,3 +320,96 @@ func (s *LiveThreadService) Leave(ctx context.Context, id string) (*Response, er return s.client.Do(ctx, req, nil) } + +// Invite another user to contribute to the live thread. +// If permissions is nil, all permissions will be granted. +// Requires the "manage" permission. +func (s *LiveThreadService) Invite(ctx context.Context, id, username string, permissions *LiveThreadPermissions) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", "liveupdate_contributor_invite") + form.Set("permissions", permissions.String()) + + path := fmt.Sprintf("/api/live/%s/invite_contributor", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Uninvite a user that's been invited to contribute to a live thread via their full ID. +// Requires the "manage" permission. +func (s *LiveThreadService) Uninvite(ctx context.Context, threadID, userID string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", userID) + + path := fmt.Sprintf("/api/live/%s/rm_contributor_invite", threadID) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// SetPermissions sets the permissions for the contributor in the live thread. +// If permissions is nil, all permissions will be granted. +// Requires the "manage" permission. +func (s *LiveThreadService) SetPermissions(ctx context.Context, id, username string, permissions *LiveThreadPermissions) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", "liveupdate_contributor_invite") + form.Set("permissions", permissions.String()) + + path := fmt.Sprintf("/api/live/%s/set_contributor_permissions", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Revoke a user's contributorship via their full ID. +// Requires the "manage" permission. +func (s *LiveThreadService) Revoke(ctx context.Context, threadID, userID string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", userID) + + path := fmt.Sprintf("/api/live/%s/rm_contributor", threadID) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Report the live thread. +// The reason should be one of: +// spam, vote-manipulation, personal-information, sexualizing-minors, site-breaking +func (s *LiveThreadService) Report(ctx context.Context, id, reason string) (*Response, error) { + switch reason { + case "spam", "vote-manipulation", "personal-information", "sexualizing-minors", "site-breaking": + default: + return nil, errors.New("invalid reason for reporting live thread: " + reason) + } + + form := url.Values{} + form.Set("api_type", "json") + form.Set("type", reason) + + path := fmt.Sprintf("api/live/%s/report", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/reddit/live-thread_test.go b/reddit/live-thread_test.go index adffaa2..3bfa108 100644 --- a/reddit/live-thread_test.go +++ b/reddit/live-thread_test.go @@ -29,6 +29,45 @@ var expectedLiveThread = &LiveThread{ NSFW: false, } +var expectedLiveThreads = []*LiveThread{ + { + ID: "15nevtv8e54dh", + FullID: "LiveUpdateEvent_15nevtv8e54dh", + Created: &Timestamp{time.Date(2020, 9, 16, 1, 20, 27, 0, time.UTC)}, + + Title: "test", + Description: "test", + Resources: "", + + State: "live", + ViewerCount: 6, + ViewerCountFuzzed: true, + + WebSocketURL: "wss://ws-078adc7cb2099a9df.wss.redditmedia.com/live/15nevtv8e54dh?m=AQAA7rxiX6EpLYFCFZ0KJD4lVAPaMt0A1z2-xJ1b2dWCmxNIfMwL", + + Announcement: false, + NSFW: false, + }, + { + ID: "15ndkho8e54dh", + FullID: "LiveUpdateEvent_15ndkho8e54dh", + Created: &Timestamp{time.Date(2020, 9, 16, 1, 20, 37, 0, time.UTC)}, + + Title: "test 2", + Description: "test 2", + Resources: "", + + State: "live", + ViewerCount: 6, + ViewerCountFuzzed: true, + + WebSocketURL: "wss://ws-078adc7cb2099a9df.wss.redditmedia.com/live/15ndkho8e54dh?m=AQAA7rxiX6EpLYFCFZ0KJD4lVAPaMt0A1z2-xJ1b2dWCmxNIfMwL", + + Announcement: false, + NSFW: false, + }, +} + var expectedLiveThreadContributors = &LiveThreadContributors{ Current: []*LiveThreadContributor{ {ID: "t2_test1", Name: "test1", Permissions: []string{"all"}}, @@ -64,6 +103,26 @@ func TestLiveThreadService_Get(t *testing.T) { require.Equal(t, expectedLiveThread, liveThread) } +func TestLiveThreadService_GetMultiple(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + blob, err := readFileContents("../testdata/live-thread/live-threads.json") + require.NoError(t, err) + + mux.HandleFunc("/api/live/by_id/id1,id2", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + _, _, err = client.LiveThread.GetMultiple(ctx) + require.EqualError(t, err, "must provide at least 1 id") + + liveThreads, _, err := client.LiveThread.GetMultiple(ctx, "id1", "id2") + require.NoError(t, err) + require.Equal(t, expectedLiveThreads, liveThreads) +} + func TestLiveThreadService_Create(t *testing.T) { client, mux, teardown := setup() defer teardown() @@ -93,18 +152,77 @@ func TestLiveThreadService_Create(t *testing.T) { }) _, _, err := client.LiveThread.Create(ctx, nil) - require.EqualError(t, err, "*LiveThreadCreateRequest: cannot be nil") + require.EqualError(t, err, "*LiveThreadCreateOrUpdateRequest: cannot be nil") - id, _, err := client.LiveThread.Create(ctx, &LiveThreadCreateRequest{ + id, _, err := client.LiveThread.Create(ctx, &LiveThreadCreateOrUpdateRequest{ Title: "testtitle", Description: "testdescription", Resources: "testresources", - NSFW: true, + NSFW: Bool(true), }) require.NoError(t, err) require.Equal(t, "id123", id) } +func TestLiveThreadService_Close(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/live/id123/close_thread", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Close(ctx, "id123") + require.NoError(t, err) +} + +func TestLiveThreadService_Configure(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/live/id123/edit", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("title", "testtitle") + form.Set("description", "testdescription") + form.Set("resources", "testresources") + form.Set("nsfw", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, `{ + "json": { + "data": { + "id": "id123" + }, + "errors": [] + } + }`) + }) + + _, err := client.LiveThread.Configure(ctx, "id123", nil) + require.EqualError(t, err, "*LiveThreadCreateOrUpdateRequest: cannot be nil") + + _, err = client.LiveThread.Configure(ctx, "id123", &LiveThreadCreateOrUpdateRequest{ + Title: "testtitle", + Description: "testdescription", + Resources: "testresources", + NSFW: Bool(true), + }) + require.NoError(t, err) +} + func TestLiveThreadService_Contributors(t *testing.T) { client, mux, teardown := setup() defer teardown() @@ -176,3 +294,132 @@ func TestLiveThreadService_Leave(t *testing.T) { _, err := client.LiveThread.Leave(ctx, "id123") require.NoError(t, err) } + +func TestLiveThreadService_Invite(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/live/id123/invite_contributor", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "liveupdate_contributor_invite") + form.Set("permissions", "+all") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Invite(ctx, "id123", "testuser", nil) + require.NoError(t, err) +} + +func TestLiveThreadService_Invite_Permissions(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/live/id123/invite_contributor", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "liveupdate_contributor_invite") + form.Set("permissions", "-all,+close,-discussions,-edit,+manage,-settings,+update") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Invite(ctx, "id123", "testuser", &LiveThreadPermissions{Close: true, Manage: true, Update: true}) + require.NoError(t, err) +} + +func TestLiveThreadService_Uninvite(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/live/id123/rm_contributor_invite", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t2_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Uninvite(ctx, "id123", "t2_test") + require.NoError(t, err) +} + +func TestLiveThreadService_SetPermissions(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/live/id123/set_contributor_permissions", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "liveupdate_contributor_invite") + form.Set("permissions", "-all,-close,+discussions,+edit,-manage,+settings,-update") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.SetPermissions(ctx, "id123", "testuser", &LiveThreadPermissions{Discussions: true, Edit: true, Settings: true}) + require.NoError(t, err) +} + +func TestLiveThreadService_Revoke(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/live/id123/rm_contributor", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t2_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Revoke(ctx, "id123", "t2_test") + require.NoError(t, err) +} + +func TestLiveThreadService_Report(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/live/id123/report", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("type", "spam") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Report(ctx, "id123", "invalidreason") + require.EqualError(t, err, "invalid reason for reporting live thread: invalidreason") + + _, err = client.LiveThread.Report(ctx, "id123", "spam") + require.NoError(t, err) +} diff --git a/reddit/things.go b/reddit/things.go index 4ac7995..1f93a33 100644 --- a/reddit/things.go +++ b/reddit/things.go @@ -307,14 +307,22 @@ func (l *listing) Multis() []*Multi { return l.things.Multis } +func (l *listing) LiveThreads() []*LiveThread { + if l == nil { + return nil + } + return l.things.LiveThreads +} + type things struct { - Comments []*Comment - Mores []*More - Users []*User - Posts []*Post - Subreddits []*Subreddit - ModActions []*ModAction - Multis []*Multi + Comments []*Comment + Mores []*More + Users []*User + Posts []*Post + Subreddits []*Subreddit + ModActions []*ModAction + Multis []*Multi + LiveThreads []*LiveThread } // UnmarshalJSON implements the json.Unmarshaler interface. @@ -345,6 +353,8 @@ func (t *things) add(things ...thing) { t.ModActions = append(t.ModActions, v) case *Multi: t.Multis = append(t.Multis, v) + case *LiveThread: + t.LiveThreads = append(t.LiveThreads, v) } } } diff --git a/testdata/live-thread/live-threads.json b/testdata/live-thread/live-threads.json new file mode 100644 index 0000000..840a78c --- /dev/null +++ b/testdata/live-thread/live-threads.json @@ -0,0 +1,61 @@ +{ + "kind": "Listing", + "data": { + "modhash": null, + "dist": 2, + "children": [ + { + "kind": "LiveUpdateEvent", + "data": { + "total_views": null, + "description": "test", + "description_html": "<div class=\"md\"><p>test</p>\n</div>", + "created": 1600248027.0, + "title": "test", + "created_utc": 1600219227.0, + "button_cta": "", + "websocket_url": "wss://ws-078adc7cb2099a9df.wss.redditmedia.com/live/15nevtv8e54dh?m=AQAA7rxiX6EpLYFCFZ0KJD4lVAPaMt0A1z2-xJ1b2dWCmxNIfMwL", + "name": "LiveUpdateEvent_15nevtv8e54dh", + "is_announcement": false, + "state": "live", + "announcement_url": "", + "nsfw": false, + "viewer_count": 6, + "num_times_dismissable": 1, + "viewer_count_fuzzed": true, + "resources_html": "", + "id": "15nevtv8e54dh", + "resources": "", + "icon": "" + } + }, + { + "kind": "LiveUpdateEvent", + "data": { + "total_views": null, + "description": "test 2", + "description_html": "<div class=\"md\"><p>test 2</p>\n</div>", + "created": 1600248037.0, + "title": "test 2", + "created_utc": 1600219237.0, + "button_cta": "", + "websocket_url": "wss://ws-078adc7cb2099a9df.wss.redditmedia.com/live/15ndkho8e54dh?m=AQAA7rxiX6EpLYFCFZ0KJD4lVAPaMt0A1z2-xJ1b2dWCmxNIfMwL", + "name": "LiveUpdateEvent_15ndkho8e54dh", + "is_announcement": false, + "state": "live", + "announcement_url": "", + "nsfw": false, + "viewer_count": 6, + "num_times_dismissable": 1, + "viewer_count_fuzzed": true, + "resources_html": "", + "id": "15ndkho8e54dh", + "resources": "", + "icon": "" + } + } + ], + "after": null, + "before": null + } +}