-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathtwitter.go
344 lines (288 loc) · 9.8 KB
/
twitter.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
package async_user
import (
"context"
"crypto/ed25519"
"database/sql"
"strings"
"time"
"github.com/google/uuid"
"github.com/mr-tron/base58"
"github.com/newrelic/go-agent/v3/newrelic"
"github.com/pkg/errors"
commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1"
userpb "github.com/code-payments/code-protobuf-api/generated/go/user/v1"
"github.com/code-payments/code-server/pkg/code/common"
"github.com/code-payments/code-server/pkg/code/data/account"
"github.com/code-payments/code-server/pkg/code/data/twitter"
push_util "github.com/code-payments/code-server/pkg/code/push"
"github.com/code-payments/code-server/pkg/metrics"
"github.com/code-payments/code-server/pkg/retry"
twitter_lib "github.com/code-payments/code-server/pkg/twitter"
)
const (
tipCardRegistrationPrefix = "CodeAccount"
maxTweetSearchResults = 100 // maximum allowed
)
var (
errTwitterInvalidRegistrationValue = errors.New("twitter registration value is invalid")
errTwitterRegistrationNotFound = errors.New("twitter registration not found")
)
func (p *service) twitterRegistrationWorker(serviceCtx context.Context, interval time.Duration) error {
log := p.log.WithField("method", "twitterRegistrationWorker")
delay := interval
err := retry.Loop(
func() (err error) {
time.Sleep(delay)
nr := serviceCtx.Value(metrics.NewRelicContextKey).(*newrelic.Application)
m := nr.StartTransaction("async__user_service__handle_twitter_registration")
defer m.End()
tracedCtx := newrelic.NewContext(serviceCtx, m)
err = p.processNewTwitterRegistrations(tracedCtx)
if err != nil {
m.NoticeError(err)
log.WithError(err).Warn("failure processing new twitter registrations")
}
return err
},
retry.NonRetriableErrors(context.Canceled),
)
return err
}
func (p *service) twitterUserInfoUpdateWorker(serviceCtx context.Context, interval time.Duration) error {
log := p.log.WithField("method", "twitterUserInfoUpdateWorker")
delay := interval
err := retry.Loop(
func() (err error) {
time.Sleep(delay)
nr := serviceCtx.Value(metrics.NewRelicContextKey).(*newrelic.Application)
m := nr.StartTransaction("async__user_service__handle_twitter_user_info_update")
defer m.End()
tracedCtx := newrelic.NewContext(serviceCtx, m)
// todo: configurable parameters
records, err := p.data.GetStaleTwitterUsers(tracedCtx, 7*24*time.Hour, 32)
if err == twitter.ErrUserNotFound {
return nil
} else if err != nil {
m.NoticeError(err)
log.WithError(err).Warn("failure getting stale twitter users")
return err
}
for _, record := range records {
err := p.refreshTwitterUserInfo(tracedCtx, record.Username)
if err != nil {
m.NoticeError(err)
log.WithError(err).Warn("failure refreshing twitter user info")
return err
}
}
return nil
},
retry.NonRetriableErrors(context.Canceled),
)
return err
}
func (p *service) processNewTwitterRegistrations(ctx context.Context) error {
tweets, err := p.findNewRegistrationTweets(ctx)
if err != nil {
return errors.Wrap(err, "error finding new registration tweets")
}
for _, tweet := range tweets {
if tweet.AdditionalMetadata.Author == nil {
return errors.Errorf("author missing in tweet %s", tweet.ID)
}
// Attempt to find a verified tip account from the registration tweet
tipAccount, registrationNonce, err := p.findVerifiedTipAccountRegisteredInTweet(ctx, tweet)
switch err {
case nil:
case errTwitterInvalidRegistrationValue, errTwitterRegistrationNotFound:
continue
default:
return errors.Wrapf(err, "unexpected error processing tweet %s", tweet.ID)
}
// Save the updated tipping information
err = p.data.ExecuteInTx(ctx, sql.LevelDefault, func(ctx context.Context) error {
err = p.data.MarkTwitterNonceAsUsed(ctx, tweet.ID, *registrationNonce)
if err != nil {
return err
}
err = p.updateCachedTwitterUser(ctx, tweet.AdditionalMetadata.Author, tipAccount)
if err != nil {
return err
}
return p.data.MarkTweetAsProcessed(ctx, tweet.ID)
})
switch err {
case nil:
go push_util.SendTwitterAccountConnectedPushNotification(ctx, p.data, p.pusher, tipAccount)
case twitter.ErrDuplicateTipAddress:
err = p.data.ExecuteInTx(ctx, sql.LevelDefault, func(ctx context.Context) error {
err = p.data.MarkTwitterNonceAsUsed(ctx, tweet.ID, *registrationNonce)
if err != nil {
return err
}
return p.data.MarkTweetAsProcessed(ctx, tweet.ID)
})
if err != nil {
return errors.Wrap(err, "error saving tweet with duplicate tip address metadata")
}
return nil
case twitter.ErrDuplicateNonce:
err = p.data.MarkTweetAsProcessed(ctx, tweet.ID)
if err != nil {
return errors.Wrap(err, "error marking tweet with duplicate nonce as processed")
}
return nil
default:
return errors.Wrap(err, "error saving new registration")
}
}
return nil
}
func (p *service) refreshTwitterUserInfo(ctx context.Context, username string) error {
user, err := p.twitterClient.GetUserByUsername(ctx, username)
if err != nil {
return errors.Wrap(err, "error getting user info from twitter")
}
err = p.updateCachedTwitterUser(ctx, user, nil)
if err != nil {
return errors.Wrap(err, "error updating cached user state")
}
return nil
}
func (p *service) updateCachedTwitterUser(ctx context.Context, user *twitter_lib.User, newTipAccount *common.Account) error {
mu := p.userLocks.Get([]byte(user.Username))
mu.Lock()
defer mu.Unlock()
record, err := p.data.GetTwitterUserByUsername(ctx, user.Username)
switch err {
case twitter.ErrUserNotFound:
if newTipAccount == nil {
return errors.New("tip account must be present for newly registered twitter users")
}
record = &twitter.Record{
Username: user.Username,
}
fallthrough
case nil:
record.Name = user.Name
record.ProfilePicUrl = user.ProfileImageUrl
record.VerifiedType = toProtoVerifiedType(user.VerifiedType)
record.FollowerCount = uint32(user.PublicMetrics.FollowersCount)
if newTipAccount != nil {
record.TipAddress = newTipAccount.PublicKey().ToBase58()
}
default:
return errors.Wrap(err, "error getting cached twitter user")
}
err = p.data.SaveTwitterUser(ctx, record)
switch err {
case nil, twitter.ErrDuplicateTipAddress:
return err
default:
return errors.Wrap(err, "error updating cached twitter user")
}
}
func (p *service) findNewRegistrationTweets(ctx context.Context) ([]*twitter_lib.Tweet, error) {
var pageToken *string
var res []*twitter_lib.Tweet
for {
tweets, nextPageToken, err := p.twitterClient.SearchRecentTweets(
ctx,
tipCardRegistrationPrefix,
maxTweetSearchResults,
pageToken,
)
if err != nil {
return nil, errors.Wrap(err, "error searching tweets")
}
for _, tweet := range tweets {
isTweetProcessed, err := p.data.IsTweetProcessed(ctx, tweet.ID)
if err != nil {
return nil, errors.Wrap(err, "error checking if tweet is processed")
} else if isTweetProcessed {
// Found a checkpoint
return res, nil
}
res = append([]*twitter_lib.Tweet{tweet}, res...)
}
if nextPageToken == nil {
return res, nil
}
pageToken = nextPageToken
}
}
func (p *service) findVerifiedTipAccountRegisteredInTweet(ctx context.Context, tweet *twitter_lib.Tweet) (*common.Account, *uuid.UUID, error) {
tweetParts := strings.Fields(tweet.Text)
for _, tweetPart := range tweetParts {
// Look for the well-known prefix to indicate a potential registration value
if !strings.HasPrefix(tweetPart, tipCardRegistrationPrefix) {
continue
}
// Parse out the individual components of the registration value
tweetPart = strings.TrimSuffix(tweetPart, ".")
registrationParts := strings.Split(tweetPart, ":")
if len(registrationParts) != 4 {
return nil, nil, errTwitterInvalidRegistrationValue
}
addressString := registrationParts[1]
nonceString := registrationParts[2]
signatureString := registrationParts[3]
decodedAddress, err := base58.Decode(addressString)
if err != nil {
return nil, nil, errTwitterInvalidRegistrationValue
}
if len(decodedAddress) != 32 {
return nil, nil, errTwitterInvalidRegistrationValue
}
tipAccount, _ := common.NewAccountFromPublicKeyBytes(decodedAddress)
decodedNonce, err := base58.Decode(nonceString)
if err != nil {
return nil, nil, errTwitterInvalidRegistrationValue
}
if len(decodedNonce) != 16 {
return nil, nil, errTwitterInvalidRegistrationValue
}
nonce, _ := uuid.FromBytes(decodedNonce)
decodedSignature, err := base58.Decode(signatureString)
if err != nil {
return nil, nil, errTwitterInvalidRegistrationValue
}
if len(decodedSignature) != 64 {
return nil, nil, errTwitterInvalidRegistrationValue
}
// Validate the components of the registration value
var tipAuthority *common.Account
accountInfoRecord, err := p.data.GetAccountInfoByTokenAddress(ctx, tipAccount.PublicKey().ToBase58())
switch err {
case nil:
if accountInfoRecord.AccountType != commonpb.AccountType_PRIMARY {
return nil, nil, errTwitterInvalidRegistrationValue
}
tipAuthority, err = common.NewAccountFromPublicKeyString(accountInfoRecord.AuthorityAccount)
if err != nil {
return nil, nil, errors.Wrap(err, "invalid tip authority account")
}
case account.ErrAccountInfoNotFound:
return nil, nil, errTwitterInvalidRegistrationValue
default:
return nil, nil, errors.Wrap(err, "error getting account info")
}
if !ed25519.Verify(tipAuthority.PublicKey().ToBytes(), nonce[:], decodedSignature) {
return nil, nil, errTwitterInvalidRegistrationValue
}
return tipAccount, &nonce, nil
}
return nil, nil, errTwitterRegistrationNotFound
}
func toProtoVerifiedType(value string) userpb.TwitterUser_VerifiedType {
switch value {
case "blue":
return userpb.TwitterUser_BLUE
case "business":
return userpb.TwitterUser_BUSINESS
case "government":
return userpb.TwitterUser_GOVERNMENT
default:
return userpb.TwitterUser_NONE
}
}