├── .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 | [](https://github.com/mattn/go-mastodon/actions?query=workflow%3Atest)
4 | [](https://codecov.io/gh/mattn/go-mastodon)
5 | [](https://pkg.go.dev/github.com/mattn/go-mastodon)
6 | [](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 |
--------------------------------------------------------------------------------