diff options
| author | Xe <me@christine.website> | 2022-11-21 20:57:30 -0500 |
|---|---|---|
| committer | Xe <me@christine.website> | 2022-11-21 20:57:30 -0500 |
| commit | bec049cf7752382b2894c9526612686bbc15ed47 (patch) | |
| tree | 684161f44ef70bb4a88924a8d0d40ec24d91d9d3 | |
| parent | a1aa37164038beb230dea8ad250be9539b7647f8 (diff) | |
| download | x-bec049cf7752382b2894c9526612686bbc15ed47.tar.xz x-bec049cf7752382b2894c9526612686bbc15ed47.zip | |
basic mastodon client implemented, will do more later
Signed-off-by: Xe <me@christine.website>
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | web/error.go | 5 | ||||
| -rw-r--r-- | web/mastodon/LICENSE | 22 | ||||
| -rw-r--r-- | web/mastodon/client.go | 107 | ||||
| -rw-r--r-- | web/mastodon/doc.go | 4 | ||||
| -rw-r--r-- | web/mastodon/examples/authenticated.go | 66 | ||||
| -rw-r--r-- | web/mastodon/examples/mkapp.go | 84 | ||||
| -rw-r--r-- | web/mastodon/oauth2.go | 115 | ||||
| -rw-r--r-- | web/mastodon/status.go | 68 | ||||
| -rw-r--r-- | web/mastodon/types.go | 299 | ||||
| -rw-r--r-- | web/mastodon/websocket.go | 104 |
11 files changed, 871 insertions, 5 deletions
@@ -48,6 +48,7 @@ require ( golang.org/x/net v0.0.0-20221002022538-bcab6841153b golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 gopkg.in/tucnak/telebot.v2 v2.0.0-20190415090633-8c1c512262f2 + nhooyr.io/websocket v1.8.7 tailscale.com v1.32.2 tulpa.dev/cadey/jvozba v0.0.0-20200326200349-f0ebe310be06 within.website/johaus v1.1.0 @@ -145,5 +146,4 @@ require ( google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.28.0 // indirect gvisor.dev/gvisor v0.0.0-20220817001344-846276b3dbc5 // indirect - nhooyr.io/websocket v1.8.7 // indirect ) diff --git a/web/error.go b/web/error.go index d5ff31a..923b29e 100644 --- a/web/error.go +++ b/web/error.go @@ -22,10 +22,7 @@ func NewError(wantStatusCode int, resp *http.Response) error { } resp.Body.Close() - loc, err := resp.Location() - if err != nil { - return err - } + loc := resp.Request.URL return &Error{ WantStatus: wantStatusCode, diff --git a/web/mastodon/LICENSE b/web/mastodon/LICENSE new file mode 100644 index 0000000..f0f42d2 --- /dev/null +++ b/web/mastodon/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2017 Ollivier Robert +Copyright (c) 2017 Mikael Berthe <mikael@lilotux.net> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/web/mastodon/client.go b/web/mastodon/client.go new file mode 100644 index 0000000..bdcd8ec --- /dev/null +++ b/web/mastodon/client.go @@ -0,0 +1,107 @@ +package mastodon + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "within.website/x/web" + "within.website/x/web/useragent" +) + +// Client is the client for Mastodon +type Client struct { + cli *http.Client + server *url.URL + token string +} + +// Unauthenticated makes a new unauthenticated Mastodon client. +func Unauthenticated(botName, botURL, instanceURL string) (*Client, error) { + u, err := url.Parse(instanceURL) + if err != nil { + return nil, err + } + + return &Client{ + cli: &http.Client{Transport: useragent.Transport(botName, botURL, http.DefaultTransport)}, + server: u, + }, nil +} + +// Authenticated makes a new authenticated Mastodon client. +func Authenticated(botName, botURL, instanceURL, token string) (*Client, error) { + u, err := url.Parse(instanceURL) + if err != nil { + return nil, err + } + + return &Client{ + cli: &http.Client{ + Transport: useragent.Transport(botName, botURL, authTransport{token, http.DefaultTransport}), + }, + server: u, + token: token, + }, nil +} + +type authTransport struct { + bearerToken string + next http.RoundTripper +} + +var ( + _ http.RoundTripper = &authTransport{} +) + +func (at authTransport) RoundTrip(r *http.Request) (*http.Response, error) { + r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.bearerToken)) + + return at.next.RoundTrip(r) +} + +func (c *Client) doJSONPost(ctx context.Context, path string, wantCode int, data any) (*http.Response, error) { + h := http.Header{} + h.Set("Content-Type", "application/json") + h.Set("Accept", "application/json") + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(data); err != nil { + return nil, err + } + + return c.doRequest(ctx, http.MethodPost, "/api/v1/apps", h, http.StatusOK, &buf) +} + +func (c *Client) doRequest(ctx context.Context, method, path string, headers http.Header, wantCode int, body io.Reader) (*http.Response, error) { + u, err := c.server.Parse(path) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, u.String(), body) + if err != nil { + return nil, fmt.Errorf("mastodon: can't make request: %w", err) + } + + for key, hn := range headers { + for _, hv := range hn { + req.Header.Set(key, hv) + } + } + + resp, err := c.cli.Do(req) + if err != nil { + return nil, fmt.Errorf("mastodon: HTTP response error: %w", err) + } + + if resp.StatusCode != wantCode { + return nil, web.NewError(wantCode, resp) + } + + return resp, nil +} diff --git a/web/mastodon/doc.go b/web/mastodon/doc.go new file mode 100644 index 0000000..311da4f --- /dev/null +++ b/web/mastodon/doc.go @@ -0,0 +1,4 @@ +// Package mastodon is an API client for Mastodon[1], specifically designed for making bots. +// +// [1]: https://docs.joinmastodon.org/ +package mastodon diff --git a/web/mastodon/examples/authenticated.go b/web/mastodon/examples/authenticated.go new file mode 100644 index 0000000..478c587 --- /dev/null +++ b/web/mastodon/examples/authenticated.go @@ -0,0 +1,66 @@ +//go:build ignore + +package main + +import ( + "bufio" + "context" + "fmt" + "log" + "os" + "strings" + + "within.website/x/web/mastodon" +) + +func promptInput(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + fmt.Fprint(os.Stderr, ": ") + reader := bufio.NewReader(os.Stdin) + // ReadString will block until the delimiter is entered + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + + // remove the delimeter from the string + input = strings.TrimSuffix(input, "\n") + return input, nil +} + +func main() { + instance, err := promptInput("URL of the mastodon server (incl https://)") + if err != nil { + log.Fatal(err) + } + + token, err := promptInput("Mastodon token") + if err != nil { + log.Fatal(err) + } + + cli, err := mastodon.Authenticated("Xe/x test", "https://within.website/.x.botinfo", instance, token) + if err != nil { + log.Fatal(err) + } + + if err := cli.VerifyCredentials(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println("your token works!") + + statusText, err := promptInput("toot") + if err != nil { + log.Fatal(err) + } + + st, err := cli.CreateStatus(context.Background(), mastodon.CreateStatusParams{ + Status: statusText, + }) + if err != nil { + log.Fatal(err) + } + + log.Println(st.URL) +} diff --git a/web/mastodon/examples/mkapp.go b/web/mastodon/examples/mkapp.go new file mode 100644 index 0000000..04dd666 --- /dev/null +++ b/web/mastodon/examples/mkapp.go @@ -0,0 +1,84 @@ +//go:build ignore + +package main + +import ( + "bufio" + "context" + "fmt" + "log" + "os" + "strings" + "time" + + "within.website/x/web/mastodon" +) + +func promptInput(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + fmt.Fprint(os.Stderr, ": ") + reader := bufio.NewReader(os.Stdin) + // ReadString will block until the delimiter is entered + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + + // remove the delimeter from the string + input = strings.TrimSuffix(input, "\n") + return input, nil +} + +func main() { + instance, err := promptInput("URL of the mastodon server (incl https://)") + if err != nil { + log.Fatal(err) + } + + cli, err := mastodon.Unauthenticated("Xe/x test", "https://within.website/.x.botinfo", instance) + if err != nil { + log.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + app, err := cli.CreateApplication(ctx, mastodon.CreateApplicationRequest{ + ClientName: "Xe/x test", + RedirectURIs: "urn:ietf:wg:oauth:2.0:oob", // default if not set + Scopes: "read write follow push", // default if not set + Website: "https://within.website/.x.botinfo", + }) + + if err != nil { + log.Fatal(err) + } + + authURL, err := cli.AuthorizeURL(app, "read write follow push") + if err != nil { + log.Fatal(err) + } + + fmt.Println(authURL) + + code, err := promptInput("please paste the code") + if err != nil { + log.Fatal(err) + } + + tokenInfo, err := cli.FetchToken(ctx, app, code, "read write follow push") + if err != nil { + log.Fatal(err) + } + + cli, err = mastodon.Authenticated("Xe/x test", "https://within.website/.x.botinfo", instance, tokenInfo.AccessToken) + if err != nil { + log.Fatal(err) + } + + if err := cli.VerifyCredentials(ctx); err != nil { + log.Fatal(err) + } + + fmt.Printf("MASTODON_CLIENT_ID=%s\nMASTODON_CLIENT_SECRET=%s\nMASTODON_INSTANCE=%s\nMASTODON_TOKEN=%s", app.ClientID, app.ClientSecret, instance, tokenInfo.AccessToken) +} diff --git a/web/mastodon/oauth2.go b/web/mastodon/oauth2.go new file mode 100644 index 0000000..73944be --- /dev/null +++ b/web/mastodon/oauth2.go @@ -0,0 +1,115 @@ +package mastodon + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + + "within.website/x/web" +) + +type CreateApplicationRequest struct { + ClientName string `json:"client_name"` + RedirectURIs string `json:"redirect_uris"` + Scopes string `json:"scopes"` + Website string `json:"website"` +} + +type OAuth2Application struct { + ID string `json:"id"` + Name string `json:"name"` + Website string `json:"website"` + RedirectURI string `json:"redirect_uri"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + VapidKey string `json:"vapid_key"` +} + +type TokenInfo struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + CreatedAt MastodonDate `json:"created_at"` +} + +func (c *Client) CreateApplication(ctx context.Context, car CreateApplicationRequest) (*OAuth2Application, error) { + if car.RedirectURIs == "" { + car.RedirectURIs = "urn:ietf:wg:oauth:2.0:oob" + } + + if car.Scopes == "" { + car.Scopes = "read write follow push" + } + + resp, err := c.doJSONPost(ctx, "/api/v1/apps", http.StatusOK, car) + if err != nil { + return nil, err + } + + var result OAuth2Application + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} + +func (c *Client) AuthorizeURL(app *OAuth2Application, scope string) (string, error) { + u, err := c.server.Parse("/oauth/authorize") + if err != nil { + return "", err + } + + q := u.Query() + q.Set("client_id", app.ClientID) + q.Set("scope", scope) + q.Set("redirect_uri", app.RedirectURI) + q.Set("response_type", "code") + + u.RawQuery = q.Encode() + + return u.String(), nil +} + +func (c *Client) FetchToken(ctx context.Context, app *OAuth2Application, code, scope string) (*TokenInfo, error) { + u, err := c.server.Parse("/oauth/token") + if err != nil { + return nil, err + } + + form := url.Values{} + + form.Set("client_id", app.ClientID) + form.Set("client_secret", app.ClientSecret) + form.Set("redirect_uri", app.RedirectURI) + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("scope", scope) + + resp, err := c.cli.PostForm(u.String(), form) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, web.NewError(http.StatusOK, resp) + } + + defer resp.Body.Close() + + var result TokenInfo + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func (c *Client) VerifyCredentials(ctx context.Context) error { + h := http.Header{} + h.Set("Accept", "application/json") + _, err := c.doRequest(ctx, http.MethodGet, "/api/v1/apps/verify_credentials", h, http.StatusOK, nil) + return err +} diff --git a/web/mastodon/status.go b/web/mastodon/status.go new file mode 100644 index 0000000..fb95fb0 --- /dev/null +++ b/web/mastodon/status.go @@ -0,0 +1,68 @@ +package mastodon + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "time" +) + +type CreateStatusParams struct { + Status string `json:"status"` + InReplyTo string `json:"in_reply_to_id"` + MediaIDs []string `json:"media_ids"` + SpoilerText string `json:"spoiler_text"` + Visibility string `json:"visibility"` + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` +} + +func (csp CreateStatusParams) Values() url.Values { + + result := url.Values{} + + result.Set("status", csp.Status) + + if csp.Visibility != "" { + result.Set("visibility", csp.Visibility) + } + + if csp.InReplyTo != "" { + result.Set("in_reply_to_id", csp.InReplyTo) + } + + if csp.SpoilerText != "" { + result.Set("spoiler_text", csp.SpoilerText) + } + + for i, id := range csp.MediaIDs { + qID := fmt.Sprintf("[%d]media_ids", i) + result.Set(qID, id) + } + + return result +} + +func (c *Client) CreateStatus(ctx context.Context, csp CreateStatusParams) (*Status, error) { + vals := csp.Values() + + u, err := c.server.Parse("/api/v1/statuses") + if err != nil { + return nil, err + } + + resp, err := c.cli.PostForm(u.String(), vals) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var result Status + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + return nil, err + } + + return &result, nil +} diff --git a/web/mastodon/types.go b/web/mastodon/types.go new file mode 100644 index 0000000..d225d59 --- /dev/null +++ b/web/mastodon/types.go @@ -0,0 +1,299 @@ +package mastodon + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// types copied from here: https://github.com/McKael/madon/blob/master/types.go + +// MastodonDate is a custom type for the timestamps returned by some API calls +// It is used, for example, by 'v1/instance/activity' and 'v2/search'. +// The date returned by those Mastodon API calls is a string containing a +// timestamp in seconds +type MastodonDate struct { + time.Time +} + +// UnmarshalJSON handles deserialization for custom MastodonDate type +func (act *MastodonDate) UnmarshalJSON(b []byte) error { + s, err := strconv.ParseInt(strings.Trim(string(b), "\""), 10, 64) + if err != nil { + return err + } + if s == 0 { + act.Time = time.Unix(0, 0) + return nil + } + act.Time = time.Unix(s, 0) + return nil +} + +// MarshalJSON handles serialization for custom MastodonDate type +func (act *MastodonDate) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf("\"%d\"", act.Unix())), nil +} + +// DomainName is a domain name string, as returned by the domain_blocks API +type DomainName string + +// InstancePeer is a peer name, as returned by the instance/peers API +type InstancePeer string + +// Account represents a Mastodon account entity +type Account struct { + ID string `json:"id"` + Username string `json:"username"` + Acct string `json:"acct"` + DisplayName string `json:"display_name"` + Note string `json:"note"` + URL string `json:"url"` + Avatar string `json:"avatar"` + AvatarStatic string `json:"avatar_static"` + Header string `json:"header"` + HeaderStatic string `json:"header_static"` + Locked bool `json:"locked"` + CreatedAt time.Time `json:"created_at"` + FollowersCount int64 `json:"followers_count"` + FollowingCount int64 `json:"following_count"` + StatusesCount int64 `json:"statuses_count"` + Moved *Account `json:"moved"` + Bot bool `json:"bot"` + Emojis []Emoji `json:"emojis"` + Fields *[]Field `json:"fields"` + Source *SourceParams `json:"source"` +} + +// Announcement is a single server announcement. +type Announcement struct { + ID string `json:"id"` + Content string `json:"content"` + StartsAt *time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + AllDay bool `json:"all_day"` + PublishedAt time.Time `json:"published_at"` + UpdatedAt *time.Time `json:"updated_at"` + Read bool `json:"read"` + + // TODO(Xe): handle mentions, status, tags, emojis, reactions +} + +// Application represents a Mastodon application entity +type Application struct { + Name string `json:"name"` + Website string `json:"website"` +} + +// Attachment represents a Mastodon media attachment entity +type Attachment struct { + ID string `json:"id"` + Type string `json:"type"` + URL string `json:"url"` + RemoteURL *string `json:"remote_url"` + PreviewURL string `json:"preview_url"` + TextURL *string `json:"text_url"` + Meta *struct { + Original struct { + Size string `json:"size"` + Aspect float64 `json:"aspect"` + Width int `json:"width"` + Height int `json:"height"` + } `json:"original"` + Small struct { + Size string `json:"size"` + Aspect float64 `json:"aspect"` + Width int `json:"width"` + Height int `json:"height"` + } `json:"small"` + } `json:"meta"` + Description *string `json:"description"` +} + +// Card represents a Mastodon preview card entity +type Card struct { + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + Image string `json:"image"` + Type *string `json:"type"` + AuthorName *string `json:"author_name"` + AuthorURL *string `json:"author_url"` + ProviderName *string `json:"provider_name"` + ProviderURL *string `json:"provider_url"` + EmbedURL *string `json:"embed_url"` + HTML *string `json:"html"` + Width *int `json:"width"` + Height *int `json:"height"` +} + +// Context represents a Mastodon context entity +type Context struct { + Ancestors []Status `json:"ancestors"` + Descendants []Status `json:"descendants"` +} + +// Conversation represents a conversation with "direct message" visibility. +type Conversation struct { + ID string `json:"id"` + Unread bool `json:"unread"` + Accounts []Account `json:"accounts"` + LastStatus *Status `json:"status"` +} + +// Emoji represents a Mastodon emoji entity +type Emoji struct { + ShortCode string `json:"shortcode"` + URL string `json:"url"` + StaticURL string `json:"static_url"` + VisibleInPicker bool `json:"visible_in_picker"` +} + +// EmojiReaction represents an emoji reaction to an announcement. +type EmojiReaction struct { + Name string `json:"name"` + Count int `json:"count"` + AnnouncementID string `json:"announcement_id"` +} + +// Error represents a Mastodon error entity +type Error struct { + Text string `json:"error"` +} + +// Instance represents a Mastodon instance entity +type Instance struct { + URI string `json:"uri"` + Title string `json:"title"` + Description string `json:"description"` + Email string `json:"email"` + Version string `json:"version"` + + URLs struct { + SteamingAPI string `json:"streaming_api"` + } `json:"urls"` + Stats struct { + UserCount int64 `json:"user_count"` + StatusCount int64 `json:"status_count"` + DomainCount int64 `json:"domain_count"` + } `json:"stats"` + Thumbnail *string `json:"thumbnail"` + Languages []string `json:"languages"` + ContactAccount *Account `json:"contact_account"` +} + +// List represents a Mastodon list entity +type List struct { + ID string `json:"id"` + Title string `json:"title"` +} + +// Mention represents a Mastodon mention entity +type Mention struct { + ID string `json:"id"` + URL string `json:"url"` + Username string `json:"username"` + Acct string `json:"acct"` +} + +// Notification represents a Mastodon notification entity +type Notification struct { + ID string `json:"id"` + Type string `json:"type"` + CreatedAt time.Time `json:"created_at"` + Account *Account `json:"account"` + Status *Status `json:"status"` +} + +// Relationship represents a Mastodon relationship entity +type Relationship struct { + ID string `json:"id"` + Following bool `json:"following"` + //ShowingReblogs bool `json:"showing_reblogs"` // Incoherent type + FollowedBy bool `json:"followed_by"` + Blocking bool `json:"blocking"` + Muting bool `json:"muting"` + Requested bool `json:"requested"` + DomainBlocking bool `jsin:"domain_blocking"` + MutingNotifications bool `json:"muting_notifications"` + ShowingReblogs bool `json:"showing_reblogs"` + Endorsed bool `json:"endorsed"` +} + +// Report represents a Mastodon report entity +type Report struct { + ID string `json:"id"` + ActionTaken string `json:"action_taken"` +} + +// Results represents a Mastodon search results entity +type Results struct { + Accounts []Account `json:"accounts"` + Statuses []Status `json:"statuses"` + Hashtags []Tag `json:"hashtags"` +} + +// Status represents a Mastodon status entity +type Status struct { + ID string `json:"id"` + URI string `json:"uri"` + URL string `json:"url"` + Account *Account `json:"account"` + InReplyToID *string `json:"in_reply_to_id"` + InReplyToAccountID *string `json:"in_reply_to_account_id"` + Reblog *Status `json:"reblog"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + ReblogsCount int64 `json:"reblogs_count"` + FavouritesCount int64 `json:"favourites_count"` + RepliesCount int64 `json:"replies_count"` + Reblogged bool `json:"reblogged"` + Favourited bool `json:"favourited"` + Muted bool `json:"muted"` + Pinned bool `json:"pinned"` + Sensitive bool `json:"sensitive"` + SpoilerText string `json:"spoiler_text"` + Visibility string `json:"visibility"` + MediaAttachments []Attachment `json:"media_attachments"` + Mentions []Mention `json:"mentions"` + Tags []Tag `json:"tags"` + Emojis []Emoji `json:"emojis"` + Application *Application `json:"application"` + Language *string `json:"language"` +} + +// Tag represents a Mastodon tag entity +type Tag struct { + Name string `json:"name"` + URL string `json:"url"` + History []struct { + Day MastodonDate `json:"day"` + Uses int64 `json:"uses,string"` + Accounts int64 `json:"accounts,string"` + } `json:"history"` +} + +// WeekActivity represents a Mastodon instance activity "week" entity +type WeekActivity struct { + Week MastodonDate `json:"week"` + Statuses int64 `json:"statuses,string"` + Logins int64 `json:"logins,string"` + Registrations int64 `json:"registrations,string"` +} + +// Field is a single field structure +// (Used for the verify_credentials endpoint) +type Field struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// SourceParams is a source params structure +type SourceParams struct { // Used for verify_credentials + Privacy *string `json:"privacy,omitempty"` + Language *string `json:"language,omitempty"` + Sensitive *bool `json:"sensitive,omitempty"` + Note *string `json:"note,omitempty"` + Fields *[]Field `json:"fields,omitempty"` +} diff --git a/web/mastodon/websocket.go b/web/mastodon/websocket.go new file mode 100644 index 0000000..bf6b846 --- /dev/null +++ b/web/mastodon/websocket.go @@ -0,0 +1,104 @@ +package mastodon + +import ( + "context" + "encoding/json" + "net/url" + "time" + + "nhooyr.io/websocket" + "within.website/ln" + "within.website/ln/opname" +) + +// WSSubscribeRequest is a websocket instruction to subscribe to a streaming feed. +type WSSubscribeRequest struct { + Type string `json:"type"` // should be "subscribe" or "unsubscribe" + Stream string `json:"stream"` + Hashtag string `json:"hashtag,omitempty"` +} + +// WSMessage is a websocket message. Whenever you get something from the streaming service, it will fit into this box. +type WSMessage struct { + Stream []string `json:"stream"` + Event string `json:"event"` + Payload string `json:"payload"` // json string +} + +// StreamMessages is a low-level message streaming facility. +func (c *Client) StreamMessages(ctx context.Context, subreq ...WSSubscribeRequest) (chan WSMessage, error) { + result := make(chan WSMessage, 10) + ctx = opname.With(ctx, "websocket-streaming") + + u, err := c.server.Parse("/api/vi/streaming/") + if err != nil { + return nil, err + } + + q := u.Query() + q.Set("access_token", c.token) + u.RawQuery = q.Encode() + + go func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + } + + if err := doWebsocket(ctx, u, result, subreq); err != nil { + ln.Error(ctx, err, ln.Info("websocket error, retrying")) + } + time.Sleep(time.Minute) + } + }(ctx) + + return result, nil +} + +func doWebsocket(ctx context.Context, u *url.URL, result chan WSMessage, subreq []WSSubscribeRequest) error { + conn, _, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{}) + if err != nil { + return err + } + defer conn.Close(websocket.StatusNormalClosure, "doWebsocket function returned") + + for _, sub := range subreq { + data, err := json.Marshal(sub) + if err != nil { + return err + } + err = conn.Write(ctx, websocket.MessageText, data) + if err != nil { + return err + } + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + + default: + } + + msgType, data, err := conn.Read(ctx) + if err != nil { + return err + } + + if msgType != websocket.MessageText { + ln.Log(ctx, ln.Info("got non-text message from mastodon")) + continue + } + + var msg WSMessage + err = json.Unmarshal(data, &msg) + if err != nil { + return err + } + + result <- msg + } +} |
