aboutsummaryrefslogtreecommitdiff
path: root/mastodon
diff options
context:
space:
mode:
Diffstat (limited to 'mastodon')
-rw-r--r--mastodon/LICENSE21
-rw-r--r--mastodon/README.md142
-rw-r--r--mastodon/accounts.go314
-rw-r--r--mastodon/apps.go96
-rw-r--r--mastodon/go.mod8
-rw-r--r--mastodon/go.sum4
-rw-r--r--mastodon/helper.go55
-rw-r--r--mastodon/instance.go65
-rw-r--r--mastodon/lists.go107
-rw-r--r--mastodon/mastodon.go388
-rw-r--r--mastodon/notification.go42
-rw-r--r--mastodon/report.go39
-rw-r--r--mastodon/status.go297
-rw-r--r--mastodon/streaming.go166
-rw-r--r--mastodon/streaming_ws.go195
-rw-r--r--mastodon/unixtime.go20
16 files changed, 1959 insertions, 0 deletions
diff --git a/mastodon/LICENSE b/mastodon/LICENSE
new file mode 100644
index 0000000..42066c7
--- /dev/null
+++ b/mastodon/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 Yasuhiro Matsumoto
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/mastodon/README.md b/mastodon/README.md
new file mode 100644
index 0000000..9be937f
--- /dev/null
+++ b/mastodon/README.md
@@ -0,0 +1,142 @@
+# 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)
+
+## Usage
+
+### Application
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "github.com/mattn/go-mastodon"
+)
+
+func main() {
+ app, err := mastodon.RegisterApp(context.Background(), &mastodon.AppConfig{
+ Server: "https://mstdn.jp",
+ ClientName: "client-name",
+ Scopes: "read write follow",
+ Website: "https://github.com/mattn/go-mastodon",
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("client-id : %s\n", app.ClientID)
+ fmt.Printf("client-secret: %s\n", app.ClientSecret)
+}
+```
+
+### Client
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "github.com/mattn/go-mastodon"
+)
+
+func main() {
+ c := mastodon.NewClient(&mastodon.Config{
+ Server: "https://mstdn.jp",
+ ClientID: "client-id",
+ ClientSecret: "client-secret",
+ })
+ err := c.Authenticate(context.Background(), "your-email", "your-password")
+ if err != nil {
+ log.Fatal(err)
+ }
+ timeline, err := c.GetTimelineHome(context.Background(), nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+ for i := len(timeline) - 1; i >= 0; i-- {
+ fmt.Println(timeline[i])
+ }
+}
+```
+
+## Status of implementations
+
+* [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
+* [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
+* [x] GET /api/v1/accounts/:id/unblock
+* [x] GET /api/v1/accounts/:id/mute
+* [x] GET /api/v1/accounts/:id/unmute
+* [x] GET /api/v1/accounts/:id/lists
+* [x] GET /api/v1/accounts/relationships
+* [x] GET /api/v1/accounts/search
+* [x] POST /api/v1/apps
+* [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
+* [x] GET /api/v1/instance/activity
+* [x] GET /api/v1/instance/peers
+* [x] GET /api/v1/lists
+* [x] GET /api/v1/lists/:id/accounts
+* [x] GET /api/v1/lists/:id
+* [x] POST /api/v1/lists
+* [x] PUT /api/v1/lists/:id
+* [x] DELETE /api/v1/lists/:id
+* [x] POST /api/v1/lists/:id/accounts
+* [x] DELETE /api/v1/lists/:id/accounts
+* [x] POST /api/v1/media
+* [x] GET /api/v1/mutes
+* [x] GET /api/v1/notifications
+* [x] GET /api/v1/notifications/:id
+* [x] POST /api/v1/notifications/clear
+* [x] GET /api/v1/reports
+* [x] POST /api/v1/reports
+* [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
+* [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
+* [x] GET /api/v1/timelines/public
+* [x] GET /api/v1/timelines/tag/:hashtag
+* [x] GET /api/v1/timelines/list/:id
+
+## Installation
+
+```
+$ go get github.com/mattn/go-mastodon
+```
+
+## License
+
+MIT
+
+## Author
+
+Yasuhiro Matsumoto (a.k.a. mattn)
diff --git a/mastodon/accounts.go b/mastodon/accounts.go
new file mode 100644
index 0000000..e6f5a6d
--- /dev/null
+++ b/mastodon/accounts.go
@@ -0,0 +1,314 @@
+package mastodon
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+)
+
+// Account hold information for mastodon account.
+type Account struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Acct string `json:"acct"`
+ DisplayName string `json:"display_name"`
+ Locked bool `json:"locked"`
+ CreatedAt time.Time `json:"created_at"`
+ FollowersCount int64 `json:"followers_count"`
+ FollowingCount int64 `json:"following_count"`
+ StatusesCount int64 `json:"statuses_count"`
+ Note string `json:"note"`
+ URL string `json:"url"`
+ Avatar string `json:"avatar"`
+ AvatarStatic string `json:"avatar_static"`
+ Header string `json:"header"`
+ HeaderStatic string `json:"header_static"`
+ Emojis []Emoji `json:"emojis"`
+ Moved *Account `json:"moved"`
+ Fields []Field `json:"fields"`
+ Bot bool `json:"bot"`
+}
+
+// Field is a Mastodon account profile field.
+type Field struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+ VerifiedAt time.Time `json:"verified_at"`
+}
+
+// AccountSource is a Mastodon account profile field.
+type AccountSource struct {
+ Privacy *string `json:"privacy"`
+ Sensitive *bool `json:"sensitive"`
+ Language *string `json:"language"`
+ Note *string `json:"note"`
+ Fields *[]Field `json:"fields"`
+}
+
+// GetAccount return Account.
+func (c *Client) GetAccount(ctx context.Context, id string) (*Account, error) {
+ var account Account
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s", url.PathEscape(string(id))), nil, &account, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &account, nil
+}
+
+// GetAccountCurrentUser return Account of current user.
+func (c *Client) GetAccountCurrentUser(ctx context.Context) (*Account, error) {
+ var account Account
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/accounts/verify_credentials", nil, &account, nil)
+ 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
+ Locked *bool
+ Fields *[]Field
+ Source *AccountSource
+
+ // 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.Locked != nil {
+ params.Set("locked", strconv.FormatBool(*profile.Locked))
+ }
+ if profile.Fields != nil {
+ for idx, field := range *profile.Fields {
+ params.Set(fmt.Sprintf("fields_attributes[%d][name]", idx), field.Name)
+ params.Set(fmt.Sprintf("fields_attributes[%d][value]", idx), field.Value)
+ }
+ }
+ if profile.Source != nil {
+ if profile.Source.Privacy != nil {
+ params.Set("source[privacy]", *profile.Source.Privacy)
+ }
+ if profile.Source.Sensitive != nil {
+ params.Set("source[sensitive]", strconv.FormatBool(*profile.Source.Sensitive))
+ }
+ if profile.Source.Language != nil {
+ params.Set("source[language]", *profile.Source.Language)
+ }
+ }
+ 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, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &account, nil
+}
+
+// GetAccountStatuses return statuses by specified accuont.
+func (c *Client) GetAccountStatuses(ctx context.Context, id string, pg *Pagination) ([]*Status, error) {
+ var statuses []*Status
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/statuses", url.PathEscape(string(id))), nil, &statuses, pg)
+ if err != nil {
+ return nil, err
+ }
+ return statuses, nil
+}
+
+// GetAccountFollowers return followers list.
+func (c *Client) GetAccountFollowers(ctx context.Context, id string, pg *Pagination) ([]*Account, error) {
+ var accounts []*Account
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/followers", url.PathEscape(string(id))), nil, &accounts, pg)
+ if err != nil {
+ return nil, err
+ }
+ return accounts, nil
+}
+
+// GetAccountFollowing return following list.
+func (c *Client) GetAccountFollowing(ctx context.Context, id string, pg *Pagination) ([]*Account, error) {
+ var accounts []*Account
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/following", url.PathEscape(string(id))), nil, &accounts, pg)
+ if err != nil {
+ return nil, err
+ }
+ return accounts, nil
+}
+
+// GetBlocks return block list.
+func (c *Client) GetBlocks(ctx context.Context, pg *Pagination) ([]*Account, error) {
+ var accounts []*Account
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/blocks", nil, &accounts, pg)
+ if err != nil {
+ return nil, err
+ }
+ return accounts, nil
+}
+
+// Relationship hold information for relation-ship to the account.
+type Relationship struct {
+ ID string `json:"id"`
+ Following bool `json:"following"`
+ FollowedBy bool `json:"followed_by"`
+ Blocking bool `json:"blocking"`
+ Muting bool `json:"muting"`
+ MutingNotifications bool `json:"muting_notifications"`
+ Requested bool `json:"requested"`
+ DomainBlocking bool `json:"domain_blocking"`
+ ShowingReblogs bool `json:"showing_reblogs"`
+ Endorsed bool `json:"endorsed"`
+}
+
+// AccountFollow follow the account.
+func (c *Client) AccountFollow(ctx context.Context, id string) (*Relationship, error) {
+ var relationship Relationship
+ err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/follow", url.PathEscape(string(id))), nil, &relationship, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &relationship, nil
+}
+
+// AccountUnfollow unfollow the account.
+func (c *Client) AccountUnfollow(ctx context.Context, id string) (*Relationship, error) {
+ var relationship Relationship
+ err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/unfollow", url.PathEscape(string(id))), nil, &relationship, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &relationship, nil
+}
+
+// AccountBlock block the account.
+func (c *Client) AccountBlock(ctx context.Context, id string) (*Relationship, error) {
+ var relationship Relationship
+ err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/block", url.PathEscape(string(id))), nil, &relationship, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &relationship, nil
+}
+
+// AccountUnblock unblock the account.
+func (c *Client) AccountUnblock(ctx context.Context, id string) (*Relationship, error) {
+ var relationship Relationship
+ err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/unblock", url.PathEscape(string(id))), nil, &relationship, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &relationship, nil
+}
+
+// AccountMute mute the account.
+func (c *Client) AccountMute(ctx context.Context, id string) (*Relationship, error) {
+ var relationship Relationship
+ err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/mute", url.PathEscape(string(id))), nil, &relationship, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &relationship, nil
+}
+
+// AccountUnmute unmute the account.
+func (c *Client) AccountUnmute(ctx context.Context, id string) (*Relationship, error) {
+ var relationship Relationship
+ err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/unmute", url.PathEscape(string(id))), nil, &relationship, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &relationship, nil
+}
+
+// GetAccountRelationships return relationship for the account.
+func (c *Client) GetAccountRelationships(ctx context.Context, ids []string) ([]*Relationship, error) {
+ params := url.Values{}
+ for _, id := range ids {
+ params.Add("id[]", id)
+ }
+
+ var relationships []*Relationship
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/accounts/relationships", params, &relationships, nil)
+ if err != nil {
+ return nil, err
+ }
+ return relationships, nil
+}
+
+// AccountsSearch search accounts by query.
+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(ctx, http.MethodGet, "/api/v1/accounts/search", params, &accounts, nil)
+ if err != nil {
+ return nil, err
+ }
+ return accounts, nil
+}
+
+// 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(ctx, http.MethodPost, "/api/v1/follows", params, &account, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &account, nil
+}
+
+// GetFollowRequests return follow-requests.
+func (c *Client) GetFollowRequests(ctx context.Context, pg *Pagination) ([]*Account, error) {
+ var accounts []*Account
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/follow_requests", nil, &accounts, pg)
+ 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 string) error {
+ return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/follow_requests/%s/authorize", url.PathEscape(string(id))), nil, nil, nil)
+}
+
+// FollowRequestReject is rejects the follow request of user with id.
+func (c *Client) FollowRequestReject(ctx context.Context, id string) error {
+ return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/follow_requests/%s/reject", url.PathEscape(string(id))), nil, nil, nil)
+}
+
+// GetMutes returns the list of users muted by the current user.
+func (c *Client) GetMutes(ctx context.Context, pg *Pagination) ([]*Account, error) {
+ var accounts []*Account
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/mutes", nil, &accounts, pg)
+ if err != nil {
+ return nil, err
+ }
+ return accounts, nil
+}
diff --git a/mastodon/apps.go b/mastodon/apps.go
new file mode 100644
index 0000000..5d925c3
--- /dev/null
+++ b/mastodon/apps.go
@@ -0,0 +1,96 @@
+package mastodon
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+)
+
+// AppConfig is a setting for registering applications.
+type AppConfig struct {
+ http.Client
+ Server string
+ ClientName string
+
+ // Where the user should be redirected after authorization (for no redirect, use urn:ietf:wg:oauth:2.0:oob)
+ RedirectURIs string
+
+ // This can be a space-separated list of items listed on the /settings/applications/new page of any Mastodon
+ // instance. "read", "write", and "follow" are top-level scopes that include all the permissions of the more
+ // specific scopes like "read:favourites", "write:statuses", and "write:follows".
+ Scopes string
+
+ // Optional.
+ Website string
+}
+
+// Application is mastodon application.
+type Application struct {
+ ID string `json:"id"`
+ RedirectURI string `json:"redirect_uri"`
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"client_secret"`
+
+ // AuthURI is not part of the Mastodon API; it is generated by go-mastodon.
+ AuthURI string `json:"auth_uri,omitempty"`
+}
+
+// RegisterApp returns the mastodon application.
+func RegisterApp(ctx context.Context, appConfig *AppConfig) (*Application, error) {
+ params := url.Values{}
+ params.Set("client_name", appConfig.ClientName)
+ if appConfig.RedirectURIs == "" {
+ params.Set("redirect_uris", "urn:ietf:wg:oauth:2.0:oob")
+ } else {
+ params.Set("redirect_uris", appConfig.RedirectURIs)
+ }
+ params.Set("scopes", appConfig.Scopes)
+ params.Set("website", appConfig.Website)
+
+ u, err := url.Parse(appConfig.Server)
+ if err != nil {
+ return nil, err
+ }
+ u.Path = path.Join(u.Path, "/api/v1/apps")
+
+ req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(params.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := appConfig.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, parseAPIError("bad request", resp)
+ }
+
+ var app Application
+ err = json.NewDecoder(resp.Body).Decode(&app)
+ if err != nil {
+ return nil, err
+ }
+
+ u, err = url.Parse(appConfig.Server)
+ if err != nil {
+ return nil, err
+ }
+ u.Path = path.Join(u.Path, "/oauth/authorize")
+ u.RawQuery = url.Values{
+ "scope": {appConfig.Scopes},
+ "response_type": {"code"},
+ "redirect_uri": {app.RedirectURI},
+ "client_id": {app.ClientID},
+ }.Encode()
+
+ app.AuthURI = u.String()
+
+ return &app, nil
+}
diff --git a/mastodon/go.mod b/mastodon/go.mod
new file mode 100644
index 0000000..ea24109
--- /dev/null
+++ b/mastodon/go.mod
@@ -0,0 +1,8 @@
+module mastodon
+
+go 1.13
+
+require (
+ github.com/gorilla/websocket v1.4.1
+ github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
+)
diff --git a/mastodon/go.sum b/mastodon/go.sum
new file mode 100644
index 0000000..3ec24b2
--- /dev/null
+++ b/mastodon/go.sum
@@ -0,0 +1,4 @@
+github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
+github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y=
+github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE=
diff --git a/mastodon/helper.go b/mastodon/helper.go
new file mode 100644
index 0000000..05af20f
--- /dev/null
+++ b/mastodon/helper.go
@@ -0,0 +1,55 @@
+package mastodon
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "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 }
+
+func parseAPIError(prefix string, resp *http.Response) error {
+ errMsg := fmt.Sprintf("%s: %s", prefix, resp.Status)
+ var e struct {
+ Error string `json:"error"`
+ }
+
+ json.NewDecoder(resp.Body).Decode(&e)
+ if e.Error != "" {
+ errMsg = fmt.Sprintf("%s: %s", errMsg, e.Error)
+ }
+
+ return errors.New(errMsg)
+}
diff --git a/mastodon/instance.go b/mastodon/instance.go
new file mode 100644
index 0000000..3217450
--- /dev/null
+++ b/mastodon/instance.go
@@ -0,0 +1,65 @@
+package mastodon
+
+import (
+ "context"
+ "net/http"
+)
+
+// Instance hold information for mastodon instance.
+type Instance struct {
+ URI string `json:"uri"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ EMail string `json:"email"`
+ Version string `json:"version,omitempty"`
+ Thumbnail string `json:"thumbnail,omitempty"`
+ URLs map[string]string `json:"urls,omitempty"`
+ Stats *InstanceStats `json:"stats,omitempty"`
+ Languages []string `json:"languages"`
+ ContactAccount *Account `json:"account"`
+}
+
+// InstanceStats hold information for mastodon instance stats.
+type InstanceStats struct {
+ UserCount int64 `json:"user_count"`
+ StatusCount int64 `json:"status_count"`
+ DomainCount int64 `json:"domain_count"`
+}
+
+// GetInstance return Instance.
+func (c *Client) GetInstance(ctx context.Context) (*Instance, error) {
+ var instance Instance
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance", nil, &instance, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &instance, nil
+}
+
+// WeeklyActivity hold information for mastodon weekly activity.
+type WeeklyActivity struct {
+ Week Unixtime `json:"week"`
+ Statuses int64 `json:"statuses,string"`
+ Logins int64 `json:"logins,string"`
+ Registrations int64 `json:"registrations,string"`
+}
+
+// GetInstanceActivity return instance activity.
+func (c *Client) GetInstanceActivity(ctx context.Context) ([]*WeeklyActivity, error) {
+ var activity []*WeeklyActivity
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance/activity", nil, &activity, nil)
+ if err != nil {
+ return nil, err
+ }
+ return activity, nil
+}
+
+// GetInstancePeers return instance peers.
+func (c *Client) GetInstancePeers(ctx context.Context) ([]string, error) {
+ var peers []string
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance/peers", nil, &peers, nil)
+ if err != nil {
+ return nil, err
+ }
+ return peers, nil
+}
diff --git a/mastodon/lists.go b/mastodon/lists.go
new file mode 100644
index 0000000..d323b79
--- /dev/null
+++ b/mastodon/lists.go
@@ -0,0 +1,107 @@
+package mastodon
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+)
+
+// List is metadata for a list of users.
+type List struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+}
+
+// GetLists returns all the lists on the current account.
+func (c *Client) GetLists(ctx context.Context) ([]*List, error) {
+ var lists []*List
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/lists", nil, &lists, nil)
+ if err != nil {
+ return nil, err
+ }
+ return lists, nil
+}
+
+// GetAccountLists returns the lists containing a given account.
+func (c *Client) GetAccountLists(ctx context.Context, id string) ([]*List, error) {
+ var lists []*List
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/lists", url.PathEscape(string(id))), nil, &lists, nil)
+ if err != nil {
+ return nil, err
+ }
+ return lists, nil
+}
+
+// GetListAccounts returns the accounts in a given list.
+func (c *Client) GetListAccounts(ctx context.Context, id string) ([]*Account, error) {
+ var accounts []*Account
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(id))), url.Values{"limit": {"0"}}, &accounts, nil)
+ if err != nil {
+ return nil, err
+ }
+ return accounts, nil
+}
+
+// GetList retrieves a list by string.
+func (c *Client) GetList(ctx context.Context, id string) (*List, error) {
+ var list List
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), nil, &list, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &list, nil
+}
+
+// CreateList creates a new list with a given title.
+func (c *Client) CreateList(ctx context.Context, title string) (*List, error) {
+ params := url.Values{}
+ params.Set("title", title)
+
+ var list List
+ err := c.doAPI(ctx, http.MethodPost, "/api/v1/lists", params, &list, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &list, nil
+}
+
+// RenameList assigns a new title to a list.
+func (c *Client) RenameList(ctx context.Context, id string, title string) (*List, error) {
+ params := url.Values{}
+ params.Set("title", title)
+
+ var list List
+ err := c.doAPI(ctx, http.MethodPut, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), params, &list, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &list, nil
+}
+
+// DeleteList removes a list.
+func (c *Client) DeleteList(ctx context.Context, id string) error {
+ return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), nil, nil, nil)
+}
+
+// AddToList adds accounts to a list.
+//
+// Only accounts already followed by the user can be added to a list.
+func (c *Client) AddToList(ctx context.Context, list string, accounts ...string) error {
+ params := url.Values{}
+ for _, acct := range accounts {
+ params.Add("account_ids", string(acct))
+ }
+
+ return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil)
+}
+
+// RemoveFromList removes accounts from a list.
+func (c *Client) RemoveFromList(ctx context.Context, list string, accounts ...string) error {
+ params := url.Values{}
+ for _, acct := range accounts {
+ params.Add("account_ids", string(acct))
+ }
+
+ return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil)
+}
diff --git a/mastodon/mastodon.go b/mastodon/mastodon.go
new file mode 100644
index 0000000..ff86d2b
--- /dev/null
+++ b/mastodon/mastodon.go
@@ -0,0 +1,388 @@
+// Package mastodon provides functions and structs for accessing the mastodon API.
+package mastodon
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/tomnomnom/linkheader"
+)
+
+// Config is a setting for access mastodon APIs.
+type Config struct {
+ Server string
+ ClientID string
+ ClientSecret string
+ AccessToken string
+}
+
+// Client is a API client for mastodon.
+type Client struct {
+ http.Client
+ config *Config
+}
+
+func (c *Client) doAPI(ctx context.Context, method string, uri string, params interface{}, res interface{}, pg *Pagination) error {
+ u, err := url.Parse(c.config.Server)
+ if err != nil {
+ return err
+ }
+ u.Path = path.Join(u.Path, uri)
+
+ var req *http.Request
+ ct := "application/x-www-form-urlencoded"
+ if values, ok := params.(url.Values); ok {
+ var body io.Reader
+ if method == http.MethodGet {
+ if pg != nil {
+ values = pg.setValues(values)
+ }
+ u.RawQuery = values.Encode()
+ } else {
+ body = strings.NewReader(values.Encode())
+ }
+ req, err = http.NewRequest(method, u.String(), body)
+ if err != nil {
+ return err
+ }
+ } else if file, ok := params.(string); ok {
+ f, err := os.Open(file)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ part, err := mw.CreateFormFile("file", filepath.Base(file))
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(part, f)
+ if err != nil {
+ return err
+ }
+ err = mw.Close()
+ if err != nil {
+ return err
+ }
+ req, err = http.NewRequest(method, u.String(), &buf)
+ if err != nil {
+ return err
+ }
+ ct = mw.FormDataContentType()
+ } else if reader, ok := params.(io.Reader); ok {
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ part, err := mw.CreateFormFile("file", "upload")
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(part, reader)
+ if err != nil {
+ return err
+ }
+ err = mw.Close()
+ if err != nil {
+ return err
+ }
+ req, err = http.NewRequest(method, u.String(), &buf)
+ if err != nil {
+ return err
+ }
+ ct = mw.FormDataContentType()
+ } else {
+ if method == http.MethodGet && pg != nil {
+ u.RawQuery = pg.toValues().Encode()
+ }
+ req, err = http.NewRequest(method, u.String(), nil)
+ if err != nil {
+ return err
+ }
+ }
+ req = req.WithContext(ctx)
+ req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
+ if params != nil {
+ req.Header.Set("Content-Type", ct)
+ }
+
+ var resp *http.Response
+ backoff := 1000 * time.Millisecond
+ for {
+ resp, err = c.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ // handle status code 429, which indicates the server is throttling
+ // our requests. Do an exponential backoff and retry the request.
+ if resp.StatusCode == 429 {
+ if backoff > time.Hour {
+ break
+ }
+ backoff *= 2
+
+ select {
+ case <-time.After(backoff):
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ continue
+ }
+ break
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return parseAPIError("bad request", resp)
+ } else if res == nil {
+ return nil
+ } else if pg != nil {
+ if lh := resp.Header.Get("Link"); lh != "" {
+ pg2, err := newPagination(lh)
+ if err != nil {
+ return err
+ }
+ *pg = *pg2
+ }
+ }
+ return json.NewDecoder(resp.Body).Decode(&res)
+}
+
+// NewClient return new mastodon API client.
+func NewClient(config *Config) *Client {
+ return &Client{
+ Client: *http.DefaultClient,
+ config: config,
+ }
+}
+
+// Authenticate get access-token to the API.
+func (c *Client) Authenticate(ctx context.Context, username, password string) error {
+ params := url.Values{
+ "client_id": {c.config.ClientID},
+ "client_secret": {c.config.ClientSecret},
+ "grant_type": {"password"},
+ "username": {username},
+ "password": {password},
+ "scope": {"read write follow"},
+ }
+
+ return c.authenticate(ctx, params)
+}
+
+// AuthenticateToken logs in using a grant token returned by Application.AuthURI.
+//
+// redirectURI should be the same as Application.RedirectURI.
+func (c *Client) AuthenticateToken(ctx context.Context, authCode, redirectURI string) error {
+ params := url.Values{
+ "client_id": {c.config.ClientID},
+ "client_secret": {c.config.ClientSecret},
+ "grant_type": {"authorization_code"},
+ "code": {authCode},
+ "redirect_uri": {redirectURI},
+ }
+
+ return c.authenticate(ctx, params)
+}
+
+func (c *Client) authenticate(ctx context.Context, params url.Values) error {
+ u, err := url.Parse(c.config.Server)
+ if err != nil {
+ return err
+ }
+ u.Path = path.Join(u.Path, "/oauth/token")
+
+ req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(params.Encode()))
+ if err != nil {
+ return err
+ }
+ req = req.WithContext(ctx)
+ 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 resp.StatusCode != http.StatusOK {
+ return parseAPIError("bad authorization", resp)
+ }
+
+ var res struct {
+ AccessToken string `json:"access_token"`
+ }
+ err = json.NewDecoder(resp.Body).Decode(&res)
+ if err != nil {
+ return err
+ }
+ c.config.AccessToken = res.AccessToken
+ return nil
+}
+
+func (c *Client) GetAccessToken(ctx context.Context) string {
+ if c == nil || c.config == nil {
+ return ""
+ }
+ return c.config.AccessToken
+}
+
+// Toot is struct to post status.
+type Toot struct {
+ Status string `json:"status"`
+ InReplyToID string `json:"in_reply_to_id"`
+ MediaIDs []string `json:"media_ids"`
+ Sensitive bool `json:"sensitive"`
+ SpoilerText string `json:"spoiler_text"`
+ Visibility string `json:"visibility"`
+}
+
+// Mention hold information for mention.
+type Mention struct {
+ URL string `json:"url"`
+ Username string `json:"username"`
+ Acct string `json:"acct"`
+ ID string `json:"id"`
+}
+
+// Tag hold information for tag.
+type Tag struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ History []History `json:"history"`
+}
+
+// History hold information for history.
+type History struct {
+ Day string `json:"day"`
+ Uses int64 `json:"uses"`
+ Accounts int64 `json:"accounts"`
+}
+
+// Attachment hold information for attachment.
+type Attachment struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ URL string `json:"url"`
+ RemoteURL string `json:"remote_url"`
+ PreviewURL string `json:"preview_url"`
+ TextURL string `json:"text_url"`
+ Description string `json:"description"`
+ Meta AttachmentMeta `json:"meta"`
+}
+
+// AttachmentMeta holds information for attachment metadata.
+type AttachmentMeta struct {
+ Original AttachmentSize `json:"original"`
+ Small AttachmentSize `json:"small"`
+}
+
+// AttachmentSize holds information for attatchment size.
+type AttachmentSize struct {
+ Width int64 `json:"width"`
+ Height int64 `json:"height"`
+ Size string `json:"size"`
+ Aspect float64 `json:"aspect"`
+}
+
+// Emoji hold information for CustomEmoji.
+type Emoji struct {
+ ShortCode string `json:"shortcode"`
+ StaticURL string `json:"static_url"`
+ URL string `json:"url"`
+ VisibleInPicker bool `json:"visible_in_picker"`
+}
+
+// Results hold information for search result.
+type Results struct {
+ Accounts []*Account `json:"accounts"`
+ Statuses []*Status `json:"statuses"`
+ Hashtags []string `json:"hashtags"`
+}
+
+// Pagination is a struct for specifying the get range.
+type Pagination struct {
+ MaxID string
+ SinceID string
+ MinID string
+ Limit int64
+}
+
+func newPagination(rawlink string) (*Pagination, error) {
+ if rawlink == "" {
+ return nil, errors.New("empty link header")
+ }
+
+ p := &Pagination{}
+ for _, link := range linkheader.Parse(rawlink) {
+ switch link.Rel {
+ case "next":
+ maxID, err := getPaginationID(link.URL, "max_id")
+ if err != nil {
+ return nil, err
+ }
+ p.MaxID = maxID
+ case "prev":
+ sinceID, err := getPaginationID(link.URL, "since_id")
+ if err != nil {
+ return nil, err
+ }
+ p.SinceID = sinceID
+
+ minID, err := getPaginationID(link.URL, "min_id")
+ if err != nil {
+ return nil, err
+ }
+ p.MinID = minID
+ }
+ }
+
+ return p, nil
+}
+
+func getPaginationID(rawurl, key string) (string, error) {
+ u, err := url.Parse(rawurl)
+ if err != nil {
+ return "", err
+ }
+
+ val := u.Query().Get(key)
+ if val == "" {
+ return "", nil
+ }
+
+ return string(val), nil
+}
+
+func (p *Pagination) toValues() url.Values {
+ return p.setValues(url.Values{})
+}
+
+func (p *Pagination) setValues(params url.Values) url.Values {
+ if p.MaxID != "" {
+ params.Set("max_id", string(p.MaxID))
+ }
+ if p.SinceID != "" {
+ params.Set("since_id", string(p.SinceID))
+ }
+ if p.MinID != "" {
+ params.Set("min_id", string(p.MinID))
+ }
+ if p.Limit > 0 {
+ params.Set("limit", fmt.Sprint(p.Limit))
+ }
+
+ return params
+}
diff --git a/mastodon/notification.go b/mastodon/notification.go
new file mode 100644
index 0000000..236fcbf
--- /dev/null
+++ b/mastodon/notification.go
@@ -0,0 +1,42 @@
+package mastodon
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+// Notification hold information for mastodon notification.
+type Notification struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ CreatedAt time.Time `json:"created_at"`
+ Account Account `json:"account"`
+ Status *Status `json:"status"`
+}
+
+// GetNotifications return notifications.
+func (c *Client) GetNotifications(ctx context.Context, pg *Pagination) ([]*Notification, error) {
+ var notifications []*Notification
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/notifications", nil, &notifications, pg)
+ if err != nil {
+ return nil, err
+ }
+ return notifications, nil
+}
+
+// GetNotification return notification.
+func (c *Client) GetNotification(ctx context.Context, id string) (*Notification, error) {
+ var notification Notification
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/notifications/%v", id), nil, &notification, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &notification, nil
+}
+
+// ClearNotifications clear notifications.
+func (c *Client) ClearNotifications(ctx context.Context) error {
+ return c.doAPI(ctx, http.MethodPost, "/api/v1/notifications/clear", nil, nil, nil)
+}
diff --git a/mastodon/report.go b/mastodon/report.go
new file mode 100644
index 0000000..920614a
--- /dev/null
+++ b/mastodon/report.go
@@ -0,0 +1,39 @@
+package mastodon
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+)
+
+// Report hold information for mastodon report.
+type Report struct {
+ ID int64 `json:"id"`
+ ActionTaken bool `json:"action_taken"`
+}
+
+// GetReports return report of the current user.
+func (c *Client) GetReports(ctx context.Context) ([]*Report, error) {
+ var reports []*Report
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/reports", nil, &reports, nil)
+ if err != nil {
+ return nil, err
+ }
+ return reports, nil
+}
+
+// Report reports the report
+func (c *Client) Report(ctx context.Context, accountID string, ids []string, comment string) (*Report, error) {
+ params := url.Values{}
+ params.Set("account_id", string(accountID))
+ for _, id := range ids {
+ params.Add("status_ids[]", string(id))
+ }
+ params.Set("comment", comment)
+ var report Report
+ err := c.doAPI(ctx, http.MethodPost, "/api/v1/reports", params, &report, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &report, nil
+}
diff --git a/mastodon/status.go b/mastodon/status.go
new file mode 100644
index 0000000..fd69914
--- /dev/null
+++ b/mastodon/status.go
@@ -0,0 +1,297 @@
+package mastodon
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// Status is struct to hold status.
+type Status struct {
+ ID string `json:"id"`
+ URI string `json:"uri"`
+ URL string `json:"url"`
+ Account Account `json:"account"`
+ InReplyToID interface{} `json:"in_reply_to_id"`
+ InReplyToAccountID interface{} `json:"in_reply_to_account_id"`
+ Reblog *Status `json:"reblog"`
+ Content string `json:"content"`
+ CreatedAt time.Time `json:"created_at"`
+ Emojis []Emoji `json:"emojis"`
+ RepliesCount int64 `json:"replies_count"`
+ ReblogsCount int64 `json:"reblogs_count"`
+ FavouritesCount int64 `json:"favourites_count"`
+ Reblogged interface{} `json:"reblogged"`
+ Favourited interface{} `json:"favourited"`
+ Muted interface{} `json:"muted"`
+ Sensitive bool `json:"sensitive"`
+ SpoilerText string `json:"spoiler_text"`
+ Visibility string `json:"visibility"`
+ MediaAttachments []Attachment `json:"media_attachments"`
+ Mentions []Mention `json:"mentions"`
+ Tags []Tag `json:"tags"`
+ Card *Card `json:"card"`
+ Application Application `json:"application"`
+ Language string `json:"language"`
+ Pinned interface{} `json:"pinned"`
+}
+
+// Context hold information for mastodon context.
+type Context struct {
+ Ancestors []*Status `json:"ancestors"`
+ Descendants []*Status `json:"descendants"`
+}
+
+// Card hold information for mastodon card.
+type Card struct {
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Image string `json:"image"`
+ Type string `json:"type"`
+ AuthorName string `json:"author_name"`
+ AuthorURL string `json:"author_url"`
+ ProviderName string `json:"provider_name"`
+ ProviderURL string `json:"provider_url"`
+ HTML string `json:"html"`
+ Width int64 `json:"width"`
+ Height int64 `json:"height"`
+}
+
+// GetFavourites return the favorite list of the current user.
+func (c *Client) GetFavourites(ctx context.Context, pg *Pagination) ([]*Status, error) {
+ var statuses []*Status
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/favourites", nil, &statuses, pg)
+ if err != nil {
+ return nil, err
+ }
+ return statuses, nil
+}
+
+// GetStatus return status specified by id.
+func (c *Client) GetStatus(ctx context.Context, id string) (*Status, error) {
+ var status Status
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s", id), nil, &status, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &status, nil
+}
+
+// GetStatusContext return status specified by id.
+func (c *Client) GetStatusContext(ctx context.Context, id string) (*Context, error) {
+ var context Context
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/context", id), nil, &context, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &context, nil
+}
+
+// GetStatusCard return status specified by id.
+func (c *Client) GetStatusCard(ctx context.Context, id string) (*Card, error) {
+ var card Card
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/card", id), nil, &card, nil)
+ 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 string, pg *Pagination) ([]*Account, error) {
+ var accounts []*Account
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/reblogged_by", id), nil, &accounts, pg)
+ 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 string, pg *Pagination) ([]*Account, error) {
+ var accounts []*Account
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/favourited_by", id), nil, &accounts, pg)
+ 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 string) (*Status, error) {
+ var status Status
+ err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/reblog", id), nil, &status, nil)
+ 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 string) (*Status, error) {
+ var status Status
+ err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/unreblog", id), nil, &status, nil)
+ 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 string) (*Status, error) {
+ var status Status
+ err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/favourite", id), nil, &status, nil)
+ 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 string) (*Status, error) {
+ var status Status
+ err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/unfavourite", id), nil, &status, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &status, nil
+}
+
+// GetTimelineHome return statuses from home timeline.
+func (c *Client) GetTimelineHome(ctx context.Context, pg *Pagination) ([]*Status, error) {
+ var statuses []*Status
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/timelines/home", nil, &statuses, pg)
+ if err != nil {
+ return nil, err
+ }
+ return statuses, nil
+}
+
+// GetTimelinePublic return statuses from public timeline.
+func (c *Client) GetTimelinePublic(ctx context.Context, isLocal bool, pg *Pagination) ([]*Status, error) {
+ params := url.Values{}
+ if isLocal {
+ params.Set("local", "t")
+ }
+
+ var statuses []*Status
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/timelines/public", params, &statuses, pg)
+ if err != nil {
+ return nil, err
+ }
+ return statuses, nil
+}
+
+// GetTimelineHashtag return statuses from tagged timeline.
+func (c *Client) GetTimelineHashtag(ctx context.Context, tag string, isLocal bool, pg *Pagination) ([]*Status, error) {
+ params := url.Values{}
+ if isLocal {
+ params.Set("local", "t")
+ }
+
+ var statuses []*Status
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/timelines/tag/%s", url.PathEscape(tag)), params, &statuses, pg)
+ if err != nil {
+ return nil, err
+ }
+ return statuses, nil
+}
+
+// GetTimelineList return statuses from a list timeline.
+func (c *Client) GetTimelineList(ctx context.Context, id string, pg *Pagination) ([]*Status, error) {
+ var statuses []*Status
+ err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/timelines/list/%s", url.PathEscape(string(id))), nil, &statuses, pg)
+ if err != nil {
+ return nil, err
+ }
+ return statuses, nil
+}
+
+// GetTimelineMedia return statuses from media timeline.
+// NOTE: This is an experimental feature of pawoo.net.
+func (c *Client) GetTimelineMedia(ctx context.Context, isLocal bool, pg *Pagination) ([]*Status, error) {
+ params := url.Values{}
+ params.Set("media", "t")
+ if isLocal {
+ params.Set("local", "t")
+ }
+
+ var statuses []*Status
+ err := c.doAPI(ctx, http.MethodGet, "/api/v1/timelines/public", params, &statuses, pg)
+ if err != nil {
+ return nil, err
+ }
+ return statuses, nil
+}
+
+// PostStatus post the toot.
+func (c *Client) PostStatus(ctx context.Context, toot *Toot) (*Status, error) {
+ params := url.Values{}
+ params.Set("status", toot.Status)
+ if toot.InReplyToID != "" {
+ params.Set("in_reply_to_id", string(toot.InReplyToID))
+ }
+ if toot.MediaIDs != nil {
+ for _, media := range toot.MediaIDs {
+ params.Add("media_ids[]", string(media))
+ }
+ }
+ if toot.Visibility != "" {
+ params.Set("visibility", fmt.Sprint(toot.Visibility))
+ }
+ if toot.Sensitive {
+ params.Set("sensitive", "true")
+ }
+ if toot.SpoilerText != "" {
+ params.Set("spoiler_text", toot.SpoilerText)
+ }
+
+ var status Status
+ err := c.doAPI(ctx, http.MethodPost, "/api/v1/statuses", params, &status, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &status, nil
+}
+
+// DeleteStatus delete the toot.
+func (c *Client) DeleteStatus(ctx context.Context, id string) error {
+ return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/statuses/%s", id), nil, 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, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &results, nil
+}
+
+// UploadMedia upload a media attachment from a file.
+func (c *Client) UploadMedia(ctx context.Context, file string) (*Attachment, error) {
+ var attachment Attachment
+ err := c.doAPI(ctx, http.MethodPost, "/api/v1/media", file, &attachment, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &attachment, nil
+}
+
+// UploadMediaFromReader uploads a media attachment from a io.Reader.
+func (c *Client) UploadMediaFromReader(ctx context.Context, reader io.Reader) (*Attachment, error) {
+ var attachment Attachment
+ err := c.doAPI(ctx, http.MethodPost, "/api/v1/media", reader, &attachment, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &attachment, nil
+}
diff --git a/mastodon/streaming.go b/mastodon/streaming.go
new file mode 100644
index 0000000..77ae284
--- /dev/null
+++ b/mastodon/streaming.go
@@ -0,0 +1,166 @@
+package mastodon
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+)
+
+// UpdateEvent is struct for passing status event to app.
+type UpdateEvent struct {
+ Status *Status `json:"status"`
+}
+
+func (e *UpdateEvent) event() {}
+
+// NotificationEvent is struct for passing notification event to app.
+type NotificationEvent struct {
+ Notification *Notification `json:"notification"`
+}
+
+func (e *NotificationEvent) event() {}
+
+// DeleteEvent is struct for passing deletion event to app.
+type DeleteEvent struct{ ID string }
+
+func (e *DeleteEvent) event() {}
+
+// ErrorEvent is struct for passing errors to app.
+type ErrorEvent struct{ err error }
+
+func (e *ErrorEvent) event() {}
+func (e *ErrorEvent) Error() string { return e.err.Error() }
+
+// Event is interface passing events to app.
+type Event interface {
+ event()
+}
+
+func handleReader(q chan Event, r io.Reader) error {
+ var name string
+ s := bufio.NewScanner(r)
+ for s.Scan() {
+ line := s.Text()
+ token := strings.SplitN(line, ":", 2)
+ if len(token) != 2 {
+ continue
+ }
+ switch strings.TrimSpace(token[0]) {
+ case "event":
+ name = strings.TrimSpace(token[1])
+ case "data":
+ var err error
+ switch name {
+ case "update":
+ var status Status
+ err = json.Unmarshal([]byte(token[1]), &status)
+ if err == nil {
+ q <- &UpdateEvent{&status}
+ }
+ case "notification":
+ var notification Notification
+ err = json.Unmarshal([]byte(token[1]), &notification)
+ if err == nil {
+ q <- &NotificationEvent{&notification}
+ }
+ case "delete":
+ q <- &DeleteEvent{ID: string(strings.TrimSpace(token[1]))}
+ }
+ if err != nil {
+ q <- &ErrorEvent{err}
+ }
+ }
+ }
+ return s.Err()
+}
+
+func (c *Client) streaming(ctx context.Context, p string, params url.Values) (chan Event, error) {
+ u, err := url.Parse(c.config.Server)
+ if err != nil {
+ return nil, err
+ }
+ u.Path = path.Join(u.Path, "/api/v1/streaming", p)
+ u.RawQuery = params.Encode()
+
+ req, err := http.NewRequest(http.MethodGet, u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
+
+ q := make(chan Event)
+ go func() {
+ defer close(q)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ c.doStreaming(req, q)
+ }
+ }()
+ return q, nil
+}
+
+func (c *Client) doStreaming(req *http.Request, q chan Event) {
+ resp, err := c.Do(req)
+ if err != nil {
+ q <- &ErrorEvent{err}
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ q <- &ErrorEvent{parseAPIError("bad request", resp)}
+ return
+ }
+
+ err = handleReader(q, resp.Body)
+ if err != nil {
+ q <- &ErrorEvent{err}
+ }
+}
+
+// StreamingUser return channel to read events on home.
+func (c *Client) StreamingUser(ctx context.Context) (chan Event, error) {
+ return c.streaming(ctx, "user", nil)
+}
+
+// StreamingPublic return channel to read events on public.
+func (c *Client) StreamingPublic(ctx context.Context, isLocal bool) (chan Event, error) {
+ p := "public"
+ if isLocal {
+ p = path.Join(p, "local")
+ }
+
+ return c.streaming(ctx, p, nil)
+}
+
+// StreamingHashtag return channel to read events on tagged timeline.
+func (c *Client) StreamingHashtag(ctx context.Context, tag string, isLocal bool) (chan Event, error) {
+ params := url.Values{}
+ params.Set("tag", tag)
+
+ p := "hashtag"
+ if isLocal {
+ p = path.Join(p, "local")
+ }
+
+ return c.streaming(ctx, p, params)
+}
+
+// StreamingList return channel to read events on a list.
+func (c *Client) StreamingList(ctx context.Context, id string) (chan Event, error) {
+ params := url.Values{}
+ params.Set("list", string(id))
+
+ return c.streaming(ctx, "list", params)
+}
diff --git a/mastodon/streaming_ws.go b/mastodon/streaming_ws.go
new file mode 100644
index 0000000..838f65b
--- /dev/null
+++ b/mastodon/streaming_ws.go
@@ -0,0 +1,195 @@
+package mastodon
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "path"
+ "strings"
+
+ "github.com/gorilla/websocket"
+)
+
+// WSClient is a WebSocket client.
+type WSClient struct {
+ websocket.Dialer
+ client *Client
+}
+
+// NewWSClient return WebSocket client.
+func (c *Client) NewWSClient() *WSClient { return &WSClient{client: c} }
+
+// Stream is a struct of data that flows in streaming.
+type Stream struct {
+ Event string `json:"event"`
+ Payload interface{} `json:"payload"`
+}
+
+// StreamingWSUser return channel to read events on home using WebSocket.
+func (c *WSClient) StreamingWSUser(ctx context.Context) (chan Event, error) {
+ return c.streamingWS(ctx, "user", "")
+}
+
+// StreamingWSPublic return channel to read events on public using WebSocket.
+func (c *WSClient) StreamingWSPublic(ctx context.Context, isLocal bool) (chan Event, error) {
+ s := "public"
+ if isLocal {
+ s += ":local"
+ }
+
+ return c.streamingWS(ctx, s, "")
+}
+
+// StreamingWSHashtag return channel to read events on tagged timeline using WebSocket.
+func (c *WSClient) StreamingWSHashtag(ctx context.Context, tag string, isLocal bool) (chan Event, error) {
+ s := "hashtag"
+ if isLocal {
+ s += ":local"
+ }
+
+ return c.streamingWS(ctx, s, tag)
+}
+
+// StreamingWSList return channel to read events on a list using WebSocket.
+func (c *WSClient) StreamingWSList(ctx context.Context, id string) (chan Event, error) {
+ return c.streamingWS(ctx, "list", string(id))
+}
+
+func (c *WSClient) streamingWS(ctx context.Context, stream, tag string) (chan Event, error) {
+ params := url.Values{}
+ params.Set("access_token", c.client.config.AccessToken)
+ params.Set("stream", stream)
+ if tag != "" {
+ params.Set("tag", tag)
+ }
+
+ u, err := changeWebSocketScheme(c.client.config.Server)
+ if err != nil {
+ return nil, err
+ }
+ u.Path = path.Join(u.Path, "/api/v1/streaming")
+ u.RawQuery = params.Encode()
+
+ q := make(chan Event)
+ go func() {
+ defer close(q)
+ for {
+ err := c.handleWS(ctx, u.String(), q)
+ if err != nil {
+ return
+ }
+ }
+ }()
+
+ return q, nil
+}
+
+func (c *WSClient) handleWS(ctx context.Context, rawurl string, q chan Event) error {
+ conn, err := c.dialRedirect(rawurl)
+ if err != nil {
+ q <- &ErrorEvent{err: err}
+
+ // End.
+ return err
+ }
+
+ // Close the WebSocket when the context is canceled.
+ go func() {
+ <-ctx.Done()
+ conn.Close()
+ }()
+
+ for {
+ select {
+ case <-ctx.Done():
+ q <- &ErrorEvent{err: ctx.Err()}
+
+ // End.
+ return ctx.Err()
+ default:
+ }
+
+ var s Stream
+ err := conn.ReadJSON(&s)
+ if err != nil {
+ q <- &ErrorEvent{err: err}
+
+ // Reconnect.
+ break
+ }
+
+ err = nil
+ switch s.Event {
+ case "update":
+ var status Status
+ err = json.Unmarshal([]byte(s.Payload.(string)), &status)
+ if err == nil {
+ q <- &UpdateEvent{Status: &status}
+ }
+ case "notification":
+ var notification Notification
+ err = json.Unmarshal([]byte(s.Payload.(string)), &notification)
+ if err == nil {
+ q <- &NotificationEvent{Notification: &notification}
+ }
+ case "delete":
+ if f, ok := s.Payload.(float64); ok {
+ q <- &DeleteEvent{ID: fmt.Sprint(int64(f))}
+ } else {
+ q <- &DeleteEvent{ID: strings.TrimSpace(s.Payload.(string))}
+ }
+ }
+ if err != nil {
+ q <- &ErrorEvent{err}
+ }
+ }
+
+ return nil
+}
+
+func (c *WSClient) dialRedirect(rawurl string) (conn *websocket.Conn, err error) {
+ for {
+ conn, rawurl, err = c.dial(rawurl)
+ if err != nil {
+ return nil, err
+ } else if conn != nil {
+ return conn, nil
+ }
+ }
+}
+
+func (c *WSClient) dial(rawurl string) (*websocket.Conn, string, error) {
+ conn, resp, err := c.Dial(rawurl, nil)
+ if err != nil && err != websocket.ErrBadHandshake {
+ return nil, "", err
+ }
+ defer resp.Body.Close()
+
+ if loc := resp.Header.Get("Location"); loc != "" {
+ u, err := changeWebSocketScheme(loc)
+ if err != nil {
+ return nil, "", err
+ }
+
+ return nil, u.String(), nil
+ }
+
+ return conn, "", err
+}
+
+func changeWebSocketScheme(rawurl string) (*url.URL, error) {
+ u, err := url.Parse(rawurl)
+ if err != nil {
+ return nil, err
+ }
+
+ switch u.Scheme {
+ case "http":
+ u.Scheme = "ws"
+ case "https":
+ u.Scheme = "wss"
+ }
+
+ return u, nil
+}
diff --git a/mastodon/unixtime.go b/mastodon/unixtime.go
new file mode 100644
index 0000000..a935a9e
--- /dev/null
+++ b/mastodon/unixtime.go
@@ -0,0 +1,20 @@
+package mastodon
+
+import (
+ "strconv"
+ "time"
+)
+
+type Unixtime time.Time
+
+func (t *Unixtime) UnmarshalJSON(data []byte) error {
+ if len(data) > 0 && data[0] == '"' && data[len(data)-1] == '"' {
+ data = data[1 : len(data)-1]
+ }
+ ts, err := strconv.ParseInt(string(data), 10, 64)
+ if err != nil {
+ return err
+ }
+ *t = Unixtime(time.Unix(ts, 0))
+ return nil
+}