feat: Follow and unfollow accounts #8

Manually merged
dananglin merged 1 commit from follow-and-unfollow-accounts into main 2024-05-20 17:35:50 +01:00
7 changed files with 369 additions and 24 deletions
Showing only changes of commit 1a95384ba0 - Show all commits

81
cmd/enbas/follow.go Normal file
View file

@ -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
}

View file

@ -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()

View file

@ -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
}

View file

@ -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
}

70
internal/client/follow.go Normal file
View file

@ -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
}

View file

@ -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
}

41
internal/model/follows.go Normal file
View file

@ -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
}