pax_global_header00006660000000000000000000000064146402654450014524gustar00rootroot0000000000000052 comment=bd433add53a35dcd6c50d4a71c6cedaafc7e96a2 madon-3.0.0/000077500000000000000000000000001464026544500126225ustar00rootroot00000000000000madon-3.0.0/.gitignore000066400000000000000000000005501464026544500146120ustar00rootroot00000000000000# Created by .ignore support plugin (hsz.mobi) ### Go template # Binaries for programs and plugins *.exe *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ *.swp .idea/ .local/ madon-3.0.0/LICENSE000066400000000000000000000021461464026544500136320ustar00rootroot00000000000000MIT License Copyright (c) 2017 Ollivier Robert Copyright (c) 2017 Mikael Berthe 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. madon-3.0.0/README.md000066400000000000000000000033631464026544500141060ustar00rootroot00000000000000# madon Golang library for the Mastodon API [![godoc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://godoc.org/github.com/McKael/madon) [![license](https://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://raw.githubusercontent.com/McKael/madon/master/LICENSE) [![Go Report Card](https://goreportcard.com/badge/github.com/McKael/madon)](https://goreportcard.com/report/github.com/McKael/madon) `madon` is a [Go](https://golang.org/) library to access the Mastondon REST API. This implementation covers 100% of the current API, including the streaming API. The [madonctl](https://github.com/McKael/madonctl) console client uses this library exhaustively. ## Installation To install the library (Go >= v1.5 required): go get github.com/McKael/madon For minimal compatibility with Go modules support (in Go v1.11), it is recommended to use Go version 1.9+. You can test it with my CLI tool: go get github.com/McKael/madonctl ## Usage This section has not been written yet (PR welcome). For now please check [godoc](https://godoc.org/github.com/McKael/madon) and check the [madonctl](https://github.com/McKael/madonctl) project implementation. ## History This API implementation was initially submitted as a PR for gondole. The repository is actually a fork of my gondole branch so that history and credits are preserved. ## References - [madonctl](https://github.com/McKael/madonctl) (console client based on madon) - [Mastodon API documentation](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) - [Mastodon Streaming API documentation](https://github.com/tootsuite/documentation/blob/master/Using-the-API/Streaming-API.md) - [Mastodon repository](https://github.com/tootsuite/mastodon) madon-3.0.0/account.go000066400000000000000000000374711464026544500146210ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "os" "path/filepath" "github.com/pkg/errors" "github.com/sendgrid/rest" ) // getAccountsOptions contains option fields for POST and DELETE API calls type getAccountsOptions struct { // The ID is used for most commands ID ActivityID // Following can be set to true to limit a search to "following" accounts Following bool // The Q field (query) is used when searching for accounts Q string Limit *LimitParams } // UpdateAccountParams contains option fields for the UpdateAccount command type UpdateAccountParams struct { DisplayName *string Note *string AvatarImagePath *string HeaderImagePath *string Locked *bool Bot *bool FieldsAttributes *[]Field Source *SourceParams } // updateRelationship returns a Relationship entity // The operation 'op' can be "follow", "unfollow", "block", "unblock", // "mute", "unmute". // The id is optional and depends on the operation. func (mc *Client) updateRelationship(op string, id ActivityID, params apiCallParams) (*Relationship, error) { var endPoint string method := rest.Post switch op { case "follow", "unfollow", "block", "unblock", "mute", "unmute", "pin", "unpin": endPoint = "accounts/" + id + "/" + op default: return nil, ErrInvalidParameter } var rel Relationship if err := mc.apiCall("v1/"+endPoint, method, params, nil, nil, &rel); err != nil { return nil, err } return &rel, nil } // getSingleAccount returns an account entity // The operation 'op' can be "account", "verify_credentials", // "follow_requests/authorize" or // "follow_requests/reject". // The id is optional and depends on the operation. func (mc *Client) getSingleAccount(op string, id ActivityID) (*Account, error) { var endPoint string method := rest.Get switch op { case "account": endPoint = "accounts/" + id case "verify_credentials": endPoint = "accounts/verify_credentials" case "follow_requests/authorize", "follow_requests/reject": // The documentation is incorrect, the endpoint actually // is "follow_requests/:id/{authorize|reject}" endPoint = op[:16] + id + "/" + op[16:] method = rest.Post default: return nil, ErrInvalidParameter } var account Account if err := mc.apiCall("v1/"+endPoint, method, nil, nil, nil, &account); err != nil { return nil, err } return &account, nil } // getMultipleAccounts returns a list of account entities // If lopt.All is true, several requests will be made until the API server // has nothing to return. func (mc *Client) getMultipleAccounts(endPoint string, params apiCallParams, lopt *LimitParams) ([]Account, error) { var accounts []Account var links apiLinks if err := mc.apiCall("v1/"+endPoint, rest.Get, params, lopt, &links, &accounts); err != nil { return nil, err } if lopt != nil { // Fetch more pages to reach our limit for (lopt.All || lopt.Limit > len(accounts)) && links.next != nil { accountSlice := []Account{} newlopt := links.next links = apiLinks{} if err := mc.apiCall("v1/"+endPoint, rest.Get, params, newlopt, &links, &accountSlice); err != nil { return nil, err } accounts = append(accounts, accountSlice...) } } return accounts, nil } // getMultipleAccountsHelper returns a list of account entities // The operation 'op' can be "followers", "following", "search", "blocks", // "mutes", "follow_requests". // The id is optional and depends on the operation. // If opts.All is true, several requests will be made until the API server // has nothing to return. func (mc *Client) getMultipleAccountsHelper(op string, opts *getAccountsOptions) ([]Account, error) { var endPoint string var lopt *LimitParams if opts != nil { lopt = opts.Limit } switch op { case "followers", "following": if opts == nil || opts.ID == "" { return []Account{}, ErrInvalidID } endPoint = "accounts/" + opts.ID + "/" + op case "follow_requests", "blocks", "mutes": endPoint = op case "search": if opts == nil || opts.Q == "" { return []Account{}, ErrInvalidParameter } endPoint = "accounts/" + op case "reblogged_by", "favourited_by": if opts == nil || opts.ID == "" { return []Account{}, ErrInvalidID } endPoint = "statuses/" + opts.ID + "/" + op default: return nil, ErrInvalidParameter } // Handle target-specific query parameters params := make(apiCallParams) if op == "search" { params["q"] = opts.Q if opts.Following { params["following"] = "true" } } return mc.getMultipleAccounts(endPoint, params, lopt) } // GetAccount returns an account entity // The returned value can be nil if there is an error or if the // requested ID does not exist. func (mc *Client) GetAccount(accountID ActivityID) (*Account, error) { account, err := mc.getSingleAccount("account", accountID) if err != nil { return nil, err } if account != nil && account.ID == "" { return nil, ErrEntityNotFound } return account, nil } // GetCurrentAccount returns the current user account func (mc *Client) GetCurrentAccount() (*Account, error) { account, err := mc.getSingleAccount("verify_credentials", "") if err != nil { return nil, err } if account != nil && account.ID == "" { return nil, ErrEntityNotFound } return account, nil } // GetAccountFollowers returns the list of accounts following a given account func (mc *Client) GetAccountFollowers(accountID ActivityID, lopt *LimitParams) ([]Account, error) { o := &getAccountsOptions{ID: accountID, Limit: lopt} return mc.getMultipleAccountsHelper("followers", o) } // GetAccountFollowing returns the list of accounts a given account is following func (mc *Client) GetAccountFollowing(accountID ActivityID, lopt *LimitParams) ([]Account, error) { o := &getAccountsOptions{ID: accountID, Limit: lopt} return mc.getMultipleAccountsHelper("following", o) } // FollowAccount follows an account // 'reblogs' can be used to specify if boots should be displayed or hidden. func (mc *Client) FollowAccount(accountID ActivityID, reblogs *bool) (*Relationship, error) { var params apiCallParams if reblogs != nil { params = make(apiCallParams) if *reblogs { params["reblogs"] = "true" } else { params["reblogs"] = "false" } } rel, err := mc.updateRelationship("follow", accountID, params) if err != nil { return nil, err } if rel == nil { return nil, ErrEntityNotFound } return rel, nil } // UnfollowAccount unfollows an account func (mc *Client) UnfollowAccount(accountID ActivityID) (*Relationship, error) { rel, err := mc.updateRelationship("unfollow", accountID, nil) if err != nil { return nil, err } if rel == nil { return nil, ErrEntityNotFound } return rel, nil } // FollowRemoteAccount follows a remote account // The parameter 'uri' is a URI (e.g. "username@domain"). func (mc *Client) FollowRemoteAccount(uri string) (*Account, error) { if uri == "" { return nil, ErrInvalidID } params := make(apiCallParams) params["uri"] = uri var account Account if err := mc.apiCall("v1/follows", rest.Post, params, nil, nil, &account); err != nil { return nil, err } if account.ID == "" { return nil, ErrEntityNotFound } return &account, nil } // BlockAccount blocks an account func (mc *Client) BlockAccount(accountID ActivityID) (*Relationship, error) { rel, err := mc.updateRelationship("block", accountID, nil) if err != nil { return nil, err } if rel == nil { return nil, ErrEntityNotFound } return rel, nil } // UnblockAccount unblocks an account func (mc *Client) UnblockAccount(accountID ActivityID) (*Relationship, error) { rel, err := mc.updateRelationship("unblock", accountID, nil) if err != nil { return nil, err } if rel == nil { return nil, ErrEntityNotFound } return rel, nil } // MuteAccount mutes an account // Note that with current Mastodon API, muteNotifications defaults to true // when it is not provided. func (mc *Client) MuteAccount(accountID ActivityID, muteNotifications *bool) (*Relationship, error) { var params apiCallParams if muteNotifications != nil { params = make(apiCallParams) if *muteNotifications { params["notifications"] = "true" } else { params["notifications"] = "false" } } rel, err := mc.updateRelationship("mute", accountID, params) if err != nil { return nil, err } if rel == nil { return nil, ErrEntityNotFound } return rel, nil } // UnmuteAccount unmutes an account func (mc *Client) UnmuteAccount(accountID ActivityID) (*Relationship, error) { rel, err := mc.updateRelationship("unmute", accountID, nil) if err != nil { return nil, err } if rel == nil { return nil, ErrEntityNotFound } return rel, nil } // SearchAccounts returns a list of accounts matching the query string // The lopt parameter is optional (can be nil) or can be used to set a limit. func (mc *Client) SearchAccounts(query string, following bool, lopt *LimitParams) ([]Account, error) { o := &getAccountsOptions{Q: query, Limit: lopt, Following: following} return mc.getMultipleAccountsHelper("search", o) } // GetBlockedAccounts returns the list of blocked accounts // The lopt parameter is optional (can be nil). func (mc *Client) GetBlockedAccounts(lopt *LimitParams) ([]Account, error) { o := &getAccountsOptions{Limit: lopt} return mc.getMultipleAccountsHelper("blocks", o) } // GetMutedAccounts returns the list of muted accounts // The lopt parameter is optional (can be nil). func (mc *Client) GetMutedAccounts(lopt *LimitParams) ([]Account, error) { o := &getAccountsOptions{Limit: lopt} return mc.getMultipleAccountsHelper("mutes", o) } // GetAccountFollowRequests returns the list of follow requests accounts // The lopt parameter is optional (can be nil). func (mc *Client) GetAccountFollowRequests(lopt *LimitParams) ([]Account, error) { o := &getAccountsOptions{Limit: lopt} return mc.getMultipleAccountsHelper("follow_requests", o) } // GetAccountRelationships returns a list of relationship entities for the given accounts func (mc *Client) GetAccountRelationships(accountIDs []ActivityID) ([]Relationship, error) { if len(accountIDs) < 1 { return nil, ErrInvalidID } params := make(apiCallParams) for i, id := range accountIDs { if id == "" { return nil, ErrInvalidID } qID := fmt.Sprintf("[%d]id", i) params[qID] = id } var rl []Relationship if err := mc.apiCall("v1/accounts/relationships", rest.Get, params, nil, nil, &rl); err != nil { return nil, err } return rl, nil } // GetAccountStatuses returns a list of status entities for the given account // If onlyMedia is true, returns only statuses that have media attachments. // If onlyPinned is true, returns only statuses that have been pinned. // If excludeReplies is true, skip statuses that reply to other statuses. // If lopt.All is true, several requests will be made until the API server // has nothing to return. // If lopt.Limit is set (and not All), several queries can be made until the // limit is reached. func (mc *Client) GetAccountStatuses(accountID ActivityID, onlyPinned, onlyMedia, excludeReplies bool, lopt *LimitParams) ([]Status, error) { if accountID == "" { return nil, ErrInvalidID } endPoint := "accounts/" + accountID + "/" + "statuses" params := make(apiCallParams) if onlyMedia { params["only_media"] = "true" } if onlyPinned { params["pinned"] = "true" } if excludeReplies { params["exclude_replies"] = "true" } return mc.getMultipleStatuses(endPoint, params, lopt) } // FollowRequestAuthorize authorizes or rejects an account follow-request func (mc *Client) FollowRequestAuthorize(accountID ActivityID, authorize bool) error { endPoint := "follow_requests/reject" if authorize { endPoint = "follow_requests/authorize" } _, err := mc.getSingleAccount(endPoint, accountID) return err } // UpdateAccount updates the connected user's account data // // The fields avatar & headerImage are considered as file paths // and their content will be uploaded. // Please note that currently Mastodon leaks the avatar file name: // https://github.com/tootsuite/mastodon/issues/5776 // // All fields can be nil, in which case they are not updated. // 'DisplayName' and 'Note' can be set to "" to delete previous values. // Setting 'Locked' to true means all followers should be approved. // You can set 'Bot' to true to indicate this is a service (automated) account. // I'm not sure images can be deleted -- only replaced AFAICS. func (mc *Client) UpdateAccount(cmdParams UpdateAccountParams) (*Account, error) { const endPoint = "accounts/update_credentials" params := make(apiCallParams) if cmdParams.DisplayName != nil { params["display_name"] = *cmdParams.DisplayName } if cmdParams.Note != nil { params["note"] = *cmdParams.Note } if cmdParams.Locked != nil { if *cmdParams.Locked { params["locked"] = "true" } else { params["locked"] = "false" } } if cmdParams.Bot != nil { if *cmdParams.Bot { params["bot"] = "true" } else { params["bot"] = "false" } } if cmdParams.FieldsAttributes != nil { if len(*cmdParams.FieldsAttributes) > 4 { return nil, errors.New("too many fields (max=4)") } for i, attr := range *cmdParams.FieldsAttributes { qName := fmt.Sprintf("fields_attributes[%d][name]", i) qValue := fmt.Sprintf("fields_attributes[%d][value]", i) params[qName] = attr.Name params[qValue] = attr.Value } } if cmdParams.Source != nil { s := cmdParams.Source if s.Privacy != nil { params["source[privacy]"] = *s.Privacy } if s.Language != nil { params["source[language]"] = *s.Language } if s.Sensitive != nil { params["source[sensitive]"] = fmt.Sprintf("%v", *s.Sensitive) } } var err error var avatar, headerImage []byte avatar, err = readFile(cmdParams.AvatarImagePath) if err != nil { return nil, err } headerImage, err = readFile(cmdParams.HeaderImagePath) if err != nil { return nil, err } var formBuf bytes.Buffer w := multipart.NewWriter(&formBuf) if avatar != nil { formWriter, err := w.CreateFormFile("avatar", filepath.Base(*cmdParams.AvatarImagePath)) if err != nil { return nil, errors.Wrap(err, "avatar upload") } formWriter.Write(avatar) } if headerImage != nil { formWriter, err := w.CreateFormFile("header", filepath.Base(*cmdParams.HeaderImagePath)) if err != nil { return nil, errors.Wrap(err, "header upload") } formWriter.Write(headerImage) } for k, v := range params { fw, err := w.CreateFormField(k) if err != nil { return nil, errors.Wrapf(err, "form field: %s", k) } n, err := io.WriteString(fw, v) if err != nil { return nil, errors.Wrapf(err, "writing field: %s", k) } if n != len(v) { return nil, errors.Wrapf(err, "partial field: %s", k) } } w.Close() // Prepare the request req, err := mc.prepareRequest("v1/"+endPoint, rest.Patch, params) if err != nil { return nil, errors.Wrap(err, "prepareRequest failed") } req.Headers["Content-Type"] = w.FormDataContentType() req.Body = formBuf.Bytes() // Make API call r, err := restAPI(req) if err != nil { return nil, errors.Wrap(err, "account update failed") } // Check for error reply var errorResult Error if err := json.Unmarshal([]byte(r.Body), &errorResult); err == nil { // The empty object is not an error if errorResult.Text != "" { return nil, errors.New(errorResult.Text) } } // Not an error reply; let's unmarshal the data var account Account if err := json.Unmarshal([]byte(r.Body), &account); err != nil { return nil, errors.Wrap(err, "cannot decode API response") } return &account, nil } // readFile is a helper function to read a file's contents. func readFile(filename *string) ([]byte, error) { if filename == nil || *filename == "" { return nil, nil } file, err := os.Open(*filename) if err != nil { return nil, err } defer file.Close() fStat, err := file.Stat() if err != nil { return nil, err } buffer := make([]byte, fStat.Size()) _, err = file.Read(buffer) if err != nil { return nil, err } return buffer, nil } madon-3.0.0/api.go000066400000000000000000000164501464026544500137300ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Copyright 2017 Ollivier Robert Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "regexp" "strconv" "strings" "time" "github.com/pkg/errors" "github.com/sendgrid/rest" ) type apiLinks struct { next, prev *LimitParams } func parseLink(links []string) (*apiLinks, error) { if len(links) == 0 { return nil, nil } al := new(apiLinks) linkRegex := regexp.MustCompile(`<([^>]+)>; rel="([^"]+)`) for _, l := range links { m := linkRegex.FindAllStringSubmatch(l, -1) for _, submatch := range m { if len(submatch) != 3 { continue } // Parse URL u, err := url.Parse(submatch[1]) if err != nil { return al, err } var lp *LimitParams since := u.Query().Get("since_id") max := u.Query().Get("max_id") lim := u.Query().Get("limit") if since == "" && max == "" { continue } lp = new(LimitParams) if since != "" { lp.SinceID = since if err != nil { return al, err } } if max != "" { lp.MaxID = max if err != nil { return al, err } } if lim != "" { lp.Limit, err = strconv.Atoi(lim) if err != nil { return al, err } } switch submatch[2] { case "prev": al.prev = lp case "next": al.next = lp } } } return al, nil } // restAPI actually does the HTTP query // It is a copy of rest.API with better handling of parameters with multiple values func restAPI(request rest.Request) (*rest.Response, error) { // Our encoded parameters var urlpstr string c := &rest.Client{HTTPClient: http.DefaultClient} // Build the HTTP request object. if len(request.QueryParams) != 0 { urlp := url.Values{} arrayRe := regexp.MustCompile(`^\[\d+\](.*)$`) for key, value := range request.QueryParams { // It seems Mastodon doesn't like parameters with index // numbers, but it needs the brackets. // Let's check if the key matches '^.+\[.*\]$' // Do not proceed if there's another bracket pair. klen := len(key) if klen == 0 { continue } if m := arrayRe.FindStringSubmatch(key); len(m) > 0 { // This is an array, let's remove the index number key = m[1] + "[]" } urlp.Add(key, value) } urlpstr = urlp.Encode() } switch request.Method { case "GET": // Add parameters to the URL if we have any. if len(urlpstr) > 0 { request.BaseURL += "?" + urlpstr } default: // Pleroma at least needs the API parameters in the body rather than // the URL for `POST` requests. Which is fair according to // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-2 // which suggests that `GET` requests should have URL parameters // and `POST` requests should have the encoded parameters in the body. // // HOWEVER for file uploads, we've already got a properly encoded body // which means we ignore this step. if len(request.Body) == 0 { request.Body = []byte(urlpstr) } } req, err := http.NewRequest(string(request.Method), request.BaseURL, bytes.NewBuffer(request.Body)) if err != nil { return nil, err } for key, value := range request.Headers { req.Header.Set(key, value) } _, exists := req.Header["Content-Type"] if len(request.Body) > 0 && !exists { // Make sure we have the correct content type for form submission. req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } // Build the HTTP client and make the request. res, err := c.MakeRequest(req) if err != nil { return nil, err } if res.StatusCode < 200 || res.StatusCode >= 300 { var errorText string // Try to unmarshal the returned error object for a description mastodonError := Error{} decodeErr := json.NewDecoder(res.Body).Decode(&mastodonError) if decodeErr != nil { // Decode unsuccessful, fallback to generic error based on response code errorText = http.StatusText(res.StatusCode) } else { errorText = mastodonError.Text } // Please note that the error string code is used by Search() // to check the error cause. const errFormatString = "bad server status code (%d)" return nil, errors.Errorf(errFormatString+": %s", res.StatusCode, errorText) } // Build Response object. response, err := rest.BuildResponse(res) if err != nil { return nil, err } return response, nil } // prepareRequest inserts all pre-defined stuff func (mc *Client) prepareRequest(target string, method rest.Method, params apiCallParams) (rest.Request, error) { var req rest.Request if mc == nil { return req, ErrUninitializedClient } endPoint := mc.APIBase + "/" + target // Request headers hdrs := make(map[string]string) hdrs["User-Agent"] = fmt.Sprintf("madon/%s", MadonVersion) if mc.UserToken != nil { hdrs["Authorization"] = fmt.Sprintf("Bearer %s", mc.UserToken.AccessToken) } req = rest.Request{ BaseURL: endPoint, Headers: hdrs, Method: method, QueryParams: params, } return req, nil } // apiCall makes a call to the Mastodon API server // If links is not nil, the prev/next links from the API response headers // will be set (if they exist) in the structure. func (mc *Client) apiCall(endPoint string, method rest.Method, params apiCallParams, limitOptions *LimitParams, links *apiLinks, data interface{}) error { if mc == nil { return errors.New("use of uninitialized madon client") } if limitOptions != nil { if params == nil { params = make(apiCallParams) } if limitOptions.Limit > 0 { params["limit"] = strconv.Itoa(limitOptions.Limit) } if limitOptions.SinceID != "" { params["since_id"] = limitOptions.SinceID } if limitOptions.MaxID != "" { params["max_id"] = limitOptions.MaxID } } // Prepare query req, err := mc.prepareRequest(endPoint, method, params) if err != nil { return err } // Make API call r, err := restAPI(req) if err != nil { return errors.Wrapf(err, "API query (%s) failed", endPoint) } if links != nil { pLinks, err := parseLink(r.Headers["Link"]) if err != nil { return errors.Wrapf(err, "cannot decode header links (%s)", method) } if pLinks != nil { *links = *pLinks } } // Check for error reply var errorResult Error if err := json.Unmarshal([]byte(r.Body), &errorResult); err == nil { // The empty object is not an error if errorResult.Text != "" { return errors.New(errorResult.Text) } } // Not an error reply; let's unmarshal the data err = json.Unmarshal([]byte(r.Body), &data) if err != nil { return errors.Wrapf(err, "cannot decode API response (%s)", method) } return nil } /* Mastodon timestamp handling */ // 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... // 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.Time{} 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 } madon-3.0.0/app.go000066400000000000000000000042751464026544500137410ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Copyright 2017 Ollivier Robert Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "net/url" "strings" "github.com/pkg/errors" "github.com/sendgrid/rest" ) type registerApp struct { ID ActivityID `json:"id"` ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` } // buildInstanceURL creates the URL from the instance name or cleans up the // provided URL func buildInstanceURL(instanceName string) (string, error) { if instanceName == "" { return "", errors.New("no instance provided") } instanceURL := instanceName if !strings.Contains(instanceURL, "/") { instanceURL = "https://" + instanceName } u, err := url.ParseRequestURI(instanceURL) if err != nil { return "", err } u.Path = "" u.RawPath = "" u.RawQuery = "" u.Fragment = "" return u.String(), nil } // NewApp registers a new application with a given instance func NewApp(name, website string, scopes []string, redirectURI, instanceName string) (mc *Client, err error) { instanceURL, err := buildInstanceURL(instanceName) if err != nil { return nil, err } mc = &Client{ Name: name, InstanceURL: instanceURL, APIBase: instanceURL + currentAPIPath, } params := make(apiCallParams) params["client_name"] = name if website != "" { params["website"] = website } params["scopes"] = strings.Join(scopes, " ") if redirectURI != "" { params["redirect_uris"] = redirectURI } else { params["redirect_uris"] = NoRedirect } var app registerApp if err := mc.apiCall("v1/apps", rest.Post, params, nil, nil, &app); err != nil { return nil, err } mc.ID = app.ClientID mc.Secret = app.ClientSecret return } // RestoreApp recreates an application client with existing secrets func RestoreApp(name, instanceName, appID, appSecret string, userToken *UserToken) (mc *Client, err error) { instanceURL, err := buildInstanceURL(instanceName) if err != nil { return nil, err } return &Client{ Name: name, InstanceURL: instanceURL, APIBase: instanceURL + currentAPIPath, ID: appID, Secret: appSecret, UserToken: userToken, }, nil } madon-3.0.0/domain.go000066400000000000000000000030411464026544500144160ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "github.com/sendgrid/rest" ) // GetBlockedDomains returns the current user blocked domains // If lopt.All is true, several requests will be made until the API server // has nothing to return. func (mc *Client) GetBlockedDomains(lopt *LimitParams) ([]DomainName, error) { const endPoint = "domain_blocks" var links apiLinks var domains []DomainName if err := mc.apiCall("v1/"+endPoint, rest.Get, nil, lopt, &links, &domains); err != nil { return nil, err } if lopt != nil { // Fetch more pages to reach our limit for (lopt.All || lopt.Limit > len(domains)) && links.next != nil { domainSlice := []DomainName{} newlopt := links.next links = apiLinks{} if err := mc.apiCall("v1/"+endPoint, rest.Get, nil, newlopt, &links, &domainSlice); err != nil { return nil, err } domains = append(domains, domainSlice...) } } return domains, nil } // BlockDomain blocks the specified domain func (mc *Client) BlockDomain(domain DomainName) error { const endPoint = "domain_blocks" params := make(apiCallParams) params["domain"] = string(domain) return mc.apiCall("v1/"+endPoint, rest.Post, params, nil, nil, nil) } // UnblockDomain unblocks the specified domain func (mc *Client) UnblockDomain(domain DomainName) error { const endPoint = "domain_blocks" params := make(apiCallParams) params["domain"] = string(domain) return mc.apiCall("v1/"+endPoint, rest.Delete, params, nil, nil, nil) } madon-3.0.0/emoji.go000066400000000000000000000007271464026544500142620ustar00rootroot00000000000000/* Copyright 2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "github.com/sendgrid/rest" ) // GetCustomEmojis returns a list with the server custom emojis func (mc *Client) GetCustomEmojis(lopt *LimitParams) ([]Emoji, error) { var emojiList []Emoji if err := mc.apiCall("v1/custom_emojis", rest.Get, nil, lopt, nil, &emojiList); err != nil { return nil, err } return emojiList, nil } madon-3.0.0/endorsements.go000066400000000000000000000021471464026544500156630ustar00rootroot00000000000000/* Copyright 2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "github.com/sendgrid/rest" ) // GetEndorsements returns the list of user's endorsements func (mc *Client) GetEndorsements(lopt *LimitParams) ([]Account, error) { endPoint := "endorsements" method := rest.Get var accountList []Account if err := mc.apiCall("v1/"+endPoint, method, nil, lopt, nil, &accountList); err != nil { return nil, err } return accountList, nil } // PinAccount adds the account to the endorsement list func (mc *Client) PinAccount(accountID ActivityID) (*Relationship, error) { rel, err := mc.updateRelationship("pin", accountID, nil) if err != nil { return nil, err } if rel == nil { return nil, ErrEntityNotFound } return rel, nil } // UnpinAccount removes the account from the endorsement list func (mc *Client) UnpinAccount(accountID ActivityID) (*Relationship, error) { rel, err := mc.updateRelationship("unpin", accountID, nil) if err != nil { return nil, err } if rel == nil { return nil, ErrEntityNotFound } return rel, nil } madon-3.0.0/go.mod000066400000000000000000000005571464026544500137370ustar00rootroot00000000000000module github.com/McKael/madon/v3 require ( github.com/gorilla/websocket v1.5.3 github.com/kr/pretty v0.1.0 // indirect github.com/pkg/errors v0.9.1 github.com/sendgrid/rest v2.6.9+incompatible github.com/stretchr/testify v1.7.0 golang.org/x/net v0.26.0 golang.org/x/oauth2 v0.21.0 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) go 1.13 madon-3.0.0/go.sum000066400000000000000000000201341464026544500137550ustar00rootroot00000000000000cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= madon-3.0.0/instance.go000066400000000000000000000022721464026544500147600ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "github.com/sendgrid/rest" ) // GetCurrentInstance returns current instance information func (mc *Client) GetCurrentInstance() (*Instance, error) { var i Instance if err := mc.apiCall("v1/instance", rest.Get, nil, nil, nil, &i); err != nil { return nil, err } return &i, nil } // GetInstancePeers returns current instance peers // The peers are defined as the domains of users the instance has previously // resolved. func (mc *Client) GetInstancePeers() ([]InstancePeer, error) { var peers []InstancePeer if err := mc.apiCall("v1/instance/peers", rest.Get, nil, nil, nil, &peers); err != nil { return nil, err } return peers, nil } // GetInstanceActivity returns current instance activity // The activity contains the counts of active users, locally posted statuses, // and new registrations in weekly buckets. func (mc *Client) GetInstanceActivity() ([]WeekActivity, error) { var activity []WeekActivity if err := mc.apiCall("v1/instance/activity", rest.Get, nil, nil, nil, &activity); err != nil { return nil, err } return activity, nil } madon-3.0.0/lists.go000066400000000000000000000072211464026544500143110ustar00rootroot00000000000000/* Copyright 2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "fmt" "github.com/pkg/errors" "github.com/sendgrid/rest" ) // GetList returns a List entity func (mc *Client) GetList(listID ActivityID) (*List, error) { if listID == "" { return nil, errors.New("invalid list ID") } endPoint := "lists/" + listID var list List if err := mc.apiCall("v1/"+endPoint, rest.Get, nil, nil, nil, &list); err != nil { return nil, err } return &list, nil } // GetLists returns a list of List entities // If accountID is > 0, this will return the lists containing this account. // If lopt.All is true, several requests will be made until the API server // has nothing to return. func (mc *Client) GetLists(accountID ActivityID, lopt *LimitParams) ([]List, error) { endPoint := "lists" if accountID != "" { endPoint = "accounts/" + accountID + "/lists" } var lists []List var links apiLinks if err := mc.apiCall("v1/"+endPoint, rest.Get, nil, lopt, &links, &lists); err != nil { return nil, err } if lopt != nil { // Fetch more pages to reach our limit for (lopt.All || lopt.Limit > len(lists)) && links.next != nil { listSlice := []List{} newlopt := links.next links = apiLinks{} if err := mc.apiCall("v1/"+endPoint, rest.Get, nil, newlopt, &links, &listSlice); err != nil { return nil, err } lists = append(lists, listSlice...) } } return lists, nil } // CreateList creates a List func (mc *Client) CreateList(title string) (*List, error) { params := apiCallParams{"title": title} method := rest.Post return mc.setSingleList(method, "", params) } // UpdateList updates an existing List func (mc *Client) UpdateList(listID ActivityID, title string) (*List, error) { if listID == "" { return nil, errors.New("invalid list ID") } params := apiCallParams{"title": title} method := rest.Put return mc.setSingleList(method, listID, params) } // DeleteList deletes a list func (mc *Client) DeleteList(listID ActivityID) error { if listID == "" { return errors.New("invalid list ID") } method := rest.Delete _, err := mc.setSingleList(method, listID, nil) return err } // GetListAccounts returns the accounts belonging to a given list func (mc *Client) GetListAccounts(listID ActivityID, lopt *LimitParams) ([]Account, error) { endPoint := "lists/" + listID + "/accounts" return mc.getMultipleAccounts(endPoint, nil, lopt) } // AddListAccounts adds the accounts to a given list func (mc *Client) AddListAccounts(listID ActivityID, accountIDs []ActivityID) error { endPoint := "lists/" + listID + "/accounts" method := rest.Post params := make(apiCallParams) for i, id := range accountIDs { if id == "" { return ErrInvalidID } qID := fmt.Sprintf("[%d]account_ids", i) params[qID] = id } return mc.apiCall("v1/"+endPoint, method, params, nil, nil, nil) } // RemoveListAccounts removes the accounts from the given list func (mc *Client) RemoveListAccounts(listID ActivityID, accountIDs []ActivityID) error { endPoint := "lists/" + listID + "/accounts" method := rest.Delete params := make(apiCallParams) for i, id := range accountIDs { if id == "" { return ErrInvalidID } qID := fmt.Sprintf("[%d]account_ids", i) params[qID] = id } return mc.apiCall("v1/"+endPoint, method, params, nil, nil, nil) } func (mc *Client) setSingleList(method rest.Method, listID ActivityID, params apiCallParams) (*List, error) { var endPoint string if listID != "" { endPoint = "lists/" + listID } else { endPoint = "lists" } var list List if err := mc.apiCall("v1/"+endPoint, method, params, nil, nil, &list); err != nil { return nil, err } return &list, nil } madon-3.0.0/login.go000066400000000000000000000060121464026544500142600ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "encoding/json" "strings" "golang.org/x/net/context" "golang.org/x/oauth2" "github.com/pkg/errors" "github.com/sendgrid/rest" ) const oAuthRelPath = "/oauth/" // UserToken represents a user token as returned by the Mastodon API type UserToken struct { AccessToken string `json:"access_token"` CreatedAt int64 `json:"created_at"` Scope string `json:"scope"` TokenType string `json:"token_type"` } // LoginBasic does basic user authentication func (mc *Client) LoginBasic(username, password string, scopes []string) error { if mc == nil { return ErrUninitializedClient } if username == "" { return errors.New("missing username") } if password == "" { return errors.New("missing password") } hdrs := make(map[string]string) opts := make(map[string]string) hdrs["User-Agent"] = "madon/" + MadonVersion opts["grant_type"] = "password" opts["client_id"] = mc.ID opts["client_secret"] = mc.Secret opts["username"] = username opts["password"] = password if len(scopes) > 0 { opts["scope"] = strings.Join(scopes, " ") } req := rest.Request{ BaseURL: mc.InstanceURL + oAuthRelPath + "token", Headers: hdrs, QueryParams: opts, Method: rest.Post, } r, err := restAPI(req) if err != nil { return err } var resp UserToken err = json.Unmarshal([]byte(r.Body), &resp) if err != nil { return errors.Wrap(err, "cannot unmarshal server response") } mc.UserToken = &resp return nil } // SetUserToken sets an existing user credentials // No verification of the arguments is made. func (mc *Client) SetUserToken(token, username, password string, scopes []string) error { if mc == nil { return ErrUninitializedClient } mc.UserToken = &UserToken{ AccessToken: token, Scope: strings.Join(scopes, " "), TokenType: "bearer", } return nil } // LoginOAuth2 handles OAuth2 authentication // If code is empty, the URL to the server consent page will be returned; // if not, the user token is set. func (mc *Client) LoginOAuth2(code string, scopes []string) (string, error) { if mc == nil { return "", ErrUninitializedClient } conf := &oauth2.Config{ ClientID: mc.ID, ClientSecret: mc.Secret, Scopes: scopes, Endpoint: oauth2.Endpoint{ AuthURL: mc.InstanceURL + oAuthRelPath + "authorize", TokenURL: mc.InstanceURL + oAuthRelPath + "token", }, RedirectURL: NoRedirect, } if code == "" { // URL to consent page to ask for permission // for the scopes specified above. url := conf.AuthCodeURL("state", oauth2.AccessTypeOffline) + "&client_name=madonctl&redirect_uris=urn:ietf:wg:oauth:2.0:oob" return url, nil } // Return token t, err := conf.Exchange(context.TODO(), code) if err != nil { return "", errors.Wrap(err, "cannot convert code into a token") } if t == nil || t.AccessToken == "" { return "", errors.New("empty token") } return "", mc.SetUserToken(t.AccessToken, "", "", scopes) } madon-3.0.0/madon.go000066400000000000000000000021621464026544500142500ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Copyright 2017 Ollivier Robert Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "github.com/pkg/errors" ) // LimitParams contains common limit/paging options for the Mastodon REST API type LimitParams struct { Limit int // Number of items per query SinceID, MaxID ActivityID // Boundaries All bool // Get as many items as possible } // apiCallParams is a map with the parameters for an API call type apiCallParams map[string]string const ( // MadonVersion contains the version of the Madon library MadonVersion = "3.0.0" currentAPIPath = "/api" // NoRedirect is the URI for no redirection in the App registration NoRedirect = "urn:ietf:wg:oauth:2.0:oob" ) // Error codes var ( ErrUninitializedClient = errors.New("use of uninitialized madon client") ErrAlreadyRegistered = errors.New("app already registered") ErrEntityNotFound = errors.New("entity not found") ErrInvalidParameter = errors.New("incorrect parameter") ErrInvalidID = errors.New("incorrect entity ID") ) madon-3.0.0/madon_test.go000066400000000000000000000006041464026544500153060ustar00rootroot00000000000000package madon import ( "testing" "github.com/sendgrid/rest" "github.com/stretchr/testify/assert" ) func TestPrepareRequest(t *testing.T) { mc := &Client{ Name: "foo", ID: "666", Secret: "biiiip", APIBase: "http://example.com", } req, err := mc.prepareRequest("bar", rest.Get, nil) assert.NoError(t, err, "no error") assert.NotNil(t, req.Headers, "not nil") } madon-3.0.0/media.go000066400000000000000000000070211464026544500142300ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "bytes" "encoding/json" "io" "mime/multipart" "os" "path/filepath" "github.com/pkg/errors" "github.com/sendgrid/rest" ) const mediaUploadFieldName = "file" // UploadMedia uploads the given file and returns an attachment // The description and focus arguments can be empty strings. // 'focus' is the "focal point", written as two comma-delimited floating points. func (mc *Client) UploadMedia(filePath, description, focus string) (*Attachment, error) { if filePath == "" { return nil, ErrInvalidParameter } f, err := os.Open(filePath) if err != nil { return nil, errors.Wrap(err, "cannot read file") } defer f.Close() return mc.UploadMediaReader(f, filepath.Base(f.Name()), description, focus) } // UploadMediaReader uploads data from the given reader and returns an attachment // name, description and focus arguments can be empty strings. // 'focus' is the "focal point", written as two comma-delimited floating points. func (mc *Client) UploadMediaReader(f io.Reader, name, description, focus string) (*Attachment, error) { buf := bytes.Buffer{} w := multipart.NewWriter(&buf) var formWriter io.Writer var err error if len(name) > 0 { formWriter, err = w.CreateFormFile(mediaUploadFieldName, name) } else { formWriter, err = w.CreateFormField(mediaUploadFieldName) } if err != nil { return nil, errors.Wrap(err, "media upload") } if _, err = io.Copy(formWriter, f); err != nil { return nil, errors.Wrap(err, "media upload") } var params apiCallParams if description != "" || focus != "" { params = make(apiCallParams) if description != "" { params["description"] = description } if focus != "" { params["focus"] = focus } } for k, v := range params { fw, err := w.CreateFormField(k) if err != nil { return nil, errors.Wrapf(err, "form field: %s", k) } n, err := io.WriteString(fw, v) if err != nil { return nil, errors.Wrapf(err, "writing field: %s", k) } if n != len(v) { return nil, errors.Wrapf(err, "partial field: %s", k) } } w.Close() req, err := mc.prepareRequest("v1/media", rest.Post, params) if err != nil { return nil, errors.Wrap(err, "media prepareRequest failed") } req.Headers["Content-Type"] = w.FormDataContentType() req.Body = buf.Bytes() // Make API call r, err := restAPI(req) if err != nil { return nil, errors.Wrap(err, "media upload failed") } // Check for error reply var errorResult Error if err := json.Unmarshal([]byte(r.Body), &errorResult); err == nil { // The empty object is not an error if errorResult.Text != "" { return nil, errors.New(errorResult.Text) } } // Not an error reply; let's unmarshal the data var attachment Attachment err = json.Unmarshal([]byte(r.Body), &attachment) if err != nil { return nil, errors.Wrap(err, "cannot decode API response (media)") } return &attachment, nil } // UpdateMedia updates the description and focal point of a media // One of the description and focus arguments can be nil to not be updated. func (mc *Client) UpdateMedia(mediaID ActivityID, description, focus *string) (*Attachment, error) { params := make(apiCallParams) if description != nil { params["description"] = *description } if focus != nil { params["focus"] = *focus } endPoint := "media/" + mediaID var attachment Attachment if err := mc.apiCall("v1/"+endPoint, rest.Put, params, nil, nil, &attachment); err != nil { return nil, err } return &attachment, nil } madon-3.0.0/notifications.go000066400000000000000000000051661464026544500160320ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "fmt" "github.com/sendgrid/rest" ) // GetNotifications returns the list of the user's notifications // excludeTypes is an array of notifications to exclude ("follow", "favourite", // "reblog", "mention"). It can be nil. // If lopt.All is true, several requests will be made until the API server // has nothing to return. // If lopt.Limit is set (and not All), several queries can be made until the // limit is reached. func (mc *Client) GetNotifications(excludeTypes []string, lopt *LimitParams) ([]Notification, error) { var notifications []Notification var links apiLinks var params apiCallParams if len(excludeTypes) > 0 { params = make(apiCallParams) for i, eType := range excludeTypes { qID := fmt.Sprintf("[%d]exclude_types", i) params[qID] = eType } } if err := mc.apiCall("v1/notifications", rest.Get, params, lopt, &links, ¬ifications); err != nil { return nil, err } if lopt != nil { // Fetch more pages to reach our limit for (lopt.All || lopt.Limit > len(notifications)) && links.next != nil { notifSlice := []Notification{} newlopt := links.next links = apiLinks{} if err := mc.apiCall("v1/notifications", rest.Get, nil, newlopt, &links, ¬ifSlice); err != nil { return nil, err } notifications = append(notifications, notifSlice...) } } return notifications, nil } // GetNotification returns a notification // The returned notification can be nil if there is an error or if the // requested notification does not exist. func (mc *Client) GetNotification(notificationID ActivityID) (*Notification, error) { if notificationID == "" { return nil, ErrInvalidID } var endPoint = "notifications/" + notificationID var notification Notification if err := mc.apiCall("v1/"+endPoint, rest.Get, nil, nil, nil, ¬ification); err != nil { return nil, err } if notification.ID == "" { return nil, ErrEntityNotFound } return ¬ification, nil } // DismissNotification deletes a notification func (mc *Client) DismissNotification(notificationID ActivityID) error { if notificationID == "" { return ErrInvalidID } endPoint := "notifications/dismiss" params := apiCallParams{"id": notificationID} err := mc.apiCall("v1/"+endPoint, rest.Post, params, nil, nil, &Notification{}) return err } // ClearNotifications deletes all notifications from the Mastodon server for // the authenticated user func (mc *Client) ClearNotifications() error { err := mc.apiCall("v1/notifications/clear", rest.Post, nil, nil, nil, &Notification{}) return err } madon-3.0.0/report.go000066400000000000000000000022101464026544500144570ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "fmt" "github.com/sendgrid/rest" ) // GetReports returns the current user's reports // (I don't know if the limit options are used by the API server.) func (mc *Client) GetReports(lopt *LimitParams) ([]Report, error) { var reports []Report if err := mc.apiCall("v1/reports", rest.Get, nil, lopt, nil, &reports); err != nil { return nil, err } return reports, nil } // ReportUser reports the user account func (mc *Client) ReportUser(accountID ActivityID, statusIDs []ActivityID, comment string) (*Report, error) { if accountID == "" || comment == "" || len(statusIDs) < 1 { return nil, ErrInvalidParameter } params := make(apiCallParams) params["account_id"] = accountID params["comment"] = comment for i, id := range statusIDs { if id == "" { return nil, ErrInvalidID } qID := fmt.Sprintf("[%d]status_ids", i) params[qID] = id } var report Report if err := mc.apiCall("v1/reports", rest.Post, params, nil, nil, &report); err != nil { return nil, err } return &report, nil } madon-3.0.0/search.go000066400000000000000000000032701464026544500144200ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "strings" "github.com/sendgrid/rest" ) // Search search for contents (accounts or statuses) and returns a Results func (mc *Client) searchV1(params apiCallParams) (*Results, error) { // We use a custom structure with shadowed Hashtags field, // since the v1 version only returns strings. var resultsV1 struct { Results Hashtags []string `json:"hashtags"` } if err := mc.apiCall("v1/"+"search", rest.Get, params, nil, nil, &resultsV1); err != nil { return nil, err } var results Results results.Accounts = resultsV1.Accounts results.Statuses = resultsV1.Statuses for _, t := range resultsV1.Hashtags { results.Hashtags = append(results.Hashtags, Tag{Name: t}) } return &results, nil } func (mc *Client) searchV2(params apiCallParams) (*Results, error) { var results Results if err := mc.apiCall("v2/"+"search", rest.Get, params, nil, nil, &results); err != nil { return nil, err } return &results, nil } // Search search for contents (accounts or statuses) and returns a Results func (mc *Client) Search(query string, resolve bool) (*Results, error) { if query == "" { return nil, ErrInvalidParameter } // The parameters are the same in both v1 & v2 API versions params := make(apiCallParams) params["q"] = query if resolve { params["resolve"] = "true" } r, err := mc.searchV2(params) // This is not a very beautiful way to check the error cause, I admit. if err != nil && strings.Contains(err.Error(), "bad server status code (404)") { // Fall back to v1 API endpoint r, err = mc.searchV1(params) } return r, err } madon-3.0.0/status.go000066400000000000000000000212271464026544500145000ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "fmt" "github.com/pkg/errors" "github.com/sendgrid/rest" ) // PostStatusParams contains option fields for the PostStatus command type PostStatusParams struct { Text string InReplyTo ActivityID MediaIDs []ActivityID Sensitive bool SpoilerText string Visibility string } // updateStatusOptions contains option fields for POST and DELETE API calls type updateStatusOptions struct { // The ID is used for most commands ID ActivityID // The following fields are used for posting a new status Status string InReplyToID ActivityID MediaIDs []ActivityID Sensitive bool SpoilerText string Visibility string // "direct", "private", "unlisted" or "public" } // getMultipleStatuses returns a list of status entities // If lopt.All is true, several requests will be made until the API server // has nothing to return. func (mc *Client) getMultipleStatuses(endPoint string, params apiCallParams, lopt *LimitParams) ([]Status, error) { var statuses []Status var links apiLinks if err := mc.apiCall("v1/"+endPoint, rest.Get, params, lopt, &links, &statuses); err != nil { return nil, err } if lopt != nil { // Fetch more pages to reach our limit for (lopt.All || lopt.Limit > len(statuses)) && links.next != nil { statusSlice := []Status{} newlopt := links.next links = apiLinks{} if err := mc.apiCall("v1/"+endPoint, rest.Get, params, newlopt, &links, &statusSlice); err != nil { return nil, err } statuses = append(statuses, statusSlice...) } } return statuses, nil } // queryStatusData queries the statuses API // The operation 'op' can be empty or "status" (the status itself), "context", // "card", "reblogged_by", "favourited_by". // The data argument will receive the object(s) returned by the API server. func (mc *Client) queryStatusData(statusID ActivityID, op string, data interface{}) error { if statusID == "" { return ErrInvalidID } endPoint := "statuses/" + statusID if op != "" && op != "status" { switch op { case "context", "card", "reblogged_by", "favourited_by": default: return ErrInvalidParameter } endPoint += "/" + op } return mc.apiCall("v1/"+endPoint, rest.Get, nil, nil, nil, data) } // updateStatusData updates the statuses // The operation 'op' can be empty or "status" (to post a status), "delete" // (for deleting a status), "reblog"/"unreblog", "favourite"/"unfavourite", // "mute"/"unmute" (for conversations) or "pin"/"unpin". // The data argument will receive the object(s) returned by the API server. func (mc *Client) updateStatusData(op string, opts updateStatusOptions, data interface{}) error { method := rest.Post endPoint := "statuses" params := make(apiCallParams) switch op { case "", "status": op = "status" if opts.Status == "" { return ErrInvalidParameter } switch opts.Visibility { case "", "direct", "private", "unlisted", "public": // Okay default: return ErrInvalidParameter } if len(opts.MediaIDs) > 4 { return errors.New("too many (>4) media IDs") } case "delete": method = rest.Delete if opts.ID == "" { return ErrInvalidID } endPoint += "/" + opts.ID case "reblog", "unreblog", "favourite", "unfavourite": if opts.ID == "" { return ErrInvalidID } endPoint += "/" + opts.ID + "/" + op case "mute", "unmute", "pin", "unpin": if opts.ID == "" { return ErrInvalidID } endPoint += "/" + opts.ID + "/" + op default: return ErrInvalidParameter } // Form items for a new toot if op == "status" { params["status"] = opts.Status if opts.InReplyToID != "" { params["in_reply_to_id"] = opts.InReplyToID } for i, id := range opts.MediaIDs { if id == "" { return ErrInvalidID } qID := fmt.Sprintf("[%d]media_ids", i) params[qID] = id } if opts.Sensitive { params["sensitive"] = "true" } if opts.SpoilerText != "" { params["spoiler_text"] = opts.SpoilerText } if opts.Visibility != "" { params["visibility"] = opts.Visibility } } return mc.apiCall("v1/"+endPoint, method, params, nil, nil, data) } // GetStatus returns a status // The returned status can be nil if there is an error or if the // requested ID does not exist. func (mc *Client) GetStatus(statusID ActivityID) (*Status, error) { var status Status if err := mc.queryStatusData(statusID, "status", &status); err != nil { return nil, err } if status.ID == "" { return nil, ErrEntityNotFound } return &status, nil } // GetStatusContext returns a status context func (mc *Client) GetStatusContext(statusID ActivityID) (*Context, error) { var context Context if err := mc.queryStatusData(statusID, "context", &context); err != nil { return nil, err } return &context, nil } // GetStatusCard returns a status card func (mc *Client) GetStatusCard(statusID ActivityID) (*Card, error) { var card Card if err := mc.queryStatusData(statusID, "card", &card); err != nil { return nil, err } return &card, nil } // GetStatusRebloggedBy returns a list of the accounts who reblogged a status func (mc *Client) GetStatusRebloggedBy(statusID ActivityID, lopt *LimitParams) ([]Account, error) { o := &getAccountsOptions{ID: statusID, Limit: lopt} return mc.getMultipleAccountsHelper("reblogged_by", o) } // GetStatusFavouritedBy returns a list of the accounts who favourited a status func (mc *Client) GetStatusFavouritedBy(statusID ActivityID, lopt *LimitParams) ([]Account, error) { o := &getAccountsOptions{ID: statusID, Limit: lopt} return mc.getMultipleAccountsHelper("favourited_by", o) } // PostStatus posts a new "toot" // All parameters but "text" can be empty. // Visibility must be empty, or one of "direct", "private", "unlisted" and "public". func (mc *Client) PostStatus(cmdParams PostStatusParams) (*Status, error) { var status Status o := updateStatusOptions{ Status: cmdParams.Text, InReplyToID: cmdParams.InReplyTo, MediaIDs: cmdParams.MediaIDs, Sensitive: cmdParams.Sensitive, SpoilerText: cmdParams.SpoilerText, Visibility: cmdParams.Visibility, } err := mc.updateStatusData("status", o, &status) if err != nil { return nil, err } if status.ID == "" { return nil, ErrEntityNotFound // TODO Change error message } return &status, err } // DeleteStatus deletes a status func (mc *Client) DeleteStatus(statusID ActivityID) error { var status Status o := updateStatusOptions{ID: statusID} err := mc.updateStatusData("delete", o, &status) return err } // ReblogStatus reblogs a status func (mc *Client) ReblogStatus(statusID ActivityID) error { var status Status o := updateStatusOptions{ID: statusID} err := mc.updateStatusData("reblog", o, &status) return err } // UnreblogStatus unreblogs a status func (mc *Client) UnreblogStatus(statusID ActivityID) error { var status Status o := updateStatusOptions{ID: statusID} err := mc.updateStatusData("unreblog", o, &status) return err } // FavouriteStatus favourites a status func (mc *Client) FavouriteStatus(statusID ActivityID) error { var status Status o := updateStatusOptions{ID: statusID} err := mc.updateStatusData("favourite", o, &status) return err } // UnfavouriteStatus unfavourites a status func (mc *Client) UnfavouriteStatus(statusID ActivityID) error { var status Status o := updateStatusOptions{ID: statusID} err := mc.updateStatusData("unfavourite", o, &status) return err } // PinStatus pins a status func (mc *Client) PinStatus(statusID ActivityID) error { var status Status o := updateStatusOptions{ID: statusID} err := mc.updateStatusData("pin", o, &status) return err } // UnpinStatus unpins a status func (mc *Client) UnpinStatus(statusID ActivityID) error { var status Status o := updateStatusOptions{ID: statusID} err := mc.updateStatusData("unpin", o, &status) return err } // MuteConversation mutes the conversation containing a status func (mc *Client) MuteConversation(statusID ActivityID) (*Status, error) { var status Status o := updateStatusOptions{ID: statusID} err := mc.updateStatusData("mute", o, &status) return &status, err } // UnmuteConversation unmutes the conversation containing a status func (mc *Client) UnmuteConversation(statusID ActivityID) (*Status, error) { var status Status o := updateStatusOptions{ID: statusID} err := mc.updateStatusData("unmute", o, &status) return &status, err } // GetFavourites returns the list of the user's favourites // If lopt.All is true, several requests will be made until the API server // has nothing to return. // If lopt.Limit is set (and not All), several queries can be made until the // limit is reached. func (mc *Client) GetFavourites(lopt *LimitParams) ([]Status, error) { return mc.getMultipleStatuses("favourites", nil, lopt) } madon-3.0.0/streams.go000066400000000000000000000121441464026544500146310ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "encoding/json" "net/url" "strings" "github.com/gorilla/websocket" "github.com/pkg/errors" ) // StreamEvent contains a single event from the streaming API type StreamEvent struct { Event string // Name of the event (error, update, notification or delete) Data interface{} // Status, Notification or status ID Error error // Error message from the StreamListener } // openStream opens a stream URL and returns an http.Response // Note that the caller should close the connection when it's done reading // the stream. // The stream name can be "user", "local", "public", "direct", "list" or // "hashtag". // When it is "hashtag", the param argument contains the hashtag. // When it is "list", the param argument contains the list ID. func (mc *Client) openStream(streamName, param string) (*websocket.Conn, error) { var tag, list string switch streamName { case "public", "public:media", "public:local", "public:local:media", "public:remote", "public:remote:media", "user", "user:notification", "direct": case "hashtag", "hashtag:local": if param == "" { return nil, ErrInvalidParameter } tag = param case "list": if param == "" { return nil, ErrInvalidParameter } list = param default: return nil, ErrInvalidParameter } if !strings.HasPrefix(mc.APIBase, "http") { return nil, errors.New("cannot create Websocket URL: unexpected API base URL") } // Build streaming websocket URL u, err := url.Parse("ws" + mc.APIBase[4:] + "/v1/streaming/") if err != nil { return nil, errors.Wrap(err, "cannot create Websocket URL") } urlParams := url.Values{} urlParams.Add("stream", streamName) urlParams.Add("access_token", mc.UserToken.AccessToken) if tag != "" { urlParams.Add("tag", tag) } else if list != "" { urlParams.Add("list", list) } u.RawQuery = urlParams.Encode() c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) return c, err } // readStream reads from the http.Response and sends events to the events channel // It stops when the connection is closed or when the stopCh channel is closed. // The foroutine will close the doneCh channel when it terminates. func (mc *Client) readStream(events chan<- StreamEvent, stopCh <-chan bool, doneCh chan bool, c *websocket.Conn) { defer c.Close() defer close(doneCh) go func() { select { case <-stopCh: // Close connection c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) case <-doneCh: // Leave } }() for { var msg struct { Event string Payload interface{} } err := c.ReadJSON(&msg) if err != nil { if strings.Contains(err.Error(), "close 1000 (normal)") { break // Connection properly closed } e := errors.Wrap(err, "read error") events <- StreamEvent{Event: "error", Error: e} break } var obj interface{} // Decode API object switch msg.Event { case "update", "status.update": strPayload, ok := msg.Payload.(string) if !ok { e := errors.New("could not decode status: payload isn't a string") events <- StreamEvent{Event: "error", Error: e} continue } var s Status if err := json.Unmarshal([]byte(strPayload), &s); err != nil { e := errors.Wrap(err, "could not decode status") events <- StreamEvent{Event: "error", Error: e} continue } obj = s case "notification": strPayload, ok := msg.Payload.(string) if !ok { e := errors.New("could not decode notification: payload isn't a string") events <- StreamEvent{Event: "error", Error: e} continue } var notif Notification if err := json.Unmarshal([]byte(strPayload), ¬if); err != nil { e := errors.Wrap(err, "could not decode notification") events <- StreamEvent{Event: "error", Error: e} continue } obj = notif case "delete": strPayload, ok := msg.Payload.(string) if !ok { e := errors.New("could not decode deletion: payload isn't a string") events <- StreamEvent{Event: "error", Error: e} continue } obj = strPayload // statusID default: e := errors.Errorf("unhandled event '%s'", msg.Event) events <- StreamEvent{Event: "error", Error: e} continue } // Send event to the channel events <- StreamEvent{Event: msg.Event, Data: obj} } } // StreamListener listens to a stream from the Mastodon server // The stream 'name' can be "user", "local", "public" or "hashtag". // For 'hashtag', the hashTag argument cannot be empty. // The events are sent to the events channel (the errors as well). // The streaming is terminated if the 'stopCh' channel is closed. // The 'doneCh' channel is closed if the connection is closed by the server. // Please note that this method launches a goroutine to listen to the events. func (mc *Client) StreamListener(name, hashTag string, events chan<- StreamEvent, stopCh <-chan bool, doneCh chan bool) error { if mc == nil { return ErrUninitializedClient } conn, err := mc.openStream(name, hashTag) if err != nil { return err } go mc.readStream(events, stopCh, doneCh, conn) return nil } madon-3.0.0/suggestions.go000066400000000000000000000014231464026544500155230ustar00rootroot00000000000000/* Copyright 2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "github.com/sendgrid/rest" ) // GetSuggestions returns a list of follow suggestions from the server func (mc *Client) GetSuggestions(lopt *LimitParams) ([]Account, error) { endPoint := "suggestions" method := rest.Get var accountList []Account if err := mc.apiCall("v1/"+endPoint, method, nil, lopt, nil, &accountList); err != nil { return nil, err } return accountList, nil } // DeleteSuggestion removes the account from the suggestion list func (mc *Client) DeleteSuggestion(accountID ActivityID) error { endPoint := "suggestions/" + accountID method := rest.Delete return mc.apiCall("v1/"+endPoint, method, nil, nil, nil, nil) } madon-3.0.0/timelines.go000066400000000000000000000033671464026544500151530ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "strings" "github.com/pkg/errors" ) // GetTimelines returns a timeline (a list of statuses) // timeline can be "home", "public", "direct", a hashtag (use ":hashtag" or // "#hashtag") or a list (use "!N", e.g. "!42" for list ID #42). // For the public timelines, you can set 'local' to true to get only the // local instance. // Set 'onlyMedia' to true to only get statuses that have media attachments. // If lopt.All is true, several requests will be made until the API server // has nothing to return. // If lopt.Limit is set (and not All), several queries can be made until the // limit is reached. func (mc *Client) GetTimelines(timeline string, local, onlyMedia bool, lopt *LimitParams) ([]Status, error) { var endPoint string switch { case timeline == "home", timeline == "public", timeline == "direct": endPoint = "timelines/" + timeline case strings.HasPrefix(timeline, ":"), strings.HasPrefix(timeline, "#"): hashtag := timeline[1:] if hashtag == "" { return nil, errors.New("timelines API: empty hashtag") } endPoint = "timelines/tag/" + hashtag case len(timeline) > 1 && strings.HasPrefix(timeline, "!"): // Check the timeline is a number for _, n := range timeline[1:] { if n < '0' || n > '9' { return nil, errors.New("timelines API: invalid list ID") } } endPoint = "timelines/list/" + timeline[1:] default: return nil, errors.New("GetTimelines: bad timelines argument") } params := make(apiCallParams) if timeline == "public" && local { params["local"] = "true" } if onlyMedia { params["only_media"] = "true" } return mc.getMultipleStatuses(endPoint, params, lopt) } madon-3.0.0/types.go000066400000000000000000000207001464026544500143140ustar00rootroot00000000000000/* Copyright 2017-2018 Mikael Berthe Copyright 2017 Ollivier Robert Licensed under the MIT license. Please see the LICENSE file is this directory. */ package madon import ( "time" ) // Generic type alias for ActivityPub IDs type ActivityID = string // MastodonDate is a custom type for the timestamps returned by some API calls type MastodonDate struct { time.Time } // Client contains data for a madon client application type Client struct { Name string // Name of the client ID string // Application ID Secret string // Application secret APIBase string // API prefix URL InstanceURL string // Instance base URL UserToken *UserToken // User token } /* Entities - Everything manipulated/returned by the API */ // 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 ActivityID `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"` } // 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 ActivityID `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"` } // 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"` } // 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 ActivityID `json:"id"` Title string `json:"title"` } // Mention represents a Mastodon mention entity type Mention struct { ID ActivityID `json:"id"` URL string `json:"url"` Username string `json:"username"` Acct string `json:"acct"` } // Notification represents a Mastodon notification entity type Notification struct { ID ActivityID `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 ActivityID `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 ActivityID `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 ActivityID `json:"id"` URI string `json:"uri"` URL string `json:"url"` Account *Account `json:"account"` InReplyToID *ActivityID `json:"in_reply_to_id"` InReplyToAccountID *ActivityID `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"` }