├── .github ├── FUNDING.yml └── workflows │ └── test.yaml ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── accounts.go ├── accounts_test.go ├── apps.go ├── apps_test.go ├── cmd └── mstdn │ ├── cmd_account.go │ ├── cmd_account_test.go │ ├── cmd_delete.go │ ├── cmd_delete_test.go │ ├── cmd_follow.go │ ├── cmd_follow_test.go │ ├── cmd_followers.go │ ├── cmd_followers_test.go │ ├── cmd_instance.go │ ├── cmd_instance_activity.go │ ├── cmd_instance_peers.go │ ├── cmd_instance_test.go │ ├── cmd_mikami.go │ ├── cmd_mikami_test.go │ ├── cmd_notification.go │ ├── cmd_notification_test.go │ ├── cmd_search.go │ ├── cmd_search_test.go │ ├── cmd_stream.go │ ├── cmd_stream_test.go │ ├── cmd_test.go │ ├── cmd_timeline.go │ ├── cmd_timeline_test.go │ ├── cmd_toot.go │ ├── cmd_toot_test.go │ ├── cmd_upload.go │ ├── cmd_upload_test.go │ ├── cmd_xsearch.go │ ├── cmd_xsearch_test.go │ ├── go.mod │ ├── go.sum │ ├── main.go │ └── main_test.go ├── compat.go ├── compat_test.go ├── example_test.go ├── examples ├── public-application │ └── main.go ├── user-credentials │ └── main.go └── user-oauth-authorization │ └── main.go ├── filters.go ├── filters_test.go ├── go.mod ├── go.sum ├── go.test.sh ├── go.work ├── go.work.sum ├── helper.go ├── helper_test.go ├── instance.go ├── instance_test.go ├── lists.go ├── lists_test.go ├── mastodon.go ├── mastodon_test.go ├── notification.go ├── notification_test.go ├── polls.go ├── polls_test.go ├── report.go ├── report_test.go ├── status.go ├── status_test.go ├── streaming.go ├── streaming_test.go ├── streaming_ws.go ├── streaming_ws_test.go ├── tags.go ├── tags_test.go ├── testdata └── logo.png └── unixtime.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: mattn # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | os: [windows-latest, macos-latest, ubuntu-latest] 14 | go: ["1.21", "1.22"] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-go@v3 19 | with: 20 | go-version: ${{ matrix.go }} 21 | 22 | - run: go generate ./... 23 | - run: git diff --cached --exit-code 24 | - run: go test ./... -v -cover -coverprofile coverage.out 25 | - run: go test -bench . -benchmem 26 | 27 | - uses: codecov/codecov-action@v2 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | .idea -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${fileDirname}", 13 | "console": "integratedTerminal" 14 | }, 15 | { 16 | "name": "mstdn", 17 | "type": "go", 18 | "request": "launch", 19 | "mode": "auto", 20 | // "cwd": "${workspaceFolder}/cmd/mstdn", 21 | "program": "${workspaceFolder}/cmd/mstdn", 22 | "console": "integratedTerminal" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Yasuhiro Matsumoto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-mastodon 2 | 3 | [![Build Status](https://github.com/mattn/go-mastodon/workflows/test/badge.svg?branch=master)](https://github.com/mattn/go-mastodon/actions?query=workflow%3Atest) 4 | [![Codecov](https://codecov.io/gh/mattn/go-mastodon/branch/master/graph/badge.svg)](https://codecov.io/gh/mattn/go-mastodon) 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/mattn/go-mastodon.svg)](https://pkg.go.dev/github.com/mattn/go-mastodon) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/mattn/go-mastodon)](https://goreportcard.com/report/github.com/mattn/go-mastodon) 7 | 8 | 9 | ## Usage 10 | 11 | There are three ways to authenticate users. Fully working examples can be found in the [examples](./examples) directory. 12 | 13 | ### User Credentials 14 | 15 | This method is the simplest and allows you to use an application registered in your account to interact with the Mastodon API on your behalf. 16 | 17 | * Create an application on Mastodon by navigating to: Preferences > Development > New Application 18 | * Select the necessary scopes 19 | 20 | **Working example:** [examples/user-credentials/main.go](./examples/user-credentials/main.go) 21 | 22 | ### Public Application 23 | 24 | Public applications use application tokens and have limited access to the API, allowing access only to public data. 25 | 26 | **Learn more at:** [Mastodon docs](https://docs.joinmastodon.org/client/token/) 27 | 28 | **Working example:** [examples/public-application/main.go](./examples/public-application/main.go) 29 | 30 | ### Application with Client Credentials (OAuth) 31 | 32 | This option allows you to create an application that can interact with the Mastodon API on behalf of a user. It registers the application and requests user authorization to obtain an access token. 33 | 34 | **Learn more at:** [Mastodon docs](https://docs.joinmastodon.org/client/authorized/) 35 | 36 | **Working example:** [examples/user-oauth-authorization/main.go](./examples/user-oauth-authorization/main.go) 37 | 38 | ## Status of implementations 39 | 40 | * [x] GET /api/v1/accounts/:id 41 | * [x] GET /api/v1/accounts/verify_credentials 42 | * [x] PATCH /api/v1/accounts/update_credentials 43 | * [x] GET /api/v1/accounts/:id/followers 44 | * [x] GET /api/v1/accounts/:id/following 45 | * [x] GET /api/v1/accounts/:id/statuses 46 | * [x] POST /api/v1/accounts/:id/follow 47 | * [x] POST /api/v1/accounts/:id/unfollow 48 | * [x] GET /api/v1/accounts/:id/block 49 | * [x] GET /api/v1/accounts/:id/unblock 50 | * [x] GET /api/v1/accounts/:id/mute 51 | * [x] GET /api/v1/accounts/:id/unmute 52 | * [x] GET /api/v1/accounts/:id/lists 53 | * [x] GET /api/v1/accounts/relationships 54 | * [x] GET /api/v1/accounts/search 55 | * [x] GET /api/v1/apps/verify_credentials 56 | * [x] GET /api/v1/bookmarks 57 | * [x] POST /api/v1/apps 58 | * [x] GET /api/v1/blocks 59 | * [x] GET /api/v1/conversations 60 | * [x] DELETE /api/v1/conversations/:id 61 | * [x] POST /api/v1/conversations/:id/read 62 | * [x] GET /api/v1/favourites 63 | * [x] GET /api/v1/filters 64 | * [x] POST /api/v1/filters 65 | * [x] GET /api/v1/filters/:id 66 | * [x] PUT /api/v1/filters/:id 67 | * [x] DELETE /api/v1/filters/:id 68 | * [x] GET /api/v1/follow_requests 69 | * [x] POST /api/v1/follow_requests/:id/authorize 70 | * [x] POST /api/v1/follow_requests/:id/reject 71 | * [x] GET /api/v1/followed_tags 72 | * [x] POST /api/v1/follows 73 | * [x] GET /api/v1/instance 74 | * [x] GET /api/v1/instance/activity 75 | * [x] GET /api/v1/instance/peers 76 | * [x] GET /api/v1/lists 77 | * [x] GET /api/v1/lists/:id/accounts 78 | * [x] GET /api/v1/lists/:id 79 | * [x] POST /api/v1/lists 80 | * [x] PUT /api/v1/lists/:id 81 | * [x] DELETE /api/v1/lists/:id 82 | * [x] POST /api/v1/lists/:id/accounts 83 | * [x] DELETE /api/v1/lists/:id/accounts 84 | * [x] POST /api/v1/media 85 | * [x] GET /api/v1/mutes 86 | * [x] GET /api/v1/notifications 87 | * [x] GET /api/v1/notifications/:id 88 | * [x] POST /api/v1/notifications/:id/dismiss 89 | * [x] POST /api/v1/notifications/clear 90 | * [x] POST /api/v1/push/subscription 91 | * [x] GET /api/v1/push/subscription 92 | * [x] PUT /api/v1/push/subscription 93 | * [x] DELETE /api/v1/push/subscription 94 | * [x] GET /api/v1/reports 95 | * [x] POST /api/v1/reports 96 | * [x] GET /api/v2/search 97 | * [x] GET /api/v1/statuses/:id 98 | * [x] GET /api/v1/statuses/:id/context 99 | * [x] GET /api/v1/statuses/:id/card 100 | * [x] GET /api/v1/statuses/:id/history 101 | * [x] GET /api/v1/statuses/:id/reblogged_by 102 | * [x] GET /api/v1/statuses/:id/source 103 | * [x] GET /api/v1/statuses/:id/favourited_by 104 | * [x] POST /api/v1/statuses 105 | * [x] PUT /api/v1/statuses/:id 106 | * [x] DELETE /api/v1/statuses/:id 107 | * [x] POST /api/v1/statuses/:id/reblog 108 | * [x] POST /api/v1/statuses/:id/unreblog 109 | * [x] POST /api/v1/statuses/:id/favourite 110 | * [x] POST /api/v1/statuses/:id/unfavourite 111 | * [x] POST /api/v1/statuses/:id/bookmark 112 | * [x] POST /api/v1/statuses/:id/unbookmark 113 | * [x] GET /api/v1/timelines/home 114 | * [x] GET /api/v1/timelines/public 115 | * [x] GET /api/v1/timelines/tag/:hashtag 116 | * [x] GET /api/v1/timelines/list/:id 117 | * [x] GET /api/v1/streaming/user 118 | * [x] GET /api/v1/streaming/public 119 | * [x] GET /api/v1/streaming/hashtag?tag=:hashtag 120 | * [x] GET /api/v1/streaming/hashtag/local?tag=:hashtag 121 | * [x] GET /api/v1/streaming/list?list=:list_id 122 | * [x] GET /api/v1/streaming/direct 123 | * [x] GET /api/v1/endorsements 124 | * [x] GET /api/v1/tags/:hashtag 125 | * [x] POST /api/v1/tags/:hashtag/follow 126 | * [x] POST /api/v1/tags/:hashtag/unfollow 127 | 128 | ## Installation 129 | 130 | ```shell 131 | go install github.com/mattn/go-mastodon@latest 132 | ``` 133 | 134 | ## License 135 | 136 | MIT 137 | 138 | ## Author 139 | 140 | Yasuhiro Matsumoto (a.k.a. mattn) 141 | -------------------------------------------------------------------------------- /apps.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/url" 8 | "path" 9 | "strings" 10 | ) 11 | 12 | // AppConfig is a setting for registering applications. 13 | type AppConfig struct { 14 | http.Client 15 | Server string 16 | ClientName string 17 | 18 | // Where the user should be redirected after authorization (for no redirect, use urn:ietf:wg:oauth:2.0:oob) 19 | RedirectURIs string 20 | 21 | // This can be a space-separated list of items listed on the /settings/applications/new page of any Mastodon 22 | // instance. "read", "write", and "follow" are top-level scopes that include all the permissions of the more 23 | // specific scopes like "read:favourites", "write:statuses", and "write:follows". 24 | Scopes string 25 | 26 | // Optional. 27 | Website string 28 | } 29 | 30 | // Application is a mastodon application. 31 | type Application struct { 32 | ID ID `json:"id"` 33 | RedirectURI string `json:"redirect_uri"` 34 | ClientID string `json:"client_id"` 35 | ClientSecret string `json:"client_secret"` 36 | 37 | // AuthURI is not part of the Mastodon API; it is generated by go-mastodon. 38 | AuthURI string `json:"auth_uri,omitempty"` 39 | } 40 | 41 | // RegisterApp returns the mastodon application. 42 | func RegisterApp(ctx context.Context, appConfig *AppConfig) (*Application, error) { 43 | params := url.Values{} 44 | params.Set("client_name", appConfig.ClientName) 45 | if appConfig.RedirectURIs == "" { 46 | params.Set("redirect_uris", "urn:ietf:wg:oauth:2.0:oob") 47 | } else { 48 | params.Set("redirect_uris", appConfig.RedirectURIs) 49 | } 50 | params.Set("scopes", appConfig.Scopes) 51 | params.Set("website", appConfig.Website) 52 | 53 | u, err := url.Parse(appConfig.Server) 54 | if err != nil { 55 | return nil, err 56 | } 57 | u.Path = path.Join(u.Path, "/api/v1/apps") 58 | 59 | req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(params.Encode())) 60 | if err != nil { 61 | return nil, err 62 | } 63 | req = req.WithContext(ctx) 64 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 65 | resp, err := appConfig.Do(req) 66 | if err != nil { 67 | return nil, err 68 | } 69 | defer resp.Body.Close() 70 | 71 | if resp.StatusCode != http.StatusOK { 72 | return nil, parseAPIError("bad request", resp) 73 | } 74 | 75 | var app Application 76 | err = json.NewDecoder(resp.Body).Decode(&app) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | u, err = url.Parse(appConfig.Server) 82 | if err != nil { 83 | return nil, err 84 | } 85 | u.Path = path.Join(u.Path, "/oauth/authorize") 86 | u.RawQuery = url.Values{ 87 | "scope": {appConfig.Scopes}, 88 | "response_type": {"code"}, 89 | "redirect_uri": {app.RedirectURI}, 90 | "client_id": {app.ClientID}, 91 | }.Encode() 92 | 93 | app.AuthURI = u.String() 94 | 95 | return &app, nil 96 | } 97 | 98 | // ApplicationVerification is mastodon application. 99 | type ApplicationVerification struct { 100 | Name string `json:"name"` 101 | Website string `json:"website"` 102 | VapidKey string `json:"vapid_key"` 103 | } 104 | 105 | // VerifyAppCredentials returns the mastodon application. 106 | func (c *Client) VerifyAppCredentials(ctx context.Context) (*ApplicationVerification, error) { 107 | var application ApplicationVerification 108 | err := c.doAPI(ctx, http.MethodGet, "/api/v1/apps/verify_credentials", nil, &application, nil) 109 | if err != nil { 110 | return nil, err 111 | } 112 | return &application, nil 113 | } 114 | -------------------------------------------------------------------------------- /apps_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestRegisterApp(t *testing.T) { 13 | isNotJSON := true 14 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | if r.Method != http.MethodPost { 16 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 17 | return 18 | } else if r.URL.Path != "/api/v1/apps" { 19 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 20 | return 21 | } else if r.FormValue("redirect_uris") != "urn:ietf:wg:oauth:2.0:oob" { 22 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 23 | return 24 | } else if isNotJSON { 25 | isNotJSON = false 26 | fmt.Fprintln(w, `Apps`) 27 | return 28 | } 29 | fmt.Fprintln(w, `{"id": 123, "client_id": "foo", "client_secret": "bar"}`) 30 | })) 31 | defer ts.Close() 32 | 33 | // Status not ok. 34 | _, err := RegisterApp(context.Background(), &AppConfig{ 35 | Server: ts.URL, 36 | RedirectURIs: "/", 37 | }) 38 | if err == nil { 39 | t.Fatalf("should be fail: %v", err) 40 | } 41 | 42 | // Error in url.Parse 43 | _, err = RegisterApp(context.Background(), &AppConfig{ 44 | Server: ":", 45 | }) 46 | if err == nil { 47 | t.Fatalf("should be fail: %v", err) 48 | } 49 | 50 | // Error in json.NewDecoder 51 | _, err = RegisterApp(context.Background(), &AppConfig{ 52 | Server: ts.URL, 53 | }) 54 | if err == nil { 55 | t.Fatalf("should be fail: %v", err) 56 | } 57 | 58 | // Success. 59 | app, err := RegisterApp(context.Background(), &AppConfig{ 60 | Server: ts.URL, 61 | Scopes: "read write follow", 62 | }) 63 | if err != nil { 64 | t.Fatalf("should not be fail: %v", err) 65 | } 66 | if string(app.ID) != "123" { 67 | t.Fatalf("want %q but %q", "bar", app.ClientSecret) 68 | } 69 | if app.ClientID != "foo" { 70 | t.Fatalf("want %q but %q", "foo", app.ClientID) 71 | } 72 | if app.ClientSecret != "bar" { 73 | t.Fatalf("want %q but %q", "bar", app.ClientSecret) 74 | } 75 | } 76 | 77 | func TestRegisterAppWithCancel(t *testing.T) { 78 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 79 | time.Sleep(3 * time.Second) 80 | fmt.Fprintln(w, `{"client_id": "foo", "client_secret": "bar"}`) 81 | })) 82 | defer ts.Close() 83 | 84 | ctx, cancel := context.WithCancel(context.Background()) 85 | cancel() 86 | _, err := RegisterApp(ctx, &AppConfig{ 87 | Server: ts.URL, 88 | Scopes: "read write follow", 89 | }) 90 | if err == nil { 91 | t.Fatalf("should be fail: %v", err) 92 | } 93 | if want := fmt.Sprintf("Post %q: context canceled", ts.URL+"/api/v1/apps"); want != err.Error() { 94 | t.Fatalf("want %q but %q", want, err.Error()) 95 | } 96 | } 97 | 98 | func TestVerifyAppCredentials(t *testing.T) { 99 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 100 | if r.Header.Get("Authorization") != "Bearer zoo" { 101 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 102 | return 103 | } 104 | if r.URL.Path != "/api/v1/apps/verify_credentials" { 105 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 106 | return 107 | } 108 | fmt.Fprintln(w, `{"name":"zzz","website":"yyy","vapid_key":"xxx"}`) 109 | })) 110 | defer ts.Close() 111 | 112 | client := NewClient(&Config{ 113 | Server: ts.URL, 114 | ClientID: "foo", 115 | ClientSecret: "bar", 116 | AccessToken: "zip", 117 | }) 118 | _, err := client.VerifyAppCredentials(context.Background()) 119 | if err == nil { 120 | t.Fatalf("should be fail: %v", err) 121 | } 122 | 123 | client = NewClient(&Config{ 124 | Server: ts.URL, 125 | ClientID: "foo", 126 | ClientSecret: "bar", 127 | AccessToken: "zoo", 128 | }) 129 | a, err := client.VerifyAppCredentials(context.Background()) 130 | if err != nil { 131 | t.Fatalf("should not be fail: %v", err) 132 | } 133 | if a.Name != "zzz" { 134 | t.Fatalf("want %q but %q", "zzz", a.Name) 135 | } 136 | if a.Website != "yyy" { 137 | t.Fatalf("want %q but %q", "yyy", a.Name) 138 | } 139 | if a.VapidKey != "xxx" { 140 | t.Fatalf("want %q but %q", "xxx", a.Name) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_account.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/mattn/go-mastodon" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func cmdAccount(c *cli.Context) error { 12 | client := c.App.Metadata["client"].(*mastodon.Client) 13 | account, err := client.GetAccountCurrentUser(context.Background()) 14 | if err != nil { 15 | return err 16 | } 17 | fmt.Fprintf(c.App.Writer, "URI : %v\n", account.Acct) 18 | fmt.Fprintf(c.App.Writer, "ID : %v\n", account.ID) 19 | fmt.Fprintf(c.App.Writer, "Username : %v\n", account.Username) 20 | fmt.Fprintf(c.App.Writer, "Acct : %v\n", account.Acct) 21 | fmt.Fprintf(c.App.Writer, "DisplayName : %v\n", account.DisplayName) 22 | fmt.Fprintf(c.App.Writer, "Locked : %v\n", account.Locked) 23 | fmt.Fprintf(c.App.Writer, "CreatedAt : %v\n", account.CreatedAt.Local()) 24 | fmt.Fprintf(c.App.Writer, "FollowersCount: %v\n", account.FollowersCount) 25 | fmt.Fprintf(c.App.Writer, "FollowingCount: %v\n", account.FollowingCount) 26 | fmt.Fprintf(c.App.Writer, "StatusesCount : %v\n", account.StatusesCount) 27 | fmt.Fprintf(c.App.Writer, "Note : %v\n", textContent(account.Note)) 28 | fmt.Fprintf(c.App.Writer, "URL : %v\n", account.URL) 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_account_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func TestCmdAccount(t *testing.T) { 13 | out := testWithServer( 14 | func(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/api/v1/accounts/verify_credentials": 17 | fmt.Fprintln(w, `{"username": "zzz"}`) 18 | return 19 | } 20 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 21 | return 22 | }, 23 | func(app *cli.App) { 24 | app.Run([]string{"mstdn", "account"}) 25 | }, 26 | ) 27 | if !strings.Contains(out, "zzz") { 28 | t.Fatalf("%q should be contained in output of command: %v", "zzz", out) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/mattn/go-mastodon" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func cmdDelete(c *cli.Context) error { 12 | client := c.App.Metadata["client"].(*mastodon.Client) 13 | if !c.Args().Present() { 14 | return errors.New("arguments required") 15 | } 16 | for i := 0; i < c.NArg(); i++ { 17 | err := client.DeleteStatus(context.Background(), mastodon.ID(c.Args().Get(i))) 18 | if err != nil { 19 | return err 20 | } 21 | } 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_delete_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func TestCmdDelete(t *testing.T) { 12 | ok := false 13 | f := func(w http.ResponseWriter, r *http.Request) { 14 | switch r.URL.Path { 15 | case "/api/v1/statuses/123": 16 | fmt.Fprintln(w, `{}`) 17 | ok = true 18 | return 19 | } 20 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 21 | return 22 | } 23 | testWithServer( 24 | f, func(app *cli.App) { 25 | app.Run([]string{"mstdn", "delete", "122"}) 26 | }, 27 | ) 28 | if ok { 29 | t.Fatal("something wrong to sequence to follow account") 30 | } 31 | 32 | ok = false 33 | testWithServer( 34 | f, func(app *cli.App) { 35 | app.Run([]string{"mstdn", "delete", "123"}) 36 | }, 37 | ) 38 | if !ok { 39 | t.Fatal("something wrong to sequence to follow account") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_follow.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/mattn/go-mastodon" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func cmdFollow(c *cli.Context) error { 12 | client := c.App.Metadata["client"].(*mastodon.Client) 13 | if !c.Args().Present() { 14 | return errors.New("arguments required") 15 | } 16 | for i := 0; i < c.NArg(); i++ { 17 | account, err := client.AccountsSearch(context.Background(), c.Args().Get(i), 1) 18 | if err != nil { 19 | return err 20 | } 21 | if len(account) == 0 { 22 | continue 23 | } 24 | _, err = client.AccountFollow(context.Background(), account[0].ID) 25 | if err != nil { 26 | return err 27 | } 28 | } 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_follow_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func TestCmdFollow(t *testing.T) { 12 | ok := false 13 | testWithServer( 14 | func(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/api/v1/accounts/search": 17 | q := r.URL.Query().Get("q") 18 | if q == "mattn" { 19 | fmt.Fprintln(w, `[{"id": 123}]`) 20 | return 21 | } else if q == "different_id" { 22 | fmt.Fprintln(w, `[{"id": 1234567}]`) 23 | return 24 | } else if q == "empty" { 25 | fmt.Fprintln(w, `[]`) 26 | return 27 | } 28 | case "/api/v1/accounts/123/follow": 29 | fmt.Fprintln(w, `{"id": 123}`) 30 | ok = true 31 | return 32 | } 33 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 34 | return 35 | }, 36 | func(app *cli.App) { 37 | err := app.Run([]string{"mstdn", "follow", "mattn"}) 38 | if err != nil { 39 | t.Fatalf("should not be fail: %v", err) 40 | } 41 | }, 42 | func(app *cli.App) { 43 | err := app.Run([]string{"mstdn", "follow"}) 44 | if err == nil { 45 | t.Fatalf("should be fail: %v", err) 46 | } 47 | }, 48 | func(app *cli.App) { 49 | err := app.Run([]string{"mstdn", "follow", "fail"}) 50 | if err == nil { 51 | t.Fatalf("should be fail: %v", err) 52 | } 53 | }, 54 | func(app *cli.App) { 55 | err := app.Run([]string{"mstdn", "follow", "empty"}) 56 | if err != nil { 57 | t.Fatalf("should not be fail: %v", err) 58 | } 59 | }, 60 | func(app *cli.App) { 61 | err := app.Run([]string{"mstdn", "follow", "different_id"}) 62 | if err == nil { 63 | t.Fatalf("should be fail: %v", err) 64 | } 65 | }, 66 | ) 67 | if !ok { 68 | t.Fatal("something wrong to sequence to follow account") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_followers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/mattn/go-mastodon" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func cmdFollowers(c *cli.Context) error { 13 | client := c.App.Metadata["client"].(*mastodon.Client) 14 | config := c.App.Metadata["config"].(*mastodon.Config) 15 | 16 | account, err := client.GetAccountCurrentUser(context.Background()) 17 | if err != nil { 18 | return err 19 | } 20 | var followers []*mastodon.Account 21 | var pg mastodon.Pagination 22 | for { 23 | fs, err := client.GetAccountFollowers(context.Background(), account.ID, &pg) 24 | if err != nil { 25 | return err 26 | } 27 | followers = append(followers, fs...) 28 | if pg.MaxID == "" { 29 | break 30 | } 31 | pg.SinceID = "" 32 | pg.MinID = "" 33 | time.Sleep(10 * time.Second) 34 | } 35 | s := newScreen(config) 36 | for _, follower := range followers { 37 | fmt.Fprintf(c.App.Writer, "%v,%v\n", follower.ID, s.acct(follower.Acct)) 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_followers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func TestCmdFollowers(t *testing.T) { 13 | out := testWithServer( 14 | func(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/api/v1/accounts/verify_credentials": 17 | fmt.Fprintln(w, `{"id": 123}`) 18 | return 19 | case "/api/v1/accounts/123/followers": 20 | w.Header().Set("Link", `; rel="prev"`) 21 | fmt.Fprintln(w, `[{"id": 234, "username": "ZZZ", "acct": "zzz"}]`) 22 | return 23 | } 24 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 25 | return 26 | }, 27 | func(app *cli.App) { 28 | app.Run([]string{"mstdn", "followers"}) 29 | }, 30 | ) 31 | if !strings.Contains(out, "zzz") { 32 | t.Fatalf("%q should be contained in output of command: %v", "zzz", out) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_instance.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | 8 | "github.com/mattn/go-mastodon" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func cmdInstance(c *cli.Context) error { 13 | client := c.App.Metadata["client"].(*mastodon.Client) 14 | instance, err := client.GetInstance(context.Background()) 15 | if err != nil { 16 | return err 17 | } 18 | fmt.Fprintf(c.App.Writer, "URI : %s\n", instance.URI) 19 | fmt.Fprintf(c.App.Writer, "Title : %s\n", instance.Title) 20 | fmt.Fprintf(c.App.Writer, "Description: %s\n", instance.Description) 21 | fmt.Fprintf(c.App.Writer, "EMail : %s\n", instance.EMail) 22 | if instance.Version != "" { 23 | fmt.Fprintf(c.App.Writer, "Version : %s\n", instance.Version) 24 | } 25 | if instance.Thumbnail != "" { 26 | fmt.Fprintf(c.App.Writer, "Thumbnail : %s\n", instance.Thumbnail) 27 | } 28 | if instance.URLs != nil { 29 | var keys []string 30 | for _, k := range instance.URLs { 31 | keys = append(keys, k) 32 | } 33 | sort.Strings(keys) 34 | for _, k := range keys { 35 | fmt.Fprintf(c.App.Writer, "%s: %s\n", k, instance.URLs[k]) 36 | } 37 | } 38 | if instance.Stats != nil { 39 | fmt.Fprintf(c.App.Writer, "User Count : %v\n", instance.Stats.UserCount) 40 | fmt.Fprintf(c.App.Writer, "Status Count : %v\n", instance.Stats.StatusCount) 41 | fmt.Fprintf(c.App.Writer, "Domain Count : %v\n", instance.Stats.DomainCount) 42 | } 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_instance_activity.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/mattn/go-mastodon" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func cmdInstanceActivity(c *cli.Context) error { 12 | client := c.App.Metadata["client"].(*mastodon.Client) 13 | activities, err := client.GetInstanceActivity(context.Background()) 14 | if err != nil { 15 | return err 16 | } 17 | for _, activity := range activities { 18 | fmt.Fprintf(c.App.Writer, "Logins : %v\n", activity.Logins) 19 | fmt.Fprintf(c.App.Writer, "Registrations : %v\n", activity.Registrations) 20 | fmt.Fprintf(c.App.Writer, "Statuses : %v\n", activity.Statuses) 21 | fmt.Fprintf(c.App.Writer, "Week : %v\n", activity.Week) 22 | } 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_instance_peers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/mattn/go-mastodon" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func cmdInstancePeers(c *cli.Context) error { 12 | client := c.App.Metadata["client"].(*mastodon.Client) 13 | peers, err := client.GetInstancePeers(context.Background()) 14 | if err != nil { 15 | return err 16 | } 17 | for _, peer := range peers { 18 | fmt.Fprintln(c.App.Writer, peer) 19 | } 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_instance_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func TestCmdInstance(t *testing.T) { 13 | out := testWithServer( 14 | func(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/api/v1/instance": 17 | fmt.Fprintln(w, `{"title": "zzz"}`) 18 | return 19 | } 20 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 21 | return 22 | }, 23 | func(app *cli.App) { 24 | app.Run([]string{"mstdn", "instance"}) 25 | }, 26 | ) 27 | if !strings.Contains(out, "zzz") { 28 | t.Fatalf("%q should be contained in output of command: %v", "zzz", out) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_mikami.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | ) 6 | 7 | func cmdMikami(c *cli.Context) error { 8 | return xSearch(c.App.Metadata["xsearch_url"].(string), "三上", c.App.Writer) 9 | } 10 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_mikami_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | func TestCmdMikami(t *testing.T) { 14 | ok := false 15 | buf := bytes.NewBuffer(nil) 16 | testWithServer( 17 | func(w http.ResponseWriter, r *http.Request) { 18 | if r.URL.Query().Get("q") == "三上" { 19 | ok = true 20 | fmt.Fprintln(w, `
`) 21 | } 22 | }, 23 | func(app *cli.App) { 24 | app.Writer = buf 25 | err := app.Run([]string{"mstdn", "mikami"}) 26 | if err != nil { 27 | t.Fatalf("should not be fail: %v", err) 28 | } 29 | }, 30 | ) 31 | if !ok { 32 | t.Fatal("should be search Mikami") 33 | } 34 | result := buf.String() 35 | if !strings.Contains(result, "http://example.com/@test/1") { 36 | t.Fatalf("%q should be contained in output of search: %s", "http://example.com/@test/1", result) 37 | } 38 | if !strings.Contains(result, "三上") { 39 | t.Fatalf("%q should be contained in output of search: %s", "三上", result) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_notification.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/fatih/color" 8 | "github.com/mattn/go-mastodon" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func cmdNotification(c *cli.Context) error { 13 | client := c.App.Metadata["client"].(*mastodon.Client) 14 | notifications, err := client.GetNotifications(context.Background(), nil) 15 | if err != nil { 16 | return err 17 | } 18 | for _, n := range notifications { 19 | if n.Status != nil { 20 | color.Set(color.FgHiRed) 21 | fmt.Fprint(c.App.Writer, n.Account.Acct) 22 | color.Set(color.Reset) 23 | fmt.Fprintln(c.App.Writer, " "+n.Type) 24 | s := n.Status 25 | fmt.Fprintln(c.App.Writer, textContent(s.Content)) 26 | } 27 | } 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_notification_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func TestCmdNotification(t *testing.T) { 13 | out := testWithServer( 14 | func(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/api/v1/notifications": 17 | fmt.Fprintln(w, `[{"type": "rebloged", "status": {"content": "foo"}}]`) 18 | return 19 | } 20 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 21 | return 22 | }, 23 | func(app *cli.App) { 24 | app.Run([]string{"mstdn", "notification"}) 25 | }, 26 | ) 27 | if !strings.Contains(out, "rebloged") { 28 | t.Fatalf("%q should be contained in output of command: %v", "rebloged", out) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_search.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/mattn/go-mastodon" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func cmdSearch(c *cli.Context) error { 13 | if !c.Args().Present() { 14 | return errors.New("arguments required") 15 | } 16 | 17 | client := c.App.Metadata["client"].(*mastodon.Client) 18 | config := c.App.Metadata["config"].(*mastodon.Config) 19 | 20 | results, err := client.Search(context.Background(), argstr(c), false) 21 | if err != nil { 22 | return err 23 | } 24 | s := newScreen(config) 25 | if len(results.Accounts) > 0 { 26 | fmt.Fprintln(c.App.Writer, "===ACCOUNT===") 27 | for _, result := range results.Accounts { 28 | fmt.Fprintf(c.App.Writer, "%v,%v\n", result.ID, s.acct(result.Acct)) 29 | } 30 | fmt.Fprintln(c.App.Writer) 31 | } 32 | if len(results.Statuses) > 0 { 33 | fmt.Fprintln(c.App.Writer, "===STATUS===") 34 | for _, result := range results.Statuses { 35 | s.displayStatus(c.App.Writer, result) 36 | } 37 | fmt.Fprintln(c.App.Writer) 38 | } 39 | if len(results.Hashtags) > 0 { 40 | fmt.Fprintln(c.App.Writer, "===HASHTAG===") 41 | for _, result := range results.Hashtags { 42 | fmt.Fprintf(c.App.Writer, "#%v\n", result) 43 | } 44 | fmt.Fprintln(c.App.Writer) 45 | } 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_search_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func TestCmdSearch(t *testing.T) { 13 | out := testWithServer( 14 | func(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/api/v2/search": 17 | fmt.Fprintln(w, `{"accounts": [{"id": 234, "acct": "zzz"}], "statuses":[{"id": 345, "content": "yyy"}], "hashtags": [{"name": "www"}, {"name": "わろす"}]}`) 18 | return 19 | } 20 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 21 | return 22 | }, 23 | func(app *cli.App) { 24 | app.Run([]string{"mstdn", "search", "zzz"}) 25 | }, 26 | ) 27 | for _, s := range []string{"zzz", "yyy", "www", "わろす"} { 28 | if !strings.Contains(out, s) { 29 | t.Fatalf("%q should be contained in output of command: %v", s, out) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_stream.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "text/template" 11 | 12 | "github.com/mattn/go-mastodon" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | // SimpleJSON is a struct for output JSON for data to be simple used 17 | type SimpleJSON struct { 18 | ID mastodon.ID `json:"id"` 19 | Username string `json:"username"` 20 | Acct string `json:"acct"` 21 | Avatar string `json:"avatar"` 22 | Content string `json:"content"` 23 | } 24 | 25 | func checkFlag(f ...bool) bool { 26 | n := 0 27 | for _, on := range f { 28 | if on { 29 | n++ 30 | } 31 | } 32 | return n > 1 33 | } 34 | 35 | func cmdStream(c *cli.Context) error { 36 | asJSON := c.Bool("json") 37 | asSimpleJSON := c.Bool("simplejson") 38 | asFormat := c.String("template") 39 | 40 | if checkFlag(asJSON, asSimpleJSON, asFormat != "") { 41 | return errors.New("cannot speicify two or three options in --json/--simplejson/--template") 42 | } 43 | tx, err := template.New("mstdn").Funcs(template.FuncMap{ 44 | "nl": func(s string) string { 45 | return s + "\n" 46 | }, 47 | "text": textContent, 48 | }).Parse(asFormat) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | client := c.App.Metadata["client"].(*mastodon.Client) 54 | config := c.App.Metadata["config"].(*mastodon.Config) 55 | 56 | ctx, cancel := context.WithCancel(context.Background()) 57 | defer cancel() 58 | sc := make(chan os.Signal, 1) 59 | signal.Notify(sc, os.Interrupt) 60 | 61 | var q chan mastodon.Event 62 | 63 | t := c.String("type") 64 | if t == "public" { 65 | q, err = client.StreamingPublic(ctx, false) 66 | } else if t == "" || t == "public/local" { 67 | q, err = client.StreamingPublic(ctx, true) 68 | } else if strings.HasPrefix(t, "user:") { 69 | q, err = client.StreamingUser(ctx) 70 | } else if strings.HasPrefix(t, "hashtag:") { 71 | q, err = client.StreamingHashtag(ctx, t[8:], false) 72 | } else { 73 | return errors.New("invalid type") 74 | } 75 | if err != nil { 76 | return err 77 | } 78 | go func() { 79 | <-sc 80 | cancel() 81 | }() 82 | 83 | c.App.Metadata["signal"] = sc 84 | 85 | s := newScreen(config) 86 | for e := range q { 87 | if asJSON { 88 | json.NewEncoder(c.App.Writer).Encode(e) 89 | } else if asSimpleJSON { 90 | switch t := e.(type) { 91 | case *mastodon.UpdateEvent: 92 | json.NewEncoder(c.App.Writer).Encode(&SimpleJSON{ 93 | ID: t.Status.ID, 94 | Username: t.Status.Account.Username, 95 | Acct: t.Status.Account.Acct, 96 | Avatar: t.Status.Account.AvatarStatic, 97 | Content: textContent(t.Status.Content), 98 | }) 99 | case *mastodon.UpdateEditEvent: 100 | json.NewEncoder(c.App.Writer).Encode(&SimpleJSON{ 101 | ID: t.Status.ID, 102 | Username: t.Status.Account.Username, 103 | Acct: t.Status.Account.Acct, 104 | Avatar: t.Status.Account.AvatarStatic, 105 | Content: textContent(t.Status.Content), 106 | }) 107 | } 108 | } else if asFormat != "" { 109 | tx.ExecuteTemplate(c.App.Writer, "mstdn", e) 110 | } else { 111 | switch t := e.(type) { 112 | case *mastodon.UpdateEvent: 113 | s.displayStatus(c.App.Writer, t.Status) 114 | case *mastodon.UpdateEditEvent: 115 | s.displayStatus(c.App.Writer, t.Status) 116 | case *mastodon.NotificationEvent: 117 | // TODO s.displayStatus(c.App.Writer, t.Notification.Status) 118 | case *mastodon.ErrorEvent: 119 | s.displayError(c.App.Writer, t) 120 | } 121 | } 122 | } 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_stream_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/mattn/go-mastodon" 14 | ) 15 | 16 | func TestCmdStream(t *testing.T) { 17 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | if r.URL.Path != "/api/v1/streaming/public/local" { 19 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 20 | return 21 | } 22 | f, _ := w.(http.Flusher) 23 | fmt.Fprintln(w, ` 24 | event: update 25 | data: {"content": "foo", "account":{"acct":"FOO"}} 26 | `) 27 | f.Flush() 28 | 29 | fmt.Fprintln(w, ` 30 | event: update 31 | data: {"content": "bar", "account":{"acct":"BAR"}} 32 | `) 33 | f.Flush() 34 | return 35 | })) 36 | defer ts.Close() 37 | 38 | config := &mastodon.Config{ 39 | Server: ts.URL, 40 | ClientID: "foo", 41 | ClientSecret: "bar", 42 | AccessToken: "zoo", 43 | } 44 | client := mastodon.NewClient(config) 45 | 46 | var buf bytes.Buffer 47 | app := makeApp() 48 | app.Writer = &buf 49 | app.Metadata = map[string]interface{}{ 50 | "client": client, 51 | "config": config, 52 | } 53 | 54 | stop := func() { 55 | time.Sleep(5 * time.Second) 56 | if sig, ok := app.Metadata["signal"]; ok { 57 | sig.(chan os.Signal) <- os.Interrupt 58 | return 59 | } 60 | panic("timeout") 61 | } 62 | 63 | var out string 64 | 65 | go stop() 66 | app.Run([]string{"mstdn", "stream"}) 67 | out = buf.String() 68 | if !strings.Contains(out, "FOO@") { 69 | t.Fatalf("%q should be contained in output of command: %v", "FOO@", out) 70 | } 71 | if !strings.Contains(out, "foo") { 72 | t.Fatalf("%q should be contained in output of command: %v", "foo", out) 73 | } 74 | 75 | go stop() 76 | app.Run([]string{"mstdn", "stream", "--simplejson"}) 77 | out = buf.String() 78 | if !strings.Contains(out, "FOO@") { 79 | t.Fatalf("%q should be contained in output of command: %v", "FOO@", out) 80 | } 81 | if !strings.Contains(out, "foo") { 82 | t.Fatalf("%q should be contained in output of command: %v", "foo", out) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | 8 | "github.com/mattn/go-mastodon" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func testWithServer(h http.HandlerFunc, testFuncs ...func(*cli.App)) string { 13 | ts := httptest.NewServer(h) 14 | defer ts.Close() 15 | 16 | cli.OsExiter = func(n int) {} 17 | 18 | client := mastodon.NewClient(&mastodon.Config{ 19 | Server: ts.URL, 20 | ClientID: "foo", 21 | ClientSecret: "bar", 22 | AccessToken: "zoo", 23 | }) 24 | 25 | var buf bytes.Buffer 26 | app := makeApp() 27 | app.Writer = &buf 28 | app.Metadata = map[string]interface{}{ 29 | "client": client, 30 | "config": &mastodon.Config{ 31 | Server: "https://example.com", 32 | }, 33 | "xsearch_url": ts.URL, 34 | } 35 | 36 | for _, f := range testFuncs { 37 | f(app) 38 | } 39 | 40 | return buf.String() 41 | } 42 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_timeline.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | 8 | "github.com/mattn/go-mastodon" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func cmdTimeline(c *cli.Context) error { 13 | client := c.App.Metadata["client"].(*mastodon.Client) 14 | config := c.App.Metadata["config"].(*mastodon.Config) 15 | timeline, err := client.GetTimelineHome(context.Background(), nil) 16 | if err != nil { 17 | return err 18 | } 19 | s := newScreen(config) 20 | for i := len(timeline) - 1; i >= 0; i-- { 21 | s.displayStatus(c.App.Writer, timeline[i]) 22 | } 23 | return nil 24 | } 25 | 26 | func cmdTimelineHome(c *cli.Context) error { 27 | return cmdTimeline(c) 28 | } 29 | 30 | func cmdTimelinePublic(c *cli.Context) error { 31 | client := c.App.Metadata["client"].(*mastodon.Client) 32 | config := c.App.Metadata["config"].(*mastodon.Config) 33 | timeline, err := client.GetTimelinePublic(context.Background(), false, nil) 34 | if err != nil { 35 | return err 36 | } 37 | s := newScreen(config) 38 | for i := len(timeline) - 1; i >= 0; i-- { 39 | s.displayStatus(c.App.Writer, timeline[i]) 40 | } 41 | return nil 42 | } 43 | 44 | func cmdTimelineLocal(c *cli.Context) error { 45 | client := c.App.Metadata["client"].(*mastodon.Client) 46 | config := c.App.Metadata["config"].(*mastodon.Config) 47 | timeline, err := client.GetTimelinePublic(context.Background(), true, nil) 48 | if err != nil { 49 | return err 50 | } 51 | s := newScreen(config) 52 | for i := len(timeline) - 1; i >= 0; i-- { 53 | s.displayStatus(c.App.Writer, timeline[i]) 54 | } 55 | return nil 56 | } 57 | 58 | func cmdTimelineDirect(c *cli.Context) error { 59 | client := c.App.Metadata["client"].(*mastodon.Client) 60 | config := c.App.Metadata["config"].(*mastodon.Config) 61 | timeline, err := client.GetTimelineDirect(context.Background(), nil) 62 | if err != nil { 63 | return err 64 | } 65 | s := newScreen(config) 66 | for i := len(timeline) - 1; i >= 0; i-- { 67 | s.displayStatus(c.App.Writer, timeline[i]) 68 | } 69 | return nil 70 | } 71 | 72 | func cmdTimelineHashtag(c *cli.Context) error { 73 | if !c.Args().Present() { 74 | return errors.New("arguments required") 75 | } 76 | local := c.Bool("local") 77 | tag := strings.TrimLeft(argstr(c), "#") 78 | 79 | client := c.App.Metadata["client"].(*mastodon.Client) 80 | config := c.App.Metadata["config"].(*mastodon.Config) 81 | timeline, err := client.GetTimelineHashtag(context.Background(), tag, local, nil) 82 | if err != nil { 83 | return err 84 | } 85 | s := newScreen(config) 86 | for i := len(timeline) - 1; i >= 0; i-- { 87 | s.displayStatus(c.App.Writer, timeline[i]) 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_timeline_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func TestCmdTimeline(t *testing.T) { 13 | out := testWithServer( 14 | func(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/api/v1/timelines/home": 17 | fmt.Fprintln(w, `[{"content": "home"}]`) 18 | return 19 | case "/api/v1/timelines/public": 20 | fmt.Fprintln(w, `[{"content": "public"}]`) 21 | return 22 | case "/api/v1/conversations": 23 | fmt.Fprintln(w, `[{"id": "4", "unread":false, "last_status" : {"content": "direct"}}]`) 24 | return 25 | } 26 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 27 | return 28 | }, 29 | func(app *cli.App) { 30 | app.Run([]string{"mstdn", "timeline"}) 31 | app.Run([]string{"mstdn", "timeline-home"}) 32 | app.Run([]string{"mstdn", "timeline-public"}) 33 | app.Run([]string{"mstdn", "timeline-local"}) 34 | app.Run([]string{"mstdn", "timeline-direct"}) 35 | }, 36 | ) 37 | want := strings.Join([]string{ 38 | "@example.com", 39 | "home", 40 | "@example.com", 41 | "home", 42 | "@example.com", 43 | "public", 44 | "@example.com", 45 | "public", 46 | "@example.com", 47 | "direct", 48 | }, "\n") + "\n" 49 | if !strings.Contains(out, want) { 50 | t.Fatalf("%q should be contained in output of command: %v", want, out) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_toot.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/mattn/go-mastodon" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func cmdToot(c *cli.Context) error { 13 | var toot string 14 | ff := c.String("ff") 15 | if ff != "" { 16 | text, err := readFile(ff) 17 | if err != nil { 18 | return err 19 | } 20 | toot = string(text) 21 | } else { 22 | if !c.Args().Present() { 23 | return errors.New("arguments required") 24 | } 25 | toot = argstr(c) 26 | } 27 | client := c.App.Metadata["client"].(*mastodon.Client) 28 | _, err := client.PostStatus(context.Background(), &mastodon.Toot{ 29 | Status: toot, 30 | InReplyToID: mastodon.ID(fmt.Sprint(c.String("i"))), 31 | }) 32 | return err 33 | } 34 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_toot_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func TestCmdToot(t *testing.T) { 12 | toot := "" 13 | testWithServer( 14 | func(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/api/v1/statuses": 17 | toot = r.FormValue("status") 18 | fmt.Fprintln(w, `{"id": 2345}`) 19 | return 20 | } 21 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 22 | return 23 | }, 24 | func(app *cli.App) { 25 | app.Run([]string{"mstdn", "toot", "foo"}) 26 | }, 27 | ) 28 | if toot != "foo" { 29 | t.Fatalf("want %q, got %q", "foo", toot) 30 | } 31 | } 32 | 33 | func TestCmdTootFileNotFound(t *testing.T) { 34 | var err error 35 | testWithServer( 36 | func(w http.ResponseWriter, r *http.Request) { 37 | switch r.URL.Path { 38 | case "/api/v1/statuses": 39 | fmt.Fprintln(w, `{"id": 2345}`) 40 | return 41 | } 42 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 43 | return 44 | }, 45 | func(app *cli.App) { 46 | err = app.Run([]string{"mstdn", "toot", "-ff", "not-found"}) 47 | }, 48 | ) 49 | if err == nil { 50 | t.Fatal("should be fail") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_upload.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/mattn/go-mastodon" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func cmdUpload(c *cli.Context) error { 13 | if !c.Args().Present() { 14 | return errors.New("arguments required") 15 | } 16 | client := c.App.Metadata["client"].(*mastodon.Client) 17 | for i := 0; i < c.NArg(); i++ { 18 | attachment, err := client.UploadMedia(context.Background(), c.Args().Get(i)) 19 | if err != nil { 20 | return err 21 | } 22 | if i > 0 { 23 | fmt.Fprintln(c.App.Writer) 24 | } 25 | fmt.Fprintf(c.App.Writer, "ID : %v\n", attachment.ID) 26 | fmt.Fprintf(c.App.Writer, "Type : %v\n", attachment.Type) 27 | fmt.Fprintf(c.App.Writer, "URL : %v\n", attachment.URL) 28 | fmt.Fprintf(c.App.Writer, "RemoteURL : %v\n", attachment.RemoteURL) 29 | fmt.Fprintf(c.App.Writer, "PreviewURL: %v\n", attachment.PreviewURL) 30 | fmt.Fprintf(c.App.Writer, "TextURL : %v\n", attachment.TextURL) 31 | } 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_upload_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func TestCmdUpload(t *testing.T) { 13 | out := testWithServer( 14 | func(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/api/v1/media": 17 | fmt.Fprintln(w, `{"id": 123}`) 18 | return 19 | } 20 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 21 | return 22 | }, 23 | func(app *cli.App) { 24 | app.Run([]string{"mstdn", "upload", "../../testdata/logo.png"}) 25 | }, 26 | ) 27 | if !strings.Contains(out, "123") { 28 | t.Fatalf("%q should be contained in output of command: %v", "123", out) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_xsearch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/url" 7 | 8 | "github.com/PuerkitoBio/goquery" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func cmdXSearch(c *cli.Context) error { 13 | return xSearch(c.App.Metadata["xsearch_url"].(string), c.Args().First(), c.App.Writer) 14 | } 15 | 16 | func xSearch(xsearchRawurl, query string, w io.Writer) error { 17 | u, err := url.Parse(xsearchRawurl) 18 | if err != nil { 19 | return err 20 | } 21 | params := url.Values{} 22 | params.Set("q", query) 23 | u.RawQuery = params.Encode() 24 | doc, err := goquery.NewDocument(u.String()) 25 | if err != nil { 26 | return err 27 | } 28 | doc.Find(".post").Each(func(n int, elem *goquery.Selection) { 29 | href, ok := elem.Find(".mst_content a").Attr("href") 30 | if !ok { 31 | return 32 | } 33 | text := elem.Find(".mst_content p").Text() 34 | fmt.Fprintf(w, "%s\n", href) 35 | fmt.Fprintf(w, "%s\n\n", text) 36 | }) 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /cmd/mstdn/cmd_xsearch_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | func TestCmdXSearch(t *testing.T) { 15 | testWithServer( 16 | func(w http.ResponseWriter, r *http.Request) { 17 | fmt.Fprintln(w, `
`) 18 | }, 19 | func(app *cli.App) { 20 | err := app.Run([]string{"mstdn", "xsearch", "test"}) 21 | if err != nil { 22 | t.Fatalf("should not be fail: %v", err) 23 | } 24 | }, 25 | ) 26 | } 27 | 28 | func TestXSearch(t *testing.T) { 29 | canErr := true 30 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | if canErr { 32 | canErr = false 33 | http.Error(w, http.StatusText(http.StatusInternalServerError), 9999) 34 | return 35 | } else if r.URL.Query().Get("q") == "empty" { 36 | fmt.Fprintln(w, `
`) 37 | return 38 | } 39 | 40 | fmt.Fprintln(w, `
`) 41 | })) 42 | defer ts.Close() 43 | 44 | err := xSearch(":", "", nil) 45 | if err == nil { 46 | t.Fatalf("should be fail: %v", err) 47 | } 48 | 49 | err = xSearch(ts.URL, "", nil) 50 | if err == nil { 51 | t.Fatalf("should be fail: %v", err) 52 | } 53 | 54 | buf := bytes.NewBuffer(nil) 55 | err = xSearch(ts.URL, "empty", buf) 56 | if err != nil { 57 | t.Fatalf("should not be fail: %v", err) 58 | } 59 | result := buf.String() 60 | if result != "" { 61 | t.Fatalf("the search result should be empty: %s", result) 62 | } 63 | 64 | buf = bytes.NewBuffer(nil) 65 | err = xSearch(ts.URL, "test", buf) 66 | if err != nil { 67 | t.Fatalf("should not be fail: %v", err) 68 | } 69 | result = buf.String() 70 | if !strings.Contains(result, "http://example.com/@test/1") { 71 | t.Fatalf("%q should be contained in output of search: %s", "http://example.com/@test/1", result) 72 | } 73 | if !strings.Contains(result, "test status") { 74 | t.Fatalf("%q should be contained in output of search: %s", "test status", result) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /cmd/mstdn/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mattn/go-mastodon/cmd/mstdn 2 | 3 | go 1.21 4 | 5 | toolchain go1.22.2 6 | 7 | replace github.com/mattn/go-mastodon => ../.. 8 | 9 | require ( 10 | github.com/PuerkitoBio/goquery v1.9.2 11 | github.com/fatih/color v1.16.0 12 | github.com/mattn/go-mastodon v0.0.6 13 | github.com/mattn/go-tty v0.0.5 14 | github.com/urfave/cli/v2 v2.27.2 15 | golang.org/x/net v0.24.0 16 | ) 17 | 18 | require ( 19 | github.com/andybalholm/cascadia v1.3.2 // indirect 20 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 21 | github.com/gorilla/websocket v1.5.1 // indirect 22 | github.com/mattn/go-colorable v0.1.13 // indirect 23 | github.com/mattn/go-isatty v0.0.20 // indirect 24 | github.com/mattn/go-mastodon v1.0.0 // indirect 25 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 26 | github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect 27 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect 28 | golang.org/x/sys v0.19.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /cmd/mstdn/go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= 2 | github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= 3 | github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= 4 | github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= 6 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 7 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 8 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 9 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 10 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 11 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 12 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 13 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 14 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 15 | github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= 16 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 17 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 18 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 19 | github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 20 | github.com/mattn/go-tty v0.0.5 h1:s09uXI7yDbXzzTTfw3zonKFzwGkyYlgU3OMjqA0ddz4= 21 | github.com/mattn/go-tty v0.0.5/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28= 22 | github.com/mattn/go-mastodon v1.0.0 h1:XF2aKXORjfnnuG/W4tO8JqsAH/dmkrRFNwroDZxybKE= 23 | github.com/mattn/go-mastodon v1.0.0/go.mod h1:Yyy/VEwVUBd/GKUqmd70tQYd1T3hFt6yJ1HLx0pu9EE= 24 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 25 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 26 | github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= 27 | github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= 28 | github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= 29 | github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= 30 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= 31 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= 32 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 33 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 34 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 35 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 36 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 37 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 38 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 39 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 40 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 41 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 42 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 43 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 44 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 45 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 46 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 47 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 48 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 49 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 50 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 51 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 60 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 61 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 62 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 63 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 64 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 65 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 66 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 67 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 68 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 69 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 70 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 71 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 72 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 73 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 74 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 75 | -------------------------------------------------------------------------------- /cmd/mstdn/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "net/url" 12 | "os" 13 | "path/filepath" 14 | "runtime" 15 | "strings" 16 | 17 | "github.com/fatih/color" 18 | "github.com/mattn/go-mastodon" 19 | "github.com/mattn/go-tty" 20 | "github.com/urfave/cli/v2" 21 | "golang.org/x/net/html" 22 | ) 23 | 24 | func readFile(filename string) ([]byte, error) { 25 | if filename == "-" { 26 | return ioutil.ReadAll(os.Stdin) 27 | } 28 | return ioutil.ReadFile(filename) 29 | } 30 | 31 | func textContent(s string) string { 32 | doc, err := html.Parse(strings.NewReader(s)) 33 | if err != nil { 34 | return s 35 | } 36 | var buf bytes.Buffer 37 | 38 | var extractText func(node *html.Node, w *bytes.Buffer) 39 | extractText = func(node *html.Node, w *bytes.Buffer) { 40 | if node.Type == html.TextNode { 41 | data := strings.Trim(node.Data, "\r\n") 42 | if data != "" { 43 | w.WriteString(data) 44 | } 45 | } 46 | for c := node.FirstChild; c != nil; c = c.NextSibling { 47 | extractText(c, w) 48 | } 49 | if node.Type == html.ElementNode { 50 | name := strings.ToLower(node.Data) 51 | if name == "br" { 52 | w.WriteString("\n") 53 | } 54 | } 55 | } 56 | extractText(doc, &buf) 57 | return buf.String() 58 | } 59 | 60 | var ( 61 | readUsername = func() (string, error) { 62 | b, _, err := bufio.NewReader(os.Stdin).ReadLine() 63 | if err != nil { 64 | return "", err 65 | } 66 | return string(b), nil 67 | } 68 | readPassword func() (string, error) 69 | ) 70 | 71 | func prompt() (string, string, error) { 72 | fmt.Print("E-Mail: ") 73 | email, err := readUsername() 74 | if err != nil { 75 | return "", "", err 76 | } 77 | 78 | fmt.Print("Password: ") 79 | var password string 80 | if readPassword == nil { 81 | var t *tty.TTY 82 | t, err = tty.Open() 83 | if err != nil { 84 | return "", "", err 85 | } 86 | defer t.Close() 87 | password, err = t.ReadPassword() 88 | } else { 89 | password, err = readPassword() 90 | } 91 | if err != nil { 92 | return "", "", err 93 | } 94 | return email, password, nil 95 | } 96 | 97 | func configFile(c *cli.Context) (string, error) { 98 | dir := os.Getenv("HOME") 99 | if runtime.GOOS == "windows" { 100 | dir = os.Getenv("APPDATA") 101 | if dir == "" { 102 | dir = filepath.Join(os.Getenv("USERPROFILE"), "Application Data", "mstdn") 103 | } 104 | dir = filepath.Join(dir, "mstdn") 105 | } else { 106 | dir = filepath.Join(dir, ".config", "mstdn") 107 | } 108 | if err := os.MkdirAll(dir, 0700); err != nil { 109 | return "", err 110 | } 111 | var file string 112 | profile := c.String("profile") 113 | if profile != "" { 114 | file = filepath.Join(dir, "settings-"+profile+".json") 115 | } else { 116 | file = filepath.Join(dir, "settings.json") 117 | } 118 | return file, nil 119 | } 120 | 121 | func getConfig(c *cli.Context) (string, *mastodon.Config, error) { 122 | file, err := configFile(c) 123 | if err != nil { 124 | return "", nil, err 125 | } 126 | b, err := ioutil.ReadFile(file) 127 | if err != nil && !os.IsNotExist(err) { 128 | return "", nil, err 129 | } 130 | config := &mastodon.Config{ 131 | Server: "https://mstdn.jp", 132 | ClientID: "1e463436008428a60ed14ff1f7bc0b4d923e14fc4a6827fa99560b0c0222612f", 133 | ClientSecret: "72b63de5bc11111a5aa1a7b690672d78ad6a207ce32e16ea26115048ec5d234d", 134 | } 135 | if err == nil { 136 | err = json.Unmarshal(b, &config) 137 | if err != nil { 138 | return "", nil, fmt.Errorf("could not unmarshal %v: %v", file, err) 139 | } 140 | } 141 | return file, config, nil 142 | } 143 | 144 | func authenticate(client *mastodon.Client, config *mastodon.Config, file string) error { 145 | email, password, err := prompt() 146 | if err != nil { 147 | return err 148 | } 149 | err = client.Authenticate(context.Background(), email, password) 150 | if err != nil { 151 | return err 152 | } 153 | b, err := json.MarshalIndent(config, "", " ") 154 | if err != nil { 155 | return fmt.Errorf("failed to store file: %v", err) 156 | } 157 | err = ioutil.WriteFile(file, b, 0700) 158 | if err != nil { 159 | return fmt.Errorf("failed to store file: %v", err) 160 | } 161 | return nil 162 | } 163 | 164 | func argstr(c *cli.Context) string { 165 | a := []string{} 166 | for i := 0; i < c.NArg(); i++ { 167 | a = append(a, c.Args().Get(i)) 168 | } 169 | return strings.Join(a, " ") 170 | } 171 | 172 | func fatalIf(err error) { 173 | if err == nil { 174 | return 175 | } 176 | fmt.Fprintf(os.Stderr, "%s: %v\n", os.Args[0], err) 177 | os.Exit(1) 178 | } 179 | 180 | func makeApp() *cli.App { 181 | app := cli.NewApp() 182 | app.Name = "mstdn" 183 | app.Usage = "mastodon client" 184 | app.Version = "0.0.1" 185 | app.Flags = []cli.Flag{ 186 | &cli.StringFlag{ 187 | Name: "profile", 188 | Usage: "profile name", 189 | Value: "", 190 | }, 191 | } 192 | app.Commands = []*cli.Command{ 193 | { 194 | Name: "toot", 195 | Usage: "post toot", 196 | Flags: []cli.Flag{ 197 | &cli.StringFlag{ 198 | Name: "ff", 199 | Usage: "post utf-8 string from a file(\"-\" means STDIN)", 200 | Value: "", 201 | }, 202 | &cli.StringFlag{ 203 | Name: "i", 204 | Usage: "in-reply-to", 205 | Value: "", 206 | }, 207 | }, 208 | Action: cmdToot, 209 | }, 210 | { 211 | Name: "stream", 212 | Usage: "stream statuses", 213 | Flags: []cli.Flag{ 214 | &cli.StringFlag{ 215 | Name: "type", 216 | Usage: "stream type (public,public/local,user:NAME,hashtag:TAG)", 217 | }, 218 | &cli.BoolFlag{ 219 | Name: "json", 220 | Usage: "output JSON", 221 | }, 222 | &cli.BoolFlag{ 223 | Name: "simplejson", 224 | Usage: "output simple JSON", 225 | }, 226 | &cli.StringFlag{ 227 | Name: "template", 228 | Usage: "output with tamplate format", 229 | }, 230 | }, 231 | Action: cmdStream, 232 | }, 233 | { 234 | Name: "timeline", 235 | Usage: "show timeline", 236 | Action: cmdTimeline, 237 | }, 238 | { 239 | Name: "timeline-home", 240 | Usage: "show timeline home", 241 | Action: cmdTimelineHome, 242 | }, 243 | { 244 | Name: "timeline-local", 245 | Usage: "show timeline local", 246 | Action: cmdTimelineLocal, 247 | }, 248 | { 249 | Name: "timeline-public", 250 | Usage: "show timeline public", 251 | Action: cmdTimelinePublic, 252 | }, 253 | { 254 | Name: "timeline-direct", 255 | Usage: "show timeline direct", 256 | Action: cmdTimelineDirect, 257 | }, 258 | { 259 | Name: "timeline-tag", 260 | Flags: []cli.Flag{ 261 | &cli.BoolFlag{ 262 | Name: "local", 263 | Usage: "local tags only", 264 | }, 265 | }, 266 | Usage: "show tagged timeline", 267 | Action: cmdTimelineHashtag, 268 | }, 269 | { 270 | Name: "notification", 271 | Usage: "show notification", 272 | Action: cmdNotification, 273 | }, 274 | { 275 | Name: "instance", 276 | Usage: "show instance information", 277 | Action: cmdInstance, 278 | }, 279 | { 280 | Name: "instance_activity", 281 | Usage: "show instance activity information", 282 | Action: cmdInstanceActivity, 283 | }, 284 | { 285 | Name: "instance_peers", 286 | Usage: "show instance peers information", 287 | Action: cmdInstancePeers, 288 | }, 289 | { 290 | Name: "account", 291 | Usage: "show account information", 292 | Action: cmdAccount, 293 | }, 294 | { 295 | Name: "search", 296 | Usage: "search content", 297 | Action: cmdSearch, 298 | }, 299 | { 300 | Name: "follow", 301 | Usage: "follow account", 302 | Action: cmdFollow, 303 | }, 304 | { 305 | Name: "followers", 306 | Usage: "show followers", 307 | Action: cmdFollowers, 308 | }, 309 | { 310 | Name: "upload", 311 | Usage: "upload file", 312 | Action: cmdUpload, 313 | }, 314 | { 315 | Name: "delete", 316 | Usage: "delete status", 317 | Action: cmdDelete, 318 | }, 319 | { 320 | Name: "init", 321 | Usage: "initialize profile", 322 | Action: func(c *cli.Context) error { return nil }, 323 | }, 324 | { 325 | Name: "mikami", 326 | Usage: "search mikami", 327 | Action: cmdMikami, 328 | }, 329 | { 330 | Name: "xsearch", 331 | Usage: "cross search", 332 | Action: cmdXSearch, 333 | }, 334 | } 335 | app.Setup() 336 | return app 337 | } 338 | 339 | type screen struct { 340 | host string 341 | } 342 | 343 | func newScreen(config *mastodon.Config) *screen { 344 | var host string 345 | u, err := url.Parse(config.Server) 346 | if err == nil { 347 | host = u.Host 348 | } 349 | return &screen{host} 350 | } 351 | 352 | func (s *screen) acct(a string) string { 353 | if !strings.Contains(a, "@") { 354 | a += "@" + s.host 355 | } 356 | return a 357 | } 358 | 359 | func (s *screen) displayError(w io.Writer, e error) { 360 | color.Set(color.FgYellow) 361 | fmt.Fprintln(w, e.Error()) 362 | color.Set(color.Reset) 363 | } 364 | 365 | func (s *screen) displayStatus(w io.Writer, t *mastodon.Status) { 366 | if t == nil { 367 | return 368 | } 369 | if t.Reblog != nil { 370 | color.Set(color.FgHiRed) 371 | fmt.Fprint(w, s.acct(t.Account.Acct)) 372 | color.Set(color.Reset) 373 | fmt.Fprint(w, " reblogged ") 374 | color.Set(color.FgHiBlue) 375 | fmt.Fprintln(w, s.acct(t.Reblog.Account.Acct)) 376 | fmt.Fprintln(w, textContent(t.Reblog.Content)) 377 | color.Set(color.Reset) 378 | } else { 379 | color.Set(color.FgHiRed) 380 | fmt.Fprintln(w, s.acct(t.Account.Acct)) 381 | color.Set(color.Reset) 382 | fmt.Fprintln(w, textContent(t.Content)) 383 | } 384 | } 385 | 386 | func run() int { 387 | app := makeApp() 388 | 389 | app.Before = func(c *cli.Context) error { 390 | if c.Args().Get(0) == "init" { 391 | file, err := configFile(c) 392 | if err != nil { 393 | return err 394 | } 395 | os.Remove(file) 396 | } 397 | 398 | file, config, err := getConfig(c) 399 | if err != nil { 400 | return err 401 | } 402 | 403 | client := mastodon.NewClient(config) 404 | client.UserAgent = "mstdn" 405 | app.Metadata = map[string]interface{}{ 406 | "client": client, 407 | "config": config, 408 | "xsearch_url": "http://mastodonsearch.jp/cross/", 409 | } 410 | if config.AccessToken == "" { 411 | return authenticate(client, config, file) 412 | } 413 | return nil 414 | } 415 | 416 | fatalIf(app.Run(os.Args)) 417 | return 0 418 | } 419 | 420 | func main() { 421 | os.Exit(run()) 422 | } 423 | -------------------------------------------------------------------------------- /cmd/mstdn/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "io/ioutil" 7 | "os" 8 | "testing" 9 | 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | func TestReadFileFile(t *testing.T) { 14 | b, err := readFile("main.go") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | if len(b) == 0 { 19 | t.Fatalf("should read something: %v", err) 20 | } 21 | } 22 | 23 | func TestReadFileStdin(t *testing.T) { 24 | f, err := os.Open("main.go") 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | defer f.Close() 29 | stdin := os.Stdin 30 | os.Stdin = f 31 | defer func() { 32 | os.Stdin = stdin 33 | }() 34 | 35 | b, err := readFile("-") 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | if len(b) == 0 { 40 | t.Fatalf("should read something: %v", err) 41 | } 42 | } 43 | 44 | func TestTextContent(t *testing.T) { 45 | tests := []struct { 46 | input string 47 | want string 48 | }{ 49 | {input: "", want: ""}, 50 | {input: "

foo

", want: "foo"}, 51 | {input: "

foo\nbar\nbaz

", want: "foobarbaz"}, 52 | {input: "

foo\nbar
baz

", want: "foobar\nbaz"}, 53 | } 54 | for _, test := range tests { 55 | got := textContent(test.input) 56 | if got != test.want { 57 | t.Fatalf("want %q but %q", test.want, got) 58 | } 59 | } 60 | } 61 | 62 | func TestGetConfig(t *testing.T) { 63 | tmpdir, err := ioutil.TempDir("", "mstdn") 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | home := os.Getenv("HOME") 68 | appdata := os.Getenv("APPDATA") 69 | os.Setenv("HOME", tmpdir) 70 | os.Setenv("APPDATA", tmpdir) 71 | defer func() { 72 | os.RemoveAll(tmpdir) 73 | os.Setenv("HOME", home) 74 | os.Setenv("APPDATA", appdata) 75 | }() 76 | 77 | app := makeApp() 78 | set := flag.NewFlagSet("test", 0) 79 | set.Parse([]string{"mstdn", "-profile", ""}) 80 | c := cli.NewContext(app, set, nil) 81 | file, config, err := getConfig(c) 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | if _, err := os.Stat(file); err == nil { 86 | t.Fatal("should not exists") 87 | } 88 | if config.AccessToken != "" { 89 | t.Fatalf("should be empty: %v", config.AccessToken) 90 | } 91 | if config.ClientID == "" { 92 | t.Fatalf("should not be empty") 93 | } 94 | if config.ClientSecret == "" { 95 | t.Fatalf("should not be empty") 96 | } 97 | config.AccessToken = "foo" 98 | b, err := json.MarshalIndent(config, "", " ") 99 | if err != nil { 100 | t.Fatal(err) 101 | } 102 | err = ioutil.WriteFile(file, b, 0700) 103 | if err != nil { 104 | t.Fatal(err) 105 | } 106 | file, config, err = getConfig(c) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | if _, err := os.Stat(file); err != nil { 111 | t.Fatalf("should exists: %v", err) 112 | } 113 | if got := config.AccessToken; got != "foo" { 114 | t.Fatalf("want %q but %q", "foo", got) 115 | } 116 | } 117 | 118 | func TestPrompt(t *testing.T) { 119 | readUsername = func() (string, error) { 120 | return "foo", nil 121 | } 122 | readPassword = func() (string, error) { 123 | return "bar", nil 124 | } 125 | username, password, err := prompt() 126 | if err != nil { 127 | t.Fatal(err) 128 | } 129 | if username != "foo" { 130 | t.Fatalf("want %q but %q", "foo", username) 131 | } 132 | if password != "bar" { 133 | t.Fatalf("want %q but %q", "bar", password) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /compat.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | ) 7 | 8 | type ID string 9 | 10 | func (id *ID) UnmarshalJSON(data []byte) error { 11 | if len(data) > 0 && data[0] == '"' && data[len(data)-1] == '"' { 12 | var s string 13 | if err := json.Unmarshal(data, &s); err != nil { 14 | return err 15 | } 16 | *id = ID(s) 17 | return nil 18 | } 19 | var n int64 20 | if err := json.Unmarshal(data, &n); err != nil { 21 | return err 22 | } 23 | *id = ID(strconv.FormatInt(n, 10)) 24 | return nil 25 | } 26 | 27 | // Compare compares the Mastodon IDs i and j. 28 | // Compare returns: 29 | // 30 | // -1 if i is less than j, 31 | // 0 if i equals j, 32 | // -1 if j is greater than i. 33 | // 34 | // Compare can be used as an argument of [slices.SortFunc]: 35 | // 36 | // slices.SortFunc([]mastodon.ID{id1, id2}, mastodon.ID.Compare) 37 | func (i ID) Compare(j ID) int { 38 | var ( 39 | ii = i.u64() 40 | jj = j.u64() 41 | ) 42 | 43 | switch { 44 | case ii < jj: 45 | return -1 46 | case ii == jj: 47 | return 0 48 | case jj < ii: 49 | return +1 50 | } 51 | panic("impossible") 52 | } 53 | 54 | func (i ID) u64() uint64 { 55 | if i == "" { 56 | return 0 57 | } 58 | v, err := strconv.ParseUint(string(i), 10, 64) 59 | if err != nil { 60 | panic(err) 61 | } 62 | return v 63 | } 64 | 65 | type Sbool bool 66 | 67 | func (s *Sbool) UnmarshalJSON(data []byte) error { 68 | if len(data) > 0 && data[0] == '"' && data[len(data)-1] == '"' { 69 | var str string 70 | if err := json.Unmarshal(data, &str); err != nil { 71 | return err 72 | } 73 | b, err := strconv.ParseBool(str) 74 | if err != nil { 75 | return err 76 | } 77 | *s = Sbool(b) 78 | return nil 79 | } 80 | var b bool 81 | if err := json.Unmarshal(data, &b); err != nil { 82 | return err 83 | } 84 | *s = Sbool(b) 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /compat_test.go: -------------------------------------------------------------------------------- 1 | package mastodon_test 2 | 3 | import ( 4 | "reflect" 5 | "slices" 6 | "testing" 7 | 8 | "github.com/mattn/go-mastodon" 9 | ) 10 | 11 | func TestIDCompare(t *testing.T) { 12 | ids := []mastodon.ID{ 13 | "123", 14 | "103", 15 | "", 16 | "0", 17 | "103", 18 | "122", 19 | } 20 | 21 | slices.SortFunc(ids, mastodon.ID.Compare) 22 | want := []mastodon.ID{ 23 | "", 24 | "0", 25 | "103", 26 | "103", 27 | "122", 28 | "123", 29 | } 30 | 31 | if got, want := ids, want; !reflect.DeepEqual(got, want) { 32 | t.Fatalf("invalid sorted slices:\ngot= %q\nwant=%q", got, want) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package mastodon_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/mattn/go-mastodon" 10 | ) 11 | 12 | func ExampleRegisterApp() { 13 | app, err := mastodon.RegisterApp(context.Background(), &mastodon.AppConfig{ 14 | Server: "https://mstdn.jp", 15 | ClientName: "client-name", 16 | Scopes: "read write follow", 17 | Website: "https://github.com/mattn/go-mastodon", 18 | }) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | fmt.Printf("client-id : %s\n", app.ClientID) 23 | fmt.Printf("client-secret: %s\n", app.ClientSecret) 24 | } 25 | 26 | func ExampleClient() { 27 | c := mastodon.NewClient(&mastodon.Config{ 28 | Server: "https://mstdn.jp", 29 | ClientID: "client-id", 30 | ClientSecret: "client-secret", 31 | }) 32 | err := c.Authenticate(context.Background(), "your-email", "your-password") 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | timeline, err := c.GetTimelineHome(context.Background(), nil) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | for i := len(timeline) - 1; i >= 0; i-- { 41 | fmt.Println(timeline[i]) 42 | } 43 | } 44 | 45 | func ExamplePagination() { 46 | c := mastodon.NewClient(&mastodon.Config{ 47 | Server: "https://mstdn.jp", 48 | ClientID: "client-id", 49 | ClientSecret: "client-secret", 50 | }) 51 | var followers []*mastodon.Account 52 | var pg mastodon.Pagination 53 | for { 54 | fs, err := c.GetAccountFollowers(context.Background(), "1", &pg) 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | followers = append(followers, fs...) 59 | if pg.MaxID == "" { 60 | break 61 | } 62 | time.Sleep(10 * time.Second) 63 | } 64 | for _, f := range followers { 65 | fmt.Println(f.Acct) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /examples/public-application/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/mattn/go-mastodon" 9 | ) 10 | 11 | func main() { 12 | // Register the application 13 | appConfig := &mastodon.AppConfig{ 14 | Server: "https://mastodon.social", 15 | ClientName: "publicApp", 16 | Scopes: "read write push", 17 | Website: "https://github.com/mattn/go-mastodon", 18 | RedirectURIs: "urn:ietf:wg:oauth:2.0:oob", 19 | } 20 | 21 | app, err := mastodon.RegisterApp(context.Background(), appConfig) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | 26 | config := &mastodon.Config{ 27 | Server: "https://mastodon.social", 28 | ClientID: app.ClientID, 29 | ClientSecret: app.ClientSecret, 30 | } 31 | 32 | // Create the client 33 | c := mastodon.NewClient(config) 34 | 35 | // Get an Access Token & Sets it in the client config 36 | err = c.GetAppAccessToken(context.Background(), app.RedirectURI) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | // Save credentials for later usage if you wish to do so, config file, database, etc... 42 | fmt.Println("ClientID:", c.Config.ClientID) 43 | fmt.Println("ClientSecret:", c.Config.ClientSecret) 44 | fmt.Println("Access Token:", c.Config.AccessToken) 45 | 46 | // Lookup and account id 47 | acc, err := c.AccountLookup(context.Background(), "coolapso") 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | fmt.Println(acc) 52 | 53 | pager := mastodon.Pagination{ 54 | Limit: 10, 55 | } 56 | 57 | // Get the the usernames of users following the account ID 58 | followers, err := c.GetAccountFollowers(context.Background(), acc.ID, &pager) 59 | if err != nil { 60 | log.Fatal(err) 61 | } 62 | 63 | for _, f := range followers { 64 | fmt.Println(f.Username) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/user-credentials/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/mattn/go-mastodon" 9 | ) 10 | 11 | // Create client with credentials from user generated application 12 | func main() { 13 | config := &mastodon.Config{ 14 | Server: "https://mastodon.social", 15 | ClientID: "ClientKey", 16 | ClientSecret: "ClientSecret", 17 | AccessToken: "AccessToken", 18 | } 19 | 20 | // Create the client 21 | c := mastodon.NewClient(config) 22 | 23 | // Post a toot 24 | finalText := "this is the content of my new post!" 25 | visibility := "public" 26 | 27 | toot := mastodon.Toot{ 28 | Status: finalText, 29 | Visibility: visibility, 30 | } 31 | 32 | post, err := c.PostStatus(context.Background(), &toot) 33 | if err != nil { 34 | log.Fatalf("%#v\n", err) 35 | } 36 | 37 | fmt.Println("My new post is:", post) 38 | } 39 | -------------------------------------------------------------------------------- /examples/user-oauth-authorization/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/url" 8 | "os" 9 | 10 | "github.com/mattn/go-mastodon" 11 | ) 12 | 13 | func ConfigureClient() { 14 | appConfig := &mastodon.AppConfig{ 15 | Server: "https://mastodon.social", 16 | ClientName: "publicApp", 17 | Scopes: "read write follow", 18 | Website: "https://github.com/mattn/go-mastodon", 19 | RedirectURIs: "urn:ietf:wg:oauth:2.0:oob", 20 | } 21 | 22 | app, err := mastodon.RegisterApp(context.Background(), appConfig) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | // Have the user manually get the token and send it back to us 28 | u, err := url.Parse(app.AuthURI) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | fmt.Printf("Open your browser to \n%s\n and copy/paste the given authroization code\n", u) 33 | var userAuthorizationCode string 34 | fmt.Print("Paste the code here:") 35 | fmt.Scanln(&userAuthorizationCode) 36 | 37 | config := &mastodon.Config{ 38 | Server: "https://mastodon.social", 39 | ClientID: app.ClientID, 40 | ClientSecret: app.ClientSecret, 41 | } 42 | 43 | // Create the client 44 | c := mastodon.NewClient(config) 45 | 46 | // Exchange the User authentication code with an access token, that can be used to interact with the api on behalf of the user 47 | err = c.GetUserAccessToken(context.Background(), userAuthorizationCode, app.RedirectURI) 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | 52 | // Lets Export the secrets so we can use them later to preform actions on behalf of the user 53 | // Without having to request authroization all the time. 54 | // Exporting this as Environment variables, but it can be a configuration file, or database, anywhere you'd like to keep this credentials 55 | os.Setenv("MASTODON_CLIENT_ID", c.Config.ClientID) 56 | os.Setenv("MASTODON_CLIENT_SECRET", c.Config.ClientSecret) 57 | os.Setenv("MASTODON_ACCESS_TOKEN", c.Config.AccessToken) 58 | } 59 | 60 | // Preform user actions wihtout having to re-authenticate again 61 | func doUserActions() { 62 | // Load Environment variables, config file, secrets from db 63 | clientID := os.Getenv("MASTODON_CLIENT_ID") 64 | clientSecret := os.Getenv("MASTODON_CLIENT_SECRET") 65 | accessToken := os.Getenv("MASTODON_ACCESS_TOKEN") 66 | 67 | config := &mastodon.Config{ 68 | Server: "https://mastodon.social", 69 | ClientID: clientID, 70 | ClientSecret: clientSecret, 71 | AccessToken: accessToken, 72 | } 73 | 74 | // instanciate the new client 75 | c := mastodon.NewClient(config) 76 | 77 | // Let's do some actions on behalf of the user! 78 | acct, err := c.GetAccountCurrentUser(context.Background()) 79 | if err != nil { 80 | log.Fatal(err) 81 | } 82 | fmt.Printf("Account is %v\n", acct) 83 | 84 | finalText := "this is the content of my new post!" 85 | visibility := "public" 86 | 87 | // Post a toot 88 | toot := mastodon.Toot{ 89 | Status: finalText, 90 | Visibility: visibility, 91 | } 92 | post, err := c.PostStatus(context.Background(), &toot) 93 | 94 | if err != nil { 95 | log.Fatalf("%#v\n", err) 96 | } 97 | 98 | fmt.Printf("My new post is %v\n", post) 99 | 100 | } 101 | 102 | func main() { 103 | ConfigureClient() 104 | doUserActions() 105 | } 106 | -------------------------------------------------------------------------------- /filters.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | ) 11 | 12 | // Filter is metadata for a filter of users. 13 | type Filter struct { 14 | ID ID `json:"id"` 15 | Phrase string `json:"phrase"` 16 | Context []string `json:"context"` 17 | WholeWord bool `json:"whole_word"` 18 | ExpiresAt time.Time `json:"expires_at"` 19 | Irreversible bool `json:"irreversible"` 20 | } 21 | 22 | type FilterResult struct { 23 | Filter struct { 24 | ID string `json:"id"` 25 | Title string `json:"title"` 26 | Context []string `json:"context"` 27 | ExpiresAt time.Time `json:"expires_at"` 28 | FilterAction string `json:"filter_action"` 29 | } `json:"filter"` 30 | KeywordMatches []string `json:"keyword_matches"` 31 | StatusMatches []string `json:"status_matches"` 32 | } 33 | 34 | // GetFilters returns all the filters on the current account. 35 | func (c *Client) GetFilters(ctx context.Context) ([]*Filter, error) { 36 | var filters []*Filter 37 | err := c.doAPI(ctx, http.MethodGet, "/api/v1/filters", nil, &filters, nil) 38 | if err != nil { 39 | return nil, err 40 | } 41 | return filters, nil 42 | } 43 | 44 | // GetFilter retrieves a filter by ID. 45 | func (c *Client) GetFilter(ctx context.Context, id ID) (*Filter, error) { 46 | var filter Filter 47 | err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/filters/%s", url.PathEscape(string(id))), nil, &filter, nil) 48 | if err != nil { 49 | return nil, err 50 | } 51 | return &filter, nil 52 | } 53 | 54 | // CreateFilter creates a new filter. 55 | func (c *Client) CreateFilter(ctx context.Context, filter *Filter) (*Filter, error) { 56 | if filter == nil { 57 | return nil, errors.New("filter can't be nil") 58 | } 59 | if filter.Phrase == "" { 60 | return nil, errors.New("phrase can't be empty") 61 | } 62 | if len(filter.Context) == 0 { 63 | return nil, errors.New("context can't be empty") 64 | } 65 | params := url.Values{} 66 | params.Set("phrase", filter.Phrase) 67 | for _, c := range filter.Context { 68 | params.Add("context[]", c) 69 | } 70 | if filter.WholeWord { 71 | params.Add("whole_word", "true") 72 | } 73 | if filter.Irreversible { 74 | params.Add("irreversible", "true") 75 | } 76 | if !filter.ExpiresAt.IsZero() { 77 | diff := time.Until(filter.ExpiresAt) 78 | params.Add("expires_in", fmt.Sprintf("%.0f", diff.Seconds())) 79 | } 80 | 81 | var f Filter 82 | err := c.doAPI(ctx, http.MethodPost, "/api/v1/filters", params, &f, nil) 83 | if err != nil { 84 | return nil, err 85 | } 86 | return &f, nil 87 | } 88 | 89 | // UpdateFilter updates a filter. 90 | func (c *Client) UpdateFilter(ctx context.Context, id ID, filter *Filter) (*Filter, error) { 91 | if filter == nil { 92 | return nil, errors.New("filter can't be nil") 93 | } 94 | if id == ID("") { 95 | return nil, errors.New("ID can't be empty") 96 | } 97 | if filter.Phrase == "" { 98 | return nil, errors.New("phrase can't be empty") 99 | } 100 | if len(filter.Context) == 0 { 101 | return nil, errors.New("context can't be empty") 102 | } 103 | params := url.Values{} 104 | params.Set("phrase", filter.Phrase) 105 | for _, c := range filter.Context { 106 | params.Add("context[]", c) 107 | } 108 | if filter.WholeWord { 109 | params.Add("whole_word", "true") 110 | } else { 111 | params.Add("whole_word", "false") 112 | } 113 | if filter.Irreversible { 114 | params.Add("irreversible", "true") 115 | } else { 116 | params.Add("irreversible", "false") 117 | } 118 | if !filter.ExpiresAt.IsZero() { 119 | diff := time.Until(filter.ExpiresAt) 120 | params.Add("expires_in", fmt.Sprintf("%.0f", diff.Seconds())) 121 | } else { 122 | params.Add("expires_in", "") 123 | } 124 | 125 | var f Filter 126 | err := c.doAPI(ctx, http.MethodPut, fmt.Sprintf("/api/v1/filters/%s", url.PathEscape(string(id))), params, &f, nil) 127 | if err != nil { 128 | return nil, err 129 | } 130 | return &f, nil 131 | } 132 | 133 | // DeleteFilter removes a filter. 134 | func (c *Client) DeleteFilter(ctx context.Context, id ID) error { 135 | return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/filters/%s", url.PathEscape(string(id))), nil, nil, nil) 136 | } 137 | -------------------------------------------------------------------------------- /filters_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "sort" 9 | "strings" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestGetFilters(t *testing.T) { 15 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | fmt.Fprintln(w, `[{"id": "6191", "phrase": "rust", "context": ["home"], "whole_word": true, "expires_at": "2019-05-21T13:47:31.333Z", "irreversible": false}, {"id": "5580", "phrase": "@twitter.com", "context": ["home", "notifications", "public", "thread"], "whole_word": false, "expires_at": null, "irreversible": true}]`) 17 | })) 18 | defer ts.Close() 19 | 20 | client := NewClient(&Config{ 21 | Server: ts.URL, 22 | ClientID: "foo", 23 | ClientSecret: "bar", 24 | AccessToken: "zoo", 25 | }) 26 | d, err := time.Parse(time.RFC3339Nano, "2019-05-21T13:47:31.333Z") 27 | if err != nil { 28 | t.Fatalf("should not be fail: %v", err) 29 | } 30 | tf := []Filter{ 31 | { 32 | ID: ID("6191"), 33 | Phrase: "rust", 34 | Context: []string{"home"}, 35 | WholeWord: true, 36 | ExpiresAt: d, 37 | Irreversible: false, 38 | }, 39 | { 40 | ID: ID("5580"), 41 | Phrase: "@twitter.com", 42 | Context: []string{"notifications", "home", "thread", "public"}, 43 | WholeWord: false, 44 | ExpiresAt: time.Time{}, 45 | Irreversible: true, 46 | }, 47 | } 48 | 49 | filters, err := client.GetFilters(context.Background()) 50 | if err != nil { 51 | t.Fatalf("should not be fail: %v", err) 52 | } 53 | if len(filters) != 2 { 54 | t.Fatalf("result should be two: %d", len(filters)) 55 | } 56 | for i, f := range tf { 57 | if filters[i].ID != f.ID { 58 | t.Fatalf("want %q but %q", string(f.ID), filters[i].ID) 59 | } 60 | if filters[i].Phrase != f.Phrase { 61 | t.Fatalf("want %q but %q", f.Phrase, filters[i].Phrase) 62 | } 63 | sort.Strings(filters[i].Context) 64 | sort.Strings(f.Context) 65 | if strings.Join(filters[i].Context, ", ") != strings.Join(f.Context, ", ") { 66 | t.Fatalf("want %q but %q", f.Context, filters[i].Context) 67 | } 68 | if filters[i].ExpiresAt != f.ExpiresAt { 69 | t.Fatalf("want %q but %q", f.ExpiresAt, filters[i].ExpiresAt) 70 | } 71 | if filters[i].WholeWord != f.WholeWord { 72 | t.Fatalf("want %t but %t", f.WholeWord, filters[i].WholeWord) 73 | } 74 | if filters[i].Irreversible != f.Irreversible { 75 | t.Fatalf("want %t but %t", f.Irreversible, filters[i].Irreversible) 76 | } 77 | } 78 | } 79 | 80 | func TestGetFilter(t *testing.T) { 81 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 82 | if r.URL.Path != "/api/v1/filters/1" { 83 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 84 | return 85 | } 86 | fmt.Fprintln(w, `{"id": "1", "phrase": "rust", "context": ["home"], "whole_word": true, "expires_at": "2019-05-21T13:47:31.333Z", "irreversible": false}`) 87 | })) 88 | defer ts.Close() 89 | 90 | client := NewClient(&Config{ 91 | Server: ts.URL, 92 | ClientID: "foo", 93 | ClientSecret: "bar", 94 | AccessToken: "zoo", 95 | }) 96 | _, err := client.GetFilter(context.Background(), "2") 97 | if err == nil { 98 | t.Fatalf("should be fail: %v", err) 99 | } 100 | d, err := time.Parse(time.RFC3339Nano, "2019-05-21T13:47:31.333Z") 101 | if err != nil { 102 | t.Fatalf("should not be fail: %v", err) 103 | } 104 | tf := Filter{ 105 | ID: ID("1"), 106 | Phrase: "rust", 107 | Context: []string{"home"}, 108 | WholeWord: true, 109 | ExpiresAt: d, 110 | Irreversible: false, 111 | } 112 | filter, err := client.GetFilter(context.Background(), "1") 113 | if err != nil { 114 | t.Fatalf("should not be fail: %v", err) 115 | } 116 | if filter.ID != tf.ID { 117 | t.Fatalf("want %q but %q", string(tf.ID), filter.ID) 118 | } 119 | if filter.Phrase != tf.Phrase { 120 | t.Fatalf("want %q but %q", tf.Phrase, filter.Phrase) 121 | } 122 | sort.Strings(filter.Context) 123 | sort.Strings(tf.Context) 124 | if strings.Join(filter.Context, ", ") != strings.Join(tf.Context, ", ") { 125 | t.Fatalf("want %q but %q", tf.Context, filter.Context) 126 | } 127 | if filter.ExpiresAt != tf.ExpiresAt { 128 | t.Fatalf("want %q but %q", tf.ExpiresAt, filter.ExpiresAt) 129 | } 130 | if filter.WholeWord != tf.WholeWord { 131 | t.Fatalf("want %t but %t", tf.WholeWord, filter.WholeWord) 132 | } 133 | if filter.Irreversible != tf.Irreversible { 134 | t.Fatalf("want %t but %t", tf.Irreversible, filter.Irreversible) 135 | } 136 | } 137 | 138 | func TestCreateFilter(t *testing.T) { 139 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 140 | if r.PostFormValue("phrase") != "rust" && r.PostFormValue("phrase") != "@twitter.com" { 141 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 142 | return 143 | } 144 | if r.PostFormValue("phrase") == "rust" { 145 | fmt.Fprintln(w, `{"id": "1", "phrase": "rust", "context": ["home"], "whole_word": true, "expires_at": "2019-05-21T13:47:31.333Z", "irreversible": true}`) 146 | return 147 | } else { 148 | fmt.Fprintln(w, `{"id": "2", "phrase": "@twitter.com", "context": ["home", "notifications", "public", "thread"], "whole_word": false, "expires_at": null, "irreversible": false}`) 149 | return 150 | } 151 | })) 152 | defer ts.Close() 153 | 154 | client := NewClient(&Config{ 155 | Server: ts.URL, 156 | ClientID: "foo", 157 | ClientSecret: "bar", 158 | AccessToken: "zoo", 159 | }) 160 | _, err := client.CreateFilter(context.Background(), nil) 161 | if err == nil { 162 | t.Fatalf("should be fail: %v", err) 163 | } 164 | _, err = client.CreateFilter(context.Background(), &Filter{Context: []string{"home"}}) 165 | if err == nil { 166 | t.Fatalf("should be fail: %v", err) 167 | } 168 | _, err = client.CreateFilter(context.Background(), &Filter{Phrase: "rust"}) 169 | if err == nil { 170 | t.Fatalf("should be fail: %v", err) 171 | } 172 | _, err = client.CreateFilter(context.Background(), &Filter{Phrase: "Test", Context: []string{"home"}}) 173 | if err == nil { 174 | t.Fatalf("should be fail: %v", err) 175 | } 176 | 177 | d, err := time.Parse(time.RFC3339Nano, "2019-05-21T13:47:31.333Z") 178 | if err != nil { 179 | t.Fatalf("should not be fail: %v", err) 180 | } 181 | tf := []Filter{ 182 | { 183 | ID: ID("1"), 184 | Phrase: "rust", 185 | Context: []string{"home"}, 186 | WholeWord: true, 187 | ExpiresAt: d, 188 | Irreversible: true, 189 | }, 190 | { 191 | ID: ID("2"), 192 | Phrase: "@twitter.com", 193 | Context: []string{"notifications", "home", "thread", "public"}, 194 | WholeWord: false, 195 | ExpiresAt: time.Time{}, 196 | Irreversible: false, 197 | }, 198 | } 199 | for _, f := range tf { 200 | filter, err := client.CreateFilter(context.Background(), &f) 201 | if err != nil { 202 | t.Fatalf("should not be fail: %v", err) 203 | } 204 | if filter.ID != f.ID { 205 | t.Fatalf("want %q but %q", string(f.ID), filter.ID) 206 | } 207 | if filter.Phrase != f.Phrase { 208 | t.Fatalf("want %q but %q", f.Phrase, filter.Phrase) 209 | } 210 | sort.Strings(filter.Context) 211 | sort.Strings(f.Context) 212 | if strings.Join(filter.Context, ", ") != strings.Join(f.Context, ", ") { 213 | t.Fatalf("want %q but %q", f.Context, filter.Context) 214 | } 215 | if filter.ExpiresAt != f.ExpiresAt { 216 | t.Fatalf("want %q but %q", f.ExpiresAt, filter.ExpiresAt) 217 | } 218 | if filter.WholeWord != f.WholeWord { 219 | t.Fatalf("want %t but %t", f.WholeWord, filter.WholeWord) 220 | } 221 | if filter.Irreversible != f.Irreversible { 222 | t.Fatalf("want %t but %t", f.Irreversible, filter.Irreversible) 223 | } 224 | } 225 | } 226 | 227 | func TestUpdateFilter(t *testing.T) { 228 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 229 | if r.URL.Path == "/api/v1/filters/1" { 230 | fmt.Fprintln(w, `{"id": "1", "phrase": "rust", "context": ["home"], "whole_word": true, "expires_at": "2019-05-21T13:47:31.333Z", "irreversible": true}`) 231 | return 232 | } else if r.URL.Path == "/api/v1/filters/2" { 233 | fmt.Fprintln(w, `{"id": "2", "phrase": "@twitter.com", "context": ["home", "notifications", "public", "thread"], "whole_word": false, "expires_at": null, "irreversible": false}`) 234 | return 235 | } else { 236 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 237 | return 238 | } 239 | })) 240 | defer ts.Close() 241 | 242 | client := NewClient(&Config{ 243 | Server: ts.URL, 244 | ClientID: "foo", 245 | ClientSecret: "bar", 246 | AccessToken: "zoo", 247 | }) 248 | _, err := client.UpdateFilter(context.Background(), ID("1"), nil) 249 | if err == nil { 250 | t.Fatalf("should be fail: %v", err) 251 | } 252 | _, err = client.UpdateFilter(context.Background(), ID(""), &Filter{Phrase: ""}) 253 | if err == nil { 254 | t.Fatalf("should be fail: %v", err) 255 | } 256 | _, err = client.UpdateFilter(context.Background(), ID("2"), &Filter{Phrase: ""}) 257 | if err == nil { 258 | t.Fatalf("should be fail: %v", err) 259 | } 260 | _, err = client.UpdateFilter(context.Background(), ID("2"), &Filter{Phrase: "rust"}) 261 | if err == nil { 262 | t.Fatalf("should be fail: %v", err) 263 | } 264 | _, err = client.UpdateFilter(context.Background(), ID("3"), &Filter{Phrase: "rust", Context: []string{"home"}}) 265 | if err == nil { 266 | t.Fatalf("should be fail: %v", err) 267 | } 268 | 269 | d, err := time.Parse(time.RFC3339Nano, "2019-05-21T13:47:31.333Z") 270 | if err != nil { 271 | t.Fatalf("should not be fail: %v", err) 272 | } 273 | tf := []Filter{ 274 | { 275 | ID: ID("1"), 276 | Phrase: "rust", 277 | Context: []string{"home"}, 278 | WholeWord: true, 279 | ExpiresAt: d, 280 | Irreversible: true, 281 | }, 282 | { 283 | ID: ID("2"), 284 | Phrase: "@twitter.com", 285 | Context: []string{"notifications", "home", "thread", "public"}, 286 | WholeWord: false, 287 | ExpiresAt: time.Time{}, 288 | Irreversible: false, 289 | }, 290 | } 291 | for _, f := range tf { 292 | filter, err := client.UpdateFilter(context.Background(), f.ID, &f) 293 | if err != nil { 294 | t.Fatalf("should not be fail: %v", err) 295 | } 296 | if filter.ID != f.ID { 297 | t.Fatalf("want %q but %q", string(f.ID), filter.ID) 298 | } 299 | if filter.Phrase != f.Phrase { 300 | t.Fatalf("want %q but %q", f.Phrase, filter.Phrase) 301 | } 302 | sort.Strings(filter.Context) 303 | sort.Strings(f.Context) 304 | if strings.Join(filter.Context, ", ") != strings.Join(f.Context, ", ") { 305 | t.Fatalf("want %q but %q", f.Context, filter.Context) 306 | } 307 | if filter.ExpiresAt != f.ExpiresAt { 308 | t.Fatalf("want %q but %q", f.ExpiresAt, filter.ExpiresAt) 309 | } 310 | if filter.WholeWord != f.WholeWord { 311 | t.Fatalf("want %t but %t", f.WholeWord, filter.WholeWord) 312 | } 313 | if filter.Irreversible != f.Irreversible { 314 | t.Fatalf("want %t but %t", f.Irreversible, filter.Irreversible) 315 | } 316 | } 317 | } 318 | 319 | func TestDeleteFilter(t *testing.T) { 320 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 321 | if r.URL.Path != "/api/v1/filters/1" { 322 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 323 | return 324 | } 325 | })) 326 | defer ts.Close() 327 | 328 | client := NewClient(&Config{ 329 | Server: ts.URL, 330 | ClientID: "foo", 331 | ClientSecret: "bar", 332 | AccessToken: "zoo", 333 | }) 334 | err := client.DeleteFilter(context.Background(), "2") 335 | if err == nil { 336 | t.Fatalf("should be fail: %v", err) 337 | } 338 | err = client.DeleteFilter(context.Background(), "1") 339 | if err != nil { 340 | t.Fatalf("should not be fail: %v", err) 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mattn/go-mastodon 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/gorilla/websocket v1.5.3 7 | github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 8 | ) 9 | 10 | retract [v0.0.7+incompatible, v0.0.7+incompatible] // Accidental; no major changes or features. 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 2 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 3 | github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= 4 | github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= 5 | -------------------------------------------------------------------------------- /go.test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | echo "" > coverage.txt 5 | 6 | for d in $(go list ./... | grep -v vendor); do 7 | go test -coverprofile=profile.out -covermode=atomic "$d" 8 | if [ -f profile.out ]; then 9 | cat profile.out >> coverage.txt 10 | rm profile.out 11 | fi 12 | done 13 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.23 2 | 3 | toolchain go1.23.2 4 | 5 | use ( 6 | . 7 | ./cmd/mstdn 8 | ) 9 | -------------------------------------------------------------------------------- /go.work.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 2 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 3 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 4 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 5 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 6 | golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= 7 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 8 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 9 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /helper.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | ) 10 | 11 | type APIError struct { 12 | prefix string 13 | Message string 14 | StatusCode int 15 | } 16 | 17 | func (e *APIError) Error() string { 18 | errMsg := fmt.Sprintf("%s: %d %s", e.prefix, e.StatusCode, http.StatusText(e.StatusCode)) 19 | if e.Message == "" { 20 | return errMsg 21 | } 22 | 23 | return fmt.Sprintf("%s: %s", errMsg, e.Message) 24 | } 25 | 26 | // Base64EncodeFileName returns the base64 data URI format string of the file with the file name. 27 | func Base64EncodeFileName(filename string) (string, error) { 28 | file, err := os.Open(filename) 29 | if err != nil { 30 | return "", err 31 | } 32 | defer file.Close() 33 | 34 | return Base64Encode(file) 35 | } 36 | 37 | // Base64Encode returns the base64 data URI format string of the file. 38 | func Base64Encode(file *os.File) (string, error) { 39 | fi, err := file.Stat() 40 | if err != nil { 41 | return "", err 42 | } 43 | 44 | d := make([]byte, fi.Size()) 45 | _, err = file.Read(d) 46 | if err != nil { 47 | return "", err 48 | } 49 | 50 | return "data:" + http.DetectContentType(d) + 51 | ";base64," + base64.StdEncoding.EncodeToString(d), nil 52 | } 53 | 54 | // String is a helper function to get the pointer value of a string. 55 | func String(v string) *string { return &v } 56 | 57 | func parseAPIError(prefix string, resp *http.Response) error { 58 | res := APIError{ 59 | prefix: prefix, 60 | StatusCode: resp.StatusCode, 61 | } 62 | var e struct { 63 | Error string `json:"error"` 64 | } 65 | 66 | json.NewDecoder(resp.Body).Decode(&e) 67 | if e.Error != "" { 68 | res.Message = e.Error 69 | } 70 | 71 | return &res 72 | } 73 | -------------------------------------------------------------------------------- /helper_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | const wantBase64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHEAAABxCAYAAADifkzQAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAXoSURBVHhe7Zxbcts4EEWtWUp2MM5fqryDLMNVWdNMObuYHdiVP2d24O/sQsNL8WbacIMPkWh0QzgpFCVGfLiPugFIpE7ngbtOaG5C4ul0mh6tI1pImpYo5b1u+DM/T9tFCU2zEilwizwJRRLPYWpO4rXZN4f3zGxS4lHyUiDTY7j+mJZNUFIgkZnuhSYkIrAWArl/byKbKKcWAlM8lVbXmcgMY9PIrbeg5rElYTIxDRhPG+uts1DiYeQapk9EkNgA5HnIhNef9XMgbJ+oCayVkbWzMbTE89s/07MLp09fp0f/YyW25kAnpERNYA4ptqjQfweR93VEhpLIErpWoETLUnCo2Eoiw41OrxE4R04u2Cy4S5xnSwk9glTuKqGDRGAtMoRELQtlkEvL5bHG6cSf40OdLlFnTRnNlcRSpTcrs0vUObKMUsKe/WEf7yb4lDkJBF2ioMRgRsvarfuX+9A+sekSBRaDmT1SczK7xIkSWbgWytkis0tMqCmQbM1Qvh4yu8QBizJ6DVLsh/P7dVmcvkxZ3CX6lDjLJBFApGVYm7pQ6lbpEg/GOgtBl7gXlFFRSmvQJTZAl7gTlE+2WvTR6VEMJZUie58YmPOPOm+8LrEB3ErUPvbKseW1LeL6A/At/SJFmvelFT+pIc2UU8hDq5WVtQQCtxIRkGuEWGYiz6+mQOC2nBK3042pjNYWCProtAHcZyJANgIXGelgIJMSIhMRKLTqUwmHAkEvp2txKhCEKKcSllZgVl4dCwThJBIpExwmVAhL8SgQhJUoOUTogjzgNVRNSJSkQsEHqTPCUrwLBGYSteCWRP5ZHzJ15isjSiMR3uOmEksf6uXlZVw+PDx8ONZ4/J+vd6f7z9OaPBHESW5qiiEFQlSuReMmJLKc4m5fNqxLy2xUzCUyeLJxvVyC3Lq0rSG9XXvV7dtBMJcoS9Zc+aKcdAm4XW5bIiXzB4NapFompo9TIXyeWy6B/cryqWUe1vH4kXGbiWBJGATkXqNJa5WqAxuZiUCTkhM1J3AR3F8/NdxPKM8hItUkQgAb0aTkROXWLyJ+IIFEF1k1EzvH4FIisoLNkqjZ6E4iAslSieXhgc38IpT84YRouJIoBRJLkVHpfWIDuJLIrJMZqWXnITSUjcUlUspaIExKKyIwQ9SpRlGJYxa9Xe6R6JTDrJxKkcxOtq1cs80WSu//aKr0iSyZWqmUAbxW8h4iTjWKSoSk33cOLVy9LYVxKUWnMq3leqZ4JkqRKRBBSVwC+TjHuN8Zken3h2u/T8SP60XDpJxqUqRAkj7Ha9i0fWjrxoukpsEUxLGt/Wqql9MNaAKAXI/HbJLctimLI+NG5orVJHaOozmJz8/P9W+BM8b04mEE2AL0i5K5/nDsL5N+0PqXg/fS7GX8FKcNarCOaAOZLtEIvCnWjjilNPBuO+VyjWgSb2JgA2mytUYfnTZAl9gAoSWmfd1mGugPQViJCDTa1SIVgcR6JL2XJsrpZpEzAkHPRENkxuwurYEJPU98Oj9Nzy48nh63TSGSjOTXUNFCElKiJpBApGRJqsxg7JPbRwpLcxIlqdAcWkaDKKEJJ3GtwL1AZJdYgJICH4d/7xiqbJTQhBmdWmWgBMeMQBPzxCIE6mRCSKyRhZFwLzFKSauJe4kYXKCtnS7cImFGpzIjTUao0+EihCeERArkNaTa1WxHiP0tcThckPf2SBiJsxcBD0DsXpGjxEAZSJqYYkAggo5+c1ffiQycrhqPNKByn4lLWUiBEmzDrJRSc5nK18jjaPv1SmiJc4FmJsn/5zopEwKv2b8nwkrcE2DKTDn6OFaE7BP3Bhbbag37jYgLicgM2TrbcJOJT3+dxwZqiYyajW76RIijRPD47b1I9lcWfZR8E+GccC5OwqTiamCTipRQqsXpaufhWaSrgQ2ClGagxGsQa+NudLok0gLtHJCZtfrqJVxOMTyIjITryX76zrc81Uj9ovtPbGrBNxBFehUIusQZvv99WXoWCLrEGZiNvkN0d/cfoIFu0rP2f7IAAAAASUVORK5CYII=" 12 | 13 | func TestBase64EncodeFileName(t *testing.T) { 14 | // Error in os.Open. 15 | _, err := Base64EncodeFileName("fail") 16 | if err == nil { 17 | t.Fatalf("should be fail: %v", err) 18 | } 19 | 20 | // Success. 21 | uri, err := Base64EncodeFileName("testdata/logo.png") 22 | if err != nil { 23 | t.Fatalf("should not be fail: %v", err) 24 | } 25 | if uri != wantBase64 { 26 | t.Fatalf("want %q but %q", wantBase64, uri) 27 | } 28 | } 29 | 30 | func TestBase64Encode(t *testing.T) { 31 | // Error in file.Stat. 32 | _, err := Base64Encode(nil) 33 | if err == nil { 34 | t.Fatalf("should be fail: %v", err) 35 | } 36 | 37 | // Error in file.Read. 38 | logo, err := os.Open("testdata/logo.png") 39 | if err != nil { 40 | t.Fatalf("should not be fail: %v", err) 41 | } 42 | _, err = io.ReadAll(logo) 43 | if err != nil { 44 | t.Fatalf("should not be fail: %v", err) 45 | } 46 | _, err = Base64Encode(logo) 47 | if err == nil { 48 | t.Fatalf("should be fail: %v", err) 49 | } 50 | 51 | // Success. 52 | logo, err = os.Open("testdata/logo.png") 53 | if err != nil { 54 | t.Fatalf("should not be fail: %v", err) 55 | } 56 | uri, err := Base64Encode(logo) 57 | if err != nil { 58 | t.Fatalf("should not be fail: %v", err) 59 | } 60 | if uri != wantBase64 { 61 | t.Fatalf("want %q but %q", wantBase64, uri) 62 | } 63 | } 64 | 65 | func TestString(t *testing.T) { 66 | s := "test" 67 | sp := String(s) 68 | if *sp != s { 69 | t.Fatalf("want %q but %q", s, *sp) 70 | } 71 | } 72 | 73 | func TestParseAPIError(t *testing.T) { 74 | // No api error. 75 | r := io.NopCloser(strings.NewReader(`404`)) 76 | err := parseAPIError("bad request", &http.Response{ 77 | Status: "404 Not Found", 78 | StatusCode: http.StatusNotFound, 79 | Body: r, 80 | }) 81 | want := "bad request: 404 Not Found" 82 | if err.Error() != want { 83 | t.Fatalf("want %q but %q", want, err.Error()) 84 | } 85 | 86 | // With api error. 87 | r = io.NopCloser(strings.NewReader(`{"error":"Record not found"}`)) 88 | err = parseAPIError("bad request", &http.Response{ 89 | Status: "404 Not Found", 90 | StatusCode: http.StatusNotFound, 91 | Body: r, 92 | }) 93 | want = "bad request: 404 Not Found: Record not found" 94 | if err.Error() != want { 95 | t.Fatalf("want %q but %q", want, err.Error()) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /instance.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | // Instance holds information for a mastodon instance. 9 | type Instance struct { 10 | URI string `json:"uri"` 11 | Title string `json:"title"` 12 | Description string `json:"description"` 13 | EMail string `json:"email"` 14 | Version string `json:"version,omitempty"` 15 | Thumbnail string `json:"thumbnail,omitempty"` 16 | URLs map[string]string `json:"urls,omitempty"` 17 | Stats *InstanceStats `json:"stats,omitempty"` 18 | Languages []string `json:"languages"` 19 | ContactAccount *Account `json:"contact_account"` 20 | Configuration *InstanceConfig `json:"configuration"` 21 | } 22 | 23 | type InstanceConfigMap map[string]interface{} 24 | 25 | // InstanceConfig holds configuration accessible for clients. 26 | type InstanceConfig struct { 27 | Accounts *InstanceConfigMap `json:"accounts"` 28 | Statuses *InstanceConfigMap `json:"statuses"` 29 | MediaAttachments map[string]interface{} `json:"media_attachments"` 30 | Polls *InstanceConfigMap `json:"polls"` 31 | } 32 | 33 | // InstanceStats holds information for mastodon instance stats. 34 | type InstanceStats struct { 35 | UserCount int64 `json:"user_count"` 36 | StatusCount int64 `json:"status_count"` 37 | DomainCount int64 `json:"domain_count"` 38 | } 39 | 40 | // GetInstance returns Instance. 41 | func (c *Client) GetInstance(ctx context.Context) (*Instance, error) { 42 | var instance Instance 43 | err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance", nil, &instance, nil) 44 | if err != nil { 45 | return nil, err 46 | } 47 | return &instance, nil 48 | } 49 | 50 | // GetConfig returns InstanceConfig. 51 | func (c *Instance) GetConfig() *InstanceConfig { 52 | return c.Configuration 53 | } 54 | 55 | // WeeklyActivity holds information for mastodon weekly activity. 56 | type WeeklyActivity struct { 57 | Week Unixtime `json:"week"` 58 | Statuses int64 `json:"statuses,string"` 59 | Logins int64 `json:"logins,string"` 60 | Registrations int64 `json:"registrations,string"` 61 | } 62 | 63 | // GetInstanceActivity returns instance activity. 64 | func (c *Client) GetInstanceActivity(ctx context.Context) ([]*WeeklyActivity, error) { 65 | var activity []*WeeklyActivity 66 | err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance/activity", nil, &activity, nil) 67 | if err != nil { 68 | return nil, err 69 | } 70 | return activity, nil 71 | } 72 | 73 | // GetInstancePeers returns instance peers. 74 | func (c *Client) GetInstancePeers(ctx context.Context) ([]string, error) { 75 | var peers []string 76 | err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance/peers", nil, &peers, nil) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return peers, nil 81 | } 82 | -------------------------------------------------------------------------------- /instance_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestGetInstance(t *testing.T) { 13 | canErr := true 14 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | if canErr { 16 | canErr = false 17 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 18 | return 19 | } 20 | fmt.Fprintln(w, `{"title": "mastodon", "uri": "http://mstdn.example.com", "description": "test mastodon", "email": "mstdn@mstdn.example.com", "contact_account": {"username": "mattn"}}`) 21 | })) 22 | defer ts.Close() 23 | 24 | client := NewClient(&Config{ 25 | Server: ts.URL, 26 | ClientID: "foo", 27 | ClientSecret: "bar", 28 | AccessToken: "zoo", 29 | }) 30 | _, err := client.GetInstance(context.Background()) 31 | if err == nil { 32 | t.Fatalf("should be fail: %v", err) 33 | } 34 | ins, err := client.GetInstance(context.Background()) 35 | if err != nil { 36 | t.Fatalf("should not be fail: %v", err) 37 | } 38 | if ins.Title != "mastodon" { 39 | t.Fatalf("want %q but %q", "mastodon", ins.Title) 40 | } 41 | if ins.URI != "http://mstdn.example.com" { 42 | t.Fatalf("want %q but %q", "http://mstdn.example.com", ins.URI) 43 | } 44 | if ins.Description != "test mastodon" { 45 | t.Fatalf("want %q but %q", "test mastodon", ins.Description) 46 | } 47 | if ins.EMail != "mstdn@mstdn.example.com" { 48 | t.Fatalf("want %q but %q", "mstdn@mstdn.example.com", ins.EMail) 49 | } 50 | if ins.ContactAccount.Username != "mattn" { 51 | t.Fatalf("want %q but %q", "mattn", ins.ContactAccount.Username) 52 | } 53 | } 54 | 55 | func TestGetInstanceMore(t *testing.T) { 56 | canErr := true 57 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 | if canErr { 59 | canErr = false 60 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 61 | return 62 | } 63 | fmt.Fprintln(w, `{"title": "mastodon", "uri": "http://mstdn.example.com", "description": "test mastodon", "email": "mstdn@mstdn.example.com", "version": "0.0.1", "urls":{"foo":"http://stream1.example.com", "bar": "http://stream2.example.com"}, "thumbnail": "http://mstdn.example.com/logo.png", "configuration":{"accounts": {"max_featured_tags": 10}, "statuses": {"max_characters": 500}}, "stats":{"user_count":1, "status_count":2, "domain_count":3}}}`) 64 | })) 65 | defer ts.Close() 66 | 67 | client := NewClient(&Config{ 68 | Server: ts.URL, 69 | ClientID: "foo", 70 | ClientSecret: "bar", 71 | AccessToken: "zoo", 72 | }) 73 | _, err := client.GetInstance(context.Background()) 74 | if err == nil { 75 | t.Fatalf("should be fail: %v", err) 76 | } 77 | ins, err := client.GetInstance(context.Background()) 78 | if err != nil { 79 | t.Fatalf("should not be fail: %v", err) 80 | } 81 | if ins.Title != "mastodon" { 82 | t.Fatalf("want %q but %q", "mastodon", ins.Title) 83 | } 84 | if ins.URI != "http://mstdn.example.com" { 85 | t.Fatalf("want %q but %q", "mastodon", ins.URI) 86 | } 87 | if ins.Description != "test mastodon" { 88 | t.Fatalf("want %q but %q", "test mastodon", ins.Description) 89 | } 90 | if ins.EMail != "mstdn@mstdn.example.com" { 91 | t.Fatalf("want %q but %q", "mstdn@mstdn.example.com", ins.EMail) 92 | } 93 | if ins.Version != "0.0.1" { 94 | t.Fatalf("want %q but %q", "0.0.1", ins.Version) 95 | } 96 | if ins.URLs["foo"] != "http://stream1.example.com" { 97 | t.Fatalf("want %q but %q", "http://stream1.example.com", ins.Version) 98 | } 99 | if ins.URLs["bar"] != "http://stream2.example.com" { 100 | t.Fatalf("want %q but %q", "http://stream2.example.com", ins.Version) 101 | } 102 | if ins.Thumbnail != "http://mstdn.example.com/logo.png" { 103 | t.Fatalf("want %q but %q", "http://mstdn.example.com/logo.png", ins.Thumbnail) 104 | } 105 | if ins.Stats == nil { 106 | t.Fatal("stats should not be nil") 107 | } 108 | if ins.Stats.UserCount != 1 { 109 | t.Fatalf("want %v but %v", 1, ins.Stats.UserCount) 110 | } 111 | if ins.Stats.StatusCount != 2 { 112 | t.Fatalf("want %v but %v", 2, ins.Stats.StatusCount) 113 | } 114 | if ins.Stats.DomainCount != 3 { 115 | t.Fatalf("want %v but %v", 3, ins.Stats.DomainCount) 116 | } 117 | 118 | cfg := ins.GetConfig() 119 | if cfg.Accounts == nil { 120 | t.Error("expected accounts to be non nil") 121 | } 122 | if cfg.Statuses == nil { 123 | t.Error("expected statuses to be non nil") 124 | } 125 | 126 | } 127 | 128 | func TestGetInstanceActivity(t *testing.T) { 129 | canErr := true 130 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 131 | if canErr { 132 | canErr = false 133 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 134 | return 135 | } 136 | fmt.Fprintln(w, `[{"week":"1516579200","statuses":"1","logins":"1","registrations":"0"}]`) 137 | })) 138 | defer ts.Close() 139 | 140 | client := NewClient(&Config{ 141 | Server: ts.URL, 142 | }) 143 | _, err := client.GetInstanceActivity(context.Background()) 144 | if err == nil { 145 | t.Fatalf("should be fail: %v", err) 146 | } 147 | activity, err := client.GetInstanceActivity(context.Background()) 148 | if err != nil { 149 | t.Fatalf("should not be fail: %v", err) 150 | } 151 | if activity[0].Week != Unixtime(time.Unix(1516579200, 0)) { 152 | t.Fatalf("want %v but %v", Unixtime(time.Unix(1516579200, 0)), activity[0].Week) 153 | } 154 | if activity[0].Logins != 1 { 155 | t.Fatalf("want %q but %q", 1, activity[0].Logins) 156 | } 157 | } 158 | 159 | func TestGetInstancePeers(t *testing.T) { 160 | canErr := true 161 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 162 | if canErr { 163 | canErr = false 164 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 165 | return 166 | } 167 | fmt.Fprintln(w, `["mastodon.social","mstdn.jp"]`) 168 | })) 169 | defer ts.Close() 170 | 171 | client := NewClient(&Config{ 172 | Server: ts.URL, 173 | }) 174 | _, err := client.GetInstancePeers(context.Background()) 175 | if err == nil { 176 | t.Fatalf("should be fail: %v", err) 177 | } 178 | peers, err := client.GetInstancePeers(context.Background()) 179 | if err != nil { 180 | t.Fatalf("should not be fail: %v", err) 181 | } 182 | if peers[0] != "mastodon.social" { 183 | t.Fatalf("want %q but %q", "mastodon.social", peers[0]) 184 | } 185 | if peers[1] != "mstdn.jp" { 186 | t.Fatalf("want %q but %q", "mstdn.jp", peers[1]) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /lists.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | // List is metadata for a list of users. 11 | type List struct { 12 | ID ID `json:"id"` 13 | Title string `json:"title"` 14 | } 15 | 16 | // GetLists returns all the lists on the current account. 17 | func (c *Client) GetLists(ctx context.Context) ([]*List, error) { 18 | var lists []*List 19 | err := c.doAPI(ctx, http.MethodGet, "/api/v1/lists", nil, &lists, nil) 20 | if err != nil { 21 | return nil, err 22 | } 23 | return lists, nil 24 | } 25 | 26 | // GetAccountLists returns the lists containing a given account. 27 | func (c *Client) GetAccountLists(ctx context.Context, id ID) ([]*List, error) { 28 | var lists []*List 29 | err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/lists", url.PathEscape(string(id))), nil, &lists, nil) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return lists, nil 34 | } 35 | 36 | // GetListAccounts returns the accounts in a given list. 37 | func (c *Client) GetListAccounts(ctx context.Context, id ID) ([]*Account, error) { 38 | var accounts []*Account 39 | err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(id))), url.Values{"limit": {"0"}}, &accounts, nil) 40 | if err != nil { 41 | return nil, err 42 | } 43 | return accounts, nil 44 | } 45 | 46 | // GetList retrieves a list by ID. 47 | func (c *Client) GetList(ctx context.Context, id ID) (*List, error) { 48 | var list List 49 | err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), nil, &list, nil) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return &list, nil 54 | } 55 | 56 | // CreateList creates a new list with a given title. 57 | func (c *Client) CreateList(ctx context.Context, title string) (*List, error) { 58 | params := url.Values{} 59 | params.Set("title", title) 60 | 61 | var list List 62 | err := c.doAPI(ctx, http.MethodPost, "/api/v1/lists", params, &list, nil) 63 | if err != nil { 64 | return nil, err 65 | } 66 | return &list, nil 67 | } 68 | 69 | // RenameList assigns a new title to a list. 70 | func (c *Client) RenameList(ctx context.Context, id ID, title string) (*List, error) { 71 | params := url.Values{} 72 | params.Set("title", title) 73 | 74 | var list List 75 | err := c.doAPI(ctx, http.MethodPut, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), params, &list, nil) 76 | if err != nil { 77 | return nil, err 78 | } 79 | return &list, nil 80 | } 81 | 82 | // DeleteList removes a list. 83 | func (c *Client) DeleteList(ctx context.Context, id ID) error { 84 | return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), nil, nil, nil) 85 | } 86 | 87 | // AddToList adds accounts to a list. 88 | // 89 | // Only accounts already followed by the user can be added to a list. 90 | func (c *Client) AddToList(ctx context.Context, list ID, accounts ...ID) error { 91 | params := url.Values{} 92 | for _, acct := range accounts { 93 | params.Add("account_ids[]", string(acct)) 94 | } 95 | 96 | return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil) 97 | } 98 | 99 | // RemoveFromList removes accounts from a list. 100 | func (c *Client) RemoveFromList(ctx context.Context, list ID, accounts ...ID) error { 101 | params := url.Values{} 102 | for _, acct := range accounts { 103 | params.Add("account_ids[]", string(acct)) 104 | } 105 | 106 | return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil) 107 | } 108 | -------------------------------------------------------------------------------- /lists_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestGetLists(t *testing.T) { 12 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | if r.URL.Path != "/api/v1/lists" { 14 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 15 | return 16 | } 17 | fmt.Fprintln(w, `[{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}]`) 18 | })) 19 | defer ts.Close() 20 | 21 | client := NewClient(&Config{ 22 | Server: ts.URL, 23 | ClientID: "foo", 24 | ClientSecret: "bar", 25 | AccessToken: "zoo", 26 | }) 27 | lists, err := client.GetLists(context.Background()) 28 | if err != nil { 29 | t.Fatalf("should not be fail: %v", err) 30 | } 31 | if len(lists) != 2 { 32 | t.Fatalf("result should be two: %d", len(lists)) 33 | } 34 | if lists[0].Title != "foo" { 35 | t.Fatalf("want %q but %q", "foo", lists[0].Title) 36 | } 37 | if lists[1].Title != "bar" { 38 | t.Fatalf("want %q but %q", "bar", lists[1].Title) 39 | } 40 | } 41 | 42 | func TestGetAccountLists(t *testing.T) { 43 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | if r.URL.Path != "/api/v1/accounts/1/lists" { 45 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 46 | return 47 | } 48 | fmt.Fprintln(w, `[{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}]`) 49 | })) 50 | defer ts.Close() 51 | 52 | client := NewClient(&Config{ 53 | Server: ts.URL, 54 | ClientID: "foo", 55 | ClientSecret: "bar", 56 | AccessToken: "zoo", 57 | }) 58 | _, err := client.GetAccountLists(context.Background(), "2") 59 | if err == nil { 60 | t.Fatalf("should be fail: %v", err) 61 | } 62 | lists, err := client.GetAccountLists(context.Background(), "1") 63 | if err != nil { 64 | t.Fatalf("should not be fail: %v", err) 65 | } 66 | if len(lists) != 2 { 67 | t.Fatalf("result should be two: %d", len(lists)) 68 | } 69 | if lists[0].Title != "foo" { 70 | t.Fatalf("want %q but %q", "foo", lists[0].Title) 71 | } 72 | if lists[1].Title != "bar" { 73 | t.Fatalf("want %q but %q", "bar", lists[1].Title) 74 | } 75 | } 76 | 77 | func TestGetListAccounts(t *testing.T) { 78 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 79 | if r.URL.Path != "/api/v1/lists/1/accounts" { 80 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 81 | return 82 | } 83 | fmt.Fprintln(w, `[{"username": "foo"}, {"username": "bar"}]`) 84 | })) 85 | defer ts.Close() 86 | 87 | client := NewClient(&Config{ 88 | Server: ts.URL, 89 | ClientID: "foo", 90 | ClientSecret: "bar", 91 | AccessToken: "zoo", 92 | }) 93 | _, err := client.GetListAccounts(context.Background(), "2") 94 | if err == nil { 95 | t.Fatalf("should be fail: %v", err) 96 | } 97 | accounts, err := client.GetListAccounts(context.Background(), "1") 98 | if err != nil { 99 | t.Fatalf("should not be fail: %v", err) 100 | } 101 | if len(accounts) != 2 { 102 | t.Fatalf("result should be two: %d", len(accounts)) 103 | } 104 | if accounts[0].Username != "foo" { 105 | t.Fatalf("want %q but %q", "foo", accounts[0].Username) 106 | } 107 | if accounts[1].Username != "bar" { 108 | t.Fatalf("want %q but %q", "bar", accounts[1].Username) 109 | } 110 | } 111 | 112 | func TestGetList(t *testing.T) { 113 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 114 | if r.URL.Path != "/api/v1/lists/1" { 115 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 116 | return 117 | } 118 | fmt.Fprintln(w, `{"id": "1", "title": "foo"}`) 119 | })) 120 | defer ts.Close() 121 | 122 | client := NewClient(&Config{ 123 | Server: ts.URL, 124 | ClientID: "foo", 125 | ClientSecret: "bar", 126 | AccessToken: "zoo", 127 | }) 128 | _, err := client.GetList(context.Background(), "2") 129 | if err == nil { 130 | t.Fatalf("should be fail: %v", err) 131 | } 132 | list, err := client.GetList(context.Background(), "1") 133 | if err != nil { 134 | t.Fatalf("should not be fail: %v", err) 135 | } 136 | if list.Title != "foo" { 137 | t.Fatalf("want %q but %q", "foo", list.Title) 138 | } 139 | } 140 | 141 | func TestCreateList(t *testing.T) { 142 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 143 | if r.PostFormValue("title") != "foo" { 144 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 145 | return 146 | } 147 | fmt.Fprintln(w, `{"id": "1", "title": "foo"}`) 148 | })) 149 | defer ts.Close() 150 | 151 | client := NewClient(&Config{ 152 | Server: ts.URL, 153 | ClientID: "foo", 154 | ClientSecret: "bar", 155 | AccessToken: "zoo", 156 | }) 157 | _, err := client.CreateList(context.Background(), "") 158 | if err == nil { 159 | t.Fatalf("should be fail: %v", err) 160 | } 161 | list, err := client.CreateList(context.Background(), "foo") 162 | if err != nil { 163 | t.Fatalf("should not be fail: %v", err) 164 | } 165 | if list.Title != "foo" { 166 | t.Fatalf("want %q but %q", "foo", list.Title) 167 | } 168 | } 169 | 170 | func TestRenameList(t *testing.T) { 171 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 172 | if r.URL.Path != "/api/v1/lists/1" { 173 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 174 | return 175 | } 176 | if r.PostFormValue("title") != "bar" { 177 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 178 | return 179 | } 180 | fmt.Fprintln(w, `{"id": "1", "title": "bar"}`) 181 | })) 182 | defer ts.Close() 183 | 184 | client := NewClient(&Config{ 185 | Server: ts.URL, 186 | ClientID: "foo", 187 | ClientSecret: "bar", 188 | AccessToken: "zoo", 189 | }) 190 | _, err := client.RenameList(context.Background(), "2", "bar") 191 | if err == nil { 192 | t.Fatalf("should be fail: %v", err) 193 | } 194 | list, err := client.RenameList(context.Background(), "1", "bar") 195 | if err != nil { 196 | t.Fatalf("should not be fail: %v", err) 197 | } 198 | if list.Title != "bar" { 199 | t.Fatalf("want %q but %q", "bar", list.Title) 200 | } 201 | } 202 | 203 | func TestDeleteList(t *testing.T) { 204 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 205 | if r.URL.Path != "/api/v1/lists/1" { 206 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 207 | return 208 | } 209 | if r.Method != "DELETE" { 210 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusMethodNotAllowed) 211 | return 212 | } 213 | })) 214 | defer ts.Close() 215 | 216 | client := NewClient(&Config{ 217 | Server: ts.URL, 218 | ClientID: "foo", 219 | ClientSecret: "bar", 220 | AccessToken: "zoo", 221 | }) 222 | err := client.DeleteList(context.Background(), "2") 223 | if err == nil { 224 | t.Fatalf("should be fail: %v", err) 225 | } 226 | err = client.DeleteList(context.Background(), "1") 227 | if err != nil { 228 | t.Fatalf("should not be fail: %v", err) 229 | } 230 | } 231 | 232 | func TestAddToList(t *testing.T) { 233 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 234 | if r.URL.Path != "/api/v1/lists/1/accounts" { 235 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 236 | return 237 | } 238 | if r.PostFormValue("account_ids[]") != "1" { 239 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 240 | return 241 | } 242 | })) 243 | defer ts.Close() 244 | 245 | client := NewClient(&Config{ 246 | Server: ts.URL, 247 | ClientID: "foo", 248 | ClientSecret: "bar", 249 | AccessToken: "zoo", 250 | }) 251 | err := client.AddToList(context.Background(), "1", "1") 252 | if err != nil { 253 | t.Fatalf("should not be fail: %v", err) 254 | } 255 | } 256 | 257 | func TestRemoveFromList(t *testing.T) { 258 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 259 | if r.URL.Path != "/api/v1/lists/1/accounts" { 260 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 261 | return 262 | } 263 | if r.Method != "DELETE" { 264 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusMethodNotAllowed) 265 | return 266 | } 267 | })) 268 | defer ts.Close() 269 | 270 | client := NewClient(&Config{ 271 | Server: ts.URL, 272 | ClientID: "foo", 273 | ClientSecret: "bar", 274 | AccessToken: "zoo", 275 | }) 276 | err := client.RemoveFromList(context.Background(), "1", "1") 277 | if err != nil { 278 | t.Fatalf("should not be fail: %v", err) 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /mastodon.go: -------------------------------------------------------------------------------- 1 | // Package mastodon provides functions and structs for accessing the mastodon API. 2 | package mastodon 3 | 4 | import ( 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "path" 13 | "strings" 14 | "time" 15 | 16 | "github.com/tomnomnom/linkheader" 17 | ) 18 | 19 | // Config is a setting for access mastodon APIs. 20 | type Config struct { 21 | Server string 22 | ClientID string 23 | ClientSecret string 24 | AccessToken string 25 | } 26 | 27 | type WriterResetter interface { 28 | io.Writer 29 | Reset() 30 | } 31 | 32 | // Client is a API client for mastodon. 33 | type Client struct { 34 | http.Client 35 | Config *Config 36 | UserAgent string 37 | JSONWriter io.Writer 38 | } 39 | 40 | func (c *Client) doAPI(ctx context.Context, method string, uri string, params interface{}, res interface{}, pg *Pagination) error { 41 | u, err := url.Parse(c.Config.Server) 42 | if err != nil { 43 | return err 44 | } 45 | u.Path = path.Join(u.Path, uri) 46 | 47 | var req *http.Request 48 | ct := "application/x-www-form-urlencoded" 49 | if values, ok := params.(url.Values); ok { 50 | var body io.Reader 51 | if method == http.MethodGet { 52 | if pg != nil { 53 | values = pg.setValues(values) 54 | } 55 | u.RawQuery = values.Encode() 56 | } else { 57 | body = strings.NewReader(values.Encode()) 58 | } 59 | req, err = http.NewRequest(method, u.String(), body) 60 | if err != nil { 61 | return err 62 | } 63 | } else if media, ok := params.(*Media); ok { 64 | r, contentType, err := media.bodyAndContentType() 65 | if err != nil { 66 | return err 67 | } 68 | 69 | req, err = http.NewRequest(method, u.String(), r) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | ct = contentType 75 | } else { 76 | if method == http.MethodGet && pg != nil { 77 | u.RawQuery = pg.toValues().Encode() 78 | } 79 | req, err = http.NewRequest(method, u.String(), nil) 80 | if err != nil { 81 | return err 82 | } 83 | } 84 | req = req.WithContext(ctx) 85 | req.Header.Set("Authorization", "Bearer "+c.Config.AccessToken) 86 | if params != nil { 87 | req.Header.Set("Content-Type", ct) 88 | } 89 | if c.UserAgent != "" { 90 | req.Header.Set("User-Agent", c.UserAgent) 91 | } 92 | 93 | var resp *http.Response 94 | backoff := time.Second 95 | for { 96 | resp, err = c.Do(req) 97 | if err != nil { 98 | return err 99 | } 100 | defer resp.Body.Close() 101 | 102 | // handle status code 429, which indicates the server is throttling 103 | // our requests. Do an exponential backoff and retry the request. 104 | if resp.StatusCode == 429 { 105 | if backoff > time.Hour { 106 | break 107 | } 108 | 109 | select { 110 | case <-time.After(backoff): 111 | case <-ctx.Done(): 112 | return ctx.Err() 113 | } 114 | 115 | backoff = time.Duration(1.5 * float64(backoff)) 116 | continue 117 | } 118 | break 119 | } 120 | 121 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { 122 | return parseAPIError("bad request", resp) 123 | } else if res == nil { 124 | return nil 125 | } else if pg != nil { 126 | if lh := resp.Header.Get("Link"); lh != "" { 127 | pg2, err := newPagination(lh) 128 | if err != nil { 129 | return err 130 | } 131 | *pg = *pg2 132 | } 133 | } 134 | 135 | if c.JSONWriter != nil { 136 | if resetter, ok := c.JSONWriter.(WriterResetter); ok { 137 | resetter.Reset() 138 | } 139 | return json.NewDecoder(io.TeeReader(resp.Body, c.JSONWriter)).Decode(&res) 140 | } else { 141 | return json.NewDecoder(resp.Body).Decode(&res) 142 | } 143 | } 144 | 145 | // NewClient returns a new mastodon API client. 146 | func NewClient(config *Config) *Client { 147 | return &Client{ 148 | Client: *http.DefaultClient, 149 | Config: config, 150 | } 151 | } 152 | 153 | // Authenticate gets access-token to the API. 154 | // DEPRECATED: Authenticating with username and password is no longer supported, please use 155 | // GetAppAccessToken() or GetUserAccessToken() instead 156 | func (c *Client) Authenticate(ctx context.Context, username, password string) error { 157 | params := url.Values{ 158 | "client_id": {c.Config.ClientID}, 159 | "client_secret": {c.Config.ClientSecret}, 160 | "grant_type": {"password"}, 161 | "username": {username}, 162 | "password": {password}, 163 | "scope": {"read write follow"}, 164 | } 165 | 166 | return c.authenticate(ctx, params) 167 | } 168 | 169 | // AuthenticateApp logs in using client credentials. 170 | // DEPRECATED: use GetAppAccessToken() instead 171 | func (c *Client) AuthenticateApp(ctx context.Context) error { 172 | params := url.Values{ 173 | "client_id": {c.Config.ClientID}, 174 | "client_secret": {c.Config.ClientSecret}, 175 | "grant_type": {"client_credentials"}, 176 | "redirect_uri": {"urn:ietf:wg:oauth:2.0:oob"}, 177 | } 178 | 179 | return c.authenticate(ctx, params) 180 | } 181 | 182 | // GetAppAccessToken exchanges API Credentials for an application Access Token 183 | // https://docs.joinmastodon.org/api/oauth-tokens/#app-tokens 184 | func (c *Client) GetAppAccessToken(ctx context.Context, redirectURI string) error { 185 | params := url.Values{ 186 | "client_id": {c.Config.ClientID}, 187 | "client_secret": {c.Config.ClientSecret}, 188 | "grant_type": {"client_credentials"}, 189 | "redirect_uri": {redirectURI}, 190 | } 191 | 192 | return c.getAccessToken(ctx, params) 193 | } 194 | 195 | // AuthenticateToken logs in using a grant token returned by Application.AuthURI. 196 | // redirectURI should be the same as Application.RedirectURI. 197 | // DEPRECATED: Use GetUserAccessToken() instead 198 | func (c *Client) AuthenticateToken(ctx context.Context, authCode, redirectURI string) error { 199 | params := url.Values{ 200 | "client_id": {c.Config.ClientID}, 201 | "client_secret": {c.Config.ClientSecret}, 202 | "grant_type": {"authorization_code"}, 203 | "code": {authCode}, 204 | "redirect_uri": {redirectURI}, 205 | } 206 | 207 | return c.authenticate(ctx, params) 208 | } 209 | 210 | // GetUserAccessToken exhanges a user provided authorization code for an User Access Token 211 | // https://docs.joinmastodon.org/api/oauth-tokens/#user-tokens 212 | func (c *Client) GetUserAccessToken(ctx context.Context, authCode, redirectURI string) error { 213 | params := url.Values{ 214 | "client_id": {c.Config.ClientID}, 215 | "client_secret": {c.Config.ClientSecret}, 216 | "grant_type": {"authorization_code"}, 217 | "code": {authCode}, 218 | "redirect_uri": {redirectURI}, 219 | } 220 | 221 | return c.getAccessToken(ctx, params) 222 | } 223 | 224 | // DEPRECATED: Use getAccessToken() instead 225 | func (c *Client) authenticate(ctx context.Context, params url.Values) error { 226 | u, err := url.Parse(c.Config.Server) 227 | if err != nil { 228 | return err 229 | } 230 | u.Path = path.Join(u.Path, "/oauth/token") 231 | 232 | req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(params.Encode())) 233 | if err != nil { 234 | return err 235 | } 236 | req = req.WithContext(ctx) 237 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 238 | if c.UserAgent != "" { 239 | req.Header.Set("User-Agent", c.UserAgent) 240 | } 241 | resp, err := c.Do(req) 242 | if err != nil { 243 | return err 244 | } 245 | defer resp.Body.Close() 246 | 247 | if resp.StatusCode != http.StatusOK { 248 | return parseAPIError("bad authorization", resp) 249 | } 250 | 251 | var res struct { 252 | AccessToken string `json:"access_token"` 253 | } 254 | err = json.NewDecoder(resp.Body).Decode(&res) 255 | if err != nil { 256 | return err 257 | } 258 | c.Config.AccessToken = res.AccessToken 259 | return nil 260 | } 261 | 262 | // Exchanges credentials for an access token to be used by applications and sets the access token in the client config 263 | func (c *Client) getAccessToken(ctx context.Context, params url.Values) error { 264 | u, err := url.Parse(c.Config.Server) 265 | if err != nil { 266 | return err 267 | } 268 | u.Path = path.Join(u.Path, "/oauth/token") 269 | 270 | req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(params.Encode())) 271 | if err != nil { 272 | return err 273 | } 274 | req = req.WithContext(ctx) 275 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 276 | if c.UserAgent != "" { 277 | req.Header.Set("User-Agent", c.UserAgent) 278 | } 279 | resp, err := c.Do(req) 280 | if err != nil { 281 | return err 282 | } 283 | defer resp.Body.Close() 284 | 285 | if resp.StatusCode != http.StatusOK { 286 | return parseAPIError("bad authorization", resp) 287 | } 288 | 289 | var res struct { 290 | AccessToken string `json:"access_token"` 291 | } 292 | err = json.NewDecoder(resp.Body).Decode(&res) 293 | if err != nil { 294 | return err 295 | } 296 | 297 | c.Config.AccessToken = res.AccessToken 298 | 299 | return nil 300 | } 301 | 302 | // Convenience constants for Toot.Visibility 303 | const ( 304 | VisibilityPublic = "public" 305 | VisibilityUnlisted = "unlisted" 306 | VisibilityFollowersOnly = "private" 307 | VisibilityDirectMessage = "direct" 308 | ) 309 | 310 | // Toot is a struct to post status. 311 | type Toot struct { 312 | Status string `json:"status"` 313 | InReplyToID ID `json:"in_reply_to_id"` 314 | MediaIDs []ID `json:"media_ids"` 315 | Sensitive bool `json:"sensitive"` 316 | SpoilerText string `json:"spoiler_text"` 317 | Visibility string `json:"visibility"` 318 | Language string `json:"language"` 319 | ScheduledAt *time.Time `json:"scheduled_at,omitempty"` 320 | Poll *TootPoll `json:"poll"` 321 | } 322 | 323 | // TootPoll holds information for creating a poll in Toot. 324 | type TootPoll struct { 325 | Options []string `json:"options"` 326 | ExpiresInSeconds int64 `json:"expires_in"` 327 | Multiple bool `json:"multiple"` 328 | HideTotals bool `json:"hide_totals"` 329 | } 330 | 331 | // Mention hold information for mention. 332 | type Mention struct { 333 | URL string `json:"url"` 334 | Username string `json:"username"` 335 | Acct string `json:"acct"` 336 | ID ID `json:"id"` 337 | } 338 | 339 | // Tag hold information for tag. 340 | type Tag struct { 341 | Name string `json:"name"` 342 | URL string `json:"url"` 343 | History []History `json:"history"` 344 | } 345 | 346 | // History hold information for history. 347 | type History struct { 348 | Day string `json:"day"` 349 | Uses string `json:"uses"` 350 | Accounts string `json:"accounts"` 351 | } 352 | 353 | // Attachment hold information for attachment. 354 | type Attachment struct { 355 | ID ID `json:"id"` 356 | Type string `json:"type"` 357 | URL string `json:"url"` 358 | RemoteURL string `json:"remote_url"` 359 | PreviewURL string `json:"preview_url"` 360 | TextURL string `json:"text_url"` 361 | Description string `json:"description"` 362 | BlurHash string `json:"blurhash"` 363 | Meta AttachmentMeta `json:"meta"` 364 | } 365 | 366 | // AttachmentMeta holds information for attachment metadata. 367 | type AttachmentMeta struct { 368 | Original AttachmentSize `json:"original"` 369 | Small AttachmentSize `json:"small"` 370 | Focus AttachmentFocus `json:"focus"` 371 | } 372 | 373 | // AttachmentSize holds information for attatchment size. 374 | type AttachmentSize struct { 375 | Width int64 `json:"width"` 376 | Height int64 `json:"height"` 377 | Size string `json:"size"` 378 | Aspect float64 `json:"aspect"` 379 | } 380 | 381 | // AttachmentSize holds information for attatchment size. 382 | type AttachmentFocus struct { 383 | X float64 `json:"x"` 384 | Y float64 `json:"y"` 385 | } 386 | 387 | // Emoji hold information for CustomEmoji. 388 | type Emoji struct { 389 | ShortCode string `json:"shortcode"` 390 | StaticURL string `json:"static_url"` 391 | URL string `json:"url"` 392 | VisibleInPicker bool `json:"visible_in_picker"` 393 | } 394 | 395 | // Results hold information for search result. 396 | type Results struct { 397 | Accounts []*Account `json:"accounts"` 398 | Statuses []*Status `json:"statuses"` 399 | Hashtags []*Tag `json:"hashtags"` 400 | } 401 | 402 | // Pagination is a struct for specifying the get range. 403 | type Pagination struct { 404 | MaxID ID 405 | SinceID ID 406 | MinID ID 407 | Limit int64 408 | } 409 | 410 | func newPagination(rawlink string) (*Pagination, error) { 411 | if rawlink == "" { 412 | return nil, errors.New("empty link header") 413 | } 414 | 415 | p := &Pagination{} 416 | for _, link := range linkheader.Parse(rawlink) { 417 | switch link.Rel { 418 | case "next": 419 | maxID, err := getPaginationID(link.URL, "max_id") 420 | if err != nil { 421 | return nil, err 422 | } 423 | p.MaxID = maxID 424 | case "prev": 425 | sinceID, err := getPaginationID(link.URL, "since_id") 426 | if err != nil { 427 | return nil, err 428 | } 429 | p.SinceID = sinceID 430 | 431 | minID, err := getPaginationID(link.URL, "min_id") 432 | if err != nil { 433 | return nil, err 434 | } 435 | p.MinID = minID 436 | } 437 | } 438 | 439 | return p, nil 440 | } 441 | 442 | func getPaginationID(rawurl, key string) (ID, error) { 443 | u, err := url.Parse(rawurl) 444 | if err != nil { 445 | return "", err 446 | } 447 | 448 | return ID(u.Query().Get(key)), nil 449 | } 450 | 451 | func (p *Pagination) toValues() url.Values { 452 | return p.setValues(url.Values{}) 453 | } 454 | 455 | func (p *Pagination) setValues(params url.Values) url.Values { 456 | if p.MaxID != "" { 457 | params.Set("max_id", string(p.MaxID)) 458 | } 459 | if p.SinceID != "" { 460 | params.Set("since_id", string(p.SinceID)) 461 | } 462 | if p.MinID != "" { 463 | params.Set("min_id", string(p.MinID)) 464 | } 465 | if p.Limit > 0 { 466 | params.Set("limit", fmt.Sprint(p.Limit)) 467 | } 468 | 469 | return params 470 | } 471 | -------------------------------------------------------------------------------- /notification.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "crypto/ecdsa" 6 | "encoding/base64" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | // Notification holds information for a mastodon notification. 15 | type Notification struct { 16 | ID ID `json:"id"` 17 | Type string `json:"type"` 18 | CreatedAt time.Time `json:"created_at"` 19 | Account Account `json:"account"` 20 | Status *Status `json:"status"` 21 | } 22 | 23 | type PushSubscription struct { 24 | ID ID `json:"id"` 25 | Endpoint string `json:"endpoint"` 26 | ServerKey string `json:"server_key"` 27 | Alerts *PushAlerts `json:"alerts"` 28 | } 29 | 30 | type PushAlerts struct { 31 | Follow *Sbool `json:"follow"` 32 | Favourite *Sbool `json:"favourite"` 33 | Reblog *Sbool `json:"reblog"` 34 | Mention *Sbool `json:"mention"` 35 | } 36 | 37 | // GetNotifications returns notifications. 38 | func (c *Client) GetNotifications(ctx context.Context, pg *Pagination) ([]*Notification, error) { 39 | return c.GetNotificationsExclude(ctx, nil, pg) 40 | } 41 | 42 | // GetNotificationsExclude returns notifications with excluded notifications 43 | func (c *Client) GetNotificationsExclude(ctx context.Context, exclude *[]string, pg *Pagination) ([]*Notification, error) { 44 | var notifications []*Notification 45 | params := url.Values{} 46 | if exclude != nil { 47 | for _, ex := range *exclude { 48 | params.Add("exclude_types[]", ex) 49 | } 50 | } 51 | err := c.doAPI(ctx, http.MethodGet, "/api/v1/notifications", params, ¬ifications, pg) 52 | if err != nil { 53 | return nil, err 54 | } 55 | return notifications, nil 56 | } 57 | 58 | // GetNotification returns notification. 59 | func (c *Client) GetNotification(ctx context.Context, id ID) (*Notification, error) { 60 | var notification Notification 61 | err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/notifications/%v", id), nil, ¬ification, nil) 62 | if err != nil { 63 | return nil, err 64 | } 65 | return ¬ification, nil 66 | } 67 | 68 | // DismissNotification deletes a single notification. 69 | func (c *Client) DismissNotification(ctx context.Context, id ID) error { 70 | return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/notifications/%v/dismiss", id), nil, nil, nil) 71 | } 72 | 73 | // ClearNotifications clears notifications. 74 | func (c *Client) ClearNotifications(ctx context.Context) error { 75 | return c.doAPI(ctx, http.MethodPost, "/api/v1/notifications/clear", nil, nil, nil) 76 | } 77 | 78 | // AddPushSubscription adds a new push subscription. 79 | func (c *Client) AddPushSubscription(ctx context.Context, endpoint string, public ecdsa.PublicKey, shared []byte, alerts PushAlerts) (*PushSubscription, error) { 80 | var subscription PushSubscription 81 | pk, err := public.ECDH() 82 | if err != nil { 83 | return nil, fmt.Errorf("could not retrieve ecdh public key: %w", err) 84 | } 85 | params := url.Values{} 86 | params.Add("subscription[endpoint]", endpoint) 87 | params.Add("subscription[keys][p256dh]", base64.RawURLEncoding.EncodeToString(pk.Bytes())) 88 | params.Add("subscription[keys][auth]", base64.RawURLEncoding.EncodeToString(shared)) 89 | if alerts.Follow != nil { 90 | params.Add("data[alerts][follow]", strconv.FormatBool(bool(*alerts.Follow))) 91 | } 92 | if alerts.Favourite != nil { 93 | params.Add("data[alerts][favourite]", strconv.FormatBool(bool(*alerts.Favourite))) 94 | } 95 | if alerts.Reblog != nil { 96 | params.Add("data[alerts][reblog]", strconv.FormatBool(bool(*alerts.Reblog))) 97 | } 98 | if alerts.Mention != nil { 99 | params.Add("data[alerts][mention]", strconv.FormatBool(bool(*alerts.Mention))) 100 | } 101 | err = c.doAPI(ctx, http.MethodPost, "/api/v1/push/subscription", params, &subscription, nil) 102 | if err != nil { 103 | return nil, err 104 | } 105 | return &subscription, nil 106 | } 107 | 108 | // UpdatePushSubscription updates which type of notifications are sent for the active push subscription. 109 | func (c *Client) UpdatePushSubscription(ctx context.Context, alerts *PushAlerts) (*PushSubscription, error) { 110 | var subscription PushSubscription 111 | params := url.Values{} 112 | if alerts.Follow != nil { 113 | params.Add("data[alerts][follow]", strconv.FormatBool(bool(*alerts.Follow))) 114 | } 115 | if alerts.Mention != nil { 116 | params.Add("data[alerts][favourite]", strconv.FormatBool(bool(*alerts.Favourite))) 117 | } 118 | if alerts.Reblog != nil { 119 | params.Add("data[alerts][reblog]", strconv.FormatBool(bool(*alerts.Reblog))) 120 | } 121 | if alerts.Mention != nil { 122 | params.Add("data[alerts][mention]", strconv.FormatBool(bool(*alerts.Mention))) 123 | } 124 | err := c.doAPI(ctx, http.MethodPut, "/api/v1/push/subscription", params, &subscription, nil) 125 | if err != nil { 126 | return nil, err 127 | } 128 | return &subscription, nil 129 | } 130 | 131 | // RemovePushSubscription deletes the active push subscription. 132 | func (c *Client) RemovePushSubscription(ctx context.Context) error { 133 | return c.doAPI(ctx, http.MethodDelete, "/api/v1/push/subscription", nil, nil, nil) 134 | } 135 | 136 | // GetPushSubscription retrieves information about the active push subscription. 137 | func (c *Client) GetPushSubscription(ctx context.Context) (*PushSubscription, error) { 138 | var subscription PushSubscription 139 | err := c.doAPI(ctx, http.MethodGet, "/api/v1/push/subscription", nil, &subscription, nil) 140 | if err != nil { 141 | return nil, err 142 | } 143 | return &subscription, nil 144 | } 145 | -------------------------------------------------------------------------------- /notification_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "fmt" 9 | "net/http" 10 | "net/http/httptest" 11 | "testing" 12 | ) 13 | 14 | func TestGetNotifications(t *testing.T) { 15 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | switch r.URL.Path { 17 | case "/api/v1/notifications": 18 | if r.URL.Query().Get("exclude_types[]") == "follow" { 19 | fmt.Fprintln(w, `[{"id": 321, "action_taken": true}]`) 20 | } else { 21 | fmt.Fprintln(w, `[{"id": 122, "action_taken": false}, {"id": 123, "action_taken": true}]`) 22 | } 23 | return 24 | case "/api/v1/notifications/123": 25 | fmt.Fprintln(w, `{"id": 123, "action_taken": true}`) 26 | return 27 | case "/api/v1/notifications/clear": 28 | fmt.Fprintln(w, `{}`) 29 | return 30 | case "/api/v1/notifications/123/dismiss": 31 | fmt.Fprintln(w, `{}`) 32 | return 33 | } 34 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 35 | })) 36 | defer ts.Close() 37 | 38 | client := NewClient(&Config{ 39 | Server: ts.URL, 40 | ClientID: "foo", 41 | ClientSecret: "bar", 42 | AccessToken: "zoo", 43 | }) 44 | ns, err := client.GetNotifications(context.Background(), nil) 45 | if err != nil { 46 | t.Fatalf("should not be fail: %v", err) 47 | } 48 | if len(ns) != 2 { 49 | t.Fatalf("result should be two: %d", len(ns)) 50 | } 51 | if ns[0].ID != "122" { 52 | t.Fatalf("want %v but %v", "122", ns[0].ID) 53 | } 54 | if ns[1].ID != "123" { 55 | t.Fatalf("want %v but %v", "123", ns[1].ID) 56 | } 57 | nse, err := client.GetNotificationsExclude(context.Background(), &[]string{"follow"}, nil) 58 | if err != nil { 59 | t.Fatalf("should not be fail: %v", err) 60 | } 61 | if len(nse) != 1 { 62 | t.Fatalf("result should be one: %d", len(nse)) 63 | } 64 | if nse[0].ID != "321" { 65 | t.Fatalf("want %v but %v", "321", nse[0].ID) 66 | } 67 | n, err := client.GetNotification(context.Background(), "123") 68 | if err != nil { 69 | t.Fatalf("should not be fail: %v", err) 70 | } 71 | if n.ID != "123" { 72 | t.Fatalf("want %v but %v", "123", n.ID) 73 | } 74 | err = client.ClearNotifications(context.Background()) 75 | if err != nil { 76 | t.Fatalf("should not be fail: %v", err) 77 | } 78 | err = client.DismissNotification(context.Background(), "123") 79 | if err != nil { 80 | t.Fatalf("should not be fail: %v", err) 81 | } 82 | } 83 | 84 | func TestPushSubscription(t *testing.T) { 85 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 86 | switch r.URL.Path { 87 | case "/api/v1/push/subscription": 88 | fmt.Fprintln(w, ` {"id":1,"endpoint":"https://example.org","alerts":{"follow":true,"favourite":"true","reblog":"true","mention":"true"},"server_key":"foobar"}`) 89 | return 90 | } 91 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 92 | })) 93 | defer ts.Close() 94 | 95 | client := NewClient(&Config{ 96 | Server: ts.URL, 97 | ClientID: "foo", 98 | ClientSecret: "bar", 99 | AccessToken: "zoo", 100 | }) 101 | 102 | enabled := new(Sbool) 103 | *enabled = true 104 | alerts := PushAlerts{Follow: enabled, Favourite: enabled, Reblog: enabled, Mention: enabled} 105 | 106 | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | 111 | shared := make([]byte, 16) 112 | _, err = rand.Read(shared) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | 117 | testSub := func(sub *PushSubscription, err error) { 118 | if err != nil { 119 | t.Fatalf("should not be fail: %v", err) 120 | } 121 | if sub.ID != "1" { 122 | t.Fatalf("want %v but %v", "1", sub.ID) 123 | } 124 | if sub.Endpoint != "https://example.org" { 125 | t.Fatalf("want %v but %v", "https://example.org", sub.Endpoint) 126 | } 127 | if sub.ServerKey != "foobar" { 128 | t.Fatalf("want %v but %v", "foobar", sub.ServerKey) 129 | } 130 | if *sub.Alerts.Favourite != true { 131 | t.Fatalf("want %v but %v", true, *sub.Alerts.Favourite) 132 | } 133 | if *sub.Alerts.Mention != true { 134 | t.Fatalf("want %v but %v", true, *sub.Alerts.Mention) 135 | } 136 | if *sub.Alerts.Reblog != true { 137 | t.Fatalf("want %v but %v", true, *sub.Alerts.Reblog) 138 | } 139 | if *sub.Alerts.Follow != true { 140 | t.Fatalf("want %v but %v", true, *sub.Alerts.Follow) 141 | } 142 | } 143 | 144 | sub, err := client.AddPushSubscription(context.Background(), "http://example.org", priv.PublicKey, shared, alerts) 145 | testSub(sub, err) 146 | 147 | sub, err = client.GetPushSubscription(context.Background()) 148 | testSub(sub, err) 149 | 150 | sub, err = client.UpdatePushSubscription(context.Background(), &alerts) 151 | testSub(sub, err) 152 | 153 | err = client.RemovePushSubscription(context.Background()) 154 | if err != nil { 155 | t.Fatalf("should not be fail: %v", err) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /polls.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | ) 10 | 11 | // Poll holds information for mastodon polls. 12 | type Poll struct { 13 | ID ID `json:"id"` 14 | ExpiresAt time.Time `json:"expires_at"` 15 | Expired bool `json:"expired"` 16 | Multiple bool `json:"multiple"` 17 | VotesCount int64 `json:"votes_count"` 18 | VotersCount int64 `json:"voters_count"` 19 | Options []PollOption `json:"options"` 20 | Voted bool `json:"voted"` 21 | OwnVotes []int `json:"own_votes"` 22 | Emojis []Emoji `json:"emojis"` 23 | } 24 | 25 | // Poll holds information for a mastodon poll option. 26 | type PollOption struct { 27 | Title string `json:"title"` 28 | VotesCount int64 `json:"votes_count"` 29 | } 30 | 31 | // GetPoll returns poll specified by id. 32 | func (c *Client) GetPoll(ctx context.Context, id ID) (*Poll, error) { 33 | var poll Poll 34 | err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/polls/%s", id), nil, &poll, nil) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return &poll, nil 39 | } 40 | 41 | // PollVote votes on a poll specified by id, choices is the Poll.Options index to vote on 42 | func (c *Client) PollVote(ctx context.Context, id ID, choices ...int) (*Poll, error) { 43 | params := url.Values{} 44 | for _, c := range choices { 45 | params.Add("choices[]", fmt.Sprintf("%d", c)) 46 | } 47 | 48 | var poll Poll 49 | err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/polls/%s/votes", url.PathEscape(string(id))), params, &poll, nil) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return &poll, nil 54 | } 55 | -------------------------------------------------------------------------------- /polls_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestGetPoll(t *testing.T) { 12 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | if r.URL.Path != "/api/v1/polls/1234567" { 14 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 15 | return 16 | } 17 | fmt.Fprintln(w, `{"id": "1234567", "expires_at": "2019-12-05T04:05:08.302Z", "expired": true, "multiple": false, "votes_count": 10, "voters_count": null, "voted": true, "own_votes": [1], "options": [{"title": "accept", "votes_count": 6}, {"title": "deny", "votes_count": 4}], "emojis":[{"shortcode":"💩", "url":"http://example.com", "static_url": "http://example.com/static"}]}`) 18 | })) 19 | defer ts.Close() 20 | 21 | client := NewClient(&Config{ 22 | Server: ts.URL, 23 | ClientID: "foo", 24 | ClientSecret: "bar", 25 | AccessToken: "zoo", 26 | }) 27 | _, err := client.GetPoll(context.Background(), "123") 28 | if err == nil { 29 | t.Fatalf("should be fail: %v", err) 30 | } 31 | poll, err := client.GetPoll(context.Background(), "1234567") 32 | if err != nil { 33 | t.Fatalf("should not be fail: %v", err) 34 | } 35 | if poll.Expired != true { 36 | t.Fatalf("want %t but %t", true, poll.Expired) 37 | } 38 | if poll.Multiple != false { 39 | t.Fatalf("want %t but %t", true, poll.Multiple) 40 | } 41 | if poll.VotesCount != 10 { 42 | t.Fatalf("want %d but %d", 10, poll.VotesCount) 43 | } 44 | if poll.VotersCount != 0 { 45 | t.Fatalf("want %d but %d", 0, poll.VotersCount) 46 | } 47 | if poll.Voted != true { 48 | t.Fatalf("want %t but %t", true, poll.Voted) 49 | } 50 | if len(poll.OwnVotes) != 1 { 51 | t.Fatalf("should have own votes") 52 | } 53 | if poll.OwnVotes[0] != 1 { 54 | t.Fatalf("want %d but %d", 1, poll.OwnVotes[0]) 55 | } 56 | if len(poll.Options) != 2 { 57 | t.Fatalf("should have 2 options") 58 | } 59 | if poll.Options[0].Title != "accept" { 60 | t.Fatalf("want %q but %q", "accept", poll.Options[0].Title) 61 | } 62 | if poll.Options[0].VotesCount != 6 { 63 | t.Fatalf("want %q but %q", 6, poll.Options[0].VotesCount) 64 | } 65 | if poll.Options[1].Title != "deny" { 66 | t.Fatalf("want %q but %q", "deny", poll.Options[1].Title) 67 | } 68 | if poll.Options[1].VotesCount != 4 { 69 | t.Fatalf("want %q but %q", 4, poll.Options[1].VotesCount) 70 | } 71 | if len(poll.Emojis) != 1 { 72 | t.Fatal("should have emojis") 73 | } 74 | if poll.Emojis[0].ShortCode != "💩" { 75 | t.Fatalf("want %q but %q", "💩", poll.Emojis[0].ShortCode) 76 | } 77 | if poll.Emojis[0].URL != "http://example.com" { 78 | t.Fatalf("want %q but %q", "https://example.com", poll.Emojis[0].URL) 79 | } 80 | if poll.Emojis[0].StaticURL != "http://example.com/static" { 81 | t.Fatalf("want %q but %q", "https://example.com/static", poll.Emojis[0].StaticURL) 82 | } 83 | } 84 | 85 | func TestPollVote(t *testing.T) { 86 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 87 | if r.URL.Path != "/api/v1/polls/1234567/votes" { 88 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 89 | return 90 | } 91 | if r.Method != "POST" { 92 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusMethodNotAllowed) 93 | return 94 | } 95 | fmt.Fprintln(w, `{"id": "1234567", "expires_at": "2019-12-05T04:05:08.302Z", "expired": false, "multiple": false, "votes_count": 10, "voters_count": null, "voted": true, "own_votes": [1], "options": [{"title": "accept", "votes_count": 6}, {"title": "deny", "votes_count": 4}], "emojis":[]}`) 96 | })) 97 | defer ts.Close() 98 | 99 | client := NewClient(&Config{ 100 | Server: ts.URL, 101 | ClientID: "foo", 102 | ClientSecret: "bar", 103 | AccessToken: "zoo", 104 | }) 105 | poll, err := client.PollVote(context.Background(), ID("1234567"), 1) 106 | if err != nil { 107 | t.Fatalf("should not be fail: %v", err) 108 | } 109 | if poll.Expired != false { 110 | t.Fatalf("want %t but %t", false, poll.Expired) 111 | } 112 | if poll.Multiple != false { 113 | t.Fatalf("want %t but %t", true, poll.Multiple) 114 | } 115 | if poll.VotesCount != 10 { 116 | t.Fatalf("want %d but %d", 10, poll.VotesCount) 117 | } 118 | if poll.VotersCount != 0 { 119 | t.Fatalf("want %d but %d", 0, poll.VotersCount) 120 | } 121 | if poll.Voted != true { 122 | t.Fatalf("want %t but %t", true, poll.Voted) 123 | } 124 | if len(poll.OwnVotes) != 1 { 125 | t.Fatalf("should have own votes") 126 | } 127 | if poll.OwnVotes[0] != 1 { 128 | t.Fatalf("want %d but %d", 1, poll.OwnVotes[0]) 129 | } 130 | if len(poll.Options) != 2 { 131 | t.Fatalf("should have 2 options") 132 | } 133 | if poll.Options[0].Title != "accept" { 134 | t.Fatalf("want %q but %q", "accept", poll.Options[0].Title) 135 | } 136 | if poll.Options[0].VotesCount != 6 { 137 | t.Fatalf("want %q but %q", 6, poll.Options[0].VotesCount) 138 | } 139 | if poll.Options[1].Title != "deny" { 140 | t.Fatalf("want %q but %q", "deny", poll.Options[1].Title) 141 | } 142 | if poll.Options[1].VotesCount != 4 { 143 | t.Fatalf("want %q but %q", 4, poll.Options[1].VotesCount) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /report.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | ) 8 | 9 | // Report holds information for a mastodon report. 10 | type Report struct { 11 | ID ID `json:"id"` 12 | ActionTaken bool `json:"action_taken"` 13 | } 14 | 15 | // GetReports returns report of the current user. 16 | func (c *Client) GetReports(ctx context.Context) ([]*Report, error) { 17 | var reports []*Report 18 | err := c.doAPI(ctx, http.MethodGet, "/api/v1/reports", nil, &reports, nil) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return reports, nil 23 | } 24 | 25 | // Report reports the report 26 | func (c *Client) Report(ctx context.Context, accountID ID, ids []ID, comment string) (*Report, error) { 27 | params := url.Values{} 28 | params.Set("account_id", string(accountID)) 29 | for _, id := range ids { 30 | params.Add("status_ids[]", string(id)) 31 | } 32 | params.Set("comment", comment) 33 | var report Report 34 | err := c.doAPI(ctx, http.MethodPost, "/api/v1/reports", params, &report, nil) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return &report, nil 39 | } 40 | -------------------------------------------------------------------------------- /report_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestGetReports(t *testing.T) { 12 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | if r.URL.Path != "/api/v1/reports" { 14 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 15 | return 16 | } 17 | fmt.Fprintln(w, `[{"id": 122, "action_taken": false}, {"id": 123, "action_taken": true}]`) 18 | })) 19 | defer ts.Close() 20 | 21 | client := NewClient(&Config{ 22 | Server: ts.URL, 23 | ClientID: "foo", 24 | ClientSecret: "bar", 25 | AccessToken: "zoo", 26 | }) 27 | rs, err := client.GetReports(context.Background()) 28 | if err != nil { 29 | t.Fatalf("should not be fail: %v", err) 30 | } 31 | if len(rs) != 2 { 32 | t.Fatalf("result should be two: %d", len(rs)) 33 | } 34 | if rs[0].ID != "122" { 35 | t.Fatalf("want %v but %v", 122, rs[0].ID) 36 | } 37 | if rs[1].ID != "123" { 38 | t.Fatalf("want %v but %v", 123, rs[1].ID) 39 | } 40 | } 41 | 42 | func TestReport(t *testing.T) { 43 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | if r.URL.Path != "/api/v1/reports" { 45 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 46 | return 47 | } 48 | if r.FormValue("account_id") != "122" && r.FormValue("account_id") != "123" { 49 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 50 | return 51 | } 52 | if r.FormValue("account_id") == "122" { 53 | fmt.Fprintln(w, `{"id": 1234, "action_taken": false}`) 54 | } else { 55 | fmt.Fprintln(w, `{"id": 1234, "action_taken": true}`) 56 | } 57 | })) 58 | defer ts.Close() 59 | 60 | client := NewClient(&Config{ 61 | Server: ts.URL, 62 | ClientID: "foo", 63 | ClientSecret: "bar", 64 | AccessToken: "zoo", 65 | }) 66 | _, err := client.Report(context.Background(), "121", nil, "") 67 | if err == nil { 68 | t.Fatalf("should be fail: %v", err) 69 | } 70 | rp, err := client.Report(context.Background(), "122", nil, "") 71 | if err != nil { 72 | t.Fatalf("should not be fail: %v", err) 73 | } 74 | if rp.ID != "1234" { 75 | t.Fatalf("want %q but %q", "1234", rp.ID) 76 | } 77 | if rp.ActionTaken { 78 | t.Fatalf("want %v but %v", true, rp.ActionTaken) 79 | } 80 | rp, err = client.Report(context.Background(), "123", []ID{"567"}, "") 81 | if err != nil { 82 | t.Fatalf("should not be fail: %v", err) 83 | } 84 | if rp.ID != "1234" { 85 | t.Fatalf("want %q but %q", "1234", rp.ID) 86 | } 87 | if !rp.ActionTaken { 88 | t.Fatalf("want %v but %v", false, rp.ActionTaken) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /streaming.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "encoding/json" 8 | "errors" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "path" 13 | "strings" 14 | ) 15 | 16 | // UpdateEvent is a struct for passing status event to app. 17 | type UpdateEvent struct { 18 | Status *Status `json:"status"` 19 | } 20 | 21 | func (e *UpdateEvent) event() {} 22 | 23 | // UpdateEditEvent is a struct for passing status edit event to app. 24 | type UpdateEditEvent struct { 25 | Status *Status `json:"status"` 26 | } 27 | 28 | func (e *UpdateEditEvent) event() {} 29 | 30 | // NotificationEvent is a struct for passing notification event to app. 31 | type NotificationEvent struct { 32 | Notification *Notification `json:"notification"` 33 | } 34 | 35 | func (e *NotificationEvent) event() {} 36 | 37 | // DeleteEvent is a struct for passing deletion event to app. 38 | type DeleteEvent struct{ ID ID } 39 | 40 | func (e *DeleteEvent) event() {} 41 | 42 | // ConversationEvent is a struct for passing conversationevent to app. 43 | type ConversationEvent struct { 44 | Conversation *Conversation `json:"conversation"` 45 | } 46 | 47 | func (e *ConversationEvent) event() {} 48 | 49 | // ErrorEvent is a struct for passing errors to app. 50 | type ErrorEvent struct{ Err error } 51 | 52 | func (e *ErrorEvent) event() {} 53 | func (e *ErrorEvent) Error() string { return e.Err.Error() } 54 | 55 | // Event is an interface passing events to app. 56 | type Event interface { 57 | event() 58 | } 59 | 60 | func handleReader(q chan Event, r io.Reader) error { 61 | var name string 62 | var lineBuf bytes.Buffer 63 | br := bufio.NewReader(r) 64 | for { 65 | line, isPrefix, err := br.ReadLine() 66 | if err != nil { 67 | if errors.Is(err, io.EOF) { 68 | return nil 69 | } 70 | return err 71 | } 72 | if isPrefix { 73 | lineBuf.Write(line) 74 | continue 75 | } 76 | if lineBuf.Len() > 0 { 77 | lineBuf.Write(line) 78 | line = lineBuf.Bytes() 79 | lineBuf.Reset() 80 | } 81 | 82 | token := strings.SplitN(string(line), ":", 2) 83 | if len(token) != 2 { 84 | continue 85 | } 86 | switch strings.TrimSpace(token[0]) { 87 | case "event": 88 | name = strings.TrimSpace(token[1]) 89 | case "data": 90 | var err error 91 | switch name { 92 | case "update": 93 | var status Status 94 | err = json.Unmarshal([]byte(token[1]), &status) 95 | if err == nil { 96 | q <- &UpdateEvent{&status} 97 | } 98 | case "status.update": 99 | var status Status 100 | err = json.Unmarshal([]byte(token[1]), &status) 101 | if err == nil { 102 | q <- &UpdateEditEvent{&status} 103 | } 104 | case "notification": 105 | var notification Notification 106 | err = json.Unmarshal([]byte(token[1]), ¬ification) 107 | if err == nil { 108 | q <- &NotificationEvent{¬ification} 109 | } 110 | case "conversation": 111 | var conversation Conversation 112 | err = json.Unmarshal([]byte(token[1]), &conversation) 113 | if err == nil { 114 | q <- &ConversationEvent{&conversation} 115 | } 116 | case "delete": 117 | q <- &DeleteEvent{ID: ID(strings.TrimSpace(token[1]))} 118 | } 119 | if err != nil { 120 | q <- &ErrorEvent{err} 121 | } 122 | } 123 | } 124 | } 125 | 126 | func (c *Client) streaming(ctx context.Context, p string, params url.Values) (chan Event, error) { 127 | u, err := url.Parse(c.Config.Server) 128 | if err != nil { 129 | return nil, err 130 | } 131 | u.Path = path.Join(u.Path, "/api/v1/streaming", p) 132 | u.RawQuery = params.Encode() 133 | 134 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) 135 | if err != nil { 136 | return nil, err 137 | } 138 | req.URL = u 139 | 140 | if c.Config.AccessToken != "" { 141 | req.Header.Set("Authorization", "Bearer "+c.Config.AccessToken) 142 | } 143 | 144 | q := make(chan Event) 145 | go func() { 146 | defer close(q) 147 | for { 148 | select { 149 | case <-ctx.Done(): 150 | return 151 | default: 152 | } 153 | 154 | if i, err := c.GetInstance(ctx); err == nil { 155 | if su, ok := i.URLs["streaming_api"]; ok { 156 | if u2, err := url.Parse(su); err == nil && u2.Host != "" { 157 | u.Host = u2.Host 158 | req.Host = u.Host 159 | } 160 | } 161 | } 162 | 163 | c.doStreaming(req, q) 164 | } 165 | }() 166 | return q, nil 167 | } 168 | 169 | func (c *Client) doStreaming(req *http.Request, q chan Event) { 170 | resp, err := c.Do(req) 171 | if err != nil { 172 | q <- &ErrorEvent{err} 173 | return 174 | } 175 | defer resp.Body.Close() 176 | 177 | if resp.StatusCode != http.StatusOK { 178 | q <- &ErrorEvent{parseAPIError("bad request", resp)} 179 | return 180 | } 181 | 182 | err = handleReader(q, resp.Body) 183 | if err != nil { 184 | q <- &ErrorEvent{err} 185 | } 186 | } 187 | 188 | // StreamingUser returns a channel to read events on home. 189 | func (c *Client) StreamingUser(ctx context.Context) (chan Event, error) { 190 | return c.streaming(ctx, "user", nil) 191 | } 192 | 193 | // StreamingPublic returns a channel to read events on public. 194 | func (c *Client) StreamingPublic(ctx context.Context, isLocal bool) (chan Event, error) { 195 | p := "public" 196 | if isLocal { 197 | p = path.Join(p, "local") 198 | } 199 | 200 | return c.streaming(ctx, p, nil) 201 | } 202 | 203 | // StreamingHashtag returns a channel to read events on tagged timeline. 204 | func (c *Client) StreamingHashtag(ctx context.Context, tag string, isLocal bool) (chan Event, error) { 205 | params := url.Values{} 206 | params.Set("tag", tag) 207 | 208 | p := "hashtag" 209 | if isLocal { 210 | p = path.Join(p, "local") 211 | } 212 | 213 | return c.streaming(ctx, p, params) 214 | } 215 | 216 | // StreamingList returns a channel to read events on a list. 217 | func (c *Client) StreamingList(ctx context.Context, id ID) (chan Event, error) { 218 | params := url.Values{} 219 | params.Set("list", string(id)) 220 | 221 | return c.streaming(ctx, "list", params) 222 | } 223 | 224 | // StreamingDirect returns a channel to read events on a direct messages. 225 | func (c *Client) StreamingDirect(ctx context.Context) (chan Event, error) { 226 | return c.streaming(ctx, "direct", nil) 227 | } 228 | -------------------------------------------------------------------------------- /streaming_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "sync" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | func TestHandleReader(t *testing.T) { 17 | large := "large" 18 | largeContent := strings.Repeat(large, 2*(bufio.MaxScanTokenSize/len(large))) 19 | 20 | q := make(chan Event) 21 | r := strings.NewReader(fmt.Sprintf(` 22 | event: update 23 | data: {content: error} 24 | event: update 25 | data: {"content": "foo"} 26 | event: update 27 | data: {"content": "%s"} 28 | event: notification 29 | data: {"type": "mention"} 30 | event: delete 31 | data: 1234567 32 | event: status.update 33 | data: {"content": "foo"} 34 | event: conversation 35 | data: {"id":"819516","unread":true,"accounts":[{"id":"108892712797543112","username":"a","acct":"a@pl.nulled.red","display_name":"a","locked":false,"bot":true,"discoverable":false,"group":false,"created_at":"2022-08-27T00:00:00.000Z","note":"a (pleroma edition)","url":"https://pl.nulled.red/users/a","avatar":"https://files.mastodon.social/cache/accounts/avatars/108/892/712/797/543/112/original/975674b2caa61034.png","avatar_static":"https://files.mastodon.social/cache/accounts/avatars/108/892/712/797/543/112/original/975674b2caa61034.png","header":"https://files.mastodon.social/cache/accounts/headers/108/892/712/797/543/112/original/f61d0382356caa0e.png","header_static":"https://files.mastodon.social/cache/accounts/headers/108/892/712/797/543/112/original/f61d0382356caa0e.png","followers_count":0,"following_count":0,"statuses_count":362,"last_status_at":"2022-11-13","emojis":[],"fields":[]}],"last_status":{"id":"109346889330629417","created_at":"2022-11-15T08:31:57.476Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"direct","language":null,"uri":"https://pl.nulled.red/objects/c869c5be-c184-4706-a45d-3459d9aa711c","url":"https://pl.nulled.red/objects/c869c5be-c184-4706-a45d-3459d9aa711c","replies_count":0,"reblogs_count":0,"favourites_count":0,"edited_at":null,"favourited":false,"reblogged":false,"muted":false,"bookmarked":false,"content":"test @trwnh","filtered":[],"reblog":null,"account":{"id":"108892712797543112","username":"a","acct":"a@pl.nulled.red","display_name":"a","locked":false,"bot":true,"discoverable":false,"group":false,"created_at":"2022-08-27T00:00:00.000Z","note":"a (pleroma edition)","url":"https://pl.nulled.red/users/a","avatar":"https://files.mastodon.social/cache/accounts/avatars/108/892/712/797/543/112/original/975674b2caa61034.png","avatar_static":"https://files.mastodon.social/cache/accounts/avatars/108/892/712/797/543/112/original/975674b2caa61034.png","header":"https://files.mastodon.social/cache/accounts/headers/108/892/712/797/543/112/original/f61d0382356caa0e.png","header_static":"https://files.mastodon.social/cache/accounts/headers/108/892/712/797/543/112/original/f61d0382356caa0e.png","followers_count":0,"following_count":0,"statuses_count":362,"last_status_at":"2022-11-13","emojis":[],"fields":[]},"media_attachments":[],"mentions":[{"id":"14715","username":"trwnh","url":"https://mastodon.social/@trwnh","acct":"trwnh"}],"tags":[],"emojis":[],"card":null,"poll":null}} 36 | :thump 37 | `, largeContent)) 38 | var wg sync.WaitGroup 39 | wg.Add(1) 40 | errs := make(chan error, 1) 41 | go func() { 42 | defer wg.Done() 43 | defer close(q) 44 | err := handleReader(q, r) 45 | if err != nil { 46 | t.Errorf("should not be fail: %v", err) 47 | } 48 | errs <- err 49 | }() 50 | var passUpdate, passUpdateLarge, passNotification, passDelete, passError bool 51 | for e := range q { 52 | switch event := e.(type) { 53 | case *UpdateEvent: 54 | if event.Status.Content == "foo" { 55 | passUpdate = true 56 | } else if event.Status.Content == largeContent { 57 | passUpdateLarge = true 58 | } else { 59 | t.Fatalf("bad update content: %q", event.Status.Content) 60 | } 61 | case *UpdateEditEvent: 62 | if event.Status.Content == "foo" { 63 | passUpdate = true 64 | } else if event.Status.Content == largeContent { 65 | passUpdateLarge = true 66 | } else { 67 | t.Fatalf("bad update content: %q", event.Status.Content) 68 | } 69 | case *ConversationEvent: 70 | passNotification = true 71 | if event.Conversation.ID != "819516" { 72 | t.Fatalf("want %q but %q", "819516", event.Conversation.ID) 73 | } 74 | case *NotificationEvent: 75 | passNotification = true 76 | if event.Notification.Type != "mention" { 77 | t.Fatalf("want %q but %q", "mention", event.Notification.Type) 78 | } 79 | case *DeleteEvent: 80 | passDelete = true 81 | if event.ID != "1234567" { 82 | t.Fatalf("want %q but %q", "1234567", event.ID) 83 | } 84 | case *ErrorEvent: 85 | passError = true 86 | if event.Err == nil { 87 | t.Fatalf("should be fail: %v", event.Err) 88 | } 89 | } 90 | } 91 | if !passUpdate || !passUpdateLarge || !passNotification || !passDelete || !passError { 92 | t.Fatalf("have not passed through somewhere: "+ 93 | "update: %t, update (large): %t, notification: %t, delete: %t, error: %t", 94 | passUpdate, passUpdateLarge, passNotification, passDelete, passError) 95 | } 96 | wg.Wait() 97 | err := <-errs 98 | if err != nil { 99 | t.Fatalf("should not be fail: %v", err) 100 | } 101 | } 102 | 103 | func TestStreaming(t *testing.T) { 104 | var isEnd bool 105 | canErr := true 106 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 107 | if isEnd { 108 | return 109 | } else if canErr { 110 | canErr = false 111 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 112 | return 113 | } 114 | f := w.(http.Flusher) 115 | fmt.Fprintln(w, ` 116 | event: update 117 | data: {"content": "foo"} 118 | `) 119 | f.Flush() 120 | isEnd = true 121 | })) 122 | defer ts.Close() 123 | 124 | c := NewClient(&Config{Server: ":"}) 125 | _, err := c.streaming(context.Background(), "", nil) 126 | if err == nil { 127 | t.Fatalf("should be fail: %v", err) 128 | } 129 | 130 | c = NewClient(&Config{Server: ts.URL}) 131 | ctx, cancel := context.WithCancel(context.Background()) 132 | time.AfterFunc(time.Second, cancel) 133 | q, err := c.streaming(ctx, "", nil) 134 | if err != nil { 135 | t.Fatalf("should not be fail: %v", err) 136 | } 137 | var cnt int 138 | var passUpdate bool 139 | for e := range q { 140 | switch event := e.(type) { 141 | case *ErrorEvent: 142 | if event.Err != nil && !errors.Is(event.Err, context.Canceled) { 143 | t.Fatalf("should be fail: %v", event.Err) 144 | } 145 | case *UpdateEvent: 146 | cnt++ 147 | passUpdate = true 148 | if event.Status.Content != "foo" { 149 | t.Fatalf("want %q but %q", "foo", event.Status.Content) 150 | } 151 | case *UpdateEditEvent: 152 | cnt++ 153 | passUpdate = true 154 | if event.Status.Content != "foo" { 155 | t.Fatalf("want %q but %q", "foo", event.Status.Content) 156 | } 157 | } 158 | } 159 | if cnt != 1 { 160 | t.Fatalf("result should be one: %d", cnt) 161 | } 162 | if !passUpdate { 163 | t.Fatalf("have not passed through somewhere: update %t", passUpdate) 164 | } 165 | } 166 | 167 | func TestDoStreaming(t *testing.T) { 168 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 169 | w.(http.Flusher).Flush() 170 | time.Sleep(time.Second) 171 | })) 172 | defer ts.Close() 173 | 174 | c := NewClient(&Config{Server: ts.URL}) 175 | 176 | req, err := http.NewRequest(http.MethodGet, ts.URL, nil) 177 | if err != nil { 178 | t.Fatalf("should not be fail: %v", err) 179 | } 180 | ctx, cancel := context.WithCancel(context.Background()) 181 | time.AfterFunc(time.Millisecond, cancel) 182 | req = req.WithContext(ctx) 183 | 184 | q := make(chan Event) 185 | var wg sync.WaitGroup 186 | wg.Add(1) 187 | go func() { 188 | defer wg.Done() 189 | defer close(q) 190 | c.doStreaming(req, q) 191 | if err != nil { 192 | t.Errorf("should not be fail: %v", err) 193 | } 194 | }() 195 | var passError bool 196 | for e := range q { 197 | if event, ok := e.(*ErrorEvent); ok { 198 | passError = true 199 | if event.Err == nil { 200 | t.Fatalf("should be fail: %v", event.Err) 201 | } 202 | } 203 | } 204 | if !passError { 205 | t.Fatalf("have not passed through: error %t", passError) 206 | } 207 | wg.Wait() 208 | } 209 | 210 | func TestStreamingUser(t *testing.T) { 211 | var isEnd bool 212 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 213 | if isEnd { 214 | return 215 | } else if r.URL.Path != "/api/v1/streaming/user" { 216 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 217 | return 218 | } 219 | f, _ := w.(http.Flusher) 220 | fmt.Fprintln(w, ` 221 | event: update 222 | data: {"content": "foo"} 223 | `) 224 | f.Flush() 225 | isEnd = true 226 | })) 227 | defer ts.Close() 228 | 229 | c := NewClient(&Config{Server: ts.URL}) 230 | ctx, cancel := context.WithCancel(context.Background()) 231 | time.AfterFunc(time.Second, cancel) 232 | q, err := c.StreamingUser(ctx) 233 | if err != nil { 234 | t.Fatalf("should not be fail: %v", err) 235 | } 236 | events := []Event{} 237 | for e := range q { 238 | if _, ok := e.(*ErrorEvent); !ok { 239 | events = append(events, e) 240 | } 241 | } 242 | if len(events) != 1 { 243 | t.Fatalf("result should be one: %d", len(events)) 244 | } 245 | if events[0].(*UpdateEvent).Status.Content != "foo" { 246 | t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content) 247 | } 248 | } 249 | 250 | func TestStreamingPublic(t *testing.T) { 251 | var isEnd bool 252 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 253 | if isEnd { 254 | return 255 | } else if r.URL.Path != "/api/v1/streaming/public/local" { 256 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 257 | return 258 | } 259 | f, _ := w.(http.Flusher) 260 | fmt.Fprintln(w, ` 261 | event: update 262 | data: {"content": "foo"} 263 | `) 264 | f.Flush() 265 | 266 | fmt.Fprintln(w, ` 267 | event: update 268 | data: {"content": "bar"} 269 | `) 270 | f.Flush() 271 | isEnd = true 272 | })) 273 | defer ts.Close() 274 | 275 | client := NewClient(&Config{ 276 | Server: ts.URL, 277 | ClientID: "foo", 278 | ClientSecret: "bar", 279 | AccessToken: "zoo", 280 | }) 281 | ctx, cancel := context.WithCancel(context.Background()) 282 | q, err := client.StreamingPublic(ctx, true) 283 | if err != nil { 284 | t.Fatalf("should not be fail: %v", err) 285 | } 286 | time.AfterFunc(time.Second, cancel) 287 | events := []Event{} 288 | for e := range q { 289 | if _, ok := e.(*ErrorEvent); !ok { 290 | events = append(events, e) 291 | } 292 | } 293 | if len(events) != 2 { 294 | t.Fatalf("result should be two: %d", len(events)) 295 | } 296 | if events[0].(*UpdateEvent).Status.Content != "foo" { 297 | t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content) 298 | } 299 | if events[1].(*UpdateEvent).Status.Content != "bar" { 300 | t.Fatalf("want %q but %q", "bar", events[1].(*UpdateEvent).Status.Content) 301 | } 302 | } 303 | 304 | func TestStreamingHashtag(t *testing.T) { 305 | var isEnd bool 306 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 307 | if isEnd { 308 | return 309 | } else if r.URL.Path != "/api/v1/streaming/hashtag/local" { 310 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 311 | return 312 | } 313 | f, _ := w.(http.Flusher) 314 | fmt.Fprintln(w, ` 315 | event: update 316 | data: {"content": "foo"} 317 | `) 318 | f.Flush() 319 | isEnd = true 320 | })) 321 | defer ts.Close() 322 | 323 | client := NewClient(&Config{Server: ts.URL}) 324 | ctx, cancel := context.WithCancel(context.Background()) 325 | time.AfterFunc(time.Second, cancel) 326 | q, err := client.StreamingHashtag(ctx, "hashtag", true) 327 | if err != nil { 328 | t.Fatalf("should not be fail: %v", err) 329 | } 330 | events := []Event{} 331 | for e := range q { 332 | if _, ok := e.(*ErrorEvent); !ok { 333 | events = append(events, e) 334 | } 335 | } 336 | if len(events) != 1 { 337 | t.Fatalf("result should be one: %d", len(events)) 338 | } 339 | if events[0].(*UpdateEvent).Status.Content != "foo" { 340 | t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content) 341 | } 342 | } 343 | 344 | func TestStreamingList(t *testing.T) { 345 | var isEnd bool 346 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 347 | if isEnd { 348 | return 349 | } else if r.URL.Path != "/api/v1/streaming/list" { 350 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 351 | return 352 | } 353 | f, _ := w.(http.Flusher) 354 | fmt.Fprintln(w, ` 355 | event: update 356 | data: {"content": "foo"} 357 | `) 358 | f.Flush() 359 | isEnd = true 360 | })) 361 | defer ts.Close() 362 | 363 | client := NewClient(&Config{Server: ts.URL}) 364 | ctx, cancel := context.WithCancel(context.Background()) 365 | time.AfterFunc(time.Second, cancel) 366 | q, err := client.StreamingList(ctx, "1") 367 | if err != nil { 368 | t.Fatalf("should not be fail: %v", err) 369 | } 370 | events := []Event{} 371 | for e := range q { 372 | if _, ok := e.(*ErrorEvent); !ok { 373 | events = append(events, e) 374 | } 375 | } 376 | if len(events) != 1 { 377 | t.Fatalf("result should be one: %d", len(events)) 378 | } 379 | if events[0].(*UpdateEvent).Status.Content != "foo" { 380 | t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content) 381 | } 382 | } 383 | 384 | func TestStreamingDirect(t *testing.T) { 385 | var isEnd bool 386 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 387 | if isEnd { 388 | return 389 | } else if r.URL.Path != "/api/v1/streaming/direct" { 390 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 391 | return 392 | } 393 | f, _ := w.(http.Flusher) 394 | fmt.Fprintln(w, ` 395 | event: update 396 | data: {"content": "foo"} 397 | `) 398 | f.Flush() 399 | isEnd = true 400 | })) 401 | defer ts.Close() 402 | 403 | client := NewClient(&Config{Server: ts.URL}) 404 | ctx, cancel := context.WithCancel(context.Background()) 405 | time.AfterFunc(time.Second, cancel) 406 | q, err := client.StreamingDirect(ctx) 407 | if err != nil { 408 | t.Fatalf("should not be fail: %v", err) 409 | } 410 | events := []Event{} 411 | for e := range q { 412 | if _, ok := e.(*ErrorEvent); !ok { 413 | events = append(events, e) 414 | } 415 | } 416 | if len(events) != 1 { 417 | t.Fatalf("result should be one: %d", len(events)) 418 | } 419 | if events[0].(*UpdateEvent).Status.Content != "foo" { 420 | t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content) 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /streaming_ws.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/url" 8 | "path" 9 | "strings" 10 | 11 | "github.com/gorilla/websocket" 12 | ) 13 | 14 | // WSClient is a WebSocket client. 15 | type WSClient struct { 16 | websocket.Dialer 17 | client *Client 18 | } 19 | 20 | // NewWSClient return WebSocket client. 21 | func (c *Client) NewWSClient() *WSClient { return &WSClient{client: c} } 22 | 23 | // Stream is a struct of data that flows in streaming. 24 | type Stream struct { 25 | Event string `json:"event"` 26 | Payload interface{} `json:"payload"` 27 | } 28 | 29 | // StreamingWSUser return channel to read events on home using WebSocket. 30 | func (c *WSClient) StreamingWSUser(ctx context.Context) (chan Event, error) { 31 | return c.streamingWS(ctx, "user", "") 32 | } 33 | 34 | // StreamingWSPublic return channel to read events on public using WebSocket. 35 | func (c *WSClient) StreamingWSPublic(ctx context.Context, isLocal bool) (chan Event, error) { 36 | s := "public" 37 | if isLocal { 38 | s += ":local" 39 | } 40 | 41 | return c.streamingWS(ctx, s, "") 42 | } 43 | 44 | // StreamingWSHashtag return channel to read events on tagged timeline using WebSocket. 45 | func (c *WSClient) StreamingWSHashtag(ctx context.Context, tag string, isLocal bool) (chan Event, error) { 46 | s := "hashtag" 47 | if isLocal { 48 | s += ":local" 49 | } 50 | 51 | return c.streamingWS(ctx, s, tag) 52 | } 53 | 54 | // StreamingWSList return channel to read events on a list using WebSocket. 55 | func (c *WSClient) StreamingWSList(ctx context.Context, id ID) (chan Event, error) { 56 | return c.streamingWS(ctx, "list", string(id)) 57 | } 58 | 59 | // StreamingWSDirect return channel to read events on a direct messages using WebSocket. 60 | func (c *WSClient) StreamingWSDirect(ctx context.Context) (chan Event, error) { 61 | return c.streamingWS(ctx, "direct", "") 62 | } 63 | 64 | func (c *WSClient) streamingWS(ctx context.Context, stream, tag string) (chan Event, error) { 65 | params := url.Values{} 66 | params.Set("access_token", c.client.Config.AccessToken) 67 | params.Set("stream", stream) 68 | if tag != "" { 69 | params.Set("tag", tag) 70 | } 71 | 72 | u, err := changeWebSocketScheme(c.client.Config.Server) 73 | if err != nil { 74 | return nil, err 75 | } 76 | u.Path = path.Join(u.Path, "/api/v1/streaming") 77 | u.RawQuery = params.Encode() 78 | 79 | q := make(chan Event) 80 | go func() { 81 | defer close(q) 82 | for { 83 | err := c.handleWS(ctx, u.String(), q) 84 | if err != nil { 85 | return 86 | } 87 | } 88 | }() 89 | 90 | return q, nil 91 | } 92 | 93 | func (c *WSClient) handleWS(ctx context.Context, rawurl string, q chan Event) error { 94 | conn, err := c.dialRedirect(rawurl) 95 | if err != nil { 96 | q <- &ErrorEvent{Err: err} 97 | 98 | // End. 99 | return err 100 | } 101 | 102 | // Close the WebSocket when the context is canceled. 103 | go func() { 104 | <-ctx.Done() 105 | conn.Close() 106 | }() 107 | 108 | for { 109 | select { 110 | case <-ctx.Done(): 111 | q <- &ErrorEvent{Err: ctx.Err()} 112 | 113 | // End. 114 | return ctx.Err() 115 | default: 116 | } 117 | 118 | var s Stream 119 | err := conn.ReadJSON(&s) 120 | if err != nil { 121 | q <- &ErrorEvent{Err: err} 122 | 123 | // Reconnect. 124 | break 125 | } 126 | 127 | err = nil 128 | switch s.Event { 129 | case "update": 130 | var status Status 131 | err = json.Unmarshal([]byte(s.Payload.(string)), &status) 132 | if err == nil { 133 | q <- &UpdateEvent{Status: &status} 134 | } 135 | case "status.update": 136 | var status Status 137 | err = json.Unmarshal([]byte(s.Payload.(string)), &status) 138 | if err == nil { 139 | q <- &UpdateEditEvent{Status: &status} 140 | } 141 | case "notification": 142 | var notification Notification 143 | err = json.Unmarshal([]byte(s.Payload.(string)), ¬ification) 144 | if err == nil { 145 | q <- &NotificationEvent{Notification: ¬ification} 146 | } 147 | case "conversation": 148 | var conversation Conversation 149 | err = json.Unmarshal([]byte(s.Payload.(string)), &conversation) 150 | if err == nil { 151 | q <- &ConversationEvent{Conversation: &conversation} 152 | } 153 | case "delete": 154 | if f, ok := s.Payload.(float64); ok { 155 | q <- &DeleteEvent{ID: ID(fmt.Sprint(int64(f)))} 156 | } else { 157 | q <- &DeleteEvent{ID: ID(strings.TrimSpace(s.Payload.(string)))} 158 | } 159 | } 160 | if err != nil { 161 | q <- &ErrorEvent{err} 162 | } 163 | } 164 | 165 | return nil 166 | } 167 | 168 | func (c *WSClient) dialRedirect(rawurl string) (conn *websocket.Conn, err error) { 169 | for { 170 | conn, rawurl, err = c.dial(rawurl) 171 | if err != nil { 172 | return nil, err 173 | } else if conn != nil { 174 | return conn, nil 175 | } 176 | } 177 | } 178 | 179 | func (c *WSClient) dial(rawurl string) (*websocket.Conn, string, error) { 180 | conn, resp, err := c.Dial(rawurl, nil) 181 | if err != nil && err != websocket.ErrBadHandshake { 182 | return nil, "", err 183 | } 184 | defer resp.Body.Close() 185 | 186 | if loc := resp.Header.Get("Location"); loc != "" { 187 | u, err := changeWebSocketScheme(loc) 188 | if err != nil { 189 | return nil, "", err 190 | } 191 | 192 | return nil, u.String(), nil 193 | } 194 | 195 | return conn, "", err 196 | } 197 | 198 | func changeWebSocketScheme(rawurl string) (*url.URL, error) { 199 | u, err := url.Parse(rawurl) 200 | if err != nil { 201 | return nil, err 202 | } 203 | 204 | switch u.Scheme { 205 | case "http": 206 | u.Scheme = "ws" 207 | case "https": 208 | u.Scheme = "wss" 209 | } 210 | 211 | return u, nil 212 | } 213 | -------------------------------------------------------------------------------- /streaming_ws_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/gorilla/websocket" 12 | ) 13 | 14 | func TestStreamingWSUser(t *testing.T) { 15 | ts := httptest.NewServer(http.HandlerFunc(wsMock)) 16 | defer ts.Close() 17 | 18 | client := NewClient(&Config{Server: ts.URL}).NewWSClient() 19 | ctx, cancel := context.WithCancel(context.Background()) 20 | q, err := client.StreamingWSUser(ctx) 21 | if err != nil { 22 | t.Fatalf("should not be fail: %v", err) 23 | } 24 | 25 | wsTest(t, q, cancel) 26 | } 27 | 28 | func TestStreamingWSPublic(t *testing.T) { 29 | ts := httptest.NewServer(http.HandlerFunc(wsMock)) 30 | defer ts.Close() 31 | 32 | client := NewClient(&Config{Server: ts.URL}).NewWSClient() 33 | ctx, cancel := context.WithCancel(context.Background()) 34 | q, err := client.StreamingWSPublic(ctx, false) 35 | if err != nil { 36 | t.Fatalf("should not be fail: %v", err) 37 | } 38 | 39 | wsTest(t, q, cancel) 40 | } 41 | 42 | func TestStreamingWSHashtag(t *testing.T) { 43 | ts := httptest.NewServer(http.HandlerFunc(wsMock)) 44 | defer ts.Close() 45 | 46 | client := NewClient(&Config{Server: ts.URL}).NewWSClient() 47 | ctx, cancel := context.WithCancel(context.Background()) 48 | q, err := client.StreamingWSHashtag(ctx, "zzz", true) 49 | if err != nil { 50 | t.Fatalf("should not be fail: %v", err) 51 | } 52 | wsTest(t, q, cancel) 53 | 54 | ctx, cancel = context.WithCancel(context.Background()) 55 | q, err = client.StreamingWSHashtag(ctx, "zzz", false) 56 | if err != nil { 57 | t.Fatalf("should not be fail: %v", err) 58 | } 59 | wsTest(t, q, cancel) 60 | } 61 | 62 | func TestStreamingWSList(t *testing.T) { 63 | ts := httptest.NewServer(http.HandlerFunc(wsMock)) 64 | defer ts.Close() 65 | 66 | client := NewClient(&Config{Server: ts.URL}).NewWSClient() 67 | ctx, cancel := context.WithCancel(context.Background()) 68 | q, err := client.StreamingWSList(ctx, "123") 69 | if err != nil { 70 | t.Fatalf("should not be fail: %v", err) 71 | } 72 | wsTest(t, q, cancel) 73 | } 74 | 75 | func TestStreamingWSDirect(t *testing.T) { 76 | ts := httptest.NewServer(http.HandlerFunc(wsMock)) 77 | defer ts.Close() 78 | 79 | client := NewClient(&Config{Server: ts.URL}).NewWSClient() 80 | ctx, cancel := context.WithCancel(context.Background()) 81 | q, err := client.StreamingWSDirect(ctx) 82 | if err != nil { 83 | t.Fatalf("should not be fail: %v", err) 84 | } 85 | wsTest(t, q, cancel) 86 | } 87 | 88 | func wsMock(w http.ResponseWriter, r *http.Request) { 89 | if r.URL.Path != "/api/v1/streaming" { 90 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 91 | return 92 | } 93 | 94 | u := websocket.Upgrader{} 95 | conn, err := u.Upgrade(w, r, nil) 96 | if err != nil { 97 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 98 | return 99 | } 100 | defer conn.Close() 101 | 102 | err = conn.WriteMessage(websocket.TextMessage, 103 | []byte(`{"event":"update","payload":"{\"content\":\"foo\"}"}`)) 104 | if err != nil { 105 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 106 | return 107 | } 108 | 109 | err = conn.WriteMessage(websocket.TextMessage, 110 | []byte(`{"event":"status.update","payload":"{\"content\":\"bar\"}"}`)) 111 | if err != nil { 112 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 113 | return 114 | } 115 | 116 | err = conn.WriteMessage(websocket.TextMessage, 117 | []byte(`{"event":"notification","payload":"{\"id\":123}"}`)) 118 | if err != nil { 119 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 120 | return 121 | } 122 | 123 | err = conn.WriteMessage(websocket.TextMessage, 124 | []byte(`{"event":"delete","payload":1234567}`)) 125 | if err != nil { 126 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 127 | return 128 | } 129 | 130 | err = conn.WriteMessage(websocket.TextMessage, 131 | []byte(`{"event":"conversation","payload":"{\"id\":819516}"}`)) 132 | if err != nil { 133 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 134 | return 135 | } 136 | 137 | err = conn.WriteMessage(websocket.TextMessage, 138 | []byte(`{"event":"update","payload":""}`)) 139 | if err != nil { 140 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 141 | return 142 | } 143 | 144 | time.Sleep(10 * time.Second) 145 | } 146 | 147 | func wsTest(t *testing.T, q chan Event, cancel func()) { 148 | time.AfterFunc(time.Second, func() { 149 | cancel() 150 | }) 151 | events := []Event{} 152 | for e := range q { 153 | events = append(events, e) 154 | } 155 | if len(events) != 8 { 156 | t.Fatalf("result should be 8: %d", len(events)) 157 | } 158 | if events[0].(*UpdateEvent).Status.Content != "foo" { 159 | t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content) 160 | } 161 | if events[1].(*UpdateEditEvent).Status.Content != "bar" { 162 | t.Fatalf("want %q but %q", "bar", events[1].(*UpdateEditEvent).Status.Content) 163 | } 164 | if events[2].(*NotificationEvent).Notification.ID != "123" { 165 | t.Fatalf("want %q but %q", "123", events[2].(*NotificationEvent).Notification.ID) 166 | } 167 | if events[3].(*DeleteEvent).ID != "1234567" { 168 | t.Fatalf("want %q but %q", "1234567", events[3].(*DeleteEvent).ID) 169 | } 170 | if events[4].(*ConversationEvent).Conversation.ID != "819516" { 171 | t.Fatalf("want %q but %q", "819516", events[4].(*ConversationEvent).Conversation.ID) 172 | } 173 | if errorEvent, ok := events[5].(*ErrorEvent); !ok { 174 | t.Fatalf("should be fail: %v", errorEvent.Err) 175 | } 176 | if errorEvent, ok := events[6].(*ErrorEvent); !ok { 177 | t.Fatalf("should be fail: %v", errorEvent.Err) 178 | } 179 | if errorEvent, ok := events[6].(*ErrorEvent); !ok { 180 | t.Fatalf("should be fail: %v", errorEvent.Err) 181 | } 182 | if errorEvent, ok := events[7].(*ErrorEvent); !ok { 183 | t.Fatalf("should be fail: %v", errorEvent.Err) 184 | } 185 | } 186 | 187 | func TestStreamingWS(t *testing.T) { 188 | ts := httptest.NewServer(http.HandlerFunc(wsMock)) 189 | defer ts.Close() 190 | 191 | client := NewClient(&Config{Server: ":"}).NewWSClient() 192 | _, err := client.StreamingWSPublic(context.Background(), true) 193 | if err == nil { 194 | t.Fatalf("should be fail: %v", err) 195 | } 196 | 197 | client = NewClient(&Config{Server: ts.URL}).NewWSClient() 198 | ctx, cancel := context.WithCancel(context.Background()) 199 | cancel() 200 | q, err := client.StreamingWSPublic(ctx, true) 201 | if err != nil { 202 | t.Fatalf("should not be fail: %v", err) 203 | } 204 | var wg sync.WaitGroup 205 | wg.Add(1) 206 | go func() { 207 | defer wg.Done() 208 | e := <-q 209 | if errorEvent, ok := e.(*ErrorEvent); !ok { 210 | t.Errorf("should be fail: %v", errorEvent.Err) 211 | } 212 | }() 213 | wg.Wait() 214 | } 215 | 216 | func TestHandleWS(t *testing.T) { 217 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 218 | u := websocket.Upgrader{} 219 | conn, err := u.Upgrade(w, r, nil) 220 | if err != nil { 221 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 222 | return 223 | } 224 | defer conn.Close() 225 | 226 | err = conn.WriteMessage(websocket.TextMessage, 227 | []byte(``)) 228 | if err != nil { 229 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 230 | return 231 | } 232 | 233 | time.Sleep(10 * time.Second) 234 | })) 235 | defer ts.Close() 236 | 237 | q := make(chan Event) 238 | client := NewClient(&Config{}).NewWSClient() 239 | 240 | var wg sync.WaitGroup 241 | wg.Add(1) 242 | go func() { 243 | defer wg.Done() 244 | e := <-q 245 | if errorEvent, ok := e.(*ErrorEvent); !ok { 246 | t.Errorf("should be fail: %v", errorEvent.Err) 247 | } 248 | }() 249 | err := client.handleWS(context.Background(), ":", q) 250 | if err == nil { 251 | t.Fatalf("should be fail: %v", err) 252 | } 253 | 254 | ctx, cancel := context.WithCancel(context.Background()) 255 | cancel() 256 | wg.Add(1) 257 | go func() { 258 | defer wg.Done() 259 | e := <-q 260 | if errorEvent, ok := e.(*ErrorEvent); !ok { 261 | t.Errorf("should be fail: %v", errorEvent.Err) 262 | } 263 | }() 264 | err = client.handleWS(ctx, "ws://"+ts.Listener.Addr().String(), q) 265 | if err == nil { 266 | t.Fatalf("should be fail: %v", err) 267 | } 268 | 269 | wg.Add(1) 270 | go func() { 271 | defer wg.Done() 272 | e := <-q 273 | if errorEvent, ok := e.(*ErrorEvent); !ok { 274 | t.Errorf("should be fail: %v", errorEvent.Err) 275 | } 276 | }() 277 | client.handleWS(context.Background(), "ws://"+ts.Listener.Addr().String(), q) 278 | 279 | wg.Wait() 280 | } 281 | 282 | func TestDialRedirect(t *testing.T) { 283 | client := NewClient(&Config{}).NewWSClient() 284 | _, err := client.dialRedirect(":") 285 | if err == nil { 286 | t.Fatalf("should be fail: %v", err) 287 | } 288 | } 289 | 290 | func TestDial(t *testing.T) { 291 | canErr := true 292 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 293 | if canErr { 294 | canErr = false 295 | http.Redirect(w, r, ":", http.StatusMovedPermanently) 296 | return 297 | } 298 | 299 | http.Redirect(w, r, "http://www.example.com/", http.StatusMovedPermanently) 300 | })) 301 | defer ts.Close() 302 | 303 | client := NewClient(&Config{}).NewWSClient() 304 | _, _, err := client.dial(":") 305 | if err == nil { 306 | t.Fatalf("should be fail: %v", err) 307 | } 308 | 309 | _, _, err = client.dial("ws://" + ts.Listener.Addr().String()) 310 | if err == nil { 311 | t.Fatalf("should be fail: %v", err) 312 | } 313 | 314 | _, rawurl, err := client.dial("ws://" + ts.Listener.Addr().String()) 315 | if err != nil { 316 | t.Fatalf("should not be fail: %v", err) 317 | } 318 | if rawurl != "ws://www.example.com/" { 319 | t.Fatalf("want %q but %q", "ws://www.example.com/", rawurl) 320 | } 321 | } 322 | 323 | func TestChangeWebSocketScheme(t *testing.T) { 324 | _, err := changeWebSocketScheme(":") 325 | if err == nil { 326 | t.Fatalf("should be fail: %v", err) 327 | } 328 | 329 | u, err := changeWebSocketScheme("http://example.com/") 330 | if err != nil { 331 | t.Fatalf("should not be fail: %v", err) 332 | } 333 | if u.Scheme != "ws" { 334 | t.Fatalf("want %q but %q", "ws", u.Scheme) 335 | } 336 | 337 | u, err = changeWebSocketScheme("https://example.com/") 338 | if err != nil { 339 | t.Fatalf("should not be fail: %v", err) 340 | } 341 | if u.Scheme != "wss" { 342 | t.Fatalf("want %q but %q", "wss", u.Scheme) 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /tags.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | // TagInfo gets statistics and information about a tag 11 | func (c *Client) TagInfo(ctx context.Context, tag string) (*FollowedTag, error) { 12 | var hashtag FollowedTag 13 | err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/tags/%s", url.PathEscape(string(tag))), nil, &hashtag, nil) 14 | if err != nil { 15 | return nil, err 16 | } 17 | return &hashtag, nil 18 | } 19 | 20 | // TagFollow lets you follow a hashtag 21 | func (c *Client) TagFollow(ctx context.Context, tag string) (*FollowedTag, error) { 22 | var hashtag FollowedTag 23 | err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/tags/%s/follow", url.PathEscape(string(tag))), nil, &hashtag, nil) 24 | if err != nil { 25 | return nil, err 26 | } 27 | return &hashtag, nil 28 | } 29 | 30 | // TagUnfollow unfollows a hashtag. 31 | func (c *Client) TagUnfollow(ctx context.Context, ID string) (*FollowedTag, error) { 32 | var tag FollowedTag 33 | err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/tags/%s/unfollow", ID), nil, &tag, nil) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return &tag, nil 38 | } 39 | 40 | // TagsFollowed returns a list of hashtags you follow. 41 | func (c *Client) TagsFollowed(ctx context.Context, pg *Pagination) ([]*FollowedTag, error) { 42 | var hashtags []*FollowedTag 43 | err := c.doAPI(ctx, http.MethodGet, "/api/v1/followed_tags", nil, &hashtags, pg) 44 | if err != nil { 45 | return nil, err 46 | } 47 | return hashtags, nil 48 | } 49 | -------------------------------------------------------------------------------- /tags_test.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestTagInfo(t *testing.T) { 13 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | if r.URL.Path != "/api/v1/tags/test" { 15 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 16 | return 17 | } 18 | fmt.Fprintln(w, ` 19 | { 20 | "name": "test", 21 | "url": "http://mastodon.example/tags/test", 22 | "history": [ 23 | { 24 | "day": "1668124800", 25 | "accounts": "1", 26 | "uses": "2" 27 | }, 28 | { 29 | "day": "1668038400", 30 | "accounts": "0", 31 | "uses": "0" 32 | } 33 | ] 34 | }`) 35 | })) 36 | defer ts.Close() 37 | 38 | client := NewClient(&Config{ 39 | Server: ts.URL, 40 | ClientID: "foo", 41 | ClientSecret: "bar", 42 | AccessToken: "zoo", 43 | }) 44 | _, err := client.TagInfo(context.Background(), "foo") 45 | if err == nil { 46 | t.Fatalf("should be fail: %v", err) 47 | } 48 | tag, err := client.TagInfo(context.Background(), "test") 49 | if err != nil { 50 | t.Fatalf("should not be fail: %v", err) 51 | } 52 | if tag.Name != "test" { 53 | t.Fatalf("want %q but %q", "test", tag.Name) 54 | } 55 | if tag.URL != "http://mastodon.example/tags/test" { 56 | t.Fatalf("want %q but %q", "http://mastodon.example/tags/test", tag.URL) 57 | } 58 | if len(tag.History) != 2 { 59 | t.Fatalf("result should be two: %d", len(tag.History)) 60 | } 61 | uts := UnixTimeString{ time.Unix(1668124800, 0) } 62 | if tag.History[0].Day != uts { 63 | t.Fatalf("want %q but %q", uts, tag.History[0].Day) 64 | } 65 | if tag.History[0].Accounts != 1 { 66 | t.Fatalf("want %q but %q", 1, tag.History[0].Accounts) 67 | } 68 | if tag.History[0].Uses != 2 { 69 | t.Fatalf("want %q but %q", 2, tag.History[0].Uses) 70 | } 71 | if tag.Following != false { 72 | t.Fatalf("want %v but %v", nil, tag.Following) 73 | } 74 | } 75 | 76 | func TestTagFollow(t *testing.T) { 77 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 78 | if r.URL.Path != "/api/v1/tags/test/follow" { 79 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 80 | return 81 | } 82 | fmt.Fprintln(w, ` 83 | { 84 | "name": "test", 85 | "url": "http://mastodon.example/tags/test", 86 | "history": [ 87 | { 88 | "day": "1668124800", 89 | "accounts": "1", 90 | "uses": "2" 91 | }, 92 | { 93 | "day": "1668038400", 94 | "accounts": "0", 95 | "uses": "0" 96 | } 97 | ], 98 | "following": true 99 | }`) 100 | })) 101 | defer ts.Close() 102 | 103 | client := NewClient(&Config{ 104 | Server: ts.URL, 105 | ClientID: "foo", 106 | ClientSecret: "bar", 107 | AccessToken: "zoo", 108 | }) 109 | _, err := client.TagFollow(context.Background(), "foo") 110 | if err == nil { 111 | t.Fatalf("should be fail: %v", err) 112 | } 113 | tag, err := client.TagFollow(context.Background(), "test") 114 | if err != nil { 115 | t.Fatalf("should not be fail: %v", err) 116 | } 117 | if tag.Name != "test" { 118 | t.Fatalf("want %q but %q", "test", tag.Name) 119 | } 120 | if tag.URL != "http://mastodon.example/tags/test" { 121 | t.Fatalf("want %q but %q", "http://mastodon.example/tags/test", tag.URL) 122 | } 123 | if len(tag.History) != 2 { 124 | t.Fatalf("result should be two: %d", len(tag.History)) 125 | } 126 | uts := UnixTimeString{ time.Unix(1668124800, 0) } 127 | if tag.History[0].Day != uts { 128 | t.Fatalf("want %q but %q", uts, tag.History[0].Day) 129 | } 130 | if tag.History[0].Accounts != 1 { 131 | t.Fatalf("want %q but %q", 1, tag.History[0].Accounts) 132 | } 133 | if tag.History[0].Uses != 2 { 134 | t.Fatalf("want %q but %q", 2, tag.History[0].Uses) 135 | } 136 | if tag.Following != true { 137 | t.Fatalf("want %v but %v", true, tag.Following) 138 | } 139 | } 140 | 141 | func TestTagUnfollow(t *testing.T) { 142 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 143 | if r.URL.Path != "/api/v1/tags/test/unfollow" { 144 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 145 | return 146 | } 147 | fmt.Fprintln(w, ` 148 | { 149 | "name": "test", 150 | "url": "http://mastodon.example/tags/test", 151 | "history": [ 152 | { 153 | "day": "1668124800", 154 | "accounts": "1", 155 | "uses": "2" 156 | }, 157 | { 158 | "day": "1668038400", 159 | "accounts": "0", 160 | "uses": "0" 161 | } 162 | ], 163 | "following": false 164 | }`) 165 | })) 166 | defer ts.Close() 167 | 168 | client := NewClient(&Config{ 169 | Server: ts.URL, 170 | ClientID: "foo", 171 | ClientSecret: "bar", 172 | AccessToken: "zoo", 173 | }) 174 | 175 | _, err := client.TagUnfollow(context.Background(), "foo") 176 | if err == nil { 177 | t.Fatalf("should be fail: %v", err) 178 | } 179 | tag, err := client.TagUnfollow(context.Background(), "test") 180 | if err != nil { 181 | t.Fatalf("should not be fail: %v", err) 182 | } 183 | if tag.Name != "test" { 184 | t.Fatalf("want %q but %q", "test", tag.Name) 185 | } 186 | if tag.URL != "http://mastodon.example/tags/test" { 187 | t.Fatalf("want %q but %q", "http://mastodon.example/tags/test", tag.URL) 188 | } 189 | if len(tag.History) != 2 { 190 | t.Fatalf("result should be two: %d", len(tag.History)) 191 | } 192 | uts := UnixTimeString{ time.Unix(1668124800, 0) } 193 | if tag.History[0].Day != uts { 194 | t.Fatalf("want %q but %q", uts, tag.History[0].Day) 195 | } 196 | if tag.History[0].Accounts != 1 { 197 | t.Fatalf("want %q but %q", 1, tag.History[0].Accounts) 198 | } 199 | if tag.History[0].Uses != 2 { 200 | t.Fatalf("want %q but %q", 2, tag.History[0].Uses) 201 | } 202 | if tag.Following != false { 203 | t.Fatalf("want %v but %v", false, tag.Following) 204 | } 205 | } 206 | 207 | func TestTagsFollowed(t *testing.T) { 208 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 209 | if r.URL.Path != "/api/v1/followed_tags" { 210 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 211 | return 212 | } 213 | if r.FormValue("limit") == "1" { 214 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 215 | return 216 | } 217 | 218 | fmt.Fprintln(w, ` 219 | [{ 220 | "name": "test", 221 | "url": "http://mastodon.example/tags/test", 222 | "history": [ 223 | { 224 | "day": "1668124800", 225 | "accounts": "1", 226 | "uses": "2" 227 | }, 228 | { 229 | "day": "1668038400", 230 | "accounts": "0", 231 | "uses": "0" 232 | } 233 | ], 234 | "following": true 235 | }, 236 | { 237 | "name": "foo", 238 | "url": "http://mastodon.example/tags/foo", 239 | "history": [ 240 | { 241 | "day": "1668124800", 242 | "accounts": "1", 243 | "uses": "2" 244 | }, 245 | { 246 | "day": "1668038400", 247 | "accounts": "0", 248 | "uses": "0" 249 | } 250 | ], 251 | "following": true 252 | }]`) 253 | })) 254 | defer ts.Close() 255 | 256 | client := NewClient(&Config{ 257 | Server: ts.URL, 258 | ClientID: "foo", 259 | ClientSecret: "bar", 260 | AccessToken: "zoo", 261 | }) 262 | _, err := client.TagsFollowed(context.Background(), &Pagination{Limit: 1}) 263 | if err == nil { 264 | t.Fatalf("should be fail: %v", err) 265 | } 266 | tags, err := client.TagsFollowed(context.Background(), nil) 267 | if err != nil { 268 | t.Fatalf("should not be fail: %v", err) 269 | } 270 | if len(tags) != 2 { 271 | t.Fatalf("want %q but %q", 2, len(tags)) 272 | } 273 | if tags[0].Name != "test" { 274 | t.Fatalf("want %q but %q", "test", tags[0].Name) 275 | } 276 | if tags[0].URL != "http://mastodon.example/tags/test" { 277 | t.Fatalf("want %q but %q", "http://mastodon.example/tags/test", tags[0].URL) 278 | } 279 | if len(tags[0].History) != 2 { 280 | t.Fatalf("result should be two: %d", len(tags[0].History)) 281 | } 282 | uts := UnixTimeString{ time.Unix(1668124800, 0) } 283 | if tags[0].History[0].Day != uts { 284 | t.Fatalf("want %q but %q", uts, tags[0].History[0].Day) 285 | } 286 | if tags[0].History[0].Accounts != 1 { 287 | t.Fatalf("want %q but %q", 1, tags[0].History[0].Accounts) 288 | } 289 | if tags[0].History[0].Uses != 2 { 290 | t.Fatalf("want %q but %q", 2, tags[0].History[0].Uses) 291 | } 292 | if tags[0].Following != true { 293 | t.Fatalf("want %v but %v", true, tags[0].Following) 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /testdata/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattn/go-mastodon/6efc40b8f8029c38be08215233405cfd0cfbf67d/testdata/logo.png -------------------------------------------------------------------------------- /unixtime.go: -------------------------------------------------------------------------------- 1 | package mastodon 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | type Unixtime time.Time 9 | 10 | func (t *Unixtime) UnmarshalJSON(data []byte) error { 11 | if len(data) > 0 && data[0] == '"' && data[len(data)-1] == '"' { 12 | data = data[1 : len(data)-1] 13 | } 14 | ts, err := strconv.ParseInt(string(data), 10, 64) 15 | if err != nil { 16 | return err 17 | } 18 | *t = Unixtime(time.Unix(ts, 0)) 19 | return nil 20 | } 21 | --------------------------------------------------------------------------------