diff --git a/README.md b/README.md index b959ce3..488ee2b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # go-mastodon -[![Build Status](https://travis-ci.org/mattn/go-mastodon.png?branch=master)](https://travis-ci.org/mattn/go-mastodon) +[![Build Status](https://travis-ci.org/mattn/go-mastodon.svg?branch=master)](https://travis-ci.org/mattn/go-mastodon) [![Coverage Status](https://coveralls.io/repos/github/mattn/go-mastodon/badge.svg?branch=master)](https://coveralls.io/github/mattn/go-mastodon?branch=master) [![GoDoc](https://godoc.org/github.com/mattn/go-mastodon?status.svg)](http://godoc.org/github.com/mattn/go-mastodon) [![Go Report Card](https://goreportcard.com/badge/github.com/mattn/go-mastodon)](https://goreportcard.com/report/github.com/mattn/go-mastodon) @@ -26,9 +26,10 @@ if err != nil { * [x] GET /api/v1/accounts/:id * [x] GET /api/v1/accounts/verify_credentials +* [x] PATCH /api/v1/accounts/update_credentials * [x] GET /api/v1/accounts/:id/followers * [x] GET /api/v1/accounts/:id/following -* [ ] GET /api/v1/accounts/:id/statuses +* [x] GET /api/v1/accounts/:id/statuses * [x] POST /api/v1/accounts/:id/follow * [x] POST /api/v1/accounts/:id/unfollow * [x] GET /api/v1/accounts/:id/block @@ -38,11 +39,11 @@ if err != nil { * [x] GET /api/v1/accounts/relationships * [x] GET /api/v1/accounts/search * [x] POST /api/v1/apps -* [ ] GET /api/v1/blocks -* [ ] GET /api/v1/favourites -* [ ] GET /api/v1/follow_requests -* [ ] POST /api/v1/follow_requests/authorize -* [ ] POST /api/v1/follow_requests/reject +* [x] GET /api/v1/blocks +* [x] GET /api/v1/favourites +* [x] GET /api/v1/follow_requests +* [x] POST /api/v1/follow_requests/:id/authorize +* [x] POST /api/v1/follow_requests/:id/reject * [x] POST /api/v1/follows * [x] GET /api/v1/instance * [ ] POST /api/v1/media @@ -52,21 +53,21 @@ if err != nil { * [x] POST /api/v1/notifications/clear * [ ] GET /api/v1/reports * [ ] POST /api/v1/reports -* [ ] GET /api/v1/search +* [x] GET /api/v1/search * [x] GET /api/v1/statuses/:id * [x] GET /api/v1/statuses/:id/context * [x] GET /api/v1/statuses/:id/card -* [ ] GET /api/v1/statuses/:id/reblogged_by -* [ ] GET /api/v1/statuses/:id/favourited_by -* [ ] POST /api/v1/statuses -* [ ] DELETE /api/v1/statuses/:id -* [ ] POST /api/v1/statuses/:id/reblog -* [ ] POST /api/v1/statuses/:id/unreblog -* [ ] POST /api/v1/statuses/:id/favourite -* [ ] POST /api/v1/statuses/:id/unfavourite +* [x] GET /api/v1/statuses/:id/reblogged_by +* [x] GET /api/v1/statuses/:id/favourited_by +* [x] POST /api/v1/statuses +* [x] DELETE /api/v1/statuses/:id +* [x] POST /api/v1/statuses/:id/reblog +* [x] POST /api/v1/statuses/:id/unreblog +* [x] POST /api/v1/statuses/:id/favourite +* [x] POST /api/v1/statuses/:id/unfavourite * [x] GET /api/v1/timelines/home -* [ ] GET /api/v1/timelines/public -* [ ] GET /api/v1/timelines/tag/:hashtag +* [x] GET /api/v1/timelines/public +* [x] GET /api/v1/timelines/tag/:hashtag ## Installation diff --git a/accounts.go b/accounts.go index 751e3d3..f5d933b 100644 --- a/accounts.go +++ b/accounts.go @@ -1,6 +1,7 @@ package mastodon import ( + "context" "fmt" "net/http" "net/url" @@ -27,9 +28,9 @@ type Account struct { } // GetAccount return Account. -func (c *Client) GetAccount(id int) (*Account, error) { +func (c *Client) GetAccount(ctx context.Context, id int) (*Account, error) { var account Account - err := c.doAPI(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d", id), nil, &account) + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d", id), nil, &account) if err != nil { return nil, err } @@ -37,19 +38,65 @@ func (c *Client) GetAccount(id int) (*Account, error) { } // GetAccountCurrentUser return Account of current user. -func (c *Client) GetAccountCurrentUser() (*Account, error) { +func (c *Client) GetAccountCurrentUser(ctx context.Context) (*Account, error) { var account Account - err := c.doAPI(http.MethodGet, "/api/v1/accounts/verify_credentials", nil, &account) + err := c.doAPI(ctx, http.MethodGet, "/api/v1/accounts/verify_credentials", nil, &account) if err != nil { return nil, err } return &account, nil } +// Profile is a struct for updating profiles. +type Profile struct { + // If it is nil it will not be updated. + // If it is empty, update it with empty. + DisplayName *string + Note *string + + // Set the base64 encoded character string of the image. + Avatar string + Header string +} + +// AccountUpdate updates the information of the current user. +func (c *Client) AccountUpdate(ctx context.Context, profile *Profile) (*Account, error) { + params := url.Values{} + if profile.DisplayName != nil { + params.Set("display_name", *profile.DisplayName) + } + if profile.Note != nil { + params.Set("note", *profile.Note) + } + if profile.Avatar != "" { + params.Set("avatar", profile.Avatar) + } + if profile.Header != "" { + params.Set("header", profile.Header) + } + + var account Account + err := c.doAPI(ctx, http.MethodPatch, "/api/v1/accounts/update_credentials", params, &account) + if err != nil { + return nil, err + } + return &account, nil +} + +// GetAccountStatuses return statuses by specified accuont. +func (c *Client) GetAccountStatuses(ctx context.Context, id int64) ([]*Status, error) { + var statuses []*Status + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/statuses", id), nil, &statuses) + if err != nil { + return nil, err + } + return statuses, nil +} + // GetAccountFollowers return followers list. -func (c *Client) GetAccountFollowers(id int64) ([]*Account, error) { +func (c *Client) GetAccountFollowers(ctx context.Context, id int64) ([]*Account, error) { var accounts []*Account - err := c.doAPI(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/followers", id), nil, &accounts) + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/followers", id), nil, &accounts) if err != nil { return nil, err } @@ -57,9 +104,19 @@ func (c *Client) GetAccountFollowers(id int64) ([]*Account, error) { } // GetAccountFollowing return following list. -func (c *Client) GetAccountFollowing(id int64) ([]*Account, error) { +func (c *Client) GetAccountFollowing(ctx context.Context, id int64) ([]*Account, error) { var accounts []*Account - err := c.doAPI(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/following", id), nil, &accounts) + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/following", id), nil, &accounts) + if err != nil { + return nil, err + } + return accounts, nil +} + +// GetBlocks return block list. +func (c *Client) GetBlocks(ctx context.Context) ([]*Account, error) { + var accounts []*Account + err := c.doAPI(ctx, http.MethodGet, "/api/v1/blocks", nil, &accounts) if err != nil { return nil, err } @@ -77,9 +134,9 @@ type Relationship struct { } // AccountFollow follow the account. -func (c *Client) AccountFollow(id int64) (*Relationship, error) { +func (c *Client) AccountFollow(ctx context.Context, id int64) (*Relationship, error) { var relationship Relationship - err := c.doAPI(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/follow", id), nil, &relationship) + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/follow", id), nil, &relationship) if err != nil { return nil, err } @@ -87,9 +144,9 @@ func (c *Client) AccountFollow(id int64) (*Relationship, error) { } // AccountUnfollow unfollow the account. -func (c *Client) AccountUnfollow(id int64) (*Relationship, error) { +func (c *Client) AccountUnfollow(ctx context.Context, id int64) (*Relationship, error) { var relationship Relationship - err := c.doAPI(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/unfollow", id), nil, &relationship) + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/unfollow", id), nil, &relationship) if err != nil { return nil, err } @@ -97,9 +154,9 @@ func (c *Client) AccountUnfollow(id int64) (*Relationship, error) { } // AccountBlock block the account. -func (c *Client) AccountBlock(id int64) (*Relationship, error) { +func (c *Client) AccountBlock(ctx context.Context, id int64) (*Relationship, error) { var relationship Relationship - err := c.doAPI(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/block", id), nil, &relationship) + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/block", id), nil, &relationship) if err != nil { return nil, err } @@ -107,9 +164,9 @@ func (c *Client) AccountBlock(id int64) (*Relationship, error) { } // AccountUnblock unblock the account. -func (c *Client) AccountUnblock(id int64) (*Relationship, error) { +func (c *Client) AccountUnblock(ctx context.Context, id int64) (*Relationship, error) { var relationship Relationship - err := c.doAPI(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/unblock", id), nil, &relationship) + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/unblock", id), nil, &relationship) if err != nil { return nil, err } @@ -117,9 +174,9 @@ func (c *Client) AccountUnblock(id int64) (*Relationship, error) { } // AccountMute mute the account. -func (c *Client) AccountMute(id int64) (*Relationship, error) { +func (c *Client) AccountMute(ctx context.Context, id int64) (*Relationship, error) { var relationship Relationship - err := c.doAPI(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/mute", id), nil, &relationship) + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/mute", id), nil, &relationship) if err != nil { return nil, err } @@ -127,9 +184,9 @@ func (c *Client) AccountMute(id int64) (*Relationship, error) { } // AccountUnmute unmute the account. -func (c *Client) AccountUnmute(id int64) (*Relationship, error) { +func (c *Client) AccountUnmute(ctx context.Context, id int64) (*Relationship, error) { var relationship Relationship - err := c.doAPI(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/unmute", id), nil, &relationship) + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/unmute", id), nil, &relationship) if err != nil { return nil, err } @@ -137,12 +194,12 @@ func (c *Client) AccountUnmute(id int64) (*Relationship, error) { } // GetAccountRelationship return relationship for the account. -func (c *Client) GetAccountRelationship(id int64) ([]*Relationship, error) { +func (c *Client) GetAccountRelationship(ctx context.Context, id int64) ([]*Relationship, error) { params := url.Values{} params.Set("id", fmt.Sprint(id)) var relationships []*Relationship - err := c.doAPI(http.MethodGet, "/api/v1/accounts/relationship", params, &relationships) + err := c.doAPI(ctx, http.MethodGet, "/api/v1/accounts/relationship", params, &relationships) if err != nil { return nil, err } @@ -150,26 +207,26 @@ func (c *Client) GetAccountRelationship(id int64) ([]*Relationship, error) { } // AccountsSearch search accounts by query. -func (c *Client) AccountsSearch(q string, limit int64) ([]*Account, error) { +func (c *Client) AccountsSearch(ctx context.Context, q string, limit int64) ([]*Account, error) { params := url.Values{} params.Set("q", q) params.Set("limit", fmt.Sprint(limit)) var accounts []*Account - err := c.doAPI(http.MethodGet, "/api/v1/accounts/search", params, &accounts) + err := c.doAPI(ctx, http.MethodGet, "/api/v1/accounts/search", params, &accounts) if err != nil { return nil, err } return accounts, nil } -// Follow send follow-request. -func (c *Client) FollowRemoteUser(uri string) (*Account, error) { +// FollowRemoteUser send follow-request. +func (c *Client) FollowRemoteUser(ctx context.Context, uri string) (*Account, error) { params := url.Values{} params.Set("uri", uri) var account Account - err := c.doAPI(http.MethodPost, "/api/v1/follows", params, &account) + err := c.doAPI(ctx, http.MethodPost, "/api/v1/follows", params, &account) if err != nil { return nil, err } @@ -177,14 +234,21 @@ func (c *Client) FollowRemoteUser(uri string) (*Account, error) { } // GetFollowRequests return follow-requests. -func (c *Client) GetFollowRequests(uri string) ([]*Account, error) { - params := url.Values{} - params.Set("uri", uri) - +func (c *Client) GetFollowRequests(ctx context.Context) ([]*Account, error) { var accounts []*Account - err := c.doAPI(http.MethodGet, "/api/v1/follow_requests", params, &accounts) + err := c.doAPI(ctx, http.MethodGet, "/api/v1/follow_requests", nil, &accounts) if err != nil { return nil, err } return accounts, nil } + +// FollowRequestAuthorize is authorize the follow request of user with id. +func (c *Client) FollowRequestAuthorize(ctx context.Context, id int64) error { + return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/follow_requests/%d/authorize", id), nil, nil) +} + +// FollowRequestReject is rejects the follow request of user with id. +func (c *Client) FollowRequestReject(ctx context.Context, id int64) error { + return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/follow_requests/%d/reject", id), nil, nil) +} diff --git a/accounts_test.go b/accounts_test.go index 4ee715b..e5d3497 100644 --- a/accounts_test.go +++ b/accounts_test.go @@ -1,12 +1,68 @@ package mastodon import ( + "context" "fmt" "net/http" "net/http/httptest" "testing" ) +func TestAccountUpdate(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `{"Username": "zzz"}`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + a, err := client.AccountUpdate(context.Background(), &Profile{ + DisplayName: String("display_name"), + Note: String("note"), + Avatar: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUoAAADrCAYAAAA...", + Header: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUoAAADrCAYAAAA...", + }) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if a.Username != "zzz" { + t.Fatalf("want %q but %q", "zzz", a.Username) + } +} + +func TestGetBlocks(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `[{"Username": "foo"}, {"Username": "bar"}]`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + bl, err := client.GetBlocks(context.Background()) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if len(bl) != 2 { + t.Fatalf("result should be two: %d", len(bl)) + } + if bl[0].Username != "foo" { + t.Fatalf("want %q but %q", "foo", bl[0].Username) + } + if bl[1].Username != "bar" { + t.Fatalf("want %q but %q", "bar", bl[0].Username) + } +} + func TestAccountFollow(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/accounts/1234567/follow" { @@ -24,11 +80,11 @@ func TestAccountFollow(t *testing.T) { ClientSecret: "bar", AccessToken: "zoo", }) - rel, err := client.AccountFollow(123) + rel, err := client.AccountFollow(context.Background(), 123) if err == nil { - t.Fatalf("should be fail: %v", err) + t.Fatalf("should be fail: %v", err) } - rel, err = client.AccountFollow(1234567) + rel, err = client.AccountFollow(context.Background(), 1234567) if err != nil { t.Fatalf("should not be fail: %v", err) } @@ -57,11 +113,11 @@ func TestAccountUnfollow(t *testing.T) { ClientSecret: "bar", AccessToken: "zoo", }) - rel, err := client.AccountUnfollow(123) + rel, err := client.AccountUnfollow(context.Background(), 123) if err == nil { t.Fatalf("should be fail: %v", err) } - rel, err = client.AccountUnfollow(1234567) + rel, err = client.AccountUnfollow(context.Background(), 1234567) if err != nil { t.Fatalf("should not be fail: %v", err) } @@ -72,3 +128,89 @@ func TestAccountUnfollow(t *testing.T) { t.Fatalf("want %t but %t", false, rel.Following) } } + +func TestGetFollowRequests(t *testing.T) { + canErr := true + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if canErr { + canErr = false + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + fmt.Fprintln(w, `[{"Username": "foo"}, {"Username": "bar"}]`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.GetFollowRequests(context.Background()) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + fReqs, err := client.GetFollowRequests(context.Background()) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if len(fReqs) != 2 { + t.Fatalf("result should be two: %d", len(fReqs)) + } + if fReqs[0].Username != "foo" { + t.Fatalf("want %q but %q", "foo", fReqs[0].Username) + } + if fReqs[1].Username != "bar" { + t.Fatalf("want %q but %q", "bar", fReqs[0].Username) + } +} + +func TestFollowRequestAuthorize(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/follow_requests/1234567/authorize" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + } + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + err := client.FollowRequestAuthorize(context.Background(), 123) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + err = client.FollowRequestAuthorize(context.Background(), 1234567) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } +} + +func TestFollowRequestReject(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/follow_requests/1234567/reject" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + } + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + err := client.FollowRequestReject(context.Background(), 123) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + err = client.FollowRequestReject(context.Background(), 1234567) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } +} diff --git a/apps.go b/apps.go index b5a66d2..1dd50a5 100644 --- a/apps.go +++ b/apps.go @@ -1,6 +1,7 @@ package mastodon import ( + "context" "encoding/json" "fmt" "net/http" @@ -34,7 +35,7 @@ type Application struct { } // RegisterApp returns the mastodon application. -func RegisterApp(appConfig *AppConfig) (*Application, error) { +func RegisterApp(ctx context.Context, appConfig *AppConfig) (*Application, error) { params := url.Values{} params.Set("client_name", appConfig.ClientName) if appConfig.RedirectURIs == "" { @@ -45,13 +46,13 @@ func RegisterApp(appConfig *AppConfig) (*Application, error) { params.Set("scopes", appConfig.Scopes) params.Set("website", appConfig.Website) - url, err := url.Parse(appConfig.Server) + u, err := url.Parse(appConfig.Server) if err != nil { return nil, err } - url.Path = path.Join(url.Path, "/api/v1/apps") + u.Path = path.Join(u.Path, "/api/v1/apps") - req, err := http.NewRequest(http.MethodPost, url.String(), strings.NewReader(params.Encode())) + req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(params.Encode())) if err != nil { return nil, err } diff --git a/apps_test.go b/apps_test.go new file mode 100644 index 0000000..177440e --- /dev/null +++ b/apps_test.go @@ -0,0 +1,43 @@ +package mastodon + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRegisterApp(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + if r.URL.Path != "/api/v1/apps" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + if r.FormValue("redirect_uris") != "urn:ietf:wg:oauth:2.0:oob" { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + fmt.Fprintln(w, `{"client_id": "foo", "client_secret": "bar"}`) + return + })) + defer ts.Close() + + app, err := RegisterApp(context.Background(), &AppConfig{ + Server: ts.URL, + Scopes: "read write follow", + }) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if app.ClientID != "foo" { + t.Fatalf("want %q but %q", "foo", app.ClientID) + } + if app.ClientSecret != "bar" { + t.Fatalf("want %q but %q", "bar", app.ClientSecret) + } +} diff --git a/cmd/mstdn/README.md b/cmd/mstdn/README.md index 753aab2..5e3b917 100644 --- a/cmd/mstdn/README.md +++ b/cmd/mstdn/README.md @@ -5,12 +5,24 @@ command line tool for mstdn.jp ## Usage ``` -Usage of mstdn: - -S streaming public - -ff string - post utf-8 string from a file("-" means STDIN) - -t string - toot text +NAME: + mstdn - mastodon client + +USAGE: + mstdn [global options] command [command options] [arguments...] + +VERSION: + 0.0.1 + +COMMANDS: + toot post toot + stream stream statuses + timeline show timeline + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + --help, -h show help + --version, -v print the version ``` ## Installation diff --git a/cmd/mstdn/cmd_account.go b/cmd/mstdn/cmd_account.go new file mode 100644 index 0000000..ceff781 --- /dev/null +++ b/cmd/mstdn/cmd_account.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "fmt" + + "github.com/mattn/go-mastodon" + "github.com/urfave/cli" +) + +func cmdAccount(c *cli.Context) error { + client := c.App.Metadata["client"].(*mastodon.Client) + account, err := client.GetAccountCurrentUser(context.Background()) + if err != nil { + return err + } + fmt.Fprintf(c.App.Writer, "URI : %v\n", account.Acct) + fmt.Fprintf(c.App.Writer, "ID : %v\n", account.ID) + fmt.Fprintf(c.App.Writer, "Username : %v\n", account.Username) + fmt.Fprintf(c.App.Writer, "Acct : %v\n", account.Acct) + fmt.Fprintf(c.App.Writer, "DisplayName : %v\n", account.DisplayName) + fmt.Fprintf(c.App.Writer, "Locked : %v\n", account.Locked) + fmt.Fprintf(c.App.Writer, "CreatedAt : %v\n", account.CreatedAt.Local()) + fmt.Fprintf(c.App.Writer, "FollowersCount: %v\n", account.FollowersCount) + fmt.Fprintf(c.App.Writer, "FollowingCount: %v\n", account.FollowingCount) + fmt.Fprintf(c.App.Writer, "StatusesCount : %v\n", account.StatusesCount) + fmt.Fprintf(c.App.Writer, "Note : %v\n", account.Note) + fmt.Fprintf(c.App.Writer, "URL : %v\n", account.URL) + return nil +} diff --git a/cmd/mstdn/cmd_account_test.go b/cmd/mstdn/cmd_account_test.go new file mode 100644 index 0000000..8894451 --- /dev/null +++ b/cmd/mstdn/cmd_account_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/urfave/cli" +) + +func TestCmdAccount(t *testing.T) { + out := testWithServer( + func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/accounts/verify_credentials": + fmt.Fprintln(w, `{"username": "zzz"}`) + return + } + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + }, + func(app *cli.App) { + app.Run([]string{"mstdn", "account"}) + }, + ) + if !strings.Contains(out, "zzz") { + t.Fatalf("%q should be contained in output of command: %v", "zzz", out) + } +} diff --git a/cmd/mstdn/cmd_followers.go b/cmd/mstdn/cmd_followers.go new file mode 100644 index 0000000..20fc7f2 --- /dev/null +++ b/cmd/mstdn/cmd_followers.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "fmt" + + "github.com/mattn/go-mastodon" + "github.com/urfave/cli" +) + +func cmdFollowers(c *cli.Context) error { + client := c.App.Metadata["client"].(*mastodon.Client) + account, err := client.GetAccountCurrentUser(context.Background()) + if err != nil { + return err + } + followers, err := client.GetAccountFollowers(context.Background(), account.ID) + if err != nil { + return err + } + for _, follower := range followers { + fmt.Fprintf(c.App.Writer, "%v,%v\n", follower.ID, follower.Username) + } + return nil +} diff --git a/cmd/mstdn/cmd_followers_test.go b/cmd/mstdn/cmd_followers_test.go new file mode 100644 index 0000000..5518ccf --- /dev/null +++ b/cmd/mstdn/cmd_followers_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/urfave/cli" +) + +func TestCmdFollowers(t *testing.T) { + out := testWithServer( + func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/accounts/verify_credentials": + fmt.Fprintln(w, `{"id": 123}`) + return + case "/api/v1/accounts/123/followers": + fmt.Fprintln(w, `[{"id": 234, "username": "zzz"}]`) + return + } + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + }, + func(app *cli.App) { + app.Run([]string{"mstdn", "followers"}) + }, + ) + if !strings.Contains(out, "zzz") { + t.Fatalf("%q should be contained in output of command: %v", "zzz", out) + } +} diff --git a/cmd/mstdn/cmd_instance.go b/cmd/mstdn/cmd_instance.go new file mode 100644 index 0000000..b287efc --- /dev/null +++ b/cmd/mstdn/cmd_instance.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "fmt" + + "github.com/mattn/go-mastodon" + "github.com/urfave/cli" +) + +func cmdInstance(c *cli.Context) error { + client := c.App.Metadata["client"].(*mastodon.Client) + instance, err := client.GetInstance(context.Background()) + if err != nil { + return err + } + fmt.Fprintf(c.App.Writer, "URI : %s\n", instance.URI) + fmt.Fprintf(c.App.Writer, "Title : %s\n", instance.Title) + fmt.Fprintf(c.App.Writer, "Description: %s\n", instance.Description) + fmt.Fprintf(c.App.Writer, "EMail : %s\n", instance.EMail) + return nil +} diff --git a/cmd/mstdn/cmd_instance_test.go b/cmd/mstdn/cmd_instance_test.go new file mode 100644 index 0000000..f5a6279 --- /dev/null +++ b/cmd/mstdn/cmd_instance_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/urfave/cli" +) + +func TestCmdInstance(t *testing.T) { + out := testWithServer( + func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/instance": + fmt.Fprintln(w, `{"title": "zzz"}`) + return + } + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + }, + func(app *cli.App) { + app.Run([]string{"mstdn", "instance"}) + }, + ) + if !strings.Contains(out, "zzz") { + t.Fatalf("%q should be contained in output of command: %v", "zzz", out) + } +} diff --git a/cmd/mstdn/cmd_notification.go b/cmd/mstdn/cmd_notification.go new file mode 100644 index 0000000..6936751 --- /dev/null +++ b/cmd/mstdn/cmd_notification.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "fmt" + + "github.com/fatih/color" + "github.com/mattn/go-mastodon" + "github.com/urfave/cli" +) + +func cmdNotification(c *cli.Context) error { + client := c.App.Metadata["client"].(*mastodon.Client) + notifications, err := client.GetNotifications(context.Background()) + if err != nil { + return err + } + for _, n := range notifications { + if n.Status != nil { + color.Set(color.FgHiRed) + fmt.Fprint(c.App.Writer, n.Account.Username) + color.Set(color.Reset) + fmt.Fprintln(c.App.Writer, " "+n.Type) + s := n.Status + fmt.Fprintln(c.App.Writer, textContent(s.Content)) + } + } + return nil +} diff --git a/cmd/mstdn/cmd_notification_test.go b/cmd/mstdn/cmd_notification_test.go new file mode 100644 index 0000000..25a9429 --- /dev/null +++ b/cmd/mstdn/cmd_notification_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/urfave/cli" +) + +func TestCmdNotification(t *testing.T) { + out := testWithServer( + func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/notifications": + fmt.Fprintln(w, `[{"type": "rebloged", "status": {"content": "foo"}}]`) + return + } + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + }, + func(app *cli.App) { + app.Run([]string{"mstdn", "notification"}) + }, + ) + if !strings.Contains(out, "rebloged") { + t.Fatalf("%q should be contained in output of command: %v", "rebloged", out) + } +} diff --git a/cmd/mstdn/cmd_search.go b/cmd/mstdn/cmd_search.go new file mode 100644 index 0000000..5cd754b --- /dev/null +++ b/cmd/mstdn/cmd_search.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "github.com/mattn/go-mastodon" + "github.com/urfave/cli" +) + +func cmdSearch(c *cli.Context) error { + if !c.Args().Present() { + return errors.New("arguments required") + } + + client := c.App.Metadata["client"].(*mastodon.Client) + results, err := client.Search(context.Background(), argstr(c), false) + if err != nil { + return err + } + for _, result := range results.Accounts { + fmt.Fprintln(c.App.Writer, result) + } + for _, result := range results.Statuses { + fmt.Fprintln(c.App.Writer, result) + } + for _, result := range results.Hashtags { + fmt.Fprintln(c.App.Writer, result) + } + return nil +} diff --git a/cmd/mstdn/cmd_search_test.go b/cmd/mstdn/cmd_search_test.go new file mode 100644 index 0000000..f498592 --- /dev/null +++ b/cmd/mstdn/cmd_search_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/urfave/cli" +) + +func TestCmdSearch(t *testing.T) { + out := testWithServer( + func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/search": + fmt.Fprintln(w, `{"accounts": [{"id": 234, "username": "zzz"}], "contents":[], "hashtags": []}`) + return + } + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + }, + func(app *cli.App) { + app.Run([]string{"mstdn", "search", "zzz"}) + }, + ) + if !strings.Contains(out, "zzz") { + t.Fatalf("%q should be contained in output of command: %v", "zzz", out) + } +} diff --git a/cmd/mstdn/cmd_stream.go b/cmd/mstdn/cmd_stream.go new file mode 100644 index 0000000..50aba7d --- /dev/null +++ b/cmd/mstdn/cmd_stream.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/fatih/color" + "github.com/mattn/go-mastodon" + "github.com/urfave/cli" +) + +func cmdStream(c *cli.Context) error { + client := c.App.Metadata["client"].(*mastodon.Client) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sc := make(chan os.Signal, 1) + signal.Notify(sc, os.Interrupt) + q, err := client.StreamingPublic(ctx) + if err != nil { + return err + } + go func() { + <-sc + cancel() + close(q) + }() + for e := range q { + switch t := e.(type) { + case *mastodon.UpdateEvent: + color.Set(color.FgHiRed) + fmt.Fprintln(c.App.Writer, t.Status.Account.Username) + color.Set(color.Reset) + fmt.Fprintln(c.App.Writer, textContent(t.Status.Content)) + case *mastodon.ErrorEvent: + color.Set(color.FgYellow) + fmt.Fprintln(c.App.Writer, t.Error()) + color.Set(color.Reset) + } + } + return nil +} diff --git a/cmd/mstdn/cmd_test.go b/cmd/mstdn/cmd_test.go new file mode 100644 index 0000000..1283922 --- /dev/null +++ b/cmd/mstdn/cmd_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + + "github.com/mattn/go-mastodon" + "github.com/urfave/cli" +) + +func testWithServer(h http.HandlerFunc, testFunc func(*cli.App)) string { + ts := httptest.NewServer(h) + defer ts.Close() + + client := mastodon.NewClient(&mastodon.Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + + var buf bytes.Buffer + app := makeApp() + app.Writer = &buf + app.Metadata = map[string]interface{}{ + "client": client, + } + testFunc(app) + return buf.String() +} diff --git a/cmd/mstdn/cmd_timeline.go b/cmd/mstdn/cmd_timeline.go new file mode 100644 index 0000000..26f2142 --- /dev/null +++ b/cmd/mstdn/cmd_timeline.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "fmt" + + "github.com/fatih/color" + "github.com/mattn/go-mastodon" + "github.com/urfave/cli" +) + +func cmdTimeline(c *cli.Context) error { + client := c.App.Metadata["client"].(*mastodon.Client) + timeline, err := client.GetTimelineHome(context.Background()) + if err != nil { + return err + } + for i := len(timeline) - 1; i >= 0; i-- { + t := timeline[i] + color.Set(color.FgHiRed) + fmt.Fprintln(c.App.Writer, t.Account.Username) + color.Set(color.Reset) + fmt.Fprintln(c.App.Writer, textContent(t.Content)) + } + return nil +} diff --git a/cmd/mstdn/cmd_timeline_test.go b/cmd/mstdn/cmd_timeline_test.go new file mode 100644 index 0000000..b0801a8 --- /dev/null +++ b/cmd/mstdn/cmd_timeline_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/urfave/cli" +) + +func TestCmdTimeline(t *testing.T) { + out := testWithServer( + func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/timelines/home": + fmt.Fprintln(w, `[{"content": "zzz"}]`) + return + } + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + }, + func(app *cli.App) { + app.Run([]string{"mstdn", "timeline"}) + }, + ) + if !strings.Contains(out, "zzz") { + t.Fatalf("%q should be contained in output of command: %v", "zzz", out) + } +} diff --git a/cmd/mstdn/cmd_toot.go b/cmd/mstdn/cmd_toot.go new file mode 100644 index 0000000..9a82534 --- /dev/null +++ b/cmd/mstdn/cmd_toot.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "errors" + "log" + + "github.com/mattn/go-mastodon" + "github.com/urfave/cli" +) + +func cmdToot(c *cli.Context) error { + var toot string + ff := c.String("ff") + if ff != "" { + text, err := readFile(ff) + if err != nil { + log.Fatal(err) + } + toot = string(text) + } else { + if !c.Args().Present() { + return errors.New("arguments required") + } + toot = argstr(c) + } + client := c.App.Metadata["client"].(*mastodon.Client) + _, err := client.PostStatus(context.Background(), &mastodon.Toot{ + Status: toot, + }) + return err +} diff --git a/cmd/mstdn/cmd_toot_test.go b/cmd/mstdn/cmd_toot_test.go new file mode 100644 index 0000000..8821536 --- /dev/null +++ b/cmd/mstdn/cmd_toot_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "net/http" + "testing" + + "github.com/urfave/cli" +) + +func TestCmdToot(t *testing.T) { + toot := "" + testWithServer( + func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/statuses": + toot = r.FormValue("status") + fmt.Fprintln(w, `{"ID": 2345}`) + return + } + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + }, + func(app *cli.App) { + app.Run([]string{"mstdn", "toot", "foo"}) + }, + ) + if toot != "foo" { + t.Fatalf("want %q, got %q", "foo", toot) + } +} diff --git a/cmd/mstdn/main.go b/cmd/mstdn/main.go index ab0cd4f..a3a3d15 100644 --- a/cmd/mstdn/main.go +++ b/cmd/mstdn/main.go @@ -5,28 +5,19 @@ import ( "bytes" "context" "encoding/json" - "flag" "fmt" "io/ioutil" - "log" "os" - "os/signal" "path/filepath" "runtime" "strings" - "github.com/fatih/color" "github.com/mattn/go-mastodon" "github.com/mattn/go-tty" + "github.com/urfave/cli" "golang.org/x/net/html" ) -var ( - toot = flag.String("t", "", "toot text") - stream = flag.Bool("S", false, "streaming public") - fromfile = flag.String("ff", "", "post utf-8 string from a file(\"-\" means STDIN)") -) - func readFile(filename string) ([]byte, error) { if filename == "-" { return ioutil.ReadAll(os.Stdin) @@ -37,7 +28,7 @@ func readFile(filename string) ([]byte, error) { func textContent(s string) string { doc, err := html.Parse(strings.NewReader(s)) if err != nil { - log.Fatal(err) + return s } var buf bytes.Buffer @@ -64,7 +55,7 @@ func textContent(s string) string { } var ( - readUsername func() (string, error) = func() (string, error) { + readUsername = func() (string, error) { b, _, err := bufio.NewReader(os.Stdin).ReadLine() if err != nil { return "", err @@ -121,8 +112,8 @@ func getConfig() (string, *mastodon.Config, error) { } config := &mastodon.Config{ Server: "https://mstdn.jp", - ClientID: "7d1873f3940af3e9128c81d5a2ddb3f235ccfa1cd11761efd3b8426f40898fe8", - ClientSecret: "3c8ea997c580f196453e97c1c58f6f5c131f668456bbe1ed37aaccac719397db", + ClientID: "171d45f22068a5dddbd927b9d966f5b97971ed1d3256b03d489f5b3a83cdba59", + ClientSecret: "574a2cf4b3f28a5fa0cfd285fc80cfe9daa419945163ef18f5f3d0022f4add28", } if err == nil { err = json.Unmarshal(b, &config) @@ -133,109 +124,119 @@ func getConfig() (string, *mastodon.Config, error) { return file, config, nil } -func authenticate(client *mastodon.Client, config *mastodon.Config, file string) { +func authenticate(client *mastodon.Client, config *mastodon.Config, file string) error { email, password, err := prompt() if err != nil { - log.Fatal(err) + return err } - err = client.Authenticate(email, password) + err = client.Authenticate(context.Background(), email, password) if err != nil { - log.Fatal(err) + return err } b, err := json.MarshalIndent(config, "", " ") if err != nil { - log.Fatal("failed to store file:", err) + return fmt.Errorf("failed to store file: %v", err) } err = ioutil.WriteFile(file, b, 0700) if err != nil { - log.Fatal("failed to store file:", err) + return fmt.Errorf("failed to store file: %v", err) } + return nil } -func streaming(client *mastodon.Client) { - ctx, cancel := context.WithCancel(context.Background()) - sc := make(chan os.Signal, 1) - signal.Notify(sc, os.Interrupt) - q, err := client.StreamingPublic(ctx) - if err != nil { - log.Fatal(err) - } - go func() { - <-sc - cancel() - close(q) - }() - for e := range q { - switch t := e.(type) { - case *mastodon.UpdateEvent: - color.Set(color.FgHiRed) - fmt.Println(t.Status.Account.Username) - color.Set(color.Reset) - fmt.Println(textContent(t.Status.Content)) - case *mastodon.ErrorEvent: - color.Set(color.FgYellow) - fmt.Println(t.Error()) - color.Set(color.Reset) - } +func argstr(c *cli.Context) string { + a := []string{} + for i := 0; i < c.NArg(); i++ { + a = append(a, c.Args().Get(i)) } + return strings.Join(a, " ") } -func init() { - flag.Parse() - if *fromfile != "" { - text, err := readFile(*fromfile) - if err != nil { - log.Fatal(err) - } - *toot = string(text) +func fatalIf(err error) { + if err == nil { + return } + fmt.Fprintf(os.Stderr, "%s: %v\n", os.Args[0], err) + os.Exit(1) } -func post(client *mastodon.Client, text string) { - _, err := client.PostStatus(&mastodon.Toot{ - Status: text, - }) - if err != nil { - log.Fatal(err) +func makeApp() *cli.App { + app := cli.NewApp() + app.Name = "mstdn" + app.Usage = "mastodon client" + app.Version = "0.0.1" + app.Commands = []cli.Command{ + { + Name: "toot", + Usage: "post toot", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "ff", + Usage: "post utf-8 string from a file(\"-\" means STDIN)", + Value: "", + }, + }, + Action: cmdToot, + }, + { + Name: "stream", + Usage: "stream statuses", + Action: cmdStream, + }, + { + Name: "timeline", + Usage: "show timeline", + Action: cmdTimeline, + }, + { + Name: "notification", + Usage: "show notification", + Action: cmdNotification, + }, + { + Name: "instance", + Usage: "show instance information", + Action: cmdInstance, + }, + { + Name: "account", + Usage: "show account information", + Action: cmdAccount, + }, + { + Name: "search", + Usage: "search content", + Action: cmdSearch, + }, + { + Name: "followers", + Usage: "show followers", + Action: cmdFollowers, + }, } + return app } -func timeline(client *mastodon.Client) { - timeline, err := client.GetTimelineHome() - if err != nil { - log.Fatal(err) +func run() int { + app := makeApp() + + file, config, err := getConfig() + fatalIf(err) + + client := mastodon.NewClient(config) + if config.AccessToken == "" { + err = authenticate(client, config, file) + fatalIf(err) } - for i := len(timeline) - 1; i >= 0; i-- { - t := timeline[i] - color.Set(color.FgHiRed) - fmt.Println(t.Account.Username) - color.Set(color.Reset) - fmt.Println(textContent(t.Content)) + app.Metadata = map[string]interface{}{ + "client": client, + "config": config, } + + app.Run(os.Args) + return 0 } func main() { - file, config, err := getConfig() - if err != nil { - log.Fatal(err) - } - - client := mastodon.NewClient(config) - - if config.AccessToken == "" { - authenticate(client, config, file) - return - } - - if *toot != "" { - post(client, *toot) - return - } - - if *stream { - streaming(client) - return - } - - timeline(client) + os.Exit(run()) } diff --git a/helper.go b/helper.go new file mode 100644 index 0000000..c9fefe9 --- /dev/null +++ b/helper.go @@ -0,0 +1,38 @@ +package mastodon + +import ( + "encoding/base64" + "net/http" + "os" +) + +// Base64EncodeFileName returns the base64 data URI format string of the file with the file name. +func Base64EncodeFileName(filename string) (string, error) { + file, err := os.Open(filename) + if err != nil { + return "", err + } + defer file.Close() + + return Base64Encode(file) +} + +// Base64Encode returns the base64 data URI format string of the file. +func Base64Encode(file *os.File) (string, error) { + fi, err := file.Stat() + if err != nil { + return "", err + } + + d := make([]byte, fi.Size()) + _, err = file.Read(d) + if err != nil { + return "", err + } + + return "data:" + http.DetectContentType(d) + + ";base64," + base64.StdEncoding.EncodeToString(d), nil +} + +// String is a helper function to get the pointer value of a string. +func String(v string) *string { return &v } diff --git a/helper_test.go b/helper_test.go new file mode 100644 index 0000000..a4a6429 --- /dev/null +++ b/helper_test.go @@ -0,0 +1,13 @@ +package mastodon + +import ( + "testing" +) + +func TestString(t *testing.T) { + s := "test" + sp := String(s) + if *sp != s { + t.Fatalf("want %q but %q", s, *sp) + } +} diff --git a/instance.go b/instance.go index 60e82dc..b84b77b 100644 --- a/instance.go +++ b/instance.go @@ -1,5 +1,10 @@ package mastodon +import ( + "context" + "net/http" +) + // Instance hold information for mastodon instance. type Instance struct { URI string `json:"uri"` @@ -9,9 +14,9 @@ type Instance struct { } // GetInstance return Instance. -func (c *Client) GetInstance() (*Instance, error) { +func (c *Client) GetInstance(ctx context.Context) (*Instance, error) { var instance Instance - err := c.doAPI("GET", "/api/v1/instance", nil, &instance) + err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance", nil, &instance) if err != nil { return nil, err } diff --git a/mastodon.go b/mastodon.go index b49da71..277e269 100644 --- a/mastodon.go +++ b/mastodon.go @@ -1,6 +1,7 @@ package mastodon import ( + "context" "encoding/json" "fmt" "net/http" @@ -23,32 +24,34 @@ type Client struct { config *Config } -func (c *Client) doAPI(method string, uri string, params url.Values, res interface{}) error { - url, err := url.Parse(c.config.Server) +func (c *Client) doAPI(ctx context.Context, method string, uri string, params url.Values, res interface{}) error { + u, err := url.Parse(c.config.Server) if err != nil { return err } - url.Path = path.Join(url.Path, uri) + u.Path = path.Join(u.Path, uri) - var resp *http.Response - req, err := http.NewRequest(method, url.String(), strings.NewReader(params.Encode())) + req, err := http.NewRequest(method, u.String(), strings.NewReader(params.Encode())) if err != nil { return err } + req.WithContext(ctx) req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) - resp, err = c.Do(req) + if params != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + resp, err := c.Do(req) if err != nil { return err } defer resp.Body.Close() - if res == nil { + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad request: %v", resp.Status) + } else if res == nil { return nil } - - if method == http.MethodGet && resp.StatusCode != http.StatusOK { - return fmt.Errorf("bad request: %v", resp.Status) - } - return json.NewDecoder(resp.Body).Decode(&res) } @@ -61,7 +64,7 @@ func NewClient(config *Config) *Client { } // Authenticate get access-token to the API. -func (c *Client) Authenticate(username, password string) error { +func (c *Client) Authenticate(ctx context.Context, username, password string) error { params := url.Values{} params.Set("client_id", c.config.ClientID) params.Set("client_secret", c.config.ClientSecret) @@ -70,13 +73,13 @@ func (c *Client) Authenticate(username, password string) error { params.Set("password", password) params.Set("scope", "read write follow") - url, err := url.Parse(c.config.Server) + u, err := url.Parse(c.config.Server) if err != nil { return err } - url.Path = path.Join(url.Path, "/oauth/token") + u.Path = path.Join(u.Path, "/oauth/token") - req, err := http.NewRequest(http.MethodPost, url.String(), strings.NewReader(params.Encode())) + req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(params.Encode())) if err != nil { return err } @@ -135,3 +138,10 @@ type Attachment struct { PreviewURL string `json:"preview_url"` TextURL string `json:"text_url"` } + +// Results hold information for search result. +type Results struct { + Accounts []*Account `json:"accounts"` + Statuses []*Status `json:"statuses"` + Hashtags []string `json:"hashtags"` +} diff --git a/mastodon_test.go b/mastodon_test.go index bc020f2..843df4c 100644 --- a/mastodon_test.go +++ b/mastodon_test.go @@ -7,7 +7,6 @@ import ( "net/http" "net/http/httptest" "testing" - "time" ) func TestAuthenticate(t *testing.T) { @@ -26,7 +25,7 @@ func TestAuthenticate(t *testing.T) { ClientID: "foo", ClientSecret: "bar", }) - err := client.Authenticate("invalid", "user") + err := client.Authenticate(context.Background(), "invalid", "user") if err == nil { t.Fatalf("should be fail: %v", err) } @@ -36,7 +35,7 @@ func TestAuthenticate(t *testing.T) { ClientID: "foo", ClientSecret: "bar", }) - err = client.Authenticate("valid", "user") + err = client.Authenticate(context.Background(), "valid", "user") if err != nil { t.Fatalf("should not be fail: %v", err) } @@ -58,7 +57,7 @@ func TestPostStatus(t *testing.T) { ClientID: "foo", ClientSecret: "bar", }) - _, err := client.PostStatus(&Toot{ + _, err := client.PostStatus(context.Background(), &Toot{ Status: "foobar", }) if err == nil { @@ -71,7 +70,7 @@ func TestPostStatus(t *testing.T) { ClientSecret: "bar", AccessToken: "zoo", }) - _, err = client.PostStatus(&Toot{ + _, err = client.PostStatus(context.Background(), &Toot{ Status: "foobar", }) if err != nil { @@ -91,7 +90,7 @@ func TestGetTimelineHome(t *testing.T) { ClientID: "foo", ClientSecret: "bar", }) - _, err := client.PostStatus(&Toot{ + _, err := client.PostStatus(context.Background(), &Toot{ Status: "foobar", }) if err == nil { @@ -104,7 +103,7 @@ func TestGetTimelineHome(t *testing.T) { ClientSecret: "bar", AccessToken: "zoo", }) - tl, err := client.GetTimelineHome() + tl, err := client.GetTimelineHome(context.Background()) if err != nil { t.Fatalf("should not be fail: %v", err) } @@ -144,11 +143,11 @@ func TestGetAccount(t *testing.T) { ClientSecret: "bar", AccessToken: "zoo", }) - a, err := client.GetAccount(1) + a, err := client.GetAccount(context.Background(), 1) if err == nil { t.Fatalf("should not be fail: %v", err) } - a, err = client.GetAccount(1234567) + a, err = client.GetAccount(context.Background(), 1234567) if err != nil { t.Fatalf("should not be fail: %v", err) } @@ -174,11 +173,11 @@ func TestGetAccountFollowing(t *testing.T) { ClientSecret: "bar", AccessToken: "zoo", }) - fl, err := client.GetAccountFollowing(123) + fl, err := client.GetAccountFollowing(context.Background(), 123) if err == nil { t.Fatalf("should not be fail: %v", err) } - fl, err = client.GetAccountFollowing(1234567) + fl, err = client.GetAccountFollowing(context.Background(), 1234567) if err != nil { t.Fatalf("should not be fail: %v", err) } @@ -192,89 +191,3 @@ func TestGetAccountFollowing(t *testing.T) { t.Fatalf("want %q but %q", "bar", fl[0].Username) } } - -func TestRegisterApp(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - if r.URL.Path != "/api/v1/apps" { - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - } - if r.FormValue("redirect_uris") != "urn:ietf:wg:oauth:2.0:oob" { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - fmt.Fprintln(w, `{"client_id": "foo", "client_secret": "bar"}`) - return - })) - defer ts.Close() - - app, err := RegisterApp(&AppConfig{ - Server: ts.URL, - Scopes: "read write follow", - }) - if err != nil { - t.Fatalf("should not be fail: %v", err) - } - if app.ClientID != "foo" { - t.Fatalf("want %q but %q", "foo", app.ClientID) - } - if app.ClientSecret != "bar" { - t.Fatalf("want %q but %q", "bar", app.ClientSecret) - } -} - -func TestStreamingPublic(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/streaming/public" { - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - } - f, _ := w.(http.Flusher) - fmt.Fprintln(w, ` -event: update -data: {"Content": "foo"} - `) - f.Flush() - - fmt.Fprintln(w, ` -event: update -data: {"Content": "bar"} - `) - f.Flush() - return - })) - defer ts.Close() - - client := NewClient(&Config{ - Server: ts.URL, - ClientID: "foo", - ClientSecret: "bar", - AccessToken: "zoo", - }) - ctx, cancel := context.WithCancel(context.Background()) - q, err := client.StreamingPublic(ctx) - if err != nil { - t.Fatalf("should not be fail: %v", err) - } - time.AfterFunc(3*time.Second, func() { - cancel() - close(q) - }) - events := []Event{} - for e := range q { - events = append(events, e) - } - if len(events) != 2 { - t.Fatalf("result should be two: %d", len(events)) - } - if events[0].(*UpdateEvent).Status.Content != "foo" { - t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content) - } - if events[1].(*UpdateEvent).Status.Content != "bar" { - t.Fatalf("want %q but %q", "bar", events[1].(*UpdateEvent).Status.Content) - } -} diff --git a/notification.go b/notification.go index 44ffdd8..5664adc 100644 --- a/notification.go +++ b/notification.go @@ -1,6 +1,7 @@ package mastodon import ( + "context" "fmt" "net/http" "time" @@ -16,9 +17,9 @@ type Notification struct { } // GetNotifications return notifications. -func (c *Client) GetNotifications() ([]*Notification, error) { +func (c *Client) GetNotifications(ctx context.Context) ([]*Notification, error) { var notifications []*Notification - err := c.doAPI(http.MethodGet, "/api/v1/notifications", nil, ¬ifications) + err := c.doAPI(ctx, http.MethodGet, "/api/v1/notifications", nil, ¬ifications) if err != nil { return nil, err } @@ -26,9 +27,9 @@ func (c *Client) GetNotifications() ([]*Notification, error) { } // GetNotifications return notification. -func (c *Client) GetNotification(id int64) (*Notification, error) { +func (c *Client) GetNotification(ctx context.Context, id int64) (*Notification, error) { var notification Notification - err := c.doAPI(http.MethodGet, fmt.Sprintf("/api/v1/notifications/%d", id), nil, ¬ification) + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/notifications/%d", id), nil, ¬ification) if err != nil { return nil, err } @@ -36,6 +37,6 @@ func (c *Client) GetNotification(id int64) (*Notification, error) { } // ClearNotifications clear notifications. -func (c *Client) ClearNotifications() error { - return c.doAPI(http.MethodPost, "/api/v1/notifications/clear", nil, nil) +func (c *Client) ClearNotifications(ctx context.Context) error { + return c.doAPI(ctx, http.MethodPost, "/api/v1/notifications/clear", nil, nil) } diff --git a/status.go b/status.go index b597fe5..104de1f 100644 --- a/status.go +++ b/status.go @@ -1,6 +1,7 @@ package mastodon import ( + "context" "fmt" "net/http" "net/url" @@ -33,8 +34,8 @@ type Status struct { // Context hold information for mastodon context. type Context struct { - Ancestors []*Status `ancestors` - Descendants []*Status `descendants` + Ancestors []*Status `json:"ancestors"` + Descendants []*Status `json:"descendants"` } // Card hold information for mastodon card. @@ -45,10 +46,20 @@ type Card struct { Image string `json:"image"` } +// GetFavourites return the favorite list of the current user. +func (c *Client) GetFavourites(ctx context.Context) ([]*Status, error) { + var statuses []*Status + err := c.doAPI(ctx, http.MethodGet, "/api/v1/favourites", nil, &statuses) + if err != nil { + return nil, err + } + return statuses, nil +} + // GetStatus return status specified by id. -func (c *Client) GetStatus(id string) (*Status, error) { +func (c *Client) GetStatus(ctx context.Context, id int64) (*Status, error) { var status Status - err := c.doAPI(http.MethodGet, fmt.Sprintf("/api/v1/statuses/%d", id), nil, &status) + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%d", id), nil, &status) if err != nil { return nil, err } @@ -56,9 +67,9 @@ func (c *Client) GetStatus(id string) (*Status, error) { } // GetStatusContext return status specified by id. -func (c *Client) GetStatusContext(id string) (*Context, error) { +func (c *Client) GetStatusContext(ctx context.Context, id int64) (*Context, error) { var context Context - err := c.doAPI(http.MethodGet, fmt.Sprintf("/api/v1/statuses/%d/context", id), nil, &context) + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%d/context", id), nil, &context) if err != nil { return nil, err } @@ -66,19 +77,89 @@ func (c *Client) GetStatusContext(id string) (*Context, error) { } // GetStatusCard return status specified by id. -func (c *Client) GetStatusCard(id string) (*Card, error) { +func (c *Client) GetStatusCard(ctx context.Context, id int64) (*Card, error) { var card Card - err := c.doAPI(http.MethodGet, fmt.Sprintf("/api/v1/statuses/%d/card", id), nil, &card) + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%d/card", id), nil, &card) if err != nil { return nil, err } return &card, nil } +// GetRebloggedBy returns the account list of the user who reblogged the toot of id. +func (c *Client) GetRebloggedBy(ctx context.Context, id int64) ([]*Account, error) { + var accounts []*Account + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%d/reblogged_by", id), nil, &accounts) + if err != nil { + return nil, err + } + return accounts, nil +} + +// GetFavouritedBy returns the account list of the user who liked the toot of id. +func (c *Client) GetFavouritedBy(ctx context.Context, id int64) ([]*Account, error) { + var accounts []*Account + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%d/favourited_by", id), nil, &accounts) + if err != nil { + return nil, err + } + return accounts, nil +} + +// Reblog is reblog the toot of id and return status of reblog. +func (c *Client) Reblog(ctx context.Context, id int64) (*Status, error) { + var status Status + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%d/reblog", id), nil, &status) + if err != nil { + return nil, err + } + return &status, nil +} + +// Unreblog is unreblog the toot of id and return status of the original toot. +func (c *Client) Unreblog(ctx context.Context, id int64) (*Status, error) { + var status Status + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%d/unreblog", id), nil, &status) + if err != nil { + return nil, err + } + return &status, nil +} + +// Favourite is favourite the toot of id and return status of the favourite toot. +func (c *Client) Favourite(ctx context.Context, id int64) (*Status, error) { + var status Status + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%d/favourite", id), nil, &status) + if err != nil { + return nil, err + } + return &status, nil +} + +// Unfavourite is unfavourite the toot of id and return status of the unfavourite toot. +func (c *Client) Unfavourite(ctx context.Context, id int64) (*Status, error) { + var status Status + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%d/unfavourite", id), nil, &status) + if err != nil { + return nil, err + } + return &status, nil +} + // GetTimelineHome return statuses from home timeline. -func (c *Client) GetTimelineHome() ([]*Status, error) { +func (c *Client) GetTimelineHome(ctx context.Context) ([]*Status, error) { var statuses []*Status - err := c.doAPI(http.MethodGet, "/api/v1/timelines/home", nil, &statuses) + err := c.doAPI(ctx, http.MethodGet, "/api/v1/timelines/home", nil, &statuses) + if err != nil { + return nil, err + } + return statuses, nil +} + +// GetTimelineHashtag return statuses from tagged timeline. +func (c *Client) GetTimelineHashtag(ctx context.Context, tag string) ([]*Status, error) { + var statuses []*Status + err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/timelines/tag/%s", (&url.URL{Path: tag}).EscapedPath()), nil, &statuses) if err != nil { return nil, err } @@ -86,7 +167,7 @@ func (c *Client) GetTimelineHome() ([]*Status, error) { } // PostStatus post the toot. -func (c *Client) PostStatus(toot *Toot) (*Status, error) { +func (c *Client) PostStatus(ctx context.Context, toot *Toot) (*Status, error) { params := url.Values{} params.Set("status", toot.Status) if toot.InReplyToID > 0 { @@ -96,9 +177,27 @@ func (c *Client) PostStatus(toot *Toot) (*Status, error) { //params.Set("visibility", "public") var status Status - err := c.doAPI(http.MethodPost, "/api/v1/statuses", params, &status) + err := c.doAPI(ctx, http.MethodPost, "/api/v1/statuses", params, &status) if err != nil { return nil, err } return &status, nil } + +// DeleteStatus delete the toot. +func (c *Client) DeleteStatus(ctx context.Context, id int64) error { + return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/statuses/%d", id), nil, nil) +} + +// Search search content with query. +func (c *Client) Search(ctx context.Context, q string, resolve bool) (*Results, error) { + params := url.Values{} + params.Set("q", q) + params.Set("resolve", fmt.Sprint(resolve)) + var results Results + err := c.doAPI(ctx, http.MethodGet, "/api/v1/search", params, &results) + if err != nil { + return nil, err + } + return &results, nil +} diff --git a/status_test.go b/status_test.go new file mode 100644 index 0000000..115082c --- /dev/null +++ b/status_test.go @@ -0,0 +1,259 @@ +package mastodon + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetFavourites(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `[{"Content": "foo"}, {"Content": "bar"}]`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + favs, err := client.GetFavourites(context.Background()) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if len(favs) != 2 { + t.Fatalf("result should be two: %d", len(favs)) + } + if favs[0].Content != "foo" { + t.Fatalf("want %q but %q", "foo", favs[0].Content) + } + if favs[1].Content != "bar" { + t.Fatalf("want %q but %q", "bar", favs[1].Content) + } +} + +func TestGetStatus(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/statuses/1234567" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + fmt.Fprintln(w, `{"Content": "zzz"}`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.GetStatus(context.Background(), 123) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + status, err := client.GetStatus(context.Background(), 1234567) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if status.Content != "zzz" { + t.Fatalf("want %q but %q", "zzz", status.Content) + } +} + +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" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + fmt.Fprintln(w, `[{"Username": "foo"}, {"Username": "bar"}]`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.GetRebloggedBy(context.Background(), 123) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + rbs, err := client.GetRebloggedBy(context.Background(), 1234567) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if len(rbs) != 2 { + t.Fatalf("result should be two: %d", len(rbs)) + } + if rbs[0].Username != "foo" { + t.Fatalf("want %q but %q", "foo", rbs[0].Username) + } + if rbs[1].Username != "bar" { + t.Fatalf("want %q but %q", "bar", rbs[0].Username) + } +} + +func TestGetFavouritedBy(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/statuses/1234567/favourited_by" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + fmt.Fprintln(w, `[{"Username": "foo"}, {"Username": "bar"}]`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.GetFavouritedBy(context.Background(), 123) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + fbs, err := client.GetFavouritedBy(context.Background(), 1234567) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if len(fbs) != 2 { + t.Fatalf("result should be two: %d", len(fbs)) + } + if fbs[0].Username != "foo" { + t.Fatalf("want %q but %q", "foo", fbs[0].Username) + } + if fbs[1].Username != "bar" { + t.Fatalf("want %q but %q", "bar", fbs[0].Username) + } +} + +func TestReblog(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/statuses/1234567/reblog" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + fmt.Fprintln(w, `{"Content": "zzz"}`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.Reblog(context.Background(), 123) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + status, err := client.Reblog(context.Background(), 1234567) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if status.Content != "zzz" { + t.Fatalf("want %q but %q", "zzz", status.Content) + } +} + +func TestUnreblog(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/statuses/1234567/unreblog" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + fmt.Fprintln(w, `{"Content": "zzz"}`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.Unreblog(context.Background(), 123) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + status, err := client.Unreblog(context.Background(), 1234567) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if status.Content != "zzz" { + t.Fatalf("want %q but %q", "zzz", status.Content) + } +} + +func TestFavourite(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/statuses/1234567/favourite" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + fmt.Fprintln(w, `{"Content": "zzz"}`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.Favourite(context.Background(), 123) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + status, err := client.Favourite(context.Background(), 1234567) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if status.Content != "zzz" { + t.Fatalf("want %q but %q", "zzz", status.Content) + } +} + +func TestUnfavourite(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/statuses/1234567/unfavourite" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + fmt.Fprintln(w, `{"Content": "zzz"}`) + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.Unfavourite(context.Background(), 123) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + status, err := client.Unfavourite(context.Background(), 1234567) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if status.Content != "zzz" { + t.Fatalf("want %q but %q", "zzz", status.Content) + } +} diff --git a/streaming.go b/streaming.go index 6ccf82d..ded8aad 100644 --- a/streaming.go +++ b/streaming.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "fmt" "io" "net/http" "net/url" @@ -67,14 +68,15 @@ func handleReader(ctx context.Context, q chan Event, r io.Reader) error { return ctx.Err() } -// StreamingPublic return channel to read events. -func (c *Client) StreamingPublic(ctx context.Context) (chan Event, error) { - url, err := url.Parse(c.config.Server) +func (c *Client) streaming(ctx context.Context, p string, tag string) (chan Event, error) { + u, err := url.Parse(c.config.Server) if err != nil { return nil, err } - url.Path = path.Join(url.Path, "/api/v1/streaming/public") + u.Path = path.Join(u.Path, "/api/v1/streaming/"+p) + params := url.Values{} + params.Set("tag", tag) var resp *http.Response q := make(chan Event, 10) @@ -82,20 +84,27 @@ func (c *Client) StreamingPublic(ctx context.Context) (chan Event, error) { defer ctx.Done() for { - req, err := http.NewRequest(http.MethodGet, url.String(), nil) + var in io.Reader + if tag != "" { + in = strings.NewReader(params.Encode()) + } + req, err := http.NewRequest(http.MethodGet, u.String(), in) if err == nil { req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) resp, err = c.Do(req) + if resp.StatusCode != 200 { + err = fmt.Errorf("bad request: %v", resp.Status) + } } if err == nil { err = handleReader(ctx, q, resp.Body) - resp.Body.Close() if err == nil { break } } else { q <- &ErrorEvent{err} } + resp.Body.Close() time.Sleep(3 * time.Second) } }() @@ -106,4 +115,20 @@ func (c *Client) StreamingPublic(ctx context.Context) (chan Event, error) { } }() return q, nil + +} + +// StreamingPublic return channel to read events on public. +func (c *Client) StreamingPublic(ctx context.Context) (chan Event, error) { + return c.streaming(ctx, "public", "") +} + +// StreamingHome return channel to read events on home. +func (c *Client) StreamingHome(ctx context.Context) (chan Event, error) { + return c.streaming(ctx, "home", "") +} + +// StreamingHashtag return channel to read events on tagged timeline. +func (c *Client) StreamingHashtag(ctx context.Context, tag string) (chan Event, error) { + return c.streaming(ctx, "hashtag", tag) } diff --git a/streaming_test.go b/streaming_test.go new file mode 100644 index 0000000..7227c12 --- /dev/null +++ b/streaming_test.go @@ -0,0 +1,62 @@ +package mastodon + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestStreamingPublic(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/streaming/public" { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + f, _ := w.(http.Flusher) + fmt.Fprintln(w, ` +event: update +data: {"Content": "foo"} + `) + f.Flush() + + fmt.Fprintln(w, ` +event: update +data: {"Content": "bar"} + `) + f.Flush() + return + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + ctx, cancel := context.WithCancel(context.Background()) + q, err := client.StreamingPublic(ctx) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + time.AfterFunc(3*time.Second, func() { + cancel() + close(q) + }) + events := []Event{} + for e := range q { + events = append(events, e) + } + if len(events) != 2 { + t.Fatalf("result should be two: %d", len(events)) + } + if events[0].(*UpdateEvent).Status.Content != "foo" { + t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content) + } + if events[1].(*UpdateEvent).Status.Content != "bar" { + t.Fatalf("want %q but %q", "bar", events[1].(*UpdateEvent).Status.Content) + } +}