1// Helpers for randomly generated app.bsky.* content: accounts, posts, likes,
2// follows, mentions, etc
3
4package fakedata
5
6import (
7 "bytes"
8 "context"
9 "fmt"
10 "log/slog"
11 "math/rand"
12 "time"
13
14 comatproto "github.com/bluesky-social/indigo/api/atproto"
15 appbsky "github.com/bluesky-social/indigo/api/bsky"
16 lexutil "github.com/bluesky-social/indigo/lex/util"
17 "github.com/bluesky-social/indigo/xrpc"
18
19 "github.com/brianvoe/gofakeit/v6"
20)
21
22var log = slog.Default().With("system", "fakedata")
23
24func SetLogger(logger *slog.Logger) {
25 log = logger
26}
27
28func MeasureIterations(name string) func(int) {
29 start := time.Now()
30 return func(count int) {
31 if count == 0 {
32 return
33 }
34 total := time.Since(start)
35 log.Info("wall runtime", "name", name, "count", count, "total", total, "rate", total/time.Duration(count))
36 }
37}
38
39func GenAccount(xrpcc *xrpc.Client, index int, accountType, domainSuffix string, inviteCode *string) (*AccountContext, error) {
40 if domainSuffix == "" {
41 domainSuffix = "test"
42 }
43 var handleSuffix string
44 if accountType == "celebrity" {
45 handleSuffix = "C"
46 } else {
47 handleSuffix = ""
48 }
49 prefix := gofakeit.Username()
50 if len(prefix) > 10 {
51 prefix = prefix[0:10]
52 }
53 handle := fmt.Sprintf("%s-%s%d.%s", prefix, handleSuffix, index, domainSuffix)
54 email := gofakeit.Email()
55 password := gofakeit.Password(true, true, true, true, true, 24)
56 ctx := context.TODO()
57 resp, err := comatproto.ServerCreateAccount(ctx, xrpcc, &comatproto.ServerCreateAccount_Input{
58 Email: &email,
59 Handle: handle,
60 InviteCode: inviteCode,
61 Password: &password,
62 })
63 if err != nil {
64 return nil, err
65 }
66 auth := xrpc.AuthInfo{
67 AccessJwt: resp.AccessJwt,
68 RefreshJwt: resp.RefreshJwt,
69 Handle: resp.Handle,
70 Did: resp.Did,
71 }
72 return &AccountContext{
73 Index: index,
74 AccountType: accountType,
75 Email: email,
76 Password: password,
77 Auth: auth,
78 }, nil
79}
80
81func GenProfile(xrpcc *xrpc.Client, acc *AccountContext, genAvatar, genBanner bool) error {
82
83 desc := gofakeit.HipsterSentence(12)
84 var name string
85 if acc.AccountType == "celebrity" {
86 name = gofakeit.CelebrityActor()
87 } else {
88 name = gofakeit.Name()
89 }
90
91 var avatar *lexutil.LexBlob
92 if genAvatar {
93 img := gofakeit.ImagePng(200, 200)
94 resp, err := comatproto.RepoUploadBlob(context.TODO(), xrpcc, bytes.NewReader(img))
95 if err != nil {
96 return err
97 }
98 avatar = &lexutil.LexBlob{
99 Ref: resp.Blob.Ref,
100 MimeType: "image/png",
101 Size: resp.Blob.Size,
102 }
103 }
104 var banner *lexutil.LexBlob
105 if genBanner {
106 img := gofakeit.ImageJpeg(800, 200)
107 resp, err := comatproto.RepoUploadBlob(context.TODO(), xrpcc, bytes.NewReader(img))
108 if err != nil {
109 return err
110 }
111 banner = &lexutil.LexBlob{
112 Ref: resp.Blob.Ref,
113 MimeType: "image/jpeg",
114 Size: resp.Blob.Size,
115 }
116 }
117
118 _, err := comatproto.RepoPutRecord(context.TODO(), xrpcc, &comatproto.RepoPutRecord_Input{
119 Repo: acc.Auth.Did,
120 Collection: "app.bsky.actor.profile",
121 Rkey: "self",
122 Record: &lexutil.LexiconTypeDecoder{Val: &appbsky.ActorProfile{
123 Description: &desc,
124 DisplayName: &name,
125 Avatar: avatar,
126 Banner: banner,
127 }},
128 })
129 return err
130}
131
132func GenPosts(xrpcc *xrpc.Client, catalog *AccountCatalog, acc *AccountContext, maxPosts int, fracImage float64, fracMention float64) error {
133
134 var mention *appbsky.FeedPost_Entity
135 var tgt *AccountContext
136 var text string
137 ctx := context.TODO()
138
139 if maxPosts < 1 {
140 return nil
141 }
142 count := rand.Intn(maxPosts)
143
144 // celebrities make 2x the posts
145 if acc.AccountType == "celebrity" {
146 count = count * 2
147 }
148 t1 := MeasureIterations("generate posts")
149 for i := 0; i < count; i++ {
150 text = gofakeit.Sentence(10)
151 if len(text) > 200 {
152 text = text[0:200]
153 }
154
155 // half the time, mention a celeb
156 tgt = nil
157 mention = nil
158 if fracMention > 0.0 && rand.Float64() < fracMention/2 {
159 tgt = &catalog.Regulars[rand.Intn(len(catalog.Regulars))]
160 } else if fracMention > 0.0 && rand.Float64() < fracMention/2 {
161 tgt = &catalog.Celebs[rand.Intn(len(catalog.Celebs))]
162 }
163 if tgt != nil {
164 text = "@" + tgt.Auth.Handle + " " + text
165 mention = &appbsky.FeedPost_Entity{
166 Type: "mention",
167 Value: tgt.Auth.Did,
168 Index: &appbsky.FeedPost_TextSlice{
169 Start: 0,
170 End: int64(len(tgt.Auth.Handle) + 1),
171 },
172 }
173 }
174
175 var images []*appbsky.EmbedImages_Image
176 if fracImage > 0.0 && rand.Float64() < fracImage {
177 img := gofakeit.ImageJpeg(800, 800)
178 resp, err := comatproto.RepoUploadBlob(context.TODO(), xrpcc, bytes.NewReader(img))
179 if err != nil {
180 return err
181 }
182 images = append(images, &appbsky.EmbedImages_Image{
183 Alt: gofakeit.Lunch(),
184 Image: &lexutil.LexBlob{
185 Ref: resp.Blob.Ref,
186 MimeType: "image/jpeg",
187 Size: resp.Blob.Size,
188 },
189 })
190 }
191 post := appbsky.FeedPost{
192 Text: text,
193 CreatedAt: time.Now().Format(time.RFC3339),
194 }
195 if mention != nil {
196 post.Entities = []*appbsky.FeedPost_Entity{mention}
197 }
198 if len(images) > 0 {
199 post.Embed = &appbsky.FeedPost_Embed{
200 EmbedImages: &appbsky.EmbedImages{
201 Images: images,
202 },
203 }
204 }
205 if _, err := comatproto.RepoCreateRecord(ctx, xrpcc, &comatproto.RepoCreateRecord_Input{
206 Collection: "app.bsky.feed.post",
207 Repo: acc.Auth.Did,
208 Record: &lexutil.LexiconTypeDecoder{Val: &post},
209 }); err != nil {
210 return err
211 }
212 }
213 t1(count)
214 return nil
215}
216
217func CreateFollow(xrpcc *xrpc.Client, tgt *AccountContext) error {
218 follow := &appbsky.GraphFollow{
219 CreatedAt: time.Now().Format(time.RFC3339),
220 Subject: tgt.Auth.Did,
221 }
222 _, err := comatproto.RepoCreateRecord(context.TODO(), xrpcc, &comatproto.RepoCreateRecord_Input{
223 Collection: "app.bsky.graph.follow",
224 Repo: xrpcc.Auth.Did,
225 Record: &lexutil.LexiconTypeDecoder{Val: follow},
226 })
227 return err
228}
229
230func CreateLike(xrpcc *xrpc.Client, viewPost *appbsky.FeedDefs_FeedViewPost) error {
231 ctx := context.TODO()
232 like := appbsky.FeedLike{
233 Subject: &comatproto.RepoStrongRef{
234 Uri: viewPost.Post.Uri,
235 Cid: viewPost.Post.Cid,
236 },
237 CreatedAt: time.Now().Format(time.RFC3339),
238 }
239 // TODO: may have already like? in that case should ignore error
240 _, err := comatproto.RepoCreateRecord(ctx, xrpcc, &comatproto.RepoCreateRecord_Input{
241 Collection: "app.bsky.feed.like",
242 Repo: xrpcc.Auth.Did,
243 Record: &lexutil.LexiconTypeDecoder{Val: &like},
244 })
245 return err
246}
247
248func CreateRepost(xrpcc *xrpc.Client, viewPost *appbsky.FeedDefs_FeedViewPost) error {
249 repost := &appbsky.FeedRepost{
250 CreatedAt: time.Now().Format(time.RFC3339),
251 Subject: &comatproto.RepoStrongRef{
252 Uri: viewPost.Post.Uri,
253 Cid: viewPost.Post.Cid,
254 },
255 }
256 _, err := comatproto.RepoCreateRecord(context.TODO(), xrpcc, &comatproto.RepoCreateRecord_Input{
257 Collection: "app.bsky.feed.repost",
258 Repo: xrpcc.Auth.Did,
259 Record: &lexutil.LexiconTypeDecoder{Val: repost},
260 })
261 return err
262}
263
264func CreateReply(xrpcc *xrpc.Client, viewPost *appbsky.FeedDefs_FeedViewPost) error {
265 text := gofakeit.Sentence(10)
266 if len(text) > 200 {
267 text = text[0:200]
268 }
269 parent := &comatproto.RepoStrongRef{
270 Uri: viewPost.Post.Uri,
271 Cid: viewPost.Post.Cid,
272 }
273 root := parent
274 if viewPost.Reply != nil {
275 root = &comatproto.RepoStrongRef{
276 Uri: viewPost.Reply.Root.FeedDefs_PostView.Uri,
277 Cid: viewPost.Reply.Root.FeedDefs_PostView.Cid,
278 }
279 }
280 replyPost := &appbsky.FeedPost{
281 CreatedAt: time.Now().Format(time.RFC3339),
282 Text: text,
283 Reply: &appbsky.FeedPost_ReplyRef{
284 Parent: parent,
285 Root: root,
286 },
287 }
288 _, err := comatproto.RepoCreateRecord(context.TODO(), xrpcc, &comatproto.RepoCreateRecord_Input{
289 Collection: "app.bsky.feed.post",
290 Repo: xrpcc.Auth.Did,
291 Record: &lexutil.LexiconTypeDecoder{Val: replyPost},
292 })
293 return err
294}
295
296func GenFollowsAndMutes(xrpcc *xrpc.Client, catalog *AccountCatalog, acc *AccountContext, maxFollows int, maxMutes int) error {
297
298 // TODO: have a "shape" to likelihood of doing a follow
299 var tgt *AccountContext
300
301 if maxFollows > len(catalog.Regulars) {
302 return fmt.Errorf("not enough regulars to pick maxFollowers from")
303 }
304 if maxMutes > len(catalog.Regulars) {
305 return fmt.Errorf("not enough regulars to pick maxMutes from")
306 }
307
308 regCount := 0
309 celebCount := 0
310 if maxFollows >= 1 {
311 regCount = rand.Intn(maxFollows)
312 celebCount = rand.Intn(len(catalog.Celebs))
313 }
314 t1 := MeasureIterations("generate follows")
315 for idx := range rand.Perm(len(catalog.Celebs))[:celebCount] {
316 tgt = &catalog.Celebs[idx]
317 if tgt.Auth.Did == acc.Auth.Did {
318 continue
319 }
320 if err := CreateFollow(xrpcc, tgt); err != nil {
321 return err
322 }
323 }
324 for idx := range rand.Perm(len(catalog.Regulars))[:regCount] {
325 tgt = &catalog.Regulars[idx]
326 if tgt.Auth.Did == acc.Auth.Did {
327 continue
328 }
329 if err := CreateFollow(xrpcc, tgt); err != nil {
330 return err
331 }
332 }
333 t1(regCount + celebCount)
334
335 // only muting other users, not celebs
336 muteCount := 0
337 if maxFollows >= 1 && maxMutes > 0 {
338 muteCount = rand.Intn(maxMutes)
339 }
340 t2 := MeasureIterations("generate mutes")
341 for idx := range rand.Perm(len(catalog.Regulars))[:muteCount] {
342 tgt = &catalog.Regulars[idx]
343 if tgt.Auth.Did == acc.Auth.Did {
344 continue
345 }
346 if err := appbsky.GraphMuteActor(context.TODO(), xrpcc, &appbsky.GraphMuteActor_Input{Actor: tgt.Auth.Did}); err != nil {
347 return err
348 }
349 }
350 t2(muteCount)
351 return nil
352}
353
354func GenLikesRepostsReplies(xrpcc *xrpc.Client, acc *AccountContext, fracLike, fracRepost, fracReply float64) error {
355 // fetch timeline (up to 100), and iterate over posts
356 maxTimeline := 100
357 resp, err := appbsky.FeedGetTimeline(context.TODO(), xrpcc, "", "", int64(maxTimeline))
358 if err != nil {
359 return err
360 }
361 if len(resp.Feed) > maxTimeline {
362 return fmt.Errorf("got too long timeline len=%d", len(resp.Feed))
363 }
364 for _, post := range resp.Feed {
365 // skip account's own posts
366 if post.Post.Author.Did == acc.Auth.Did {
367 continue
368 }
369
370 // generate
371 if fracLike > 0.0 && rand.Float64() < fracLike {
372 if err := CreateLike(xrpcc, post); err != nil {
373 return err
374 }
375 }
376 if fracRepost > 0.0 && rand.Float64() < fracRepost {
377 if err := CreateRepost(xrpcc, post); err != nil {
378 return err
379 }
380 }
381 if fracReply > 0.0 && rand.Float64() < fracReply {
382 if err := CreateReply(xrpcc, post); err != nil {
383 return err
384 }
385 }
386 }
387 return nil
388}
389
390func BrowseAccount(xrpcc *xrpc.Client, acc *AccountContext) error {
391 // fetch notifications
392 maxNotif := 50
393 resp, err := appbsky.NotificationListNotifications(context.TODO(), xrpcc, "", int64(maxNotif), false, nil, "")
394 if err != nil {
395 return err
396 }
397 if len(resp.Notifications) > maxNotif {
398 return fmt.Errorf("got too many notifications len=%d", len(resp.Notifications))
399 }
400 t1 := MeasureIterations("notification interactions")
401 for _, notif := range resp.Notifications {
402 switch notif.Reason {
403 case "vote":
404 fallthrough
405 case "repost":
406 fallthrough
407 case "follow":
408 _, err := appbsky.ActorGetProfile(context.TODO(), xrpcc, notif.Author.Did)
409 if err != nil {
410 return err
411 }
412 _, err = appbsky.FeedGetAuthorFeed(context.TODO(), xrpcc, notif.Author.Did, "", "", false, 50)
413 if err != nil {
414 return err
415 }
416 case "mention":
417 fallthrough
418 case "reply":
419 _, err := appbsky.FeedGetPostThread(context.TODO(), xrpcc, 4, 80, notif.Uri)
420 if err != nil {
421 return err
422 }
423 default:
424 }
425 }
426 t1(len(resp.Notifications))
427
428 // fetch timeline (up to 100), and iterate over posts
429 timelineLen := 100
430 timelineResp, err := appbsky.FeedGetTimeline(context.TODO(), xrpcc, "", "", int64(timelineLen))
431 if err != nil {
432 return err
433 }
434 if len(timelineResp.Feed) > timelineLen {
435 return fmt.Errorf("longer than expected timeline len=%d", len(timelineResp.Feed))
436 }
437 t2 := MeasureIterations("timeline interactions")
438 for _, post := range timelineResp.Feed {
439 // skip account's own posts
440 if post.Post.Author.Did == acc.Auth.Did {
441 continue
442 }
443 // TODO: should we do something different here?
444 if rand.Float64() < 0.25 {
445 _, err = appbsky.FeedGetPostThread(context.TODO(), xrpcc, 4, 80, post.Post.Uri)
446 if err != nil {
447 return err
448 }
449 } else if rand.Float64() < 0.25 {
450 _, err = appbsky.ActorGetProfile(context.TODO(), xrpcc, post.Post.Author.Did)
451 if err != nil {
452 return err
453 }
454 _, err = appbsky.FeedGetAuthorFeed(context.TODO(), xrpcc, post.Post.Author.Did, "", "", false, 50)
455 if err != nil {
456 return err
457 }
458 }
459 }
460 t2(len(timelineResp.Feed))
461
462 // notification count for good measure
463 _, err = appbsky.NotificationGetUnreadCount(context.TODO(), xrpcc, false, "")
464 return err
465}