From 589be3c1f80b4b4290b8283dcc864d26e3c71100 Mon Sep 17 00:00:00 2001 From: Paul Waldo Date: Sun, 12 Mar 2023 09:25:45 -0400 Subject: [PATCH 1/3] Adds GetFollowedTags --- .vscode/launch.json | 25 +++++++++++++ accounts.go | 46 +++++++++++++++++++++++ accounts_test.go | 89 +++++++++++++++++++++++++++++++++++++++++++++ cmd/mstdn/go.mod | 3 +- cmd/mstdn/go.sum | 2 - cmd/mstdn/main.go | 2 +- go.work | 6 +++ 7 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 go.work diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..8bc1a8b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${fileDirname}", + "console": "integratedTerminal" + }, + { + "name": "mstdn", + "type": "go", + "request": "launch", + "mode": "auto", + // "cwd": "${workspaceFolder}/cmd/mstdn", + "program": "${workspaceFolder}/cmd/mstdn", + "console": "integratedTerminal" + } + ] +} diff --git a/accounts.go b/accounts.go index 0b43e4c..2908261 100644 --- a/accounts.go +++ b/accounts.go @@ -2,6 +2,7 @@ package mastodon import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -32,6 +33,7 @@ type Account struct { Bot bool `json:"bot"` Discoverable bool `json:"discoverable"` Source *AccountSource `json:"source"` + FollowedTag []FollowedTag `json:"followed_tags"` } // Field is a Mastodon account profile field. @@ -50,6 +52,40 @@ type AccountSource struct { Fields *[]Field `json:"fields"` } +// UnixTimeString represents a time in a Unix Epoch string +type UnixTimeString struct { + time.Time +} + +func (u *UnixTimeString) UnmarshalJSON(b []byte) error { + var timestampSring string + err := json.Unmarshal(b, ×tampSring) + if err != nil { + return err + } + timestamp, err := strconv.ParseInt(timestampSring, 0, 0) + if err != nil { + return err + } + u.Time = time.Unix(timestamp, 0) + return nil +} + +// History is the history of a followed tag +type FollowedTagHistory struct { + Day UnixTimeString `json:"day,omitempty"` + Accounts int `json:"accounts,string,omitempty"` + Uses int `json:"uses,string,omitempty"` +} + +// FollowedTag is a Hash Tag followed by the user +type FollowedTag struct { + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + History []FollowedTagHistory `json:"history,omitempty"` + Following bool `json:"following,omitempty"` +} + // GetAccount return Account. func (c *Client) GetAccount(ctx context.Context, id ID) (*Account, error) { var account Account @@ -326,3 +362,13 @@ func (c *Client) GetMutes(ctx context.Context, pg *Pagination) ([]*Account, erro } return accounts, nil } + +// GetMutes returns the list of users muted by the current user. +func (c *Client) GetFollowedTags(ctx context.Context, pg *Pagination) ([]*FollowedTag, error) { + var followedTags []*FollowedTag + err := c.doAPI(ctx, http.MethodGet, "/api/v1/followed_tags", nil, &followedTags, pg) + if err != nil { + return nil, err + } + return followedTags, nil +} diff --git a/accounts_test.go b/accounts_test.go index 47e0310..9affd45 100644 --- a/accounts_test.go +++ b/accounts_test.go @@ -697,3 +697,92 @@ func TestGetMutes(t *testing.T) { t.Fatalf("want %q but %q", "bar", mutes[1].Username) } } +func TestGetFollowedTags(t *testing.T) { + canErr := true + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if canErr { + canErr = false + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + fmt.Fprintln(w, `[ + { + "name": "Test1", + "url": "http://mastodon.example/tags/test1", + "history": [ + { + "day": "1668211200", + "accounts": "0", + "uses": "0" + }, + { + "day": "1668124800", + "accounts": "0", + "uses": "0" + }, + { + "day": "1668038400", + "accounts": "0", + "uses": "0" + } + ], + "following": true + }, + { + "name": "Test2", + "url": "http://mastodon.example/tags/test2", + "history": [ + { + "day": "1668211200", + "accounts": "0", + "uses": "0" + } + ], + "following": true + } +]`) + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + _, err := client.GetFollowedTags(context.Background(), nil) + if err == nil { + t.Fatalf("should be fail: %v", err) + } + followedTags, err := client.GetFollowedTags(context.Background(), nil) + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if len(followedTags) != 2 { + t.Fatalf("result should be two: %d", len(followedTags)) + } + if followedTags[0].Name != "Test1" { + t.Fatalf("want %q but %q", "Test1", followedTags[0].Name) + } + if followedTags[0].URL != "http://mastodon.example/tags/test1" { + t.Fatalf("want %q but got %q", "http://mastodon.example/tags/test1", followedTags[0].URL) + } + if !followedTags[0].Following { + t.Fatalf("want following, but got false") + } + if 3 != len(followedTags[0].History){ + t.Fatalf("expecting first tag history length to be %d but got %d", 3, len(followedTags[0].History)) + } + if followedTags[1].Name != "Test2" { + t.Fatalf("want %q but %q", "Test2", followedTags[1].Name) + } + if followedTags[1].URL != "http://mastodon.example/tags/test2" { + t.Fatalf("want %q but got %q", "http://mastodon.example/tags/test2", followedTags[1].URL) + } + if !followedTags[1].Following { + t.Fatalf("want following, but got false") + } + if 1 != len(followedTags[1].History){ + t.Fatalf("expecting first tag history length to be %d but got %d", 1, len(followedTags[1].History)) + } +} diff --git a/cmd/mstdn/go.mod b/cmd/mstdn/go.mod index 19d310d..fe41f20 100644 --- a/cmd/mstdn/go.mod +++ b/cmd/mstdn/go.mod @@ -9,7 +9,6 @@ require ( github.com/fatih/color v1.13.0 github.com/mattn/go-mastodon v0.0.4 github.com/mattn/go-tty v0.0.4 - github.com/urfave/cli v1.13.0 - github.com/urfave/cli/v2 v2.23.5 // indirect + github.com/urfave/cli/v2 v2.23.5 golang.org/x/net v0.0.0-20220531201128-c960675eff93 ) diff --git a/cmd/mstdn/go.sum b/cmd/mstdn/go.sum index 65758c5..070c0a7 100644 --- a/cmd/mstdn/go.sum +++ b/cmd/mstdn/go.sum @@ -24,8 +24,6 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= -github.com/urfave/cli v1.13.0 h1:kkpCmfxnnnWIie2rCljcvaVrNYmsFq1ynTJH5kn1Ip4= -github.com/urfave/cli v1.13.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli/v2 v2.23.5 h1:xbrU7tAYviSpqeR3X4nEFWUdB/uDZ6DE+HxmRU7Xtyw= github.com/urfave/cli/v2 v2.23.5/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= diff --git a/cmd/mstdn/main.go b/cmd/mstdn/main.go index a52cf10..05a151b 100644 --- a/cmd/mstdn/main.go +++ b/cmd/mstdn/main.go @@ -258,7 +258,7 @@ func makeApp() *cli.App { { Name: "timeline-tag", Flags: []cli.Flag{ - cli.BoolFlag{ + &cli.BoolFlag{ Name: "local", Usage: "local tags only", }, diff --git a/go.work b/go.work new file mode 100644 index 0000000..50be376 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.19 + +use ( + . + ./cmd/mstdn +) From 9744c0f48160e9b500f0447def48581cbce3e01f Mon Sep 17 00:00:00 2001 From: Paul Waldo Date: Tue, 2 May 2023 08:01:23 -0400 Subject: [PATCH 2/3] Adds documentation for followed_tags and AuthenticateToken workflow --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/README.md b/README.md index 8440744..455d22d 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,65 @@ func main() { } ``` +### Client with Token +This option lets the user avoid storing login credentials in the application. Instead, the user's Mastodon server +provides an access token which is used to authenticate. This token can be stored in the application, but should be guarded. + +``` +package main + +import ( + "context" + "fmt" + "log" + "net/url" + + "github.com/mattn/go-mastodon" +) + +func main() { + appConfig := &mastodon.AppConfig{ + Server: "https://stranger.social", + ClientName: "client-name", + Scopes: "read write follow", + Website: "https://github.com/mattn/go-mastodon", + RedirectURIs: "urn:ietf:wg:oauth:2.0:oob", + } + app, err := mastodon.RegisterApp(context.Background(), appConfig) + if err != nil { + log.Fatal(err) + } + + // Have the user manually get the token and send it back to us + u, err := url.Parse(app.AuthURI) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Open your browser to \n%s\n and copy/paste the given token\n", u) + var token string + fmt.Print("Paste the token here:") + fmt.Scanln(&token) + config := &mastodon.Config{ + Server: "https://stranger.social", + ClientID: app.ClientID, + ClientSecret: app.ClientSecret, + AccessToken: token, + } + + c := mastodon.NewClient(config) + err = c.AuthenticateToken(context.Background(), token, "urn:ietf:wg:oauth:2.0:oob") + if err != nil { + log.Fatal((err) + } + + acct, err := c.GetAccountCurrentUser(context.Background()) + if err != nil { + log.Fatal((err) + } + fmt.Printf("Account is %v\n", acct) +} +``` + ## Status of implementations * [x] GET /api/v1/accounts/:id @@ -102,6 +161,7 @@ func main() { * [x] GET /api/v1/follow_requests * [x] POST /api/v1/follow_requests/:id/authorize * [x] POST /api/v1/follow_requests/:id/reject +* [x] GET /api/v1/followed_tags * [x] POST /api/v1/follows * [x] GET /api/v1/instance * [x] GET /api/v1/instance/activity From 4836b8899c3ea2cee4249174dcedfd1e4025cff9 Mon Sep 17 00:00:00 2001 From: Paul Waldo Date: Sun, 28 May 2023 15:16:54 -0400 Subject: [PATCH 3/3] Adds TagUnfollow --- accounts.go | 2 +- accounts_test.go | 5 ++-- status_test.go | 2 +- tags.go | 17 +++++++++++ tags_test.go | 75 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 tags.go create mode 100644 tags_test.go diff --git a/accounts.go b/accounts.go index 2908261..42b1873 100644 --- a/accounts.go +++ b/accounts.go @@ -363,7 +363,7 @@ func (c *Client) GetMutes(ctx context.Context, pg *Pagination) ([]*Account, erro return accounts, nil } -// GetMutes returns the list of users muted by the current user. +// GetFollowedTags returns the list of Hashtags followed by the user. func (c *Client) GetFollowedTags(ctx context.Context, pg *Pagination) ([]*FollowedTag, error) { var followedTags []*FollowedTag err := c.doAPI(ctx, http.MethodGet, "/api/v1/followed_tags", nil, &followedTags, pg) diff --git a/accounts_test.go b/accounts_test.go index 9affd45..7d1ffcc 100644 --- a/accounts_test.go +++ b/accounts_test.go @@ -698,6 +698,7 @@ func TestGetMutes(t *testing.T) { } } func TestGetFollowedTags(t *testing.T) { + t.Parallel() canErr := true ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if canErr { @@ -770,7 +771,7 @@ func TestGetFollowedTags(t *testing.T) { if !followedTags[0].Following { t.Fatalf("want following, but got false") } - if 3 != len(followedTags[0].History){ + if 3 != len(followedTags[0].History) { t.Fatalf("expecting first tag history length to be %d but got %d", 3, len(followedTags[0].History)) } if followedTags[1].Name != "Test2" { @@ -782,7 +783,7 @@ func TestGetFollowedTags(t *testing.T) { if !followedTags[1].Following { t.Fatalf("want following, but got false") } - if 1 != len(followedTags[1].History){ + if 1 != len(followedTags[1].History) { t.Fatalf("expecting first tag history length to be %d but got %d", 1, len(followedTags[1].History)) } } diff --git a/status_test.go b/status_test.go index 2cac4b8..c576fbb 100644 --- a/status_test.go +++ b/status_test.go @@ -729,7 +729,7 @@ func TestSearch(t *testing.T) { t.Fatalf("Hashtags have %q entries, but %q", "3", len(ret.Hashtags)) } if ret.Hashtags[2].Name != "tag3" { - t.Fatalf("Hashtags[2] should %q , but %q", "tag3", ret.Hashtags[2]) + t.Fatalf("Hashtags[2] should %v , but %v", "tag3", ret.Hashtags[2]) } } diff --git a/tags.go b/tags.go new file mode 100644 index 0000000..54a9b67 --- /dev/null +++ b/tags.go @@ -0,0 +1,17 @@ +package mastodon + +import ( + "context" + "fmt" + "net/http" +) + +// TagUnfollow unfollows a hashtag. +func (c *Client) TagUnfollow(ctx context.Context, ID string) (*FollowedTag, error) { + var tag FollowedTag + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/tags/%s/unfollow", ID), nil, &tag, nil) + if err != nil { + return nil, err + } + return &tag, nil +} diff --git a/tags_test.go b/tags_test.go new file mode 100644 index 0000000..a2eed27 --- /dev/null +++ b/tags_test.go @@ -0,0 +1,75 @@ +package mastodon + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestTagUnfollow(t *testing.T) { + t.Parallel() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `{ + "name": "Test", + "url": "http://mastodon.example/tags/test", + "history": [ + { + "day": "1668556800", + "accounts": "0", + "uses": "0" + }, + { + "day": "1668470400", + "accounts": "0", + "uses": "0" + }, + { + "day": "1668384000", + "accounts": "0", + "uses": "0" + }, + { + "day": "1668297600", + "accounts": "1", + "uses": "1" + }, + { + "day": "1668211200", + "accounts": "0", + "uses": "0" + }, + { + "day": "1668124800", + "accounts": "0", + "uses": "0" + }, + { + "day": "1668038400", + "accounts": "0", + "uses": "0" + } + ], + "following": false + }`) + })) + defer ts.Close() + + client := NewClient(&Config{ + Server: ts.URL, + ClientID: "foo", + ClientSecret: "bar", + AccessToken: "zoo", + }) + tag, err := client.TagUnfollow(context.Background(), "Test") + if err != nil { + t.Fatalf("should not be fail: %v", err) + } + if tag.Name != "Test" { + t.Fatalf("want %q but %q", "Test", tag.Name) + } + if tag.Following { + t.Fatalf("want %t but %t", false, tag.Following) + } +}