diff --git a/app/widget_maker.go b/app/widget_maker.go index ac07076c..f3ce6d5e 100644 --- a/app/widget_maker.go +++ b/app/widget_maker.go @@ -55,6 +55,7 @@ import ( "github.com/wtfutil/wtf/modules/travisci" "github.com/wtfutil/wtf/modules/trello" "github.com/wtfutil/wtf/modules/twitter" + "github.com/wtfutil/wtf/modules/twitterstats" "github.com/wtfutil/wtf/modules/unknown" "github.com/wtfutil/wtf/modules/victorops" "github.com/wtfutil/wtf/modules/weatherservices/arpansagovau" @@ -243,6 +244,9 @@ func MakeWidget( case "twitter": settings := twitter.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = twitter.NewWidget(app, pages, settings) + case "twitterstats": + settings := twitterstats.NewSettingsFromYAML(moduleName, moduleConfig, config) + widget = twitterstats.NewWidget(app, pages, settings) case "victorops": settings := victorops.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = victorops.NewWidget(app, settings) diff --git a/modules/twitter/client.go b/modules/twitter/client.go index affe0a20..2da812dd 100644 --- a/modules/twitter/client.go +++ b/modules/twitter/client.go @@ -1,9 +1,14 @@ package twitter import ( + "context" "encoding/json" "fmt" + "net/http" "strconv" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" ) /* NOTE: Currently single application ONLY @@ -12,19 +17,37 @@ import ( // Client represents the data required to connect to the Twitter API type Client struct { - apiBase string - bearerToken string - count int - screenName string + apiBase string + count int + screenName string + httpClient *http.Client } // NewClient creates and returns a new Twitter client func NewClient(settings *Settings) *Client { + var httpClient *http.Client + // If a bearer token is supplied, use that directly. Otherwise, let the Oauth client fetch a token + // using the consumer key and secret. + if settings.bearerToken == "" { + conf := &clientcredentials.Config{ + ClientID: settings.consumerKey, + ClientSecret: settings.consumerSecret, + TokenURL: "https://api.twitter.com/oauth2/token", + } + httpClient = conf.Client(oauth2.NoContext) + } else { + ctx := context.Background() + httpClient = oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: settings.bearerToken, + TokenType: "Bearer", + })) + } + client := Client{ - apiBase: "https://api.twitter.com/1.1/", - count: settings.count, - screenName: "", - bearerToken: settings.bearerToken, + apiBase: "https://api.twitter.com/1.1/", + count: settings.count, + screenName: "", + httpClient: httpClient, } return &client @@ -53,7 +76,7 @@ func (client *Client) tweets() (tweets []Tweet, err error) { strconv.Itoa(client.count), ) - data, err := Request(client.bearerToken, apiURL) + data, err := Request(client.httpClient, apiURL) if err != nil { return tweets, err } diff --git a/modules/twitter/request.go b/modules/twitter/request.go index e20a306c..8c4ee915 100644 --- a/modules/twitter/request.go +++ b/modules/twitter/request.go @@ -2,21 +2,11 @@ package twitter import ( "bytes" - "fmt" "net/http" ) -func Request(bearerToken string, apiURL string) ([]byte, error) { - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, err - } - - // Expected authorization format for single-application twitter dev accounts - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", bearerToken)) - - client := &http.Client{} - resp, err := client.Do(req) +func Request(httpClient *http.Client, apiURL string) ([]byte, error) { + resp, err := httpClient.Get(apiURL) if err != nil { return nil, err } diff --git a/modules/twitter/settings.go b/modules/twitter/settings.go index 60734759..3b4bb8ce 100644 --- a/modules/twitter/settings.go +++ b/modules/twitter/settings.go @@ -15,18 +15,22 @@ const ( type Settings struct { common *cfg.Common - bearerToken string - count int - screenNames []interface{} + bearerToken string + consumerKey string + consumerSecret string + count int + screenNames []interface{} } func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { settings := Settings{ common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), - bearerToken: ymlConfig.UString("bearerToken", os.Getenv("WTF_TWITTER_BEARER_TOKEN")), - count: ymlConfig.UInt("count", 5), - screenNames: ymlConfig.UList("screenName"), + bearerToken: ymlConfig.UString("bearerToken", os.Getenv("WTF_TWITTER_BEARER_TOKEN")), + consumerKey: ymlConfig.UString("consumerKey", os.Getenv("WTF_TWITTER_CONSUMER_KEY")), + consumerSecret: ymlConfig.UString("consumerSecret", os.Getenv("WTF_TWITTER_CONSUMER_SECRET")), + count: ymlConfig.UInt("count", 5), + screenNames: ymlConfig.UList("screenName"), } return &settings diff --git a/modules/twitterstats/client.go b/modules/twitterstats/client.go new file mode 100644 index 00000000..0fc67d78 --- /dev/null +++ b/modules/twitterstats/client.go @@ -0,0 +1,104 @@ +package twitterstats + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +// Client contains state that allows stats to be fetched about a list of Twitter users +type Client struct { + httpClient *http.Client + screenNames []string + bearerToken string +} + +// TwitterStats Represents a stats snapshot for a single Twitter user at a point in time +type TwitterStats struct { + FollowerCount int64 `json:"followers_count"` + TweetCount int64 `json:"statuses_count"` +} + +const ( + userTimelineURL = "https://api.twitter.com/1.1/users/show.json" +) + +// NewClient creates a new twitterstats client that contains an OAuth2 HTTP client which can be used +func NewClient(settings *Settings) *Client { + usernames := make([]string, len(settings.screenNames)) + for i, username := range settings.screenNames { + var ok bool + if usernames[i], ok = username.(string); !ok { + log.Fatalf("All `screenName`s in twitterstats config must be of type string") + } + } + + var httpClient *http.Client + // If a bearer token is supplied, use that directly. Otherwise, let the Oauth client fetch a token + // using the consumer key and secret. + if settings.bearerToken == "" { + conf := &clientcredentials.Config{ + ClientID: settings.consumerKey, + ClientSecret: settings.consumerSecret, + TokenURL: "https://api.twitter.com/oauth2/token", + } + httpClient = conf.Client(oauth2.NoContext) + } else { + ctx := context.Background() + httpClient = oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: settings.bearerToken, + TokenType: "Bearer", + })) + } + + client := Client{ + httpClient: httpClient, + screenNames: usernames, + } + + return &client +} + +// GetStatsForUser Fetches stats for a single user. If there is an error fetching or parsing the response +// from the Twitter API, an empty stats struct will be returned. +func (client *Client) GetStatsForUser(username string) TwitterStats { + stats := TwitterStats{ + FollowerCount: 0, + TweetCount: 0, + } + + url := fmt.Sprintf("%s?screen_name=%s", userTimelineURL, username) + res, err := client.httpClient.Get(url) + if err != nil { + return stats + } + defer res.Body.Close() + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return stats + } + + // If there is an error while parsing, just discard the error and return the empty stats + json.Unmarshal(body, &stats) + + return stats +} + +// GetStats Returns a slice of `TwitterStats` structs for each username in `client.screenNames` in the same +// order of `client.screenNames` +func (client *Client) GetStats() []TwitterStats { + stats := make([]TwitterStats, len(client.screenNames)) + + for i, username := range client.screenNames { + stats[i] = client.GetStatsForUser(username) + } + + return stats +} diff --git a/modules/twitterstats/settings.go b/modules/twitterstats/settings.go new file mode 100644 index 00000000..793ae917 --- /dev/null +++ b/modules/twitterstats/settings.go @@ -0,0 +1,36 @@ +package twitterstats + +import ( + "os" + + "github.com/olebedev/config" + "github.com/wtfutil/wtf/cfg" +) + +const ( + defaultFocusable = true + defaultTitle = "Twitter Stats" +) + +type Settings struct { + common *cfg.Common + + bearerToken string + consumerKey string + consumerSecret string + screenNames []interface{} +} + +func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { + settings := Settings{ + common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), + + bearerToken: ymlConfig.UString("bearerToken", os.Getenv("WTF_TWITTER_BEARER_TOKEN")), + consumerKey: ymlConfig.UString("consumerKey", os.Getenv("WTF_TWITTER_CONSUMER_KEY")), + consumerSecret: ymlConfig.UString("consumerSecret", os.Getenv("WTF_TWITTER_CONSUMER_SECRET")), + + screenNames: ymlConfig.UList("screenNames"), + } + + return &settings +} diff --git a/modules/twitterstats/widget.go b/modules/twitterstats/widget.go new file mode 100644 index 00000000..985f226e --- /dev/null +++ b/modules/twitterstats/widget.go @@ -0,0 +1,49 @@ +package twitterstats + +import ( + "fmt" + + "github.com/rivo/tview" + "github.com/wtfutil/wtf/view" +) + +type Widget struct { + view.TextWidget + + client *Client +} + +func NewWidget(app *tview.Application, pages *tview.Pages, settings *Settings) *Widget { + widget := Widget{ + TextWidget: view.NewTextWidget(app, settings.common), + client: NewClient(settings), + } + + widget.View.SetBorderPadding(1, 1, 1, 1) + widget.View.SetWrap(true) + widget.View.SetWordWrap(true) + + return &widget +} + +func (widget *Widget) Refresh() { + widget.Redraw(widget.content) +} + +func (widget *Widget) content() (string, string, bool) { + usernames := widget.client.screenNames + stats := widget.client.GetStats() + + // Add header row + str := fmt.Sprintf("%-16s %8s %8s\n", "Username", "Followers", "Tweets") + + // Add rows for each of the followed usernames + for i, username := range usernames { + followerCount := stats[i].FollowerCount + tweetCount := stats[i].TweetCount + + str += fmt.Sprintf("%-16s %8d %8d\n", username, followerCount, tweetCount) + } + + return "Twitter Stats", str, true +}