aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXe <me@christine.website>2022-11-21 20:57:30 -0500
committerXe <me@christine.website>2022-11-21 20:57:30 -0500
commitbec049cf7752382b2894c9526612686bbc15ed47 (patch)
tree684161f44ef70bb4a88924a8d0d40ec24d91d9d3
parenta1aa37164038beb230dea8ad250be9539b7647f8 (diff)
downloadx-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.mod2
-rw-r--r--web/error.go5
-rw-r--r--web/mastodon/LICENSE22
-rw-r--r--web/mastodon/client.go107
-rw-r--r--web/mastodon/doc.go4
-rw-r--r--web/mastodon/examples/authenticated.go66
-rw-r--r--web/mastodon/examples/mkapp.go84
-rw-r--r--web/mastodon/oauth2.go115
-rw-r--r--web/mastodon/status.go68
-rw-r--r--web/mastodon/types.go299
-rw-r--r--web/mastodon/websocket.go104
11 files changed, 871 insertions, 5 deletions
diff --git a/go.mod b/go.mod
index fc9a07b..060ea7c 100644
--- a/go.mod
+++ b/go.mod
@@ -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
+ }
+}