From ea05294cb5ff6befa35693f3076faf8a2ff4ea27 Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Thu, 30 May 2024 18:46:33 +0100 Subject: [PATCH] feat: add support for posting statuses Users can now post one line statuses. --- internal/client/statuses.go | 36 ++++++++++++ internal/executor/const.go | 13 ++++- internal/executor/create.go | 84 ++++++++++++++++++++++++++- internal/executor/errors.go | 16 +++++ internal/model/const.go | 5 ++ internal/model/list.go | 8 +-- internal/model/status.go | 58 +++++++++--------- internal/model/status_content_type.go | 55 ++++++++++++++++++ internal/model/status_visibility.go | 83 ++++++++++++++++++++++++++ 9 files changed, 322 insertions(+), 36 deletions(-) create mode 100644 internal/model/const.go create mode 100644 internal/model/status_content_type.go create mode 100644 internal/model/status_visibility.go diff --git a/internal/client/statuses.go b/internal/client/statuses.go index b6f3510..57dc85d 100644 --- a/internal/client/statuses.go +++ b/internal/client/statuses.go @@ -1,6 +1,8 @@ package client import ( + "bytes" + "encoding/json" "fmt" "net/http" @@ -22,3 +24,37 @@ func (g *Client) GetStatus(statusID string) (model.Status, error) { return status, nil } + +type CreateStatusForm struct { + Content string `json:"status"` + Language string `json:"language"` + SpoilerText string `json:"spoiler_text"` + Boostable bool `json:"boostable"` + Federated bool `json:"federated"` + Likeable bool `json:"likeable"` + Replyable bool `json:"replyable"` + Sensitive bool `json:"sensitive"` + ContentType model.StatusContentType `json:"content_type"` + Visibility model.StatusVisibility `json:"visibility"` +} + +func (g *Client) CreateStatus(form CreateStatusForm) (model.Status, error) { + data, err := json.Marshal(form) + if err != nil { + return model.Status{}, fmt.Errorf("unable to create the JSON form; %w", err) + } + + requestBody := bytes.NewBuffer(data) + url := g.Authentication.Instance + "/api/v1/statuses" + + var status model.Status + + if err := g.sendRequest(http.MethodPost, url, requestBody, &status); err != nil { + return model.Status{}, fmt.Errorf( + "received an error after sending the request to create the status; %w", + err, + ) + } + + return status, nil +} diff --git a/internal/executor/const.go b/internal/executor/const.go index 126f57e..407973f 100644 --- a/internal/executor/const.go +++ b/internal/executor/const.go @@ -3,23 +3,32 @@ package executor const ( flagAccountName = "account-name" flagBrowser = "browser" + flagContentType = "content-type" flagContent = "content" + flagEnableFederation = "enable-federation" + flagEnableLikes = "enable-likes" + flagEnableReplies = "enable-replies" + flagEnableReposts = "enable-reposts" + flagFrom = "from" flagInstance = "instance" + flagLanguage = "language" flagLimit = "limit" flagListID = "list-id" flagListTitle = "list-title" flagListRepliesPolicy = "list-replies-policy" flagMyAccount = "my-account" flagNotify = "notify" - flagFrom = "from" - flagType = "type" + flagSensitive = "sensitive" flagSkipRelationship = "skip-relationship" flagShowPreferences = "show-preferences" flagShowReposts = "show-reposts" + flagSpoilerText = "spoiler-text" flagStatusID = "status-id" flagTag = "tag" flagTimelineCategory = "timeline-category" flagTo = "to" + flagType = "type" + flagVisibility = "visibility" resourceAccount = "account" resourceBlocked = "blocked" diff --git a/internal/executor/create.go b/internal/executor/create.go index 0dcf6e3..13d306f 100644 --- a/internal/executor/create.go +++ b/internal/executor/create.go @@ -12,9 +12,19 @@ type CreateExecutor struct { *flag.FlagSet topLevelFlags TopLevelFlags + boostable bool + federated bool + likeable bool + replyable bool + sensitive bool + content string + contentType string + language string + spoilerText string resourceType string listTitle string listRepliesPolicy string + visibility string } func NewCreateExecutor(tlf TopLevelFlags, name, summary string) *CreateExecutor { @@ -24,6 +34,16 @@ func NewCreateExecutor(tlf TopLevelFlags, name, summary string) *CreateExecutor topLevelFlags: tlf, } + createExe.BoolVar(&createExe.boostable, flagEnableReposts, true, "specify if the status can be reposted/boosted by others") + createExe.BoolVar(&createExe.federated, flagEnableFederation, true, "specify if the status can be federated beyond the local timelines") + createExe.BoolVar(&createExe.likeable, flagEnableLikes, true, "specify if the status can be liked/favourited") + createExe.BoolVar(&createExe.replyable, flagEnableReplies, true, "specify if the status can be replied to") + createExe.BoolVar(&createExe.sensitive, flagSensitive, false, "specify if the status should be marked as sensitive") + createExe.StringVar(&createExe.content, flagContent, "", "the content of the status to create") + createExe.StringVar(&createExe.contentType, flagContentType, "plain", "the type that the contents should be parsed from (valid values are plain and markdown)") + createExe.StringVar(&createExe.language, flagLanguage, "", "the ISO 639 language code for this status") + createExe.StringVar(&createExe.spoilerText, flagSpoilerText, "", "the text to display as the status' warning or subject") + createExe.StringVar(&createExe.visibility, flagVisibility, "", "the visibility of the posted status") createExe.StringVar(&createExe.resourceType, flagType, "", "specify the type of resource to create") createExe.StringVar(&createExe.listTitle, flagListTitle, "", "specify the title of the list") createExe.StringVar(&createExe.listRepliesPolicy, flagListRepliesPolicy, "list", "specify the policy of the replies for this list (valid values are followed, list and none)") @@ -44,7 +64,8 @@ func (c *CreateExecutor) Execute() error { } funcMap := map[string]func(*client.Client) error{ - resourceList: c.createList, + resourceList: c.createList, + resourceStatus: c.createStatus, } doFunc, ok := funcMap[c.resourceType] @@ -75,3 +96,64 @@ func (c *CreateExecutor) createList(gtsClient *client.Client) error { return nil } + +func (c *CreateExecutor) createStatus(gtsClient *client.Client) error { + if c.content == "" { + return FlagNotSetError{flagText: flagContent} + } + + var ( + language string + visibility string + ) + + preferences, err := gtsClient.GetUserPreferences() + if err != nil { + fmt.Println("WARNING: Unable to get your posting preferences; %w", err) + } + + if c.language != "" { + language = c.language + } else { + language = preferences.PostingDefaultLanguage + } + + if c.visibility != "" { + visibility = c.visibility + } else { + visibility = preferences.PostingDefaultVisibility + } + + parsedVisibility := model.ParseStatusVisibility(visibility) + if parsedVisibility == model.StatusVisibilityUnknown { + return InvalidStatusVisibilityError{Visibility: visibility} + } + + parsedContentType := model.ParseStatusContentType(c.contentType) + if parsedContentType == model.StatusContentTypeUnknown { + return InvalidStatusContentTypeError{ContentType: c.contentType} + } + + form := client.CreateStatusForm{ + Content: c.content, + ContentType: parsedContentType, + Language: language, + SpoilerText: c.spoilerText, + Boostable: c.boostable, + Federated: c.federated, + Likeable: c.likeable, + Replyable: c.replyable, + Sensitive: c.sensitive, + Visibility: parsedVisibility, + } + + status, err := gtsClient.CreateStatus(form) + if err != nil { + return fmt.Errorf("unable to create the status; %w", err) + } + + fmt.Println("Successfully created the following status:") + fmt.Println(status) + + return nil +} diff --git a/internal/executor/errors.go b/internal/executor/errors.go index 0f1e2d5..e27a2f2 100644 --- a/internal/executor/errors.go +++ b/internal/executor/errors.go @@ -53,3 +53,19 @@ type EmptyContentError struct{} func (e EmptyContentError) Error() string { return "content should not be empty" } + +type InvalidStatusVisibilityError struct { + Visibility string +} + +func (e InvalidStatusVisibilityError) Error() string { + return "'" + e.Visibility + "' is an invalid status visibility (valid values are public, unlisted, private, mutuals_only and direct)" +} + +type InvalidStatusContentTypeError struct { + ContentType string +} + +func (e InvalidStatusContentTypeError) Error() string { + return "'" + e.ContentType + "' is an invalid status content type (valid values are plain and markdown)" +} diff --git a/internal/model/const.go b/internal/model/const.go new file mode 100644 index 0000000..c794dcc --- /dev/null +++ b/internal/model/const.go @@ -0,0 +1,5 @@ +package model + +const ( + unknownValue = "unknown" +) diff --git a/internal/model/list.go b/internal/model/list.go index 04c6cbc..d22df3c 100644 --- a/internal/model/list.go +++ b/internal/model/list.go @@ -26,7 +26,7 @@ func ParseListRepliesPolicy(policy string) (ListRepliesPolicy, error) { return ListRepliesPolicyNone, nil } - return ListRepliesPolicy(-1), errors.New("invalid list replies policy") + return ListRepliesPolicy(-1), fmt.Errorf("%q is not a valid list replies policy", policy) } func (l ListRepliesPolicy) String() string { @@ -43,12 +43,12 @@ func (l ListRepliesPolicy) String() string { } func (l ListRepliesPolicy) MarshalJSON() ([]byte, error) { - str := l.String() - if str == "" { + value := l.String() + if value == "" { return nil, errors.New("invalid list replies policy") } - return json.Marshal(str) + return json.Marshal(value) } func (l *ListRepliesPolicy) UnmarshalJSON(data []byte) error { diff --git a/internal/model/status.go b/internal/model/status.go index 1a3f725..011dca8 100644 --- a/internal/model/status.go +++ b/internal/model/status.go @@ -36,7 +36,7 @@ type Status struct { Text string `json:"text"` URI string `json:"uri"` URL string `json:"url"` - Visibility string `json:"visibility"` + Visibility StatusVisibility `json:"visibility"` } type Card struct { @@ -82,34 +82,34 @@ type PollOption struct { } type StatusReblogged struct { - Account Account `json:"account"` - Application Application `json:"application"` - Bookmarked bool `json:"bookmarked"` - Card Card `json:"card"` - Content string `json:"content"` - CreatedAt time.Time `json:"created_at"` - Emojis []Emoji `json:"emojis"` - Favourited bool `json:"favourited"` - FavouritesCount int `json:"favourites_count"` - ID string `json:"id"` - InReplyToAccountID string `json:"in_reply_to_account_id"` - InReplyToID string `json:"in_reply_to_id"` - Language string `json:"language"` - MediaAttachments []Attachment `json:"media_attachments"` - Mentions []Mention `json:"mentions"` - Muted bool `json:"muted"` - Pinned bool `json:"pinned"` - Poll Poll `json:"poll"` - Reblogged bool `json:"reblogged"` - RebloggsCount int `json:"reblogs_count"` - RepliesCount int `json:"replies_count"` - Sensitive bool `json:"sensitive"` - SpolierText string `json:"spoiler_text"` - Tags []Tag `json:"tags"` - Text string `json:"text"` - URI string `json:"uri"` - URL string `json:"url"` - Visibility string `json:"visibility"` + Account Account `json:"account"` + Application Application `json:"application"` + Bookmarked bool `json:"bookmarked"` + Card Card `json:"card"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + Emojis []Emoji `json:"emojis"` + Favourited bool `json:"favourited"` + FavouritesCount int `json:"favourites_count"` + ID string `json:"id"` + InReplyToAccountID string `json:"in_reply_to_account_id"` + InReplyToID string `json:"in_reply_to_id"` + Language string `json:"language"` + MediaAttachments []Attachment `json:"media_attachments"` + Mentions []Mention `json:"mentions"` + Muted bool `json:"muted"` + Pinned bool `json:"pinned"` + Poll Poll `json:"poll"` + Reblogged bool `json:"reblogged"` + RebloggsCount int `json:"reblogs_count"` + RepliesCount int `json:"replies_count"` + Sensitive bool `json:"sensitive"` + SpolierText string `json:"spoiler_text"` + Tags []Tag `json:"tags"` + Text string `json:"text"` + URI string `json:"uri"` + URL string `json:"url"` + Visibility StatusVisibility `json:"visibility"` } type Tag struct { diff --git a/internal/model/status_content_type.go b/internal/model/status_content_type.go new file mode 100644 index 0000000..121c848 --- /dev/null +++ b/internal/model/status_content_type.go @@ -0,0 +1,55 @@ +package model + +import ( + "encoding/json" + "fmt" +) + +type StatusContentType int + +const ( + StatusContentTypePlainText StatusContentType = iota + StatusContentTypeMarkdown + StatusContentTypeUnknown +) + +const ( + statusContentTypeTextPlainValue = "text/plain" + statusContentTypePlainValue = "plain" + statusContentTypeTextMarkdownValue = "text/markdown" + statusContentTypeMarkdownValue = "markdown" +) + +func (s StatusContentType) String() string { + mapped := map[StatusContentType]string{ + StatusContentTypeMarkdown: statusContentTypeTextMarkdownValue, + StatusContentTypePlainText: statusContentTypeTextPlainValue, + } + + output, ok := mapped[s] + if !ok { + return unknownValue + } + + return output +} + +func ParseStatusContentType(value string) StatusContentType { + switch value { + case statusContentTypePlainValue, statusContentTypeTextPlainValue: + return StatusContentTypePlainText + case statusContentTypeMarkdownValue, statusContentTypeTextMarkdownValue: + return StatusContentTypeMarkdown + } + + return StatusContentTypeUnknown +} + +func (s StatusContentType) MarshalJSON() ([]byte, error) { + value := s.String() + if value == unknownValue { + return nil, fmt.Errorf("%q is not a valid status content type", value) + } + + return json.Marshal(value) +} diff --git a/internal/model/status_visibility.go b/internal/model/status_visibility.go new file mode 100644 index 0000000..125b788 --- /dev/null +++ b/internal/model/status_visibility.go @@ -0,0 +1,83 @@ +package model + +import ( + "encoding/json" + "fmt" +) + +type StatusVisibility int + +const ( + StatusVisibilityPublic StatusVisibility = iota + StatusVisibilityPrivate + StatusVisibilityUnlisted + StatusVisibilityMutualsOnly + StatusVisibilityDirect + StatusVisibilityUnknown +) + +const ( + statusVisibilityPublicValue = "public" + statusVisibilityPrivateValue = "private" + statusVisibilityUnlistedValue = "unlisted" + statusVisibilityMutualsOnlyValue = "mutuals_only" + statusVisibilityDirectValue = "direct" +) + +func (s StatusVisibility) String() string { + mapped := map[StatusVisibility]string{ + StatusVisibilityPublic: statusVisibilityPublicValue, + StatusVisibilityPrivate: statusVisibilityPrivateValue, + StatusVisibilityUnlisted: statusVisibilityUnlistedValue, + StatusVisibilityMutualsOnly: statusVisibilityMutualsOnlyValue, + StatusVisibilityDirect: statusVisibilityDirectValue, + } + + output, ok := mapped[s] + if !ok { + return unknownValue + } + + return output +} + +func ParseStatusVisibility(value string) StatusVisibility { + mapped := map[string]StatusVisibility{ + statusVisibilityPublicValue: StatusVisibilityPublic, + statusVisibilityPrivateValue: StatusVisibilityPrivate, + statusVisibilityUnlistedValue: StatusVisibilityUnlisted, + statusVisibilityMutualsOnlyValue: StatusVisibilityMutualsOnly, + statusVisibilityDirectValue: StatusVisibilityDirect, + } + + output, ok := mapped[value] + if !ok { + return StatusVisibilityUnknown + } + + return output +} + +func (s StatusVisibility) MarshalJSON() ([]byte, error) { + value := s.String() + if value == unknownValue { + return nil, fmt.Errorf("%q is not a valid status visibility", value) + } + + return json.Marshal(value) +} + +func (s *StatusVisibility) UnmarshalJSON(data []byte) error { + var ( + value string + err error + ) + + if err = json.Unmarshal(data, &value); err != nil { + return fmt.Errorf("unable to unmarshal the data; %w", err) + } + + *s = ParseStatusVisibility(value) + + return nil +} -- 2.45.2