From 51f9d7f99979ecfd6fd6e0d85b6123acb4675260 Mon Sep 17 00:00:00 2001 From: Rasmus Lindroth Date: Sun, 20 Nov 2022 20:18:10 +0100 Subject: [PATCH] add source, history for statuses and option to update a status --- README.md | 3 + mastodon_test.go | 180 ++++++++++++++++++++++++++++++++++++++++++++++- status.go | 54 +++++++++++++- status_test.go | 82 +++++++++++++++++++++ 4 files changed, 317 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 989d6e4..8440744 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,12 @@ func main() { * [x] GET /api/v1/statuses/:id * [x] GET /api/v1/statuses/:id/context * [x] GET /api/v1/statuses/:id/card +* [x] GET /api/v1/statuses/:id/history * [x] GET /api/v1/statuses/:id/reblogged_by +* [x] GET /api/v1/statuses/:id/source * [x] GET /api/v1/statuses/:id/favourited_by * [x] POST /api/v1/statuses +* [x] PUT /api/v1/statuses/:id * [x] DELETE /api/v1/statuses/:id * [x] POST /api/v1/statuses/:id/reblog * [x] POST /api/v1/statuses/:id/unreblog diff --git a/mastodon_test.go b/mastodon_test.go index 0ef9d07..a8978b5 100644 --- a/mastodon_test.go +++ b/mastodon_test.go @@ -324,12 +324,190 @@ func TestPostStatusParams(t *testing.T) { t.Fatalf("want %q but %q", "

bar

", s.SpoilerText) } s, err = client.PostStatus(context.Background(), &Toot{ + Status: "foobar", + Poll: &TootPoll{ + Multiple: true, + Options: []string{"A", "B"}, + HideTotals: true, + }, + }) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if s.Poll == nil { + t.Fatalf("poll should not be %v", s.Poll) + } + if len(s.Poll.Options) != 2 { + t.Fatalf("want %q but %q", 2, len(s.Poll.Options)) + } + if s.Poll.Options[0].Title != "A" { + t.Fatalf("want %q but %q", "A", s.Poll.Options[0].Title) + } + if s.Poll.Options[1].Title != "B" { + t.Fatalf("want %q but %q", "B", s.Poll.Options[1].Title) + } + if s.Poll.Multiple != true { + t.Fatalf("want %t but %t", true, s.Poll.Multiple) + } +} + +func TestUpdateStatus(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer zoo" { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + fmt.Fprintln(w, `{"access_token": "zoo"}`) + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + }) + _, err := client.UpdateStatus(context.Background(), &Toot{ + Status: "foobar", + }, ID("1")) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + + client = NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err = client.UpdateStatus(context.Background(), &Toot{ + Status: "foobar", + }, ID("1")) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } +} + +func TestUpdateStatusWithCancel(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(3 * time.Second) + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := client.UpdateStatus(ctx, &Toot{ + Status: "foobar", + }, ID("1")) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + if want := fmt.Sprintf("Put %q: context canceled", ts.URL+"/api/v1/statuses/1"); want != err.Error() { + t.Fatalf("want %q but %q", want, err.Error()) + } +} +func TestUpdateStatusParams(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/statuses/1" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + r.ParseForm() + if r.FormValue("media_ids[]") != "" && r.FormValue("poll[options][]") != "" { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } + s := Status{ + ID: ID("1"), + Content: fmt.Sprintf("

%s

", r.FormValue("status")), + } + if r.FormValue("in_reply_to_id") != "" { + s.InReplyToID = ID(r.FormValue("in_reply_to_id")) + } + if r.FormValue("visibility") != "" { + s.Visibility = (r.FormValue("visibility")) + } + if r.FormValue("language") != "" { + s.Language = (r.FormValue("language")) + } + if r.FormValue("sensitive") == "true" { + s.Sensitive = true + s.SpoilerText = fmt.Sprintf("

%s

", r.FormValue("spoiler_text")) + } + if r.FormValue("media_ids[]") != "" { + for _, id := range r.Form["media_ids[]"] { + s.MediaAttachments = append(s.MediaAttachments, + Attachment{ID: ID(id)}) + } + } + if r.FormValue("poll[options][]") != "" { + p := Poll{} + for _, opt := range r.Form["poll[options][]"] { + p.Options = append(p.Options, PollOption{ + Title: opt, + VotesCount: 0, + }) + } + if r.FormValue("poll[multiple]") == "true" { + p.Multiple = true + } + s.Poll = &p + } + json.NewEncoder(w).Encode(s) + })) + defer ts.Close() + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + s, err := client.UpdateStatus(context.Background(), &Toot{ + Status: "foobar", + InReplyToID: ID("2"), + Visibility: "unlisted", + Language: "sv", + Sensitive: true, + SpoilerText: "bar", + MediaIDs: []ID{"1", "2"}, + Poll: &TootPoll{ + Options: []string{"A", "B"}, + }, + }, ID("1")) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if len(s.MediaAttachments) > 0 && s.Poll != nil { + t.Fatal("should not fail, can't have both Media and Poll") + } + if s.Content != "

foobar

" { + t.Fatalf("want %q but %q", "

foobar

", s.Content) + } + if s.InReplyToID != "2" { + t.Fatalf("want %q but %q", "2", s.InReplyToID) + } + if s.Visibility != "unlisted" { + t.Fatalf("want %q but %q", "unlisted", s.Visibility) + } + if s.Language != "sv" { + t.Fatalf("want %q but %q", "sv", s.Language) + } + if s.Sensitive != true { + t.Fatalf("want %t but %t", true, s.Sensitive) + } + if s.SpoilerText != "

bar

" { + t.Fatalf("want %q but %q", "

bar

", s.SpoilerText) + } + s, err = client.UpdateStatus(context.Background(), &Toot{ Status: "foobar", Poll: &TootPoll{ Multiple: true, Options: []string{"A", "B"}, }, - }) + }, ID("1")) if err != nil { t.Fatalf("should not be fail: %v", err) } diff --git a/status.go b/status.go index f5cc451..1319452 100644 --- a/status.go +++ b/status.go @@ -24,6 +24,7 @@ type Status struct { Reblog *Status `json:"reblog"` Content string `json:"content"` CreatedAt time.Time `json:"created_at"` + EditedAt time.Time `json:"edited_at"` Emojis []Emoji `json:"emojis"` RepliesCount int64 `json:"replies_count"` ReblogsCount int64 `json:"reblogs_count"` @@ -45,6 +46,17 @@ type Status struct { Pinned interface{} `json:"pinned"` } +// StatusHistory is a struct to hold status history data. +type StatusHistory struct { + Content string `json:"content"` + SpoilerText string `json:"spoiler_text"` + Account Account `json:"account"` + Sensitive bool `json:"sensitive"` + CreatedAt time.Time `json:"created_at"` + Emojis []Emoji `json:"emojis"` + MediaAttachments []Attachment `json:"media_attachments"` +} + // Context holds information for a mastodon context. type Context struct { Ancestors []*Status `json:"ancestors"` @@ -67,6 +79,13 @@ type Card struct { Height int64 `json:"height"` } +// Source holds source properties so a status can be edited. +type Source struct { + ID ID `json:"id"` + Text string `json:"text"` + SpoilerText string `json:"spoiler_text"` +} + // Conversation holds information for a mastodon conversation. type Conversation struct { ID ID `json:"id"` @@ -190,6 +209,25 @@ func (c *Client) GetStatusCard(ctx context.Context, id ID) (*Card, error) { return &card, nil } +func (c *Client) GetStatusSource(ctx context.Context, id ID) (*Source, error) { + var source Source + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/source", id), nil, &source, nil) + if err != nil { + return nil, err + } + return &source, nil +} + +// GetStatusHistory returns the status history specified by id. +func (c *Client) GetStatusHistory(ctx context.Context, id ID) ([]*StatusHistory, error) { + var statuses []*StatusHistory + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/history", id), nil, &statuses, nil) + if err != nil { + return nil, err + } + return statuses, nil +} + // GetRebloggedBy returns the account list of the user who reblogged the toot of id. func (c *Client) GetRebloggedBy(ctx context.Context, id ID, pg *Pagination) ([]*Account, error) { var accounts []*Account @@ -339,6 +377,15 @@ func (c *Client) GetTimelineMedia(ctx context.Context, isLocal bool, pg *Paginat // PostStatus post the toot. func (c *Client) PostStatus(ctx context.Context, toot *Toot) (*Status, error) { + return c.postStatus(ctx, toot, false, ID("none")) +} + +// UpdateStatus updates the toot. +func (c *Client) UpdateStatus(ctx context.Context, toot *Toot, id ID) (*Status, error) { + return c.postStatus(ctx, toot, true, id) +} + +func (c *Client) postStatus(ctx context.Context, toot *Toot, update bool, updateID ID) (*Status, error) { params := url.Values{} params.Set("status", toot.Status) if toot.InReplyToID != "" { @@ -376,7 +423,12 @@ func (c *Client) PostStatus(ctx context.Context, toot *Toot) (*Status, error) { } var status Status - err := c.doAPI(ctx, http.MethodPost, "/api/v1/statuses", params, &status, nil) + var err error + if !update { + err = c.doAPI(ctx, http.MethodPost, "/api/v1/statuses", params, &status, nil) + } else { + err = c.doAPI(ctx, http.MethodPut, fmt.Sprintf("/api/v1/statuses/%s", updateID), params, &status, nil) + } if err != nil { return nil, err } diff --git a/status_test.go b/status_test.go index 5209668..2cac4b8 100644 --- a/status_test.go +++ b/status_test.go @@ -172,6 +172,88 @@ func TestGetStatusContext(t *testing.T) { } } +func TestGetStatusSource(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/statuses/1234567/source" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + fmt.Fprintln(w, `{"id":"1234567","text":"Foo","spoiler_text":"Bar"}%`) + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.GetStatusSource(context.Background(), "123") + if err == nil { + t.Fatalf("should be fail: %v", err) + } + source, err := client.GetStatusSource(context.Background(), "1234567") + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if source.ID != ID("1234567") { + t.Fatalf("want %q but %q", "1234567", source.ID) + } + if source.Text != "Foo" { + t.Fatalf("want %q but %q", "Foo", source.Text) + } + if source.SpoilerText != "Bar" { + t.Fatalf("want %q but %q", "Bar", source.SpoilerText) + } +} + +func TestGetStatusHistory(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/statuses/1234567/history" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + fmt.Fprintln(w, `[{"content": "foo", "emojis":[{"shortcode":"💩", "url":"http://example.com", "static_url": "http://example.com/static"}]}, {"content": "bar", "emojis":[{"shortcode":"💩", "url":"http://example.com", "static_url": "http://example.com/static"}]}]`) + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.GetStatusHistory(context.Background(), "123") + if err == nil { + t.Fatalf("should be fail: %v", err) + } + statuses, err := client.GetStatusHistory(context.Background(), "1234567") + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if len(statuses) != 2 { + t.Fatalf("want len %q but got %q", "2", len(statuses)) + } + if statuses[0].Content != "foo" { + t.Fatalf("want %q but %q", "bar", statuses[0].Content) + } + if statuses[1].Content != "bar" { + t.Fatalf("want %q but %q", "bar", statuses[1].Content) + } + if len(statuses[0].Emojis) != 1 { + t.Fatal("should have emojis") + } + if statuses[0].Emojis[0].ShortCode != "💩" { + t.Fatalf("want %q but %q", "💩", statuses[0].Emojis[0].ShortCode) + } + if statuses[0].Emojis[0].URL != "http://example.com" { + t.Fatalf("want %q but %q", "https://example.com", statuses[0].Emojis[0].URL) + } + if statuses[0].Emojis[0].StaticURL != "http://example.com/static" { + t.Fatalf("want %q but %q", "https://example.com/static", statuses[0].Emojis[0].StaticURL) + } +} + func TestGetRebloggedBy(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/statuses/1234567/reblogged_by" {