diff options
| author | Christine Dodrill <me@christine.website> | 2017-04-22 19:29:54 -0700 |
|---|---|---|
| committer | Christine Dodrill <me@christine.website> | 2017-04-22 19:29:54 -0700 |
| commit | 09446399be0faeee09b7a323e57434f69d428826 (patch) | |
| tree | 19a8134fdad4dbe5c063836afd129ab4e1c6e882 | |
| parent | 729bbe26deac8975f2b31e4c3ddaae938f078fa5 (diff) | |
| download | x-09446399be0faeee09b7a323e57434f69d428826.tar.xz x-09446399be0faeee09b7a323e57434f69d428826.zip | |
furrybb: an experimental #furry boost bot
40 files changed, 5629 insertions, 0 deletions
diff --git a/mastodon/furrybb/.gitignore b/mastodon/furrybb/.gitignore new file mode 100644 index 0000000..73c1161 --- /dev/null +++ b/mastodon/furrybb/.gitignore @@ -0,0 +1,2 @@ +.env +furrybb diff --git a/mastodon/furrybb/README.md b/mastodon/furrybb/README.md new file mode 100644 index 0000000..c64d0eb --- /dev/null +++ b/mastodon/furrybb/README.md @@ -0,0 +1,36 @@ +furrybb +====== + +This boosts any toots with the tag `#furry`, but this can be used for other +hashtags too. Usage is simple: + +```console +$ go get github.com/Xe/x/mastodon/furrybb +$ cd $GOPATH/src/github.com/Xe/x/mastodon/furrybb +$ go run mkapp.go -help +Usage of [mkapp.go]: + -app-name string + app name for mastodon (default "Xe/x bot") + -instance string + mastodon instance + -password string + password to generate token + -redirect-uri string + redirect URI for app users (default "urn:ietf:wg:oauth:2.0:oob") + -username string + username to generate token + -website string + website for users that click the app name (default "https://github.com/Xe/x") +exit status 2 +$ go run mkapp.go [your options here] > .env +$ echo "HASHTAG=furry" >> .env +$ go build && ./furrybb +``` + +once you see: + +``` +time="2017-04-22T19:25:28-07:00" action=streaming.toots +``` + +you're all good fam. diff --git a/mastodon/furrybb/main.go b/mastodon/furrybb/main.go new file mode 100644 index 0000000..cfaf9bb --- /dev/null +++ b/mastodon/furrybb/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "github.com/McKael/madon" + "github.com/Xe/ln" + "github.com/caarlos0/env" + _ "github.com/joho/godotenv/autoload" +) + +var cfg = &struct { + Instance string `env:"INSTANCE,required"` + AppID string `env:"APP_ID,required"` + AppSecret string `env:"APP_SECRET,required"` + Token string `env:"TOKEN,required"` + Hashtag string `env:"HASHTAG,required"` +}{} + +var scopes = []string{"read", "write", "follow"} + +func main() { + err := env.Parse(cfg) + if err != nil { + ln.Fatal(ln.F{"err": err, "action": "startup"}) + } + + c, err := madon.RestoreApp("furry boostbot", cfg.Instance, cfg.AppID, cfg.AppSecret, &madon.UserToken{AccessToken: cfg.Token}) + if err != nil { + ln.Fatal(ln.F{"err": err, "action": "madon.RestoreApp"}) + } + + evChan := make(chan madon.StreamEvent, 10) + stop := make(chan bool) + done := make(chan bool) + + err = c.StreamListener("public", "", evChan, stop, done) + if err != nil { + ln.Fatal(ln.F{"err": err, "action": "c.StreamListener"}) + } + + ln.Log(ln.F{ + "action": "streaming.toots", + }) + + for { + select { + case _, ok := <-done: + if !ok { + ln.Fatal(ln.F{"action": "stream.dead"}) + } + + case ev := <-evChan: + switch ev.Event { + case "error": + ln.Fatal(ln.F{"err": ev.Error, "action": "processing.event"}) + case "update": + s := ev.Data.(madon.Status) + + for _, tag := range s.Tags { + if tag.Name == cfg.Hashtag { + err = c.ReblogStatus(s.ID) + if err != nil { + ln.Fatal(ln.F{"err": err, "action": "c.ReblogStatus", "id": s.ID}) + } + + ln.Log(ln.F{ + "action": "reblogged", + "id": s.ID, + }) + } + } + } + } + } +} diff --git a/mastodon/furrybb/mkapp.go b/mastodon/furrybb/mkapp.go new file mode 100644 index 0000000..88ad5f8 --- /dev/null +++ b/mastodon/furrybb/mkapp.go @@ -0,0 +1,38 @@ +// +build ignore + +package main + +import ( + "flag" + "fmt" + "log" + + "github.com/McKael/madon" +) + +var ( + instance = flag.String("instance", "", "mastodon instance") + appName = flag.String("app-name", "Xe/x bot", "app name for mastodon") + redirectURI = flag.String("redirect-uri", "urn:ietf:wg:oauth:2.0:oob", "redirect URI for app users") + website = flag.String("website", "https://github.com/Xe/x", "website for users that click the app name") + username = flag.String("username", "", "username to generate token") + password = flag.String("password", "", "password to generate token") +) + +var scopes = []string{"read", "write", "follow"} + +func main() { + flag.Parse() + + c, err := madon.NewApp(*appName, scopes, *redirectURI, *instance) + if err != nil { + log.Fatal(err) + } + + err = c.LoginBasic(*username, *password, scopes) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("APP_ID=%s\nAPP_SECRET=%s\nTOKEN=%s\nINSTANCE=%s", c.ID, c.Secret, c.UserToken.AccessToken, *instance) +} diff --git a/mastodon/furrybb/vendor-log b/mastodon/furrybb/vendor-log new file mode 100644 index 0000000..c9c6acd --- /dev/null +++ b/mastodon/furrybb/vendor-log @@ -0,0 +1,8 @@ +039943f9c38eb1f43c26e1ffd21ad691efc00657 github.com/McKael/madon +f759b797c0ff6b2c514202198fe5e8ba90094c14 github.com/Xe/ln +9474f19b515f52326c7d197d2d097caa7fc7485e github.com/caarlos0/env +e8f0f8aaa98dfb6586cbdf2978d511e3199a960a github.com/gorilla/websocket +726cc8b906e3d31c70a9671c90a13716a8d3f50d github.com/joho/godotenv +726cc8b906e3d31c70a9671c90a13716a8d3f50d github.com/joho/godotenv/autoload +248dadf4e9068a0b3e79f02ed0a610d935de5302 github.com/pkg/errors +14de1ac72d9ae5c3c0d7c02164c52ebd3b951a4e github.com/sendgrid/rest diff --git a/mastodon/furrybb/vendor/github.com/McKael/madon/account.go b/mastodon/furrybb/vendor/github.com/McKael/madon/account.go new file mode 100644 index 0000000..e96aa59 --- /dev/null +++ b/mastodon/furrybb/vendor/github.com/McKael/madon/account.go @@ -0,0 +1,430 @@ +/* +Copyright 2017 Mikael Berthe + +Licensed under the MIT license. Please see the LICENSE file is this directory. +*/ + +package madon + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "os" + "strconv" + "strings" + + "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 int + + // The following fields are used when searching for accounts + Q string + Limit int +} + +// getSingleAccount returns an account entity +// The operation 'op' can be "account", "verify_credentials", "follow", +// "unfollow", "block", "unblock", "mute", "unmute", +// "follow_requests/authorize" or // "follow_requests/reject". +// The id is optional and depends on the operation. +func (mc *Client) getSingleAccount(op string, id int) (*Account, error) { + var endPoint string + method := rest.Get + strID := strconv.Itoa(id) + + switch op { + case "account": + endPoint = "accounts/" + strID + case "verify_credentials": + endPoint = "accounts/verify_credentials" + case "follow", "unfollow", "block", "unblock", "mute", "unmute": + endPoint = "accounts/" + strID + "/" + op + method = rest.Post + case "follow_requests/authorize", "follow_requests/reject": + // The documentation is incorrect, the endpoint actually + // is "follow_requests/:id/{authorize|reject}" + endPoint = op[:16] + strID + "/" + op[16:] + method = rest.Post + default: + return nil, ErrInvalidParameter + } + + var account Account + if err := mc.apiCall(endPoint, method, nil, &account); err != nil { + return nil, err + } + return &account, nil +} + +// getMultipleAccounts 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. +func (mc *Client) getMultipleAccounts(op string, opts *getAccountsOptions) ([]Account, error) { + var endPoint string + + switch op { + case "followers", "following": + if opts == nil || opts.ID < 1 { + return []Account{}, ErrInvalidID + } + endPoint = "accounts/" + strconv.Itoa(opts.ID) + "/" + op + case "follow_requests", "blocks", "mutes": + endPoint = op + case "search": + if opts == nil || opts.Q == "" { + return []Account{}, ErrInvalidParameter + } + endPoint = "accounts/" + op + default: + return nil, ErrInvalidParameter + } + + // Handle target-specific query parameters + params := make(apiCallParams) + if op == "search" { + params["q"] = opts.Q + if opts.Limit > 0 { + params["limit"] = strconv.Itoa(opts.Limit) + } + } + + var accounts []Account + if err := mc.apiCall(endPoint, rest.Get, params, &accounts); err != nil { + return nil, err + } + return accounts, nil +} + +// 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 int) (*Account, error) { + account, err := mc.getSingleAccount("account", accountID) + if err != nil { + return nil, err + } + if account != nil && account.ID == 0 { + return nil, ErrEntityNotFound + } + return account, nil +} + +// GetCurrentAccount returns the current user account +func (mc *Client) GetCurrentAccount() (*Account, error) { + account, err := mc.getSingleAccount("verify_credentials", 0) + if err != nil { + return nil, err + } + if account != nil && account.ID == 0 { + return nil, ErrEntityNotFound + } + return account, nil +} + +// GetAccountFollowers returns the list of accounts following a given account +func (mc *Client) GetAccountFollowers(accountID int) ([]Account, error) { + o := &getAccountsOptions{ID: accountID} + return mc.getMultipleAccounts("followers", o) +} + +// GetAccountFollowing returns the list of accounts a given account is following +func (mc *Client) GetAccountFollowing(accountID int) ([]Account, error) { + o := &getAccountsOptions{ID: accountID} + return mc.getMultipleAccounts("following", o) +} + +// FollowAccount follows an account +func (mc *Client) FollowAccount(accountID int) error { + account, err := mc.getSingleAccount("follow", accountID) + if err != nil { + return err + } + if account != nil && account.ID != accountID { + return ErrEntityNotFound + } + return nil +} + +// UnfollowAccount unfollows an account +func (mc *Client) UnfollowAccount(accountID int) error { + account, err := mc.getSingleAccount("unfollow", accountID) + if err != nil { + return err + } + if account != nil && account.ID != accountID { + return ErrEntityNotFound + } + return nil +} + +// FollowRemoteAccount follows a remote account +// The parameter 'uri' is a URI (e.mc. "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("follows", rest.Post, params, &account); err != nil { + return nil, err + } + if account.ID == 0 { + return nil, ErrEntityNotFound + } + return &account, nil +} + +// BlockAccount blocks an account +func (mc *Client) BlockAccount(accountID int) error { + account, err := mc.getSingleAccount("block", accountID) + if err != nil { + return err + } + if account != nil && account.ID != accountID { + return ErrEntityNotFound + } + return nil +} + +// UnblockAccount unblocks an account +func (mc *Client) UnblockAccount(accountID int) error { + account, err := mc.getSingleAccount("unblock", accountID) + if err != nil { + return err + } + if account != nil && account.ID != accountID { + return ErrEntityNotFound + } + return nil +} + +// MuteAccount mutes an account +func (mc *Client) MuteAccount(accountID int) error { + account, err := mc.getSingleAccount("mute", accountID) + if err != nil { + return err + } + if account != nil && account.ID != accountID { + return ErrEntityNotFound + } + return nil +} + +// UnmuteAccount unmutes an account +func (mc *Client) UnmuteAccount(accountID int) error { + account, err := mc.getSingleAccount("unmute", accountID) + if err != nil { + return err + } + if account != nil && account.ID != accountID { + return ErrEntityNotFound + } + return nil +} + +// SearchAccounts returns a list of accounts matching the query string +// The limit parameter is optional (can be 0). +func (mc *Client) SearchAccounts(query string, limit int) ([]Account, error) { + o := &getAccountsOptions{Q: query, Limit: limit} + return mc.getMultipleAccounts("search", o) +} + +// GetBlockedAccounts returns the list of blocked accounts +func (mc *Client) GetBlockedAccounts() ([]Account, error) { + return mc.getMultipleAccounts("blocks", nil) +} + +// GetMutedAccounts returns the list of muted accounts +func (mc *Client) GetMutedAccounts() ([]Account, error) { + return mc.getMultipleAccounts("mutes", nil) +} + +// GetAccountFollowRequests returns the list of follow requests accounts +func (mc *Client) GetAccountFollowRequests() ([]Account, error) { + return mc.getMultipleAccounts("follow_requests", nil) +} + +// GetAccountRelationships returns a list of relationship entities for the given accounts +func (mc *Client) GetAccountRelationships(accountIDs []int) ([]Relationship, error) { + if len(accountIDs) < 1 { + return nil, ErrInvalidID + } + + params := make(apiCallParams) + for i, id := range accountIDs { + if id < 1 { + return nil, ErrInvalidID + } + qID := fmt.Sprintf("id[%d]", i+1) + params[qID] = strconv.Itoa(id) + } + + var rl []Relationship + if err := mc.apiCall("accounts/relationships", rest.Get, params, &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 excludeReplies is true, skip statuses that reply to other statuses. +func (mc *Client) GetAccountStatuses(accountID int, onlyMedia, excludeReplies bool) ([]Status, error) { + if accountID < 1 { + return nil, ErrInvalidID + } + + endPoint := "accounts/" + strconv.Itoa(accountID) + "/" + "statuses" + params := make(apiCallParams) + if onlyMedia { + params["only_media"] = "true" + } + if excludeReplies { + params["exclude_replies"] = "true" + } + + var sl []Status + if err := mc.apiCall(endPoint, rest.Get, params, &sl); err != nil { + return nil, err + } + return sl, nil +} + +// FollowRequestAuthorize authorizes or rejects an account follow-request +func (mc *Client) FollowRequestAuthorize(accountID int, 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 can contain base64-encoded images; if +// they do not (that is; if they don't contain ";base64,"), they are considered +// as file paths and their content will be encoded. +// All fields can be nil, in which case they are not updated. +// displayName and note can be set to "" to delete previous values; +// I'm not sure images can be deleted -- only replaced AFAICS. +func (mc *Client) UpdateAccount(displayName, note, avatar, headerImage *string) (*Account, error) { + const endPoint = "accounts/update_credentials" + params := make(apiCallParams) + + if displayName != nil { + params["display_name"] = *displayName + } + if note != nil { + params["note"] = *note + } + + var err error + avatar, err = fileToBase64(avatar, nil) + if err != nil { + return nil, err + } + headerImage, err = fileToBase64(headerImage, nil) + if err != nil { + return nil, err + } + + var formBuf bytes.Buffer + w := multipart.NewWriter(&formBuf) + + if avatar != nil { + w.WriteField("avatar", *avatar) + } + if headerImage != nil { + w.WriteField("header", *headerImage) + } + w.Close() + + // Prepare the request + req, err := mc.prepareRequest(endPoint, rest.Patch, params) + if err != nil { + return nil, fmt.Errorf("prepareRequest failed: %s", err.Error()) + } + req.Headers["Content-Type"] = w.FormDataContentType() + req.Body = formBuf.Bytes() + + // Make API call + r, err := restAPI(req) + if err != nil { + return nil, fmt.Errorf("account update failed: %s", err.Error()) + } + + // 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, fmt.Errorf("%s", 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, fmt.Errorf("cannot decode API response: %s", err.Error()) + } + return &account, nil +} + +// fileToBase64 is a helper function to convert a file's contents to +// base64-encoded data. Is the data string already contains base64 data, it +// is not modified. +// If contentType is nil, it is detected. +func fileToBase64(data, contentType *string) (*string, error) { + if data == nil { + return nil, nil + } + + if *data == "" { + return data, nil + } + + if strings.Contains(*data, ";base64,") { + return data, nil + } + + // We need to convert the file and file name to base64 + + file, err := os.Open(*data) + 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 + } + + var cType string + if contentType == nil || *contentType == "" { + cType = http.DetectContentType(buffer[:512]) + } else { + cType = *contentType + } + contentData := base64.StdEncoding.EncodeToString(buffer) + newData := "data:" + cType + ";base64," + contentData + return &newData, nil +} diff --git a/mastodon/furrybb/vendor/github.com/McKael/madon/api.go b/mastodon/furrybb/vendor/github.com/McKael/madon/api.go new file mode 100644 index 0000000..b232553 --- /dev/null +++ b/mastodon/furrybb/vendor/github.com/McKael/madon/api.go @@ -0,0 +1,137 @@ +/* +Copyright 2017 Ollivier Robert +Copyright 2017 Mikael Berthe + +Licensed under the MIT license. Please see the LICENSE file is this directory. +*/ + +package madon + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/sendgrid/rest" +) + +// 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) { + c := &rest.Client{HTTPClient: http.DefaultClient} + + // Build the HTTP request object. + if len(request.QueryParams) != 0 { + // Add parameters to the URL + request.BaseURL += "?" + urlp := url.Values{} + 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 '^.+\[.*\]$' + klen := len(key) + if klen == 0 { + continue + } + i := strings.Index(key, "[") + if key[klen-1] == ']' && i > 0 { + // This is an array, let's remove the index number + key = key[:i] + "[]" + } + urlp.Add(key, value) + } + urlpstr := urlp.Encode() + request.BaseURL += 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 { + req.Header.Set("Content-Type", "application/json") + } + + // Build the HTTP client and make the request. + res, err := c.MakeRequest(req) + if err != nil { + return nil, err + } + + // 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 +func (mc *Client) apiCall(endPoint string, method rest.Method, params apiCallParams, data interface{}) error { + if mc == nil { + return fmt.Errorf("use of uninitialized madon client") + } + + // 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 fmt.Errorf("API query (%s) failed: %s", endPoint, err.Error()) + } + + // 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 fmt.Errorf("%s", errorResult.Text) + } + } + + // Not an error reply; let's unmarshal the data + err = json.Unmarshal([]byte(r.Body), &data) + if err != nil { + return fmt.Errorf("cannot decode API response (%s): %s", method, err.Error()) + } + return nil +} diff --git a/mastodon/furrybb/vendor/github.com/McKael/madon/app.go b/mastodon/furrybb/vendor/github.com/McKael/madon/app.go new file mode 100644 index 0000000..d890224 --- /dev/null +++ b/mastodon/furrybb/vendor/github.com/McKael/madon/app.go @@ -0,0 +1,96 @@ +/* +Copyright 2017 Ollivier Robert +Copyright 2017 Mikael Berthe + +Licensed under the MIT license. Please see the LICENSE file is this directory. +*/ + +package madon + +import ( + "errors" + "net/url" + "strings" + + "github.com/sendgrid/rest" +) + +type registerApp struct { + ID int `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 string, scopes []string, redirectURI, instanceName string) (mc *Client, err error) { + instanceURL, err := buildInstanceURL(instanceName) + if err != nil { + return nil, er |
