|
| 1 | +// Package twitter provides a plugin that scrapes messages for Twitter links, |
| 2 | +// then expands them into chat messages. |
| 3 | +package twitter |
| 4 | + |
| 5 | +import ( |
| 6 | + "errors" |
| 7 | + "fmt" |
| 8 | + "github.com/dghubble/go-twitter/twitter" |
| 9 | + "github.com/go-chat-bot/bot" |
| 10 | + "golang.org/x/oauth2" |
| 11 | + "golang.org/x/oauth2/clientcredentials" |
| 12 | + "os" |
| 13 | + "regexp" |
| 14 | + "strconv" |
| 15 | + "strings" |
| 16 | +) |
| 17 | + |
| 18 | +// findTweetIDs checks a given message string for strings that look like Twitter links, |
| 19 | +// then attempts to extract the Tweet ID from the link. |
| 20 | +// It returns an array of Tweet IDs. |
| 21 | +func findTweetIDs(message string) ([]int64, error) { |
| 22 | + re := regexp.MustCompile(`http(?:s)?://(?:mobile.)?twitter.com/(?:.*)/status/([0-9]*)`) |
| 23 | + // FIXME this is only returning the LAST match, should return ALL matches |
| 24 | + result := re.FindAllStringSubmatch(message, -1) |
| 25 | + var ( |
| 26 | + tweetIDs []int64 |
| 27 | + id int64 |
| 28 | + err error |
| 29 | + ) |
| 30 | + |
| 31 | + for i := range result { |
| 32 | + last := len(result[i]) - 1 |
| 33 | + idStr := result[i][last] |
| 34 | + id, err = strconv.ParseInt(idStr, 10, 64) |
| 35 | + tweetIDs = append(tweetIDs, id) |
| 36 | + } |
| 37 | + return tweetIDs, err |
| 38 | +} |
| 39 | + |
| 40 | +// getCredentialsFromEnvironment attempts to extract the Twitter consumer key |
| 41 | +// and consumer secret from the current process environment. If either the key |
| 42 | +// or the secret is not found, it returns a pair of empty strings and a |
| 43 | +// missingAPICredentialsError. |
| 44 | +// If successful, it returns the consumer key and consumer secret. |
| 45 | +func getCredentialsFromEnvironment() (string, string, error) { |
| 46 | + key, keyOk := os.LookupEnv("TWITTER_CONSUMER_KEY") |
| 47 | + secret, secretOk := os.LookupEnv("TWITTER_CONSUMER_SECRET") |
| 48 | + if !keyOk || !secretOk { |
| 49 | + return "", "", errors.New("missing API credentials") |
| 50 | + } |
| 51 | + return key, secret, nil |
| 52 | +} |
| 53 | + |
| 54 | +// newTwitterClientConfig takes a Twitter consumer key and consumer secret and |
| 55 | +// attempts to create a clientcredentials.Config. If either the key or the secret |
| 56 | +// is an empty string, no client is returned and a missingAPICredentialsError is returned. |
| 57 | +// If successful, it returns a clientcredentials.Config. |
| 58 | +func newTwitterClientConfig(twitterConsumerKey, twitterConsumerSecret string) (*clientcredentials.Config, error) { |
| 59 | + if twitterConsumerKey == "" || twitterConsumerSecret == "" { |
| 60 | + return nil, errors.New("missing API credentials") |
| 61 | + } |
| 62 | + config := &clientcredentials.Config{ |
| 63 | + ClientID: twitterConsumerKey, |
| 64 | + ClientSecret: twitterConsumerSecret, |
| 65 | + TokenURL: "https://api.twitter.com/oauth2/token", |
| 66 | + } |
| 67 | + return config, nil |
| 68 | +} |
| 69 | + |
| 70 | +// newAuthenticatedTwitterClient uses a provided consumer key and secret to authenticate |
| 71 | +// against Twitter's Oauth2 endpoint, then validates the authentication by checking the |
| 72 | +// current RateLimit against the provided account credentials. |
| 73 | +// It returns a twitter.Client. |
| 74 | +func newAuthenticatedTwitterClient(twitterConsumerKey, twitterConsumerSecret string) (*twitter.Client, error) { |
| 75 | + config, err := newTwitterClientConfig(twitterConsumerKey, twitterConsumerSecret) |
| 76 | + if err != nil { |
| 77 | + return nil, err |
| 78 | + } |
| 79 | + |
| 80 | + httpClient := config.Client(oauth2.NoContext) |
| 81 | + client := twitter.NewClient(httpClient) |
| 82 | + err = checkTwitterClientRateLimit(client) |
| 83 | + if err != nil { |
| 84 | + return nil, err |
| 85 | + } |
| 86 | + |
| 87 | + return client, nil |
| 88 | +} |
| 89 | + |
| 90 | +// checkTwitterClientRateLimit uses the provided twitter.Client to check the remaining |
| 91 | +// RateLimit.Status for that client. |
| 92 | +// It returns an error if authentication failed or if the rate limit has been exceeded. |
| 93 | +func checkTwitterClientRateLimit(client *twitter.Client) error { |
| 94 | + // NOTE: calls to RateLimits apply against the Remaining calls for that endpoint |
| 95 | + params := twitter.RateLimitParams{Resources: []string{"statuses"}} |
| 96 | + rl, resp, err := client.RateLimits.Status(¶ms) |
| 97 | + |
| 98 | + // FIXME if i don't return this err at this point and credentials are bad, a panic happens |
| 99 | + if err != nil { |
| 100 | + return err |
| 101 | + } |
| 102 | + |
| 103 | + remaining := rl.Resources.Statuses["/statuses/show/:id"].Remaining |
| 104 | + if resp.StatusCode/200 != 1 { |
| 105 | + return errors.New(resp.Status) |
| 106 | + } |
| 107 | + |
| 108 | + if remaining == 0 { |
| 109 | + return errors.New("rate limit exceeded") |
| 110 | + } |
| 111 | + return nil |
| 112 | +} |
| 113 | + |
| 114 | +// fetchTweets takes an array of Tweet IDs and retrieves the corresponding |
| 115 | +// Statuses. |
| 116 | +// It returns an array of twitter.Tweets. |
| 117 | +func fetchTweets(client *twitter.Client, tweetIDs []int64) ([]twitter.Tweet, error) { |
| 118 | + var tweets []twitter.Tweet |
| 119 | + for _, tweetID := range tweetIDs { |
| 120 | + tweet, err := fetchTweet(client, tweetID) |
| 121 | + if err != nil { |
| 122 | + return nil, err |
| 123 | + } |
| 124 | + tweets = append(tweets, *tweet) |
| 125 | + } |
| 126 | + return tweets, nil |
| 127 | +} |
| 128 | + |
| 129 | +// fetchTweet takes a twitter.Client and a single Tweet ID and fetches the |
| 130 | +// corresponding Status. |
| 131 | +// It returns a twitter.Tweet. |
| 132 | +func fetchTweet(client *twitter.Client, tweetID int64) (*twitter.Tweet, error) { |
| 133 | + var err error |
| 134 | + // TODO get alt text |
| 135 | + // params: include_entities=true,include_ext_alt_text=true |
| 136 | + |
| 137 | + params := twitter.StatusShowParams{ |
| 138 | + TweetMode: "extended", // populate FullText field |
| 139 | + } |
| 140 | + tweet, resp, err := client.Statuses.Show(tweetID, ¶ms) |
| 141 | + |
| 142 | + // If we return nil instead of tweet, a panic happens |
| 143 | + if err != nil { |
| 144 | + return tweet, err |
| 145 | + } |
| 146 | + |
| 147 | + if resp.StatusCode/200 != 1 { |
| 148 | + err = errors.New(resp.Status) |
| 149 | + } |
| 150 | + |
| 151 | + return tweet, err |
| 152 | +} |
| 153 | + |
| 154 | +// formatTweets takes an array of twitter.Tweets and formats them in preparation for |
| 155 | +// sending as a chat message. |
| 156 | +// It returns an array of nicely formatted strings. |
| 157 | +func formatTweets(tweets []twitter.Tweet) []string { |
| 158 | + formatString := "Tweet from @%s: %s" |
| 159 | + newlines := regexp.MustCompile(`\r?\n`) |
| 160 | + var messages []string |
| 161 | + for _, tweet := range tweets { |
| 162 | + // TODO get link title, eg: Tweet from @user: look at this cool thing https://thing.cool (Link title: A Cool Thing) |
| 163 | + // tweet.Entities.Urls contains []URLEntity |
| 164 | + // fetch title from urlEntity.URL |
| 165 | + // urls plugin already correctly handles t.co links |
| 166 | + username := tweet.User.ScreenName |
| 167 | + text := newlines.ReplaceAllString(tweet.FullText, " ") |
| 168 | + newMessage := fmt.Sprintf(formatString, username, text) |
| 169 | + messages = append(messages, newMessage) |
| 170 | + } |
| 171 | + return messages |
| 172 | +} |
| 173 | + |
| 174 | +// expandTweets receives a bot.PassiveCmd and performs the full parse-and-fetch |
| 175 | +// pipeline. It sets up a client, finds Tweet IDs in the message text, fetches |
| 176 | +// the tweets, and formats them. If multiple Tweet IDs were found in the message, |
| 177 | +// all formatted Tweets will be joined into a single message. |
| 178 | +// It returns a single string suitable for sending as a chat message. |
| 179 | +func expandTweets(cmd *bot.PassiveCmd) (string, error) { |
| 180 | + var message string |
| 181 | + messageText := cmd.MessageData.Text |
| 182 | + |
| 183 | + twitterConsumerKey, twitterConsumerSecret, err := getCredentialsFromEnvironment() |
| 184 | + if err != nil { |
| 185 | + return message, err |
| 186 | + } |
| 187 | + |
| 188 | + client, err := newAuthenticatedTwitterClient(twitterConsumerKey, twitterConsumerSecret) |
| 189 | + if err != nil { |
| 190 | + return message, err |
| 191 | + } |
| 192 | + |
| 193 | + tweetIDs, err := findTweetIDs(messageText) |
| 194 | + if err != nil { |
| 195 | + return message, err |
| 196 | + } |
| 197 | + |
| 198 | + tweets, err := fetchTweets(client, tweetIDs) |
| 199 | + if err != nil { |
| 200 | + return message, err |
| 201 | + } |
| 202 | + |
| 203 | + formattedTweets := formatTweets(tweets) |
| 204 | + if formattedTweets != nil { |
| 205 | + message = strings.Join(formattedTweets, "\n") |
| 206 | + } |
| 207 | + return message, err |
| 208 | +} |
| 209 | + |
| 210 | +// init initalizes a PassiveCommand for expanding Tweets. |
| 211 | +func init() { |
| 212 | + bot.RegisterPassiveCommand( |
| 213 | + "twitter", |
| 214 | + expandTweets) |
| 215 | +} |
0 commit comments