Skip to content

Commit 16a62c4

Browse files
authored
Add Twitter worker (#97)
* Add Twitter worker for tweet registrations with Code * Add Twitter worker to refresh stale user info * Fix nil access in updateCachedTwitterUser
1 parent 6c3ce57 commit 16a62c4

File tree

10 files changed

+642
-44
lines changed

10 files changed

+642
-44
lines changed

pkg/code/async/user/service.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package async_user
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/sirupsen/logrus"
8+
9+
"github.com/code-payments/code-server/pkg/code/async"
10+
code_data "github.com/code-payments/code-server/pkg/code/data"
11+
"github.com/code-payments/code-server/pkg/sync"
12+
"github.com/code-payments/code-server/pkg/twitter"
13+
)
14+
15+
type service struct {
16+
log *logrus.Entry
17+
data code_data.Provider
18+
twitterClient *twitter.Client
19+
userLocks *sync.StripedLock
20+
}
21+
22+
func New(twitterClient *twitter.Client, data code_data.Provider) async.Service {
23+
return &service{
24+
log: logrus.StandardLogger().WithField("service", "user"),
25+
data: data,
26+
twitterClient: twitterClient,
27+
userLocks: sync.NewStripedLock(1024),
28+
}
29+
}
30+
31+
// todo: split out interval for each worker
32+
func (p *service) Start(ctx context.Context, interval time.Duration) error {
33+
go func() {
34+
err := p.twitterRegistrationWorker(ctx, interval)
35+
if err != nil && err != context.Canceled {
36+
p.log.WithError(err).Warn("twitter registration processing loop terminated unexpectedly")
37+
}
38+
}()
39+
40+
go func() {
41+
err := p.twitterUserInfoUpdateWorker(ctx, interval)
42+
if err != nil && err != context.Canceled {
43+
p.log.WithError(err).Warn("twitter user info processing loop terminated unexpectedly")
44+
}
45+
}()
46+
47+
select {
48+
case <-ctx.Done():
49+
return ctx.Err()
50+
}
51+
}

pkg/code/async/user/twitter.go

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
package async_user
2+
3+
import (
4+
"context"
5+
"strings"
6+
"time"
7+
8+
"github.com/mr-tron/base58"
9+
"github.com/newrelic/go-agent/v3/newrelic"
10+
"github.com/pkg/errors"
11+
12+
commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1"
13+
userpb "github.com/code-payments/code-protobuf-api/generated/go/user/v1"
14+
15+
"github.com/code-payments/code-server/pkg/code/common"
16+
"github.com/code-payments/code-server/pkg/code/data/account"
17+
"github.com/code-payments/code-server/pkg/code/data/twitter"
18+
"github.com/code-payments/code-server/pkg/metrics"
19+
"github.com/code-payments/code-server/pkg/retry"
20+
twitter_lib "github.com/code-payments/code-server/pkg/twitter"
21+
)
22+
23+
const (
24+
tipCardRegistrationPrefix = "accountForX="
25+
maxTweetSearchResults = 100 // maximum allowed
26+
)
27+
28+
var (
29+
errTwitterInvalidRegistrationValue = errors.New("twitter registration value is invalid")
30+
errTwitterRegistrationNotFound = errors.New("twitter registration not found")
31+
)
32+
33+
func (p *service) twitterRegistrationWorker(serviceCtx context.Context, interval time.Duration) error {
34+
log := p.log.WithField("method", "twitterRegistrationWorker")
35+
36+
delay := interval
37+
38+
err := retry.Loop(
39+
func() (err error) {
40+
time.Sleep(delay)
41+
42+
nr := serviceCtx.Value(metrics.NewRelicContextKey).(*newrelic.Application)
43+
m := nr.StartTransaction("async__user_service__handle_twitter_registration")
44+
defer m.End()
45+
tracedCtx := newrelic.NewContext(serviceCtx, m)
46+
47+
err = p.findNewTwitterRegistrations(tracedCtx)
48+
if err != nil {
49+
m.NoticeError(err)
50+
log.WithError(err).Warn("failure processing new twitter registrations")
51+
}
52+
return err
53+
},
54+
retry.NonRetriableErrors(context.Canceled),
55+
)
56+
57+
return err
58+
}
59+
60+
func (p *service) twitterUserInfoUpdateWorker(serviceCtx context.Context, interval time.Duration) error {
61+
log := p.log.WithField("method", "twitterUserInfoUpdateWorker")
62+
63+
delay := interval
64+
65+
err := retry.Loop(
66+
func() (err error) {
67+
time.Sleep(delay)
68+
69+
nr := serviceCtx.Value(metrics.NewRelicContextKey).(*newrelic.Application)
70+
m := nr.StartTransaction("async__user_service__handle_twitter_user_info_update")
71+
defer m.End()
72+
tracedCtx := newrelic.NewContext(serviceCtx, m)
73+
74+
// todo: configurable parameters
75+
records, err := p.data.GetStaleTwitterUsers(tracedCtx, 7*24*time.Hour, 32)
76+
if err == twitter.ErrUserNotFound {
77+
return nil
78+
} else if err != nil {
79+
m.NoticeError(err)
80+
log.WithError(err).Warn("failure getting stale twitter users")
81+
return err
82+
}
83+
84+
for _, record := range records {
85+
err := p.refreshTwitterUserInfo(tracedCtx, record.Username)
86+
if err != nil {
87+
m.NoticeError(err)
88+
log.WithError(err).Warn("failure refreshing twitter user info")
89+
return err
90+
}
91+
}
92+
93+
return nil
94+
},
95+
retry.NonRetriableErrors(context.Canceled),
96+
)
97+
98+
return err
99+
}
100+
101+
func (p *service) findNewTwitterRegistrations(ctx context.Context) error {
102+
var newlyProcessedTweets []string
103+
104+
err := func() error {
105+
var pageToken *string
106+
for {
107+
tweets, nextPageToken, err := p.twitterClient.SearchRecentTweets(
108+
ctx,
109+
tipCardRegistrationPrefix,
110+
maxTweetSearchResults,
111+
pageToken,
112+
)
113+
if err != nil {
114+
return errors.Wrap(err, "error searching tweets")
115+
}
116+
117+
processedUsernames := make(map[string]any)
118+
for _, tweet := range tweets {
119+
if tweet.AdditionalMetadata.Author == nil {
120+
return errors.Errorf("author missing in tweet %s", tweet.ID)
121+
}
122+
123+
isTweetProcessed, err := p.data.IsTweetProcessed(ctx, tweet.ID)
124+
if err != nil {
125+
return errors.Wrap(err, "error checking if tweet is processed")
126+
} else if isTweetProcessed {
127+
// Found a checkpoint, so stop processing
128+
return nil
129+
}
130+
131+
// Oldest tweets go first, so we are guaranteed to checkpoint everything
132+
newlyProcessedTweets = append([]string{tweet.ID}, newlyProcessedTweets...)
133+
134+
// Avoid reprocessing a Twitter user and potentially overriding the
135+
// tip address with something older.
136+
if _, ok := processedUsernames[tweet.AdditionalMetadata.Author.Username]; ok {
137+
continue
138+
}
139+
140+
tipAccount, err := findTipAccountRegisteredInTweet(tweet)
141+
switch err {
142+
case nil:
143+
case errTwitterInvalidRegistrationValue, errTwitterRegistrationNotFound:
144+
continue
145+
default:
146+
return errors.Wrapf(err, "unexpected error processing tweet %s", tweet.ID)
147+
}
148+
149+
processedUsernames[tweet.AdditionalMetadata.Author.Username] = struct{}{}
150+
151+
err = p.updateCachedTwitterUser(ctx, tweet.AdditionalMetadata.Author, tipAccount)
152+
if err != nil {
153+
return errors.Wrap(err, "error updating cached user state")
154+
}
155+
}
156+
157+
if nextPageToken == nil {
158+
return nil
159+
}
160+
pageToken = nextPageToken
161+
}
162+
}()
163+
164+
if err != nil {
165+
return err
166+
}
167+
168+
// Only update the processed tweet cache once we've found another checkpoint,
169+
// or reached the end of the Tweet feed.
170+
//
171+
// todo: add batching
172+
for _, tweetId := range newlyProcessedTweets {
173+
err := p.data.MarkTweetAsProcessed(ctx, tweetId)
174+
if err != nil {
175+
return errors.Wrap(err, "error marking tweet as processed")
176+
}
177+
}
178+
179+
return nil
180+
}
181+
182+
func (p *service) refreshTwitterUserInfo(ctx context.Context, username string) error {
183+
user, err := p.twitterClient.GetUserByUsername(ctx, username)
184+
if err != nil {
185+
return errors.Wrap(err, "error getting user info from twitter")
186+
}
187+
188+
err = p.updateCachedTwitterUser(ctx, user, nil)
189+
if err != nil {
190+
return errors.Wrap(err, "error updating cached user state")
191+
}
192+
return nil
193+
}
194+
195+
func (p *service) updateCachedTwitterUser(ctx context.Context, user *twitter_lib.User, newTipAccount *common.Account) error {
196+
mu := p.userLocks.Get([]byte(user.Username))
197+
mu.Lock()
198+
defer mu.Unlock()
199+
200+
// Validate the new tip account if it's provided
201+
if newTipAccount != nil {
202+
accountInfoRecord, err := p.data.GetAccountInfoByTokenAddress(ctx, newTipAccount.PublicKey().ToBase58())
203+
switch err {
204+
case nil:
205+
if accountInfoRecord.AccountType != commonpb.AccountType_PRIMARY {
206+
return nil
207+
}
208+
case account.ErrAccountInfoNotFound:
209+
default:
210+
return errors.Wrap(err, "error getting account info")
211+
}
212+
}
213+
214+
record, err := p.data.GetTwitterUser(ctx, user.Username)
215+
switch err {
216+
case twitter.ErrUserNotFound:
217+
if newTipAccount == nil {
218+
return errors.New("tip account must be present for newly registered twitter users")
219+
}
220+
221+
record = &twitter.Record{
222+
Username: user.Username,
223+
}
224+
225+
fallthrough
226+
case nil:
227+
record.Name = user.Name
228+
record.ProfilePicUrl = user.ProfileImageUrl
229+
record.VerifiedType = toProtoVerifiedType(user.VerifiedType)
230+
record.FollowerCount = uint32(user.PublicMetrics.FollowersCount)
231+
232+
if newTipAccount != nil {
233+
record.TipAddress = newTipAccount.PublicKey().ToBase58()
234+
}
235+
default:
236+
return errors.Wrap(err, "error getting cached twitter user")
237+
}
238+
239+
err = p.data.SaveTwitterUser(ctx, record)
240+
if err != nil {
241+
return errors.Wrap(err, "error updating cached twitter user")
242+
}
243+
return nil
244+
}
245+
246+
func findTipAccountRegisteredInTweet(tweet *twitter_lib.Tweet) (*common.Account, error) {
247+
var depositAccount *common.Account
248+
249+
parts := strings.Fields(tweet.Text)
250+
for _, part := range parts {
251+
if !strings.HasPrefix(part, tipCardRegistrationPrefix) {
252+
continue
253+
}
254+
255+
part = part[len(tipCardRegistrationPrefix):]
256+
part = strings.TrimSuffix(part, ".")
257+
258+
decoded, err := base58.Decode(part)
259+
if err != nil {
260+
return nil, errTwitterInvalidRegistrationValue
261+
}
262+
263+
if len(decoded) != 32 {
264+
return nil, errTwitterInvalidRegistrationValue
265+
}
266+
267+
depositAccount, _ = common.NewAccountFromPublicKeyBytes(decoded)
268+
return depositAccount, nil
269+
}
270+
271+
return nil, errTwitterRegistrationNotFound
272+
}
273+
274+
func toProtoVerifiedType(value string) userpb.GetTwitterUserResponse_VerifiedType {
275+
switch value {
276+
case "blue":
277+
return userpb.GetTwitterUserResponse_BLUE
278+
case "business":
279+
return userpb.GetTwitterUserResponse_BUSINESS
280+
case "government":
281+
return userpb.GetTwitterUserResponse_GOVERNMENT
282+
default:
283+
return userpb.GetTwitterUserResponse_NONE
284+
}
285+
}

pkg/code/data/internal.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,9 @@ type DatabaseData interface {
427427
// --------------------------------------------------------------------------------
428428
SaveTwitterUser(ctx context.Context, record *twitter.Record) error
429429
GetTwitterUser(ctx context.Context, username string) (*twitter.Record, error)
430+
GetStaleTwitterUsers(ctx context.Context, minAge time.Duration, limit int) ([]*twitter.Record, error)
431+
MarkTweetAsProcessed(ctx context.Context, tweetId string) error
432+
IsTweetProcessed(ctx context.Context, tweetId string) (bool, error)
430433

431434
// ExecuteInTx executes fn with a single DB transaction that is scoped to the call.
432435
// This enables more complex transactions that can span many calls across the provider.
@@ -1514,8 +1517,17 @@ func (dp *DatabaseProvider) IsEligibleForAirdrop(ctx context.Context, owner stri
15141517
// Twitter
15151518
// --------------------------------------------------------------------------------
15161519
func (dp *DatabaseProvider) SaveTwitterUser(ctx context.Context, record *twitter.Record) error {
1517-
return dp.twitter.Save(ctx, record)
1520+
return dp.twitter.SaveUser(ctx, record)
15181521
}
15191522
func (dp *DatabaseProvider) GetTwitterUser(ctx context.Context, username string) (*twitter.Record, error) {
1520-
return dp.twitter.Get(ctx, username)
1523+
return dp.twitter.GetUser(ctx, username)
1524+
}
1525+
func (dp *DatabaseProvider) GetStaleTwitterUsers(ctx context.Context, minAge time.Duration, limit int) ([]*twitter.Record, error) {
1526+
return dp.twitter.GetStaleUsers(ctx, minAge, limit)
1527+
}
1528+
func (dp *DatabaseProvider) MarkTweetAsProcessed(ctx context.Context, tweetId string) error {
1529+
return dp.twitter.MarkTweetAsProcessed(ctx, tweetId)
1530+
}
1531+
func (dp *DatabaseProvider) IsTweetProcessed(ctx context.Context, tweetId string) (bool, error) {
1532+
return dp.twitter.IsTweetProcessed(ctx, tweetId)
15211533
}

0 commit comments

Comments
 (0)