diff --git a/cmd/enbas/follow.go b/cmd/enbas/follow.go new file mode 100644 index 0000000..20ff36d --- /dev/null +++ b/cmd/enbas/follow.go @@ -0,0 +1,81 @@ +package main + +import ( + "flag" + "fmt" + + "codeflow.dananglin.me.uk/apollo/enbas/internal/client" +) + +type followCommand struct { + *flag.FlagSet + + resourceType string + accountID string + showReposts bool + notify bool + unfollow bool +} + +func newFollowCommand(name, summary string, unfollow bool) *followCommand { + command := followCommand{ + FlagSet: flag.NewFlagSet(name, flag.ExitOnError), + + unfollow: unfollow, + } + + command.StringVar(&command.resourceType, resourceTypeFlag, "", "specify the type of resource to follow") + command.StringVar(&command.accountID, accountIDFlag, "", "specify the ID of the account you want to follow") + command.BoolVar(&command.showReposts, showRepostsFlag, true, "show reposts from the account you want to follow") + command.BoolVar(&command.notify, notifyFlag, false, "get notifications when the account you want to follow posts a status") + + command.Usage = commandUsageFunc(name, summary, command.FlagSet) + + return &command +} + +func (c *followCommand) Execute() error { + funcMap := map[string]func(*client.Client) error{ + accountResource: c.followAccount, + } + + doFunc, ok := funcMap[c.resourceType] + if !ok { + return unsupportedResourceTypeError{resourceType: c.resourceType} + } + + gtsClient, err := client.NewClientFromConfig() + if err != nil { + return fmt.Errorf("unable to create the GoToSocial client; %w", err) + } + + return doFunc(gtsClient) +} + +func (c *followCommand) followAccount(gts *client.Client) error { + if c.accountID == "" { + return flagNotSetError{flagText: accountIDFlag} + } + + if c.unfollow { + return c.unfollowAccount(gts) + } + + if err := gts.FollowAccount(c.accountID, c.showReposts, c.notify); err != nil { + return fmt.Errorf("unable to follow the account; %w", err) + } + + fmt.Println("The follow request was sent successfully.") + + return nil +} + +func (c *followCommand) unfollowAccount(gts *client.Client) error { + if err := gts.UnfollowAccount(c.accountID); err != nil { + return fmt.Errorf("unable to unfollow the account; %w", err) + } + + fmt.Println("Successfully unfollowed the account.") + + return nil +} diff --git a/cmd/enbas/main.go b/cmd/enbas/main.go index b9fa5f7..95f85be 100644 --- a/cmd/enbas/main.go +++ b/cmd/enbas/main.go @@ -20,16 +20,20 @@ const ( statusIDFlag = "status-id" tagFlag = "tag" timelineCategoryFlag = "timeline-category" - timelineLimitFlag = "timeline-limit" + limitFlag = "limit" toAccountFlag = "to-account" + showRepostsFlag = "show-reposts" + notifyFlag = "notify" ) const ( - accountResource = "account" - instanceResource = "instance" - listResource = "list" - statusResource = "status" - timelineResource = "timeline" + accountResource = "account" + instanceResource = "instance" + listResource = "list" + statusResource = "status" + timelineResource = "timeline" + followersResource = "followers" + followingResource = "following" ) type Executor interface { @@ -57,6 +61,8 @@ func run() error { whoami string = "whoami" add string = "add" remove string = "remove" + follow string = "follow" + unfollow string = "unfollow" ) summaries := map[string]string{ @@ -70,6 +76,8 @@ func run() error { whoami: "print the account that you are currently logged in to", add: "add a resource to another resource", remove: "remove a resource from another resource", + follow: "follow a resource (e.g. an account)", + unfollow: "unfollow a resource (e.g. an account)", } flag.Usage = enbasUsageFunc(summaries) @@ -108,6 +116,10 @@ func run() error { executor = newAddCommand(add, summaries[add]) case remove: executor = newRemoveCommand(remove, summaries[remove]) + case follow: + executor = newFollowCommand(follow, summaries[follow], false) + case unfollow: + executor = newFollowCommand(unfollow, summaries[unfollow], true) default: flag.Usage() diff --git a/cmd/enbas/show.go b/cmd/enbas/show.go index 8d21d92..15e987d 100644 --- a/cmd/enbas/show.go +++ b/cmd/enbas/show.go @@ -12,14 +12,16 @@ import ( type showCommand struct { *flag.FlagSet - myAccount bool - resourceType string - account string - statusID string - timelineCategory string - listID string - tag string - timelineLimit int + myAccount bool + showAccountRelationship bool + resourceType string + account string + accountID string + statusID string + timelineCategory string + listID string + tag string + limit int } func newShowCommand(name, summary string) *showCommand { @@ -28,13 +30,15 @@ func newShowCommand(name, summary string) *showCommand { } command.BoolVar(&command.myAccount, myAccountFlag, false, "set to true to lookup your account") + command.BoolVar(&command.showAccountRelationship, "show-account-relationship", false, "show your relationship to the specified account") command.StringVar(&command.resourceType, resourceTypeFlag, "", "specify the type of resource to display") command.StringVar(&command.account, accountFlag, "", "specify the account URI to lookup") + command.StringVar(&command.accountID, accountIDFlag, "", "specify the account ID") command.StringVar(&command.statusID, statusIDFlag, "", "specify the ID of the status to display") command.StringVar(&command.timelineCategory, timelineCategoryFlag, "home", "specify the type of timeline to display (valid values are home, public, list and tag)") command.StringVar(&command.listID, listIDFlag, "", "specify the ID of the list to display") command.StringVar(&command.tag, tagFlag, "", "specify the name of the tag to use") - command.IntVar(&command.timelineLimit, timelineLimitFlag, 5, "specify the number of statuses to display") + command.IntVar(&command.limit, limitFlag, 20, "specify the limit of items to display") command.Usage = commandUsageFunc(name, summary, command.FlagSet) @@ -47,11 +51,13 @@ func (c *showCommand) Execute() error { } funcMap := map[string]func(*client.Client) error{ - instanceResource: c.showInstance, - accountResource: c.showAccount, - statusResource: c.showStatus, - timelineResource: c.showTimeline, - listResource: c.showList, + instanceResource: c.showInstance, + accountResource: c.showAccount, + statusResource: c.showStatus, + timelineResource: c.showTimeline, + listResource: c.showList, + followersResource: c.showFollowers, + followingResource: c.showFollowing, } doFunc, ok := funcMap[c.resourceType] @@ -103,6 +109,15 @@ func (c *showCommand) showAccount(gts *client.Client) error { fmt.Println(account) + if c.showAccountRelationship { + relationship, err := gts.GetAccountRelationship(account.ID) + if err != nil { + return fmt.Errorf("unable to retrieve the relationship to this account; %w", err) + } + + fmt.Println(relationship) + } + return nil } @@ -129,21 +144,21 @@ func (c *showCommand) showTimeline(gts *client.Client) error { switch c.timelineCategory { case "home": - timeline, err = gts.GetHomeTimeline(c.timelineLimit) + timeline, err = gts.GetHomeTimeline(c.limit) case "public": - timeline, err = gts.GetPublicTimeline(c.timelineLimit) + timeline, err = gts.GetPublicTimeline(c.limit) case "list": if c.listID == "" { return flagNotSetError{flagText: listIDFlag} } - timeline, err = gts.GetListTimeline(c.listID, c.timelineLimit) + timeline, err = gts.GetListTimeline(c.listID, c.limit) case "tag": if c.tag == "" { return flagNotSetError{flagText: tagFlag} } - timeline, err = gts.GetTagTimeline(c.tag, c.timelineLimit) + timeline, err = gts.GetTagTimeline(c.tag, c.limit) default: return invalidTimelineCategoryError{category: c.timelineCategory} } @@ -183,6 +198,7 @@ func (c *showCommand) showList(gts *client.Client) error { for i := range accounts { accountMap[accounts[i].ID] = accounts[i].Username } + list.Accounts = accountMap } @@ -208,3 +224,41 @@ func (c *showCommand) showLists(gts *client.Client) error { return nil } + +func (c *showCommand) showFollowers(gts *client.Client) error { + if c.accountID == "" { + return flagNotSetError{flagText: accountIDFlag} + } + + followers, err := gts.GetFollowers(c.accountID, c.limit) + if err != nil { + return fmt.Errorf("unable to retrieve the list of followers; %w", err) + } + + if len(followers) > 0 { + fmt.Println(followers) + } else { + fmt.Println("There are no followers for this account or the list is hidden.") + } + + return nil +} + +func (c *showCommand) showFollowing(gts *client.Client) error { + if c.accountID == "" { + return flagNotSetError{flagText: accountIDFlag} + } + + following, err := gts.GetFollowing(c.accountID, c.limit) + if err != nil { + return fmt.Errorf("unable to retrieve the list of followed accounts; %w", err) + } + + if len(following) > 0 { + fmt.Println(following) + } else { + fmt.Println("This account is not following anyone or the list is hidden.") + } + + return nil +} diff --git a/internal/client/accounts.go b/internal/client/accounts.go index 6344fdf..2195154 100644 --- a/internal/client/accounts.go +++ b/internal/client/accounts.go @@ -32,3 +32,20 @@ func (g *Client) GetAccount(accountURI string) (model.Account, error) { return account, nil } + +func (g *Client) GetAccountRelationship(accountID string) (model.AccountRelationship, error) { + path := "/api/v1/accounts/relationships?id=" + accountID + url := g.Authentication.Instance + path + + var relationships []model.AccountRelationship + + if err := g.sendRequest(http.MethodGet, url, nil, &relationships); err != nil { + return model.AccountRelationship{}, fmt.Errorf("received an error after sending the request to get the account relationship; %w", err) + } + + if len(relationships) != 1 { + return model.AccountRelationship{}, fmt.Errorf("unexpected number of account relationships returned; want 1, got %d", len(relationships)) + } + + return relationships[0], nil +} diff --git a/internal/client/follow.go b/internal/client/follow.go new file mode 100644 index 0000000..cc3e416 --- /dev/null +++ b/internal/client/follow.go @@ -0,0 +1,70 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "codeflow.dananglin.me.uk/apollo/enbas/internal/model" +) + +func (g *Client) FollowAccount(accountID string, reblogs, notify bool) error { + form := struct { + ID string `json:"id"` + Reblogs bool `json:"reblogs"` + Notify bool `json:"notify"` + }{ + ID: accountID, + Reblogs: reblogs, + Notify: notify, + } + + data, err := json.Marshal(form) + if err != nil { + return fmt.Errorf("unable to marshal the form; %w", err) + } + + requestBody := bytes.NewBuffer(data) + url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/follow", accountID) + + if err := g.sendRequest(http.MethodPost, url, requestBody, nil); err != nil { + return fmt.Errorf("received an error after sending the follow request; %w", err) + } + + return nil +} + +func (g *Client) UnfollowAccount(accountID string) error { + url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/unfollow", accountID) + + if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil { + return fmt.Errorf("received an error after sending the request to unfollow the account; %w", err) + } + + return nil +} + +func (g *Client) GetFollowers(accountID string, limit int) (model.Followers, error) { + url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/followers?limit=%d", accountID, limit) + + var followers model.Followers + + if err := g.sendRequest(http.MethodGet, url, nil, &followers); err != nil { + return nil, fmt.Errorf("received an error after sending the request to get the list of followers; %w", err) + } + + return followers, nil +} + +func (g *Client) GetFollowing(accountID string, limit int) (model.Following, error) { + url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/following?limit=%d", accountID, limit) + + var following model.Following + + if err := g.sendRequest(http.MethodGet, url, nil, &following); err != nil { + return nil, fmt.Errorf("received an error after sending the request to get the list of followed accounts; %w", err) + } + + return following, nil +} diff --git a/internal/model/account_relationship.go b/internal/model/account_relationship.go new file mode 100644 index 0000000..a7e0d9b --- /dev/null +++ b/internal/model/account_relationship.go @@ -0,0 +1,70 @@ +package model + +import ( + "fmt" + + "codeflow.dananglin.me.uk/apollo/enbas/internal/utilities" +) + +type AccountRelationship struct { + ID string `json:"id"` + PrivateNote string `json:"note"` + BlockedBy bool `json:"blocked_by"` + Blocking bool `json:"blocking"` + DomainBlocking bool `json:"domain_blocking"` + Endorsed bool `json:"endorsed"` + FollowedBy bool `json:"followed_by"` + Following bool `json:"following"` + Muting bool `json:"muting"` + MutingNotifications bool `json:"muting_notifications"` + Notifying bool `json:"notifying"` + FollowRequested bool `json:"requested"` + FollowRequestedBy bool `json:"requested_by"` + ShowingReblogs bool `json:"showing_reblogs"` +} + +func (a AccountRelationship) String() string { + format := ` +%s + %s: %t + %s: %t + %s: %t + %s: %t + %s: %t + %s: %t + %s: %t + %s: %t + %s: %t + %s: %t + %s: %t` + + privateNoteFormat := ` +%s + %s` + + output := fmt.Sprintf( + format, + utilities.HeaderFormat("YOUR RELATIONSHIP WITH THIS ACCOUNT:"), + utilities.FieldFormat("Following"), a.Following, + utilities.FieldFormat("Is following you"), a.FollowedBy, + utilities.FieldFormat("A follow request was sent and is pending"), a.FollowRequested, + utilities.FieldFormat("Received a pending follow request"), a.FollowRequestedBy, + utilities.FieldFormat("Endorsed"), a.Endorsed, + utilities.FieldFormat("Showing Reposts (boosts)"), a.ShowingReblogs, + utilities.FieldFormat("Muted"), a.Muting, + utilities.FieldFormat("Notifications muted"), a.MutingNotifications, + utilities.FieldFormat("Blocking"), a.Blocking, + utilities.FieldFormat("Is blocking you"), a.BlockedBy, + utilities.FieldFormat("Blocking account's domain"), a.DomainBlocking, + ) + + if a.PrivateNote != "" { + output += fmt.Sprintf( + privateNoteFormat, + utilities.HeaderFormat("YOUR PRIVATE NOTE ABOUT THIS ACCOUNT:"), + utilities.WrapLines(a.PrivateNote, "\n ", 80), + ) + } + + return output +} diff --git a/internal/model/follows.go b/internal/model/follows.go new file mode 100644 index 0000000..18beeed --- /dev/null +++ b/internal/model/follows.go @@ -0,0 +1,41 @@ +package model + +import ( + "fmt" + + "codeflow.dananglin.me.uk/apollo/enbas/internal/utilities" +) + +type Followers []Account + +func (f Followers) String() string { + output := "\n" + output += utilities.HeaderFormat("FOLLOWED BY:") + + for i := range f { + output += fmt.Sprintf( + "\n • %s (%s)", + utilities.DisplayNameFormat(f[i].DisplayName), + f[i].Acct, + ) + } + + return output +} + +type Following []Account + +func (f Following) String() string { + output := "\n" + output += utilities.HeaderFormat("FOLLOWING:") + + for i := range f { + output += fmt.Sprintf( + "\n • %s (%s)", + utilities.DisplayNameFormat(f[i].DisplayName), + f[i].Acct, + ) + } + + return output +}