* Remove App struct and methods in favor of Client struct and bare functions.
* Introduce Cobra cli framework
* Add version command
* Add login command
Co-authored-by: Adam Shamblin <adam@vexingworkshop.com>
Reviewed-on: https://git.vexingworkshop.com/signal9/dead-tooter/pulls/5
some this is very exciting.
The increase in volume has affected the administrators more than anyone, no
-doubht, but the users have also been affected. While Mastodon's filters and feed
+doubt, but the users have also been affected. While Mastodon's filters and feed
settings are exceedingly powerful, there are a few more things I'd like to be
able to do with my Fedi data.
* Delete follows/followers in bulk
* by account age
* by age of last post
+
+## Usage
+
+```
+dead-tooter login --host hackers.town
+```
--- /dev/null
+package tooter
+
+import "github.com/spf13/cobra"
+
+func init() {
+ rootCmd.AddCommand(cmdApp)
+}
+
+var cmdApp = &cobra.Command{
+ Use: "application",
+ Short: "Register and perform application-level actions",
+ Long: "application, man",
+ Run: func(cmd *cobra.Command, args []string) {
+ },
+}
--- /dev/null
+package tooter
+
+import (
+ "git.vexingworkshop.com/signal9/dead-tooter/pkg/mastodon"
+ "github.com/spf13/cobra"
+)
+
+var host string
+
+func init() {
+ loginCmd.Flags().StringVarP(&host,
+ "host", "H", "", "Mastodon host where your account lives.")
+ loginCmd.MarkFlagRequired("host")
+
+ rootCmd.AddCommand(loginCmd)
+}
+
+var loginCmd = &cobra.Command{
+ Use: "login",
+ Short: "Login to Mastodon",
+ Long: "Initiate login to your Mastodon server of choice",
+ Run: func(cmd *cobra.Command, args []string) {
+ login()
+ },
+}
+
+func login() {
+ app, err := mastodon.Load("dead-tooter")
+ if err != nil {
+ client := mastodon.Client{
+ ClientName: "dead-tooter",
+ RedirectUris: mastodon.RedirectUris,
+ Scopes: "read write follow push",
+ Website: "https://dead-tooter.vexingworkshop.com",
+ }
+
+ app, err = mastodon.Create(host, client)
+ if err != nil {
+ panic(err.Error())
+ }
+
+ err = app.Save()
+ if err != nil {
+ panic(err.Error())
+ }
+ }
+
+ var account mastodon.Account
+ code := account.Authorize(host, app)
+ token, err = account.RequestToken(host, app, code)
+ if err != nil {
+ panic(err.Error())
+ }
+
+ err = token.Save()
+ if err != nil {
+ panic(err.Error())
+ }
+
+ err = account.VerifyCredentials(host, token)
+ if err != nil {
+ panic(err.Error())
+ }
+}
--- /dev/null
+package tooter
+
+import (
+ "fmt"
+ "os"
+
+ "git.vexingworkshop.com/signal9/dead-tooter/pkg/mastodon"
+ "github.com/spf13/cobra"
+)
+
+var token mastodon.Token
+
+var rootCmd = &cobra.Command{
+ Use: "dead-tooter",
+ Short: "A CLI for Mastodon hate scripts",
+ Long: `Provides a collection of capabilities that may or may not
+ be present in the Mastodon web UI`,
+
+ PersistentPreRun: func(cmd *cobra.Command, args []string) {
+ var err error
+ token, err = mastodon.LoadToken()
+ if err != nil {
+ fmt.Println("No authentication token found.\nYou must login before performing futher commands.")
+ }
+ },
+
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println("d34d-t00t3r")
+ },
+
+ PersistentPostRun: func(cmd *cobra.Command, args []string) {
+ err := token.Save()
+ if err != nil {
+ panic(err.Error())
+ }
+ },
+}
+
+func Execute() {
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+}
--- /dev/null
+package tooter
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+)
+
+func init() {
+ rootCmd.AddCommand(versionCmd)
+}
+
+var versionCmd = &cobra.Command{
+ Use: "version",
+ Short: "Print the version number of d34d-t00ter",
+ Long: "Version, man, version!",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println("d34d-t00ter, Mastodon hate CLI v0.1")
+ },
+}
module git.vexingworkshop.com/signal9/dead-tooter
go 1.18
+
+require github.com/spf13/cobra v1.6.1
+
+require (
+ github.com/inconshreveable/mousetrap v1.0.1 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+)
--- /dev/null
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
+github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
+github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
package main
import (
- "fmt"
-
- "git.vexingworkshop.com/signal9/dead-tooter/pkg/mastodon"
+ cmd "git.vexingworkshop.com/signal9/dead-tooter/cmd"
)
func main() {
- app := mastodon.Application{}
- err := app.Load("dead-tooter")
- if err != nil {
- panic(err.Error())
- }
-
- token, err := app.Login()
- if err != nil {
- panic(err.Error())
- }
-
- err = app.VerifyCredentials(token)
- if err != nil {
- panic(err.Error())
- }
-
- var account mastodon.Account
- code := account.Authorize(app)
- token, err = account.RequestToken(app, code)
- if err != nil {
- panic(err.Error())
- }
-
- err = account.VerifyCredentials(token)
- if err != nil {
- panic(err.Error())
- }
-
- followers, err := account.GetFollowers(token)
- if err != nil {
- panic(err.Error())
- }
-
- fmt.Printf("%+v\n", followers)
+ cmd.Execute()
}
"os/exec"
)
+// Account represents a user of Mastodon and their associated profile.
type Account struct {
ID string `json:"id"`
UserName string `json:"username"`
Fields []Field `json:"fields"`
}
+// Source is an extra attribute that contains source values to be used with API
+// methods that verify and update credentials.
type Source struct {
Privacy string `json:"privacy"`
Sensitive bool `json:"sensitive"`
FollowRequestsCount int `json:"follow_requests_count"`
}
+// Field stores custom field data on the user account.
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
VerifiedAt string `json:"verified_at"`
}
+// Emoji is the emoji portion of the user account.
type Emoji struct {
ShortCode string `json:"shortcode"`
- Url string `json:"url"`
- StaticUrl string `json:"static_url"`
+ URL string `json:"url"`
+ StaticURL string `json:"static_url"`
VisibleInPicker bool `json:"visible_in_picker"`
}
-func (a *Account) Authorize(app Application) (code string) {
+// Authorize opens the default browser to initiate the authorization flow
+// for the current user.
+func (a *Account) Authorize(host string, app Application) (code string) {
v := url.Values{}
v.Set("client_id", app.ClientID)
v.Set("response_type", "code")
v.Set("redirect_uri", RedirectUris)
u := url.URL{
- Host: mastohost,
+ Host: host,
Path: "oauth/authorize",
RawQuery: v.Encode(),
}
return
}
-func (a *Account) RequestToken(app Application, code string) (token Token, err error) {
+// RequestToken takes the provided authorization code and returns a structure
+// containing a bearer token necessary to make future, authenticated requests
+// on the part of the user.
+func (a *Account) RequestToken(
+ host string, app Application, code string) (token Token, err error) {
+
v := url.Values{}
v.Set("client_id", app.ClientID)
v.Set("client_secret", app.ClientSecret)
v.Set("grant_type", "authorization_code")
u := url.URL{
- Host: mastohost,
- Path: "oauth/token",
- RawQuery: v.Encode(),
+ Host: host,
+ Path: "oauth/token",
}
u.Scheme = "https"
return
}
-func (a *Account) VerifyCredentials(token Token) (err error) {
+// VerifyCredentials hydrates the account object by validating the bearer token
+// against the Mastodon API
+func (a *Account) VerifyCredentials(host string, token Token) (err error) {
client := &http.Client{}
u := url.URL{
- Host: mastohost,
+ Host: host,
Path: "api/v1/accounts/verify_credentials",
}
u.Scheme = "https"
return
}
-func (a *Account) GetFollowers(token Token) (followers []Account, err error) {
+// GetFollowers returns a list of all accounts following the logged in user
+func (a *Account) GetFollowers(
+ host string, token Token) (followers []Account, err error) {
+
client := &http.Client{}
id := url.PathEscape(a.ID)
}
u := url.URL{
- Host: mastohost,
+ Host: host,
Path: path,
}
u.Scheme = "https"
+++ /dev/null
-package mastodon
-
-import (
- "encoding/json"
- "io"
- "net/http"
- "net/url"
-)
-
-// RedirectUris when passed to the redirect_uris parameter, will
-// show return the authorization code instead of redirecting the client.
-const RedirectUris = "urn:ietf:wg:oauth:2.0:oob"
-
-// App represents the basic and methods necessary to register an application
-// with a Mastodon server.
-type App struct {
- ClientName string `json:"client_name"`
- RedirectUris string `json:"redirect_uris"`
- Scopes string `json:"scopes"`
- Website string `json:"website"`
-}
-
-// Create calls the Mastodon API to register the App. It returns an Application
-// instance.
-//
-// app, err := mastodon.App{
-// ClientName: "dead-tooter",
-// RedirectUris: mastodon.RedirectUris,
-// Scopes: "read write follow push",
-// Website: "https://dead-tooter.vexingworkshop.com",
-// }.Create()
-func (a *App) Create() (application Application, err error) {
- resp, err := http.PostForm("https://hackers.town/api/v1/apps",
- url.Values{
- "client_name": {a.ClientName},
- "redirect_uris": {a.RedirectUris},
- "scopes": {a.Scopes},
- "website": {a.Website},
- },
- )
- if err != nil {
- panic(err.Error())
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- err = json.Unmarshal(body, &application)
-
- return
-}
"os"
)
-const configdir = "foodir/"
-const mastohost = "hackers.town"
-
-// Token struct contains the data returned by the Application login request.
-type Token struct {
- AccessToken string `json:"access_token"`
- TokenType string `json:"token_type"`
- Scope string `json:"scope"`
- CreatedAt int `json:"created_at"`
+const ConfigDir = "foodir/"
+
+// RedirectUris when passed to the redirect_uris parameter, will
+// show return the authorization code instead of redirecting the client.
+const RedirectUris = "urn:ietf:wg:oauth:2.0:oob"
+
+// APIError contains the application specific error message returned
+// by the Mastodon API.
+type APIError struct {
+ Error string `json:"error"`
+ ErrorDescription string `json:"error_description"`
}
// Application represents the data and functions that establish a
VapidKey string `json:"vapid_key,omitempty"`
}
-// Save will store a serialized version of the Application struct to a file.
-func (a *Application) Save() {
- err := os.Mkdir(configdir, 0750)
- if err != nil && !os.IsExist(err) {
- panic(err.Error())
+// Client represents the basic stanza required to register an application
+// with the Mastodon API
+type Client struct {
+ ClientName string `json:"client_name"`
+ RedirectUris string `json:"redirect_uris"`
+ Scopes string `json:"scopes"`
+ Website string `json:"website"`
+}
+
+// Create calls the Mastodon API to regsiter the application.
+func Create(host string, client Client) (app Application, err error) {
+ v := url.Values{}
+ v.Set("client_name", client.ClientName)
+ v.Set("redirect_uris", client.RedirectUris)
+ v.Set("scopes", client.Scopes)
+ v.Set("website", client.Website)
+
+ u := url.URL{
+ Host: host,
+ Path: "api/v1/apps",
}
+ u.Scheme = "https"
- data, err := json.Marshal(a)
+ resp, err := http.PostForm(u.String(), v)
if err != nil {
- panic(err.Error())
+ return
+ }
+ if resp.StatusCode != 200 {
+ err = errors.New(resp.Status)
+ return
}
+ defer resp.Body.Close()
- err = os.WriteFile(configdir+a.Name, data, 0666)
+ body, err := io.ReadAll(resp.Body)
if err != nil {
- panic(err.Error())
+ return
}
+ err = json.Unmarshal(body, &app)
+
+ return
}
// Load will hydrate an Application instance based upon data stored in
// a file.
-func (a *Application) Load(name string) error {
- data, err := os.ReadFile(configdir + name)
+func Load(name string) (app Application, err error) {
+ data, err := os.ReadFile(ConfigDir + name)
if err != nil && os.IsNotExist(err) {
- panic(err.Error())
+ return
+ }
+
+ err = json.Unmarshal(data, &app)
+ if err != nil {
+ return
+ }
+
+ return app, nil
+}
+
+// Save will store a serialized version of the Application struct to a file.
+func (a *Application) Save() (err error) {
+ err = os.Mkdir(ConfigDir, 0750)
+ if err != nil && !os.IsExist(err) {
+ return
+ }
+
+ data, err := json.Marshal(a)
+ if err != nil {
+ return
}
- err = json.Unmarshal(data, a)
+ err = os.WriteFile(ConfigDir+a.Name, data, 0666)
if err != nil {
- panic(err.Error())
+ return
}
return nil
// Login authenticates the application to the Mastodon API, returning
// a bearer token to be used with future requests.
-func (a *Application) Login() (token Token, err error) {
+func (a *Application) Login(host string) (token Token, err error) {
v := url.Values{}
v.Set("client_id", a.ClientID)
v.Set("client_secret", a.ClientSecret)
v.Set("grant_type", "client_credentials")
u := url.URL{
- Host: mastohost,
+ Host: host,
Path: "oauth/token",
RawQuery: v.Encode(),
}
// VerifyCredentials accepts a Token object and validates the contained
// token against the Mastodon API.
-func (a *Application) VerifyCredentials(token Token) (err error) {
+func (a *Application) VerifyCredentials(host string, token Token) (err error) {
client := &http.Client{}
u := url.URL{
- Host: mastohost,
+ Host: host,
Path: "api/v1/apps/verify_credentials",
}
u.Scheme = "https"
--- /dev/null
+package mastodon
+
+import (
+ "encoding/json"
+ "os"
+)
+
+// Token struct contains the data returned by the Application login request.
+type Token struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ Scope string `json:"scope"`
+ CreatedAt int `json:"created_at"`
+}
+
+// LoadToken deserializes the application authentication token from disc, if it
+// exists.
+func LoadToken() (token Token, err error) {
+ data, err := os.ReadFile(ConfigDir + "/token")
+ if err != nil && os.IsNotExist(err) {
+ return
+ }
+
+ err = json.Unmarshal(data, &token)
+ if err != nil {
+ return
+ }
+
+ return
+}
+
+// Save serializes the application token to disk.
+func (t *Token) Save() (err error) {
+ data, err := json.Marshal(t)
+ if err != nil {
+ return
+ }
+
+ err = os.WriteFile(ConfigDir+"/token", data, 0666)
+ if err != nil {
+ return
+ }
+
+ return
+}