Skip to content

Commit 0714a15

Browse files
authored
Add auto-reply to Twitter registration tweets (#174)
1 parent 0348502 commit 0714a15

File tree

4 files changed

+150
-30
lines changed

4 files changed

+150
-30
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ require (
6565
github.com/coreos/go-semver v0.3.0 // indirect
6666
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
6767
github.com/davecgh/go-spew v1.1.1 // indirect
68+
github.com/dghubble/oauth1 v0.7.3 // indirect
6869
github.com/docker/cli v20.10.7+incompatible // indirect
6970
github.com/docker/docker v20.10.7+incompatible // indirect
7071
github.com/docker/go-connections v0.4.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ
141141
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
142142
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
143143
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
144+
github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE=
145+
github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY=
144146
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
145147
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
146148
github.com/docker/cli v20.10.7+incompatible h1:pv/3NqibQKphWZiAskMzdz8w0PRbtTaEB+f6NwdU7Is=

pkg/code/async/user/twitter.go

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import (
44
"context"
55
"crypto/ed25519"
66
"database/sql"
7+
"fmt"
78
"strings"
89
"time"
910

1011
"github.com/google/uuid"
1112
"github.com/mr-tron/base58"
1213
"github.com/newrelic/go-agent/v3/newrelic"
1314
"github.com/pkg/errors"
15+
"github.com/sirupsen/logrus"
1416

1517
commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1"
1618
userpb "github.com/code-payments/code-protobuf-api/generated/go/user/v1"
@@ -103,6 +105,8 @@ func (p *service) twitterUserInfoUpdateWorker(serviceCtx context.Context, interv
103105
}
104106

105107
func (p *service) processNewTwitterRegistrations(ctx context.Context) error {
108+
log := p.log.WithField("method", "processNewTwitterRegistrations")
109+
106110
tweets, err := p.findNewRegistrationTweets(ctx)
107111
if err != nil {
108112
return errors.Wrap(err, "error finding new registration tweets")
@@ -113,6 +117,11 @@ func (p *service) processNewTwitterRegistrations(ctx context.Context) error {
113117
return errors.Errorf("author missing in tweet %s", tweet.ID)
114118
}
115119

120+
log := log.WithFields(logrus.Fields{
121+
"tweet": tweet.ID,
122+
"username": tweet.AdditionalMetadata.Author,
123+
})
124+
116125
// Attempt to find a verified tip account from the registration tweet
117126
tipAccount, registrationNonce, err := p.findVerifiedTipAccountRegisteredInTweet(ctx, tweet)
118127
switch err {
@@ -140,7 +149,21 @@ func (p *service) processNewTwitterRegistrations(ctx context.Context) error {
140149

141150
switch err {
142151
case nil:
143-
go push_util.SendTwitterAccountConnectedPushNotification(ctx, p.data, p.pusher, tipAccount)
152+
// todo: all of these success handlers are fire and forget best-effort delivery
153+
154+
go func() {
155+
err := push_util.SendTwitterAccountConnectedPushNotification(ctx, p.data, p.pusher, tipAccount)
156+
if err != nil {
157+
log.WithError(err).Warn("failure sending success push")
158+
}
159+
}()
160+
161+
go func() {
162+
err := p.sendRegistrationSuccessReply(ctx, tweet.ID, tweet.AdditionalMetadata.Author.Username)
163+
if err != nil {
164+
log.WithError(err).Warn("failure sending success reply")
165+
}
166+
}()
144167
case twitter.ErrDuplicateTipAddress:
145168
err = p.data.ExecuteInTx(ctx, sql.LevelDefault, func(ctx context.Context) error {
146169
err = p.data.MarkTwitterNonceAsUsed(ctx, tweet.ID, *registrationNonce)
@@ -228,25 +251,6 @@ func (p *service) updateCachedTwitterUser(ctx context.Context, user *twitter_lib
228251
}
229252
}
230253

231-
func (p *service) onTwitterUsernameNotFound(ctx context.Context, username string) error {
232-
record, err := p.data.GetTwitterUserByUsername(ctx, username)
233-
switch err {
234-
case nil:
235-
case twitter.ErrUserNotFound:
236-
return nil
237-
default:
238-
return errors.Wrap(err, "error getting cached twitter user")
239-
}
240-
241-
record.LastUpdatedAt = time.Now()
242-
243-
err = p.data.SaveTwitterUser(ctx, record)
244-
if err != nil {
245-
return errors.Wrap(err, "error updating cached twitter user")
246-
}
247-
return nil
248-
}
249-
250254
func (p *service) findNewRegistrationTweets(ctx context.Context) ([]*twitter_lib.Tweet, error) {
251255
var pageToken *string
252256
var res []*twitter_lib.Tweet
@@ -361,6 +365,36 @@ func (p *service) findVerifiedTipAccountRegisteredInTweet(ctx context.Context, t
361365
return nil, nil, errTwitterRegistrationNotFound
362366
}
363367

368+
func (p *service) sendRegistrationSuccessReply(ctx context.Context, regristrationTweetId, username string) error {
369+
// todo: localize this
370+
message := fmt.Sprintf(
371+
"@%s your X account is now connected! Share this link to receive tips: https://tipcard.getcode.com/x/%s",
372+
username,
373+
username,
374+
)
375+
_, err := p.twitterClient.SendReply(ctx, regristrationTweetId, message)
376+
return err
377+
}
378+
379+
func (p *service) onTwitterUsernameNotFound(ctx context.Context, username string) error {
380+
record, err := p.data.GetTwitterUserByUsername(ctx, username)
381+
switch err {
382+
case nil:
383+
case twitter.ErrUserNotFound:
384+
return nil
385+
default:
386+
return errors.Wrap(err, "error getting cached twitter user")
387+
}
388+
389+
record.LastUpdatedAt = time.Now()
390+
391+
err = p.data.SaveTwitterUser(ctx, record)
392+
if err != nil {
393+
return errors.Wrap(err, "error updating cached twitter user")
394+
}
395+
return nil
396+
}
397+
364398
func toProtoVerifiedType(value string) userpb.TwitterUser_VerifiedType {
365399
switch value {
366400
case "blue":

pkg/twitter/client.go

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"sync"
1313
"time"
1414

15+
"github.com/dghubble/oauth1"
1516
"github.com/pkg/errors"
1617

1718
"github.com/code-payments/code-server/pkg/metrics"
@@ -28,20 +29,24 @@ const (
2829
type Client struct {
2930
httpClient *http.Client
3031

31-
clientId string
32-
clientSecret string
32+
clientId string
33+
clientSecret string
34+
accessToken string
35+
accessTokenSecret string
3336

3437
bearerTokenMu sync.RWMutex
3538
bearerToken string
3639
lastBearerTokenRefresh time.Time
3740
}
3841

3942
// NewClient returns a new Twitter client
40-
func NewClient(clientId, clientSecret string) *Client {
43+
func NewClient(clientId, clientSecret, accessToken, accessTokenSecret string) *Client {
4144
return &Client{
42-
httpClient: http.DefaultClient,
43-
clientId: clientId,
44-
clientSecret: clientSecret,
45+
httpClient: http.DefaultClient,
46+
clientId: clientId,
47+
clientSecret: clientSecret,
48+
accessToken: accessToken,
49+
accessTokenSecret: accessTokenSecret,
4550
}
4651
}
4752

@@ -143,8 +148,16 @@ func (c *Client) SearchRecentTweets(ctx context.Context, searchString string, ma
143148
return tweets, nextToken, err
144149
}
145150

151+
// SendReply sends a reply to the provided tweet
152+
func (c *Client) SendReply(ctx context.Context, tweetId, text string) (string, error) {
153+
tracer := metrics.TraceMethodCall(ctx, metricsStructName, "SendReply")
154+
defer tracer.End()
155+
156+
return c.sendTweet(ctx, text, &tweetId)
157+
}
158+
146159
func (c *Client) getUser(ctx context.Context, fromUrl string) (*User, error) {
147-
bearerToken, err := c.getBearerToken(c.clientId, c.clientSecret)
160+
bearerToken, err := c.getBearerToken()
148161
if err != nil {
149162
return nil, err
150163
}
@@ -189,7 +202,7 @@ func (c *Client) getUser(ctx context.Context, fromUrl string) (*User, error) {
189202
}
190203

191204
func (c *Client) getTweets(ctx context.Context, fromUrl string) ([]*Tweet, *string, error) {
192-
bearerToken, err := c.getBearerToken(c.clientId, c.clientSecret)
205+
bearerToken, err := c.getBearerToken()
193206
if err != nil {
194207
return nil, nil, err
195208
}
@@ -253,7 +266,77 @@ func (c *Client) getTweets(ctx context.Context, fromUrl string) ([]*Tweet, *stri
253266
return result.Data, result.Meta.NextToken, nil
254267
}
255268

256-
func (c *Client) getBearerToken(clientId, clientSecret string) (string, error) {
269+
func (c *Client) sendTweet(ctx context.Context, text string, inReplyTo *string) (string, error) {
270+
apiUrl := baseUrl + "tweets"
271+
272+
type ReplyParams struct {
273+
InReplyToTweetId string `json:"in_reply_to_tweet_id"`
274+
}
275+
type Request struct {
276+
Text string `json:"text"`
277+
Reply *ReplyParams `json:"reply"`
278+
}
279+
280+
reqPayload := Request{
281+
Text: text,
282+
}
283+
if inReplyTo != nil {
284+
reqPayload.Reply = &ReplyParams{
285+
InReplyToTweetId: *inReplyTo,
286+
}
287+
}
288+
289+
reqJson, err := json.Marshal(reqPayload)
290+
if err != nil {
291+
return "", err
292+
}
293+
294+
req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(reqJson))
295+
if err != nil {
296+
return "", err
297+
}
298+
299+
req = req.WithContext(ctx)
300+
301+
req.Header.Set("Content-Type", "application/json")
302+
303+
config := oauth1.NewConfig(c.clientId, c.clientSecret)
304+
token := oauth1.NewToken(c.accessToken, c.accessTokenSecret)
305+
httpClient := config.Client(oauth1.NoContext, token)
306+
307+
resp, err := httpClient.Do(req)
308+
if err != nil {
309+
return "", err
310+
}
311+
defer resp.Body.Close()
312+
313+
if resp.StatusCode != http.StatusCreated {
314+
return "", fmt.Errorf("unexpected http status code: %d", resp.StatusCode)
315+
}
316+
317+
var result struct {
318+
Data struct {
319+
Id *string `json:"id"`
320+
} `json:"data"`
321+
Errors []*twitterError `json:"errors"`
322+
}
323+
324+
body, err := io.ReadAll(resp.Body)
325+
if err != nil {
326+
return "", err
327+
}
328+
329+
if err := json.Unmarshal(body, &result); err != nil {
330+
return "", err
331+
}
332+
333+
if len(result.Errors) > 0 {
334+
return "", result.Errors[0].toError()
335+
}
336+
return *result.Data.Id, nil
337+
}
338+
339+
func (c *Client) getBearerToken() (string, error) {
257340
c.bearerTokenMu.RLock()
258341
if time.Since(c.lastBearerTokenRefresh) < bearerTokenMaxAge {
259342
c.bearerTokenMu.RUnlock()
@@ -275,7 +358,7 @@ func (c *Client) getBearerToken(clientId, clientSecret string) (string, error) {
275358
}
276359

277360
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
278-
req.SetBasicAuth(clientId, clientSecret)
361+
req.SetBasicAuth(c.clientId, c.clientSecret)
279362

280363
resp, err := c.httpClient.Do(req)
281364
if err != nil {

0 commit comments

Comments
 (0)