Skip to content
This repository was archived by the owner on Mar 11, 2024. It is now read-only.

Commit c3df725

Browse files
authored
Add Twitter plugin (#45)
This plugin provides a PassiveCommand that detects Twitter links in messages, scrapes them for Tweet IDs, then posts the Tweet(s) to the chat.
1 parent abb8340 commit c3df725

File tree

3 files changed

+366
-0
lines changed

3 files changed

+366
-0
lines changed

twitter/README.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Overview
2+
3+
The Twitter plugin scrapes message text for Twitter URLs, then attempts to fetch the linked Tweet and post it to the channel, like so:
4+
5+
```
6+
08:29:19 <user> https://twitter.com/simonpierce/status/1265829199115218945
7+
08:29:21 <chatbot> Tweet from @simonpierce: Gentoo penguins like to exercise their growing chicks by makingthem run around the colony, squawking hysterically, if they want to get fed. I'm not saying it'd be fun to try this with your own kids if you're stuck at home... but I'm not not saying that. https://t.co/2Y0wewRKDw
8+
```
9+
10+
# Setting up Twitter API credentials
11+
12+
- Request Twitter development access [here](https://developer.twitter.com) (Note: the approval process takes about a week)
13+
- Once your dev access is approved, create an App [here](https://developer.twitter.com/en/apps)
14+
- Visit the App's "Keys and Tokens" tab
15+
- Save the *Consumer API Key* and *Consumer API key secret* in a safe place (it is not necessary to generate *Access token* and *Access token secret* for this plugin)
16+
- Export the required environment variables into the shell in which your go-chat-bot process will run:
17+
18+
```
19+
export TWITTER_CONSUMER_KEY="yourconsumerkeyhere" \
20+
TWITTER_CONSUMER_SECRET="yourconsumersecrethere"
21+
```

twitter/twitter.go

+215
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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(&params)
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, &params)
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+
}

twitter/twitter_test.go

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package twitter
2+
3+
import (
4+
"errors"
5+
"github.com/go-chat-bot/bot"
6+
"regexp"
7+
"testing"
8+
)
9+
10+
func TestTwitter(t *testing.T) {
11+
// given a message string, I should get back a response message string
12+
// containing one or more parsed Tweets
13+
jbouieOutput := `Tweet from @jbouie: This falls into one of my favorite genres of tweets, bona fide elites whose pretenses to understanding “common people” instead reveal their cloistered, condescending view of ordinary people. https://t.co/KV8xnG2w48`
14+
sethAbramsonOutput := `Tweet from @SethAbramson: This is the first U.S. presidential election in which "Vote Him Out Before He Kills You and Your Family" is a wholly reasonable slogan for the challenger`
15+
dmackdrwnsOutput := `Tweet from @dmackdrwns: It was pretty fun to try to manifest creatures plucked right from the minds of manic children. #georgiamuseumofart https://t.co/C983t6QjmT`
16+
17+
var cases = []struct {
18+
input, output string
19+
expectedError error
20+
}{
21+
{
22+
input: "this message has no links",
23+
output: "",
24+
expectedError: nil,
25+
}, {
26+
input: "http://twitter.com/jbouie/status/1247273759632961537",
27+
output: jbouieOutput,
28+
expectedError: nil,
29+
}, {
30+
input: "https://mobile.twitter.com/jbouie/status/1247273759632961537",
31+
output: jbouieOutput,
32+
expectedError: nil,
33+
}, {
34+
input: "wow check out this tweet https://mobile.twitter.com/jbouie/status/1247273759632961537",
35+
output: jbouieOutput,
36+
expectedError: nil,
37+
}, {
38+
input: "wow check out this tweethttps://mobile.twitter.com/jbouie/status/1247273759632961537",
39+
output: jbouieOutput,
40+
expectedError: nil,
41+
}, {
42+
input: "wow check out this tweet https://mobile.twitter.com/jbouie/status/1247273759632961537super cool right?",
43+
output: jbouieOutput,
44+
expectedError: nil,
45+
}, {
46+
input: "https://twitter.com/dmackdrwns/status/1217830568848764930/photo/1",
47+
output: dmackdrwnsOutput,
48+
expectedError: nil,
49+
}, {
50+
input: "http://twitter.com/notARealUser/status/123456789",
51+
output: "",
52+
expectedError: errors.New("twitter: 144 No status found with that ID."),
53+
}, {
54+
input: "https://twitter.com/SethAbramson/status/1259875673994338305 lol bye",
55+
output: sethAbramsonOutput,
56+
expectedError: nil,
57+
},
58+
}
59+
for i, c := range cases {
60+
testingUser := bot.User{
61+
ID: "test",
62+
Nick: "test",
63+
RealName: "test",
64+
IsBot: true,
65+
}
66+
testingMessage := bot.Message{
67+
Text: c.input,
68+
IsAction: false,
69+
}
70+
testingCmd := bot.PassiveCmd{
71+
Raw: c.input,
72+
Channel: "test",
73+
User: &testingUser,
74+
MessageData: &testingMessage,
75+
}
76+
t.Run(string(i), func(t *testing.T) {
77+
// these CANNOT run concurrently
78+
// FIXME panic here when no credentials
79+
got, err := expandTweets(&testingCmd)
80+
want := c.output
81+
if err != nil && err.Error() != c.expectedError.Error() {
82+
t.Error(err)
83+
}
84+
if got != want {
85+
t.Errorf("got %+v; want %+v", got, want)
86+
}
87+
})
88+
}
89+
}
90+
91+
func TestNewAuthenticatedTwitterClient(t *testing.T) {
92+
// TODO test case for these envvars not being set
93+
key, secret, err := getCredentialsFromEnvironment()
94+
if err != nil {
95+
t.Error(err)
96+
}
97+
var cases = []struct {
98+
key, secret string
99+
expectedError error
100+
}{
101+
{
102+
key: "",
103+
secret: "",
104+
expectedError: errors.New("missing API credentials"),
105+
}, {
106+
key: "asdf",
107+
secret: "jklmnop",
108+
expectedError: errors.New(`Get https://api.twitter.com/1.1/application/rate_limit_status.json?resources=statuses: oauth2: cannot fetch token: 403 Forbidden Response: {"errors":[{"code":99,"message":"Unable to verify your credentials","label":"authenticity_token_error"}]}`),
109+
}, {
110+
key: key,
111+
secret: secret,
112+
expectedError: nil,
113+
},
114+
}
115+
newlines := regexp.MustCompile(`\r?\n`)
116+
for i, c := range cases {
117+
t.Run(string(i), func(t *testing.T) {
118+
// these CANNOT run concurrently
119+
_, err := newAuthenticatedTwitterClient(c.key, c.secret)
120+
if err != nil {
121+
// eat newlines because they mess with our tests
122+
got := newlines.ReplaceAllString(err.Error(), " ")
123+
want := c.expectedError.Error()
124+
if got != want {
125+
t.Errorf("got %s; want %s", got, want)
126+
}
127+
}
128+
})
129+
}
130+
}

0 commit comments

Comments
 (0)