this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

automod: test capture framework (#470)

This PR is currently rebased on top of
https://github.com/bluesky-social/indigo/pull/466, to demonstrate
testing that rule. **UPDATE:** that PR merged, so now against `main`

Adds a `hepa` command to "capture" the current state of a real-world
account: currently some account metadata (identity, profile, etc), plus
some recent post records. This gets serialized to JSON for easy dumping
to file, like:

```shell
go run ./cmd/hepa/ capture-recent atproto.com > automod/testdata/capture_atprotocom.json
```

Then, a test helper function which loads this file, and processes all
the post records using an engine fixture.

Combined, these fixtures make it easy to do test-driven-development of
new rules. You find an account which recently sent spam or violated some
policy, take a capture snapshot, set up a test case, and then write a
rule which triggers and satisfies the test.

Some notes:

- tried moving the "test helpers" in to a sub-package
(`indigo/automod/automodtest`) but hit a circular import, so left where
it is
- this won't work with all rule types, and some captures/rules may need
additional mocking (eg, additional identities in the mock directory),
but that should be fine
- it usually isn't appropriate to capture real-world content in to
public code. we can be careful about what we add in this repo (indigo);
the "hackerdarkweb" example included in this PR seems fine to snapshot
to me. the code does strip "Private" account metadata by default.
- probably could use docs/comments. i'm not sure where best to put
effort, feedback welcome!

authored by

bnewbold and committed by
GitHub
705a15dc fb514302

+1743 -222
+1 -1
automod/action_dedupe_test.go
··· 19 19 func TestAccountReportDedupe(t *testing.T) { 20 20 assert := assert.New(t) 21 21 ctx := context.Background() 22 - engine := engineFixture() 22 + engine := EngineTestFixture() 23 23 engine.Rules = RuleSet{ 24 24 RecordRules: []RecordRuleFunc{ 25 25 alwaysReportAccountRule,
+21
automod/capture_test.go
··· 1 + package automod 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestNoOpCaptureReplyRule(t *testing.T) { 10 + assert := assert.New(t) 11 + 12 + engine := EngineTestFixture() 13 + capture := MustLoadCapture("testdata/capture_atprotocom.json") 14 + assert.NoError(ProcessCaptureRules(&engine, capture)) 15 + c, err := engine.GetCount("automod-quota", "report", PeriodDay) 16 + assert.NoError(err) 17 + assert.Equal(0, c) 18 + c, err = engine.GetCount("automod-quota", "takedown", PeriodDay) 19 + assert.NoError(err) 20 + assert.Equal(0, c) 21 + }
+2 -2
automod/circuit_breaker_test.go
··· 25 25 func TestTakedownCircuitBreaker(t *testing.T) { 26 26 assert := assert.New(t) 27 27 ctx := context.Background() 28 - engine := engineFixture() 28 + engine := EngineTestFixture() 29 29 dir := identity.NewMockDirectory() 30 30 engine.Directory = &dir 31 31 // note that this is a record-level action, not account-level ··· 61 61 func TestReportCircuitBreaker(t *testing.T) { 62 62 assert := assert.New(t) 63 63 ctx := context.Background() 64 - engine := engineFixture() 64 + engine := EngineTestFixture() 65 65 dir := identity.NewMockDirectory() 66 66 engine.Directory = &dir 67 67 engine.Rules = RuleSet{
-60
automod/engine.go
··· 6 6 "log/slog" 7 7 "strings" 8 8 9 - comatproto "github.com/bluesky-social/indigo/api/atproto" 10 9 "github.com/bluesky-social/indigo/atproto/identity" 11 10 "github.com/bluesky-social/indigo/atproto/syntax" 12 11 "github.com/bluesky-social/indigo/automod/countstore" ··· 163 162 } 164 163 if err := evt.PersistCounters(ctx); err != nil { 165 164 return err 166 - } 167 - return nil 168 - } 169 - 170 - func (e *Engine) FetchAndProcessRecord(ctx context.Context, aturi syntax.ATURI) error { 171 - // resolve URI, identity, and record 172 - if aturi.RecordKey() == "" { 173 - return fmt.Errorf("need a full, not partial, AT-URI: %s", aturi) 174 - } 175 - ident, err := e.Directory.Lookup(ctx, aturi.Authority()) 176 - if err != nil { 177 - return fmt.Errorf("resolving AT-URI authority: %v", err) 178 - } 179 - pdsURL := ident.PDSEndpoint() 180 - if pdsURL == "" { 181 - return fmt.Errorf("could not resolve PDS endpoint for AT-URI account: %s", ident.DID.String()) 182 - } 183 - pdsClient := xrpc.Client{Host: ident.PDSEndpoint()} 184 - 185 - e.Logger.Info("fetching record", "did", ident.DID.String(), "collection", aturi.Collection().String(), "rkey", aturi.RecordKey().String()) 186 - out, err := comatproto.RepoGetRecord(ctx, &pdsClient, "", aturi.Collection().String(), ident.DID.String(), aturi.RecordKey().String()) 187 - if err != nil { 188 - return fmt.Errorf("fetching record from Relay (%s): %v", aturi, err) 189 - } 190 - if out.Cid == nil { 191 - return fmt.Errorf("expected a CID in getRecord response") 192 - } 193 - return e.ProcessRecord(ctx, ident.DID, aturi.Path(), *out.Cid, out.Value.Val) 194 - } 195 - 196 - func (e *Engine) FetchAndProcessRecent(ctx context.Context, atid syntax.AtIdentifier, limit int) error { 197 - 198 - ident, err := e.Directory.Lookup(ctx, atid) 199 - if err != nil { 200 - return fmt.Errorf("failed to resolve AT identifier: %v", err) 201 - } 202 - pdsURL := ident.PDSEndpoint() 203 - if pdsURL == "" { 204 - return fmt.Errorf("could not resolve PDS endpoint for account: %s", ident.DID.String()) 205 - } 206 - pdsClient := xrpc.Client{Host: ident.PDSEndpoint()} 207 - 208 - resp, err := comatproto.RepoListRecords(ctx, &pdsClient, "app.bsky.feed.post", "", int64(limit), ident.DID.String(), false, "", "") 209 - if err != nil { 210 - return fmt.Errorf("failed to fetch record list: %v", err) 211 - } 212 - 213 - e.Logger.Info("got recent posts", "did", ident.DID.String(), "pds", pdsURL, "count", len(resp.Records)) 214 - // records are most-recent first; we want recent but oldest-first, so iterate backwards 215 - for i := range resp.Records { 216 - rec := resp.Records[len(resp.Records)-i-1] 217 - aturi, err := syntax.ParseATURI(rec.Uri) 218 - if err != nil { 219 - return fmt.Errorf("parsing PDS record response: %v", err) 220 - } 221 - err = e.ProcessRecord(ctx, ident.DID, aturi.Path(), rec.Cid, rec.Value.Val) 222 - if err != nil { 223 - return err 224 - } 225 165 } 226 166 return nil 227 167 }
+1 -54
automod/engine_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log/slog" 6 5 "testing" 7 - "time" 8 6 9 7 appbsky "github.com/bluesky-social/indigo/api/bsky" 10 8 "github.com/bluesky-social/indigo/atproto/identity" 11 9 "github.com/bluesky-social/indigo/atproto/syntax" 12 - "github.com/bluesky-social/indigo/automod/countstore" 13 10 14 11 "github.com/stretchr/testify/assert" 15 12 ) 16 13 17 - func simpleRule(evt *RecordEvent, post *appbsky.FeedPost) error { 18 - for _, tag := range post.Tags { 19 - if evt.InSet("bad-hashtags", tag) { 20 - evt.AddRecordLabel("bad-hashtag") 21 - break 22 - } 23 - } 24 - for _, facet := range post.Facets { 25 - for _, feat := range facet.Features { 26 - if feat.RichtextFacet_Tag != nil { 27 - tag := feat.RichtextFacet_Tag.Tag 28 - if evt.InSet("bad-hashtags", tag) { 29 - evt.AddRecordLabel("bad-hashtag") 30 - break 31 - } 32 - } 33 - } 34 - } 35 - return nil 36 - } 37 - 38 - func engineFixture() Engine { 39 - rules := RuleSet{ 40 - PostRules: []PostRuleFunc{ 41 - simpleRule, 42 - }, 43 - } 44 - cache := NewMemCacheStore(10, time.Hour) 45 - flags := NewMemFlagStore() 46 - sets := NewMemSetStore() 47 - sets.Sets["bad-hashtags"] = make(map[string]bool) 48 - sets.Sets["bad-hashtags"]["slur"] = true 49 - dir := identity.NewMockDirectory() 50 - id1 := identity.Identity{ 51 - DID: syntax.DID("did:plc:abc111"), 52 - Handle: syntax.Handle("handle.example.com"), 53 - } 54 - dir.Insert(id1) 55 - engine := Engine{ 56 - Logger: slog.Default(), 57 - Directory: &dir, 58 - Counters: countstore.NewMemCountStore(), 59 - Sets: sets, 60 - Flags: flags, 61 - Cache: cache, 62 - Rules: rules, 63 - } 64 - return engine 65 - } 66 - 67 14 func TestEngineBasics(t *testing.T) { 68 15 assert := assert.New(t) 69 16 ctx := context.Background() 70 17 71 - engine := engineFixture() 18 + engine := EngineTestFixture() 72 19 id1 := identity.Identity{ 73 20 DID: syntax.DID("did:plc:abc111"), 74 21 Handle: syntax.Handle("handle.example.com"),
+13 -7
automod/event.go
··· 307 307 } 308 308 } 309 309 310 + // flags don't require admin auth 311 + if len(newFlags) > 0 { 312 + e.Engine.Flags.Add(ctx, e.Account.Identity.DID.String(), newFlags) 313 + } 314 + 310 315 // if we can't actually talk to service, bail out early 311 316 if e.Engine.AdminClient == nil { 312 317 return nil ··· 335 340 if err != nil { 336 341 return err 337 342 } 338 - } 339 - 340 - if len(newFlags) > 0 { 341 - e.Engine.Flags.Add(ctx, e.Account.Identity.DID.String(), newFlags) 342 343 } 343 344 344 345 // reports are additionally de-duped when persisting the action, so track with a flag ··· 493 494 } 494 495 } 495 496 } 497 + 498 + // flags don't require admin auth 499 + if len(newFlags) > 0 { 500 + e.Engine.Flags.Add(ctx, atURI, newFlags) 501 + } 502 + 496 503 if e.Engine.AdminClient == nil { 497 504 return nil 498 505 } 506 + 499 507 strongRef := comatproto.RepoStrongRef{ 500 508 Cid: e.CID, 501 509 Uri: atURI, ··· 521 529 return err 522 530 } 523 531 } 524 - if len(newFlags) > 0 { 525 - e.Engine.Flags.Add(ctx, atURI, newFlags) 526 - } 532 + 527 533 for _, mr := range newReports { 528 534 e.Logger.Info("reporting record", "reasonType", mr.ReasonType, "comment", mr.Comment) 529 535 _, err := comatproto.ModerationCreateReport(ctx, xrpcc, &comatproto.ModerationCreateReport_Input{
+113
automod/fetch.go
··· 1 + package automod 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + comatproto "github.com/bluesky-social/indigo/api/atproto" 8 + "github.com/bluesky-social/indigo/atproto/identity" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + ) 12 + 13 + func (e *Engine) FetchAndProcessRecord(ctx context.Context, aturi syntax.ATURI) error { 14 + // resolve URI, identity, and record 15 + if aturi.RecordKey() == "" { 16 + return fmt.Errorf("need a full, not partial, AT-URI: %s", aturi) 17 + } 18 + ident, err := e.Directory.Lookup(ctx, aturi.Authority()) 19 + if err != nil { 20 + return fmt.Errorf("resolving AT-URI authority: %v", err) 21 + } 22 + pdsURL := ident.PDSEndpoint() 23 + if pdsURL == "" { 24 + return fmt.Errorf("could not resolve PDS endpoint for AT-URI account: %s", ident.DID.String()) 25 + } 26 + pdsClient := xrpc.Client{Host: ident.PDSEndpoint()} 27 + 28 + e.Logger.Info("fetching record", "did", ident.DID.String(), "collection", aturi.Collection().String(), "rkey", aturi.RecordKey().String()) 29 + out, err := comatproto.RepoGetRecord(ctx, &pdsClient, "", aturi.Collection().String(), ident.DID.String(), aturi.RecordKey().String()) 30 + if err != nil { 31 + return fmt.Errorf("fetching record from Relay (%s): %v", aturi, err) 32 + } 33 + if out.Cid == nil { 34 + return fmt.Errorf("expected a CID in getRecord response") 35 + } 36 + return e.ProcessRecord(ctx, ident.DID, aturi.Path(), *out.Cid, out.Value.Val) 37 + } 38 + 39 + func (e *Engine) FetchRecent(ctx context.Context, atid syntax.AtIdentifier, limit int) (*identity.Identity, []*comatproto.RepoListRecords_Record, error) { 40 + ident, err := e.Directory.Lookup(ctx, atid) 41 + if err != nil { 42 + return nil, nil, fmt.Errorf("failed to resolve AT identifier: %v", err) 43 + } 44 + pdsURL := ident.PDSEndpoint() 45 + if pdsURL == "" { 46 + return nil, nil, fmt.Errorf("could not resolve PDS endpoint for account: %s", ident.DID.String()) 47 + } 48 + pdsClient := xrpc.Client{Host: ident.PDSEndpoint()} 49 + 50 + resp, err := comatproto.RepoListRecords(ctx, &pdsClient, "app.bsky.feed.post", "", int64(limit), ident.DID.String(), false, "", "") 51 + if err != nil { 52 + return nil, nil, fmt.Errorf("failed to fetch record list: %v", err) 53 + } 54 + e.Logger.Info("got recent posts", "did", ident.DID.String(), "pds", pdsURL, "count", len(resp.Records)) 55 + return ident, resp.Records, nil 56 + } 57 + 58 + func (e *Engine) FetchAndProcessRecent(ctx context.Context, atid syntax.AtIdentifier, limit int) error { 59 + 60 + ident, records, err := e.FetchRecent(ctx, atid, limit) 61 + if err != nil { 62 + return err 63 + } 64 + // records are most-recent first; we want recent but oldest-first, so iterate backwards 65 + for i := range records { 66 + rec := records[len(records)-i-1] 67 + aturi, err := syntax.ParseATURI(rec.Uri) 68 + if err != nil { 69 + return fmt.Errorf("parsing PDS record response: %v", err) 70 + } 71 + err = e.ProcessRecord(ctx, ident.DID, aturi.Path(), rec.Cid, rec.Value.Val) 72 + if err != nil { 73 + return err 74 + } 75 + } 76 + return nil 77 + } 78 + 79 + type AccountCapture struct { 80 + CapturedAt syntax.Datetime `json:"capturedAt"` 81 + AccountMeta AccountMeta `json:"accountMeta"` 82 + PostRecords []comatproto.RepoListRecords_Record `json:"postRecords"` 83 + } 84 + 85 + func (e *Engine) CaptureRecent(ctx context.Context, atid syntax.AtIdentifier, limit int) (*AccountCapture, error) { 86 + ident, records, err := e.FetchRecent(ctx, atid, limit) 87 + if err != nil { 88 + return nil, err 89 + } 90 + pr := []comatproto.RepoListRecords_Record{} 91 + for _, r := range records { 92 + if r != nil { 93 + pr = append(pr, *r) 94 + } 95 + } 96 + 97 + // clear any pre-parsed key, which would fail to marshal as JSON 98 + ident.ParsedPublicKey = nil 99 + am, err := e.GetAccountMeta(ctx, ident) 100 + if err != nil { 101 + return nil, err 102 + } 103 + 104 + // auto-clear sensitive PII (eg, account email) 105 + am.Private = nil 106 + 107 + ac := AccountCapture{ 108 + CapturedAt: syntax.DatetimeNow(), 109 + AccountMeta: *am, 110 + PostRecords: pr, 111 + } 112 + return &ac, nil 113 + }
-50
automod/rules/fixture_test.go
··· 1 - package rules 2 - 3 - import ( 4 - "log/slog" 5 - "time" 6 - 7 - "github.com/bluesky-social/indigo/atproto/identity" 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 - "github.com/bluesky-social/indigo/automod" 10 - "github.com/bluesky-social/indigo/automod/countstore" 11 - "github.com/bluesky-social/indigo/xrpc" 12 - ) 13 - 14 - func engineFixture() automod.Engine { 15 - rules := automod.RuleSet{ 16 - PostRules: []automod.PostRuleFunc{ 17 - BadHashtagsPostRule, 18 - }, 19 - } 20 - flags := automod.NewMemFlagStore() 21 - cache := automod.NewMemCacheStore(10, time.Hour) 22 - sets := automod.NewMemSetStore() 23 - sets.Sets["bad-hashtags"] = make(map[string]bool) 24 - sets.Sets["bad-hashtags"]["slur"] = true 25 - dir := identity.NewMockDirectory() 26 - id1 := identity.Identity{ 27 - DID: syntax.DID("did:plc:abc111"), 28 - Handle: syntax.Handle("handle.example.com"), 29 - } 30 - id2 := identity.Identity{ 31 - DID: syntax.DID("did:plc:abc222"), 32 - Handle: syntax.Handle("imposter.example.com"), 33 - } 34 - dir.Insert(id1) 35 - dir.Insert(id2) 36 - adminc := xrpc.Client{ 37 - Host: "http://dummy.local", 38 - } 39 - engine := automod.Engine{ 40 - Logger: slog.Default(), 41 - Directory: &dir, 42 - Counters: countstore.NewMemCountStore(), 43 - Sets: sets, 44 - Flags: flags, 45 - Cache: cache, 46 - Rules: rules, 47 - AdminClient: &adminc, 48 - } 49 - return engine 50 - }
+1 -1
automod/rules/hashtags_test.go
··· 14 14 func TestBadHashtagPostRule(t *testing.T) { 15 15 assert := assert.New(t) 16 16 17 - engine := engineFixture() 17 + engine := automod.EngineTestFixture() 18 18 am1 := automod.AccountMeta{ 19 19 Identity: &identity.Identity{ 20 20 DID: syntax.DID("did:plc:abc111"),
+2 -2
automod/rules/misleading_test.go
··· 15 15 func TestMisleadingURLPostRule(t *testing.T) { 16 16 assert := assert.New(t) 17 17 18 - engine := engineFixture() 18 + engine := automod.EngineTestFixture() 19 19 am1 := automod.AccountMeta{ 20 20 Identity: &identity.Identity{ 21 21 DID: syntax.DID("did:plc:abc111"), ··· 50 50 func TestMisleadingMentionPostRule(t *testing.T) { 51 51 assert := assert.New(t) 52 52 53 - engine := engineFixture() 53 + engine := automod.EngineTestFixture() 54 54 am1 := automod.AccountMeta{ 55 55 Identity: &identity.Identity{ 56 56 DID: syntax.DID("did:plc:abc111"),
+29
automod/rules/replies_test.go
··· 1 + package rules 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + 7 + "github.com/bluesky-social/indigo/automod" 8 + 9 + "github.com/stretchr/testify/assert" 10 + ) 11 + 12 + func TestIdenticalReplyPostRule(t *testing.T) { 13 + assert := assert.New(t) 14 + ctx := context.Background() 15 + 16 + engine := automod.EngineTestFixture() 17 + engine.Rules = automod.RuleSet{ 18 + PostRules: []automod.PostRuleFunc{ 19 + IdenticalReplyPostRule, 20 + }, 21 + } 22 + 23 + capture := automod.MustLoadCapture("testdata/capture_hackerdarkweb.json") 24 + did := capture.AccountMeta.Identity.DID.String() 25 + assert.NoError(automod.ProcessCaptureRules(&engine, capture)) 26 + f, err := engine.Flags.Get(ctx, did) 27 + assert.NoError(err) 28 + assert.Equal([]string{"multi-identical-reply"}, f) 29 + }
+522
automod/rules/testdata/capture_hackerdarkweb.json
··· 1 + { 2 + "capturedAt": "2023-12-09T05:50:42.004Z", 3 + "accountMeta": { 4 + "Identity": { 5 + "DID": "did:plc:suvj7i7gk6vzc7wvwd3gx6zq", 6 + "Handle": "hackerdarkweb.bsky.social", 7 + "AlsoKnownAs": [ 8 + "at://hackerdarkweb.bsky.social" 9 + ], 10 + "Services": { 11 + "atproto_pds": { 12 + "Type": "AtprotoPersonalDataServer", 13 + "URL": "https://puffball.us-east.host.bsky.network" 14 + } 15 + }, 16 + "Keys": { 17 + "atproto": { 18 + "Type": "Multikey", 19 + "PublicKeyMultibase": "zQ3sha9ULLgA6zyJmk1z2uDkpkSs4Ypou33RE8XuhwWtSHF75" 20 + } 21 + }, 22 + "ParsedPublicKey": null 23 + }, 24 + "Profile": { 25 + "HasAvatar": true, 26 + "Description": "Message on WhatsApp to get cyber help support \n‪+1 (765) 734‑3839‬\nPRO HACKER🔉\nCAR TRACKING \nBINARY HACKING🔉\nACCOUNT RECOVERY💭\nSPY ON PARTNERS PHONE💭\nRECOVER SCAMMED FUNDS\nUNLOCKING AND TRACKING DEVICES\nBOT INVESTMENT", 27 + "DisplayName": "Hacker_dark_web" 28 + }, 29 + "Private": null, 30 + "AccountLabels": [ 31 + "spam" 32 + ], 33 + "AccountNegatedLabels": null, 34 + "AccountFlags": [], 35 + "FollowersCount": 3, 36 + "FollowsCount": 102, 37 + "PostsCount": 140, 38 + "Takendown": false 39 + }, 40 + "postRecords": [ 41 + { 42 + "cid": "bafyreietl6p56eogohp2cnm6k6isod7jtkbh7k3oxrrspjv7uvlwlorgyy", 43 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfxt2dprhx22", 44 + "value": { 45 + "$type": "app.bsky.feed.post", 46 + "createdAt": "2023-12-07T16:49:34.125Z", 47 + "facets": [ 48 + { 49 + "features": [ 50 + { 51 + "$type": "app.bsky.richtext.facet#mention", 52 + "did": "did:plc:zhxe4qbuwbmvliuh7subna6p" 53 + } 54 + ], 55 + "index": { 56 + "byteEnd": 24, 57 + "byteStart": 0 58 + } 59 + } 60 + ], 61 + "langs": [ 62 + "en" 63 + ], 64 + "text": "@crossbunbun.bsky.social Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 65 + } 66 + }, 67 + { 68 + "cid": "bafyreibnj3dh27zcuywx6bcbgde74f57sqjsly2iirkam2kwbztckiuzu4", 69 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfxt24db3v2f", 70 + "value": { 71 + "$type": "app.bsky.feed.post", 72 + "createdAt": "2023-12-07T16:49:26.393Z", 73 + "langs": [ 74 + "en" 75 + ], 76 + "reply": { 77 + "parent": { 78 + "cid": "bafyreiai3o2b7d5alheiiohqb3zeagplz37lngvndowzabzy2ox2gil3ji", 79 + "uri": "at://did:plc:zhxe4qbuwbmvliuh7subna6p/app.bsky.feed.post/3kfrqvnrbjk22" 80 + }, 81 + "root": { 82 + "cid": "bafyreiai3o2b7d5alheiiohqb3zeagplz37lngvndowzabzy2ox2gil3ji", 83 + "uri": "at://did:plc:zhxe4qbuwbmvliuh7subna6p/app.bsky.feed.post/3kfrqvnrbjk22" 84 + } 85 + }, 86 + "text": "Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 87 + } 88 + }, 89 + { 90 + "cid": "bafyreihtphceqkvhjfogyiwebyytddthogzdsysigopupja6wefdfdkvrm", 91 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfxszcwuyl23", 92 + "value": { 93 + "$type": "app.bsky.feed.post", 94 + "createdAt": "2023-12-07T16:48:59.765Z", 95 + "facets": [ 96 + { 97 + "features": [ 98 + { 99 + "$type": "app.bsky.richtext.facet#mention", 100 + "did": "did:plc:jhp4iwpyeqtcsrp6kifcgi7b" 101 + } 102 + ], 103 + "index": { 104 + "byteEnd": 25, 105 + "byteStart": 0 106 + } 107 + } 108 + ], 109 + "langs": [ 110 + "en" 111 + ], 112 + "text": "@cyberfoxmeow.bsky.social Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 113 + } 114 + }, 115 + { 116 + "cid": "bafyreidwryv6aofdrla243zelnspet4o66eet2pmd2vlla3yrfjeqlju2u", 117 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfxsz257d322", 118 + "value": { 119 + "$type": "app.bsky.feed.post", 120 + "createdAt": "2023-12-07T16:48:50.538Z", 121 + "langs": [ 122 + "en" 123 + ], 124 + "reply": { 125 + "parent": { 126 + "cid": "bafyreiguf5uuetq5fvr7bgh4vt42q6wzf34wlwseq5jz2s6elwuxbjtaym", 127 + "uri": "at://did:plc:jhp4iwpyeqtcsrp6kifcgi7b/app.bsky.feed.post/3kftvcwhm4m2g" 128 + }, 129 + "root": { 130 + "cid": "bafyreiguf5uuetq5fvr7bgh4vt42q6wzf34wlwseq5jz2s6elwuxbjtaym", 131 + "uri": "at://did:plc:jhp4iwpyeqtcsrp6kifcgi7b/app.bsky.feed.post/3kftvcwhm4m2g" 132 + } 133 + }, 134 + "text": "Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 135 + } 136 + }, 137 + { 138 + "cid": "bafyreiawlfmqoxkjm4woqc36nl4armlmyo26shkfgah7asne2baka43uhy", 139 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfxsyobb4s2z", 140 + "value": { 141 + "$type": "app.bsky.feed.post", 142 + "createdAt": "2023-12-07T16:48:38.093Z", 143 + "facets": [ 144 + { 145 + "features": [ 146 + { 147 + "$type": "app.bsky.richtext.facet#mention", 148 + "did": "did:plc:bzy5rjjduvvkxno5xe3evl3f" 149 + } 150 + ], 151 + "index": { 152 + "byteEnd": 24, 153 + "byteStart": 0 154 + } 155 + } 156 + ], 157 + "langs": [ 158 + "en" 159 + ], 160 + "text": "@lollardfish.bsky.social Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 161 + } 162 + }, 163 + { 164 + "cid": "bafyreif3uvcp7djvt6nyqcxoh66coigdjbvtchsgybmrcj73wemaqalr4a", 165 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfxsygqc6v2a", 166 + "value": { 167 + "$type": "app.bsky.feed.post", 168 + "createdAt": "2023-12-07T16:48:30.192Z", 169 + "langs": [ 170 + "en" 171 + ], 172 + "reply": { 173 + "parent": { 174 + "cid": "bafyreihwjky3uxjzr5bzdty2d6acbdcjo6zoloxlqhbg3swo4v74struf4", 175 + "uri": "at://did:plc:bzy5rjjduvvkxno5xe3evl3f/app.bsky.feed.post/3kfvfhdu5xi2p" 176 + }, 177 + "root": { 178 + "cid": "bafyreihwjky3uxjzr5bzdty2d6acbdcjo6zoloxlqhbg3swo4v74struf4", 179 + "uri": "at://did:plc:bzy5rjjduvvkxno5xe3evl3f/app.bsky.feed.post/3kfvfhdu5xi2p" 180 + } 181 + }, 182 + "text": "Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 183 + } 184 + }, 185 + { 186 + "cid": "bafyreign2datfgnkwajm3zffpbmsyczraifte3ui3p4mzfwmc5hamd4xqq", 187 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfxsx5p6oy2s", 188 + "value": { 189 + "$type": "app.bsky.feed.post", 190 + "createdAt": "2023-12-07T16:47:47.163Z", 191 + "langs": [ 192 + "en" 193 + ], 194 + "reply": { 195 + "parent": { 196 + "cid": "bafyreifom67hymr6xtgzn73didgft27wo5w2kky2g5zktjrpiakqelksee", 197 + "uri": "at://did:plc:c2pocrpcqo5oqts2udoapepb/app.bsky.feed.post/3kfxoxp67yn26" 198 + }, 199 + "root": { 200 + "cid": "bafyreifom67hymr6xtgzn73didgft27wo5w2kky2g5zktjrpiakqelksee", 201 + "uri": "at://did:plc:c2pocrpcqo5oqts2udoapepb/app.bsky.feed.post/3kfxoxp67yn26" 202 + } 203 + }, 204 + "text": "Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 205 + } 206 + }, 207 + { 208 + "cid": "bafyreifxevvtccaebv34dnemdb2bvyfj34jcakfqh6eptgaaaxmm4vdtja", 209 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfxswjdi5t23", 210 + "value": { 211 + "$type": "app.bsky.feed.post", 212 + "createdAt": "2023-12-07T16:47:25.788Z", 213 + "facets": [ 214 + { 215 + "features": [ 216 + { 217 + "$type": "app.bsky.richtext.facet#mention", 218 + "did": "did:plc:c2pocrpcqo5oqts2udoapepb" 219 + } 220 + ], 221 + "index": { 222 + "byteEnd": 20, 223 + "byteStart": 0 224 + } 225 + } 226 + ], 227 + "langs": [ 228 + "en" 229 + ], 230 + "text": "@johncnj.bsky.social Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 231 + } 232 + }, 233 + { 234 + "cid": "bafyreiauo4dhxpjycqqn7gmv276224x3shnc2paf44eonbatqptk5b74ti", 235 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfxswalsxn2j", 236 + "value": { 237 + "$type": "app.bsky.feed.post", 238 + "createdAt": "2023-12-07T16:47:16.632Z", 239 + "langs": [ 240 + "en" 241 + ], 242 + "reply": { 243 + "parent": { 244 + "cid": "bafyreibqcgtk44ovhreovuvp5f3z6jfwhlahktysizlgmvs5i6eajrfnby", 245 + "uri": "at://did:plc:c2pocrpcqo5oqts2udoapepb/app.bsky.feed.post/3kfxlfqfzll2p" 246 + }, 247 + "root": { 248 + "cid": "bafyreibqcgtk44ovhreovuvp5f3z6jfwhlahktysizlgmvs5i6eajrfnby", 249 + "uri": "at://did:plc:c2pocrpcqo5oqts2udoapepb/app.bsky.feed.post/3kfxlfqfzll2p" 250 + } 251 + }, 252 + "text": "Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 253 + } 254 + }, 255 + { 256 + "cid": "bafyreieapui5bwfereerup47apqbqhfzkp3eussbff3psfq5pkzpjz5xqy", 257 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfprrztvpk2s", 258 + "value": { 259 + "$type": "app.bsky.feed.post", 260 + "createdAt": "2023-12-04T12:05:43.694Z", 261 + "facets": [ 262 + { 263 + "features": [ 264 + { 265 + "$type": "app.bsky.richtext.facet#mention", 266 + "did": "did:plc:xuqnzpixtod7hf7fqe4dvepg" 267 + } 268 + ], 269 + "index": { 270 + "byteEnd": 18, 271 + "byteStart": 0 272 + } 273 + } 274 + ], 275 + "langs": [ 276 + "en" 277 + ], 278 + "text": "@dwuff.bsky.social Message on WhatsApp to recover your lost funds \n\n‪+1 (765) 734‑3839‬" 279 + } 280 + }, 281 + { 282 + "cid": "bafyreibe5tvxgexdvtzhjywgdrdmehy566g5xn33vdxihf6pff5ztne6oi", 283 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfprrjkzev2i", 284 + "value": { 285 + "$type": "app.bsky.feed.post", 286 + "createdAt": "2023-12-04T12:05:26.627Z", 287 + "langs": [ 288 + "en" 289 + ], 290 + "reply": { 291 + "parent": { 292 + "cid": "bafyreifretb4p2o4u22j3kaeckh5z2cs2xseqr3tqjfkae52zlf5omzwzq", 293 + "uri": "at://did:plc:xuqnzpixtod7hf7fqe4dvepg/app.bsky.feed.post/3kfnoc26bgk2f" 294 + }, 295 + "root": { 296 + "cid": "bafyreifretb4p2o4u22j3kaeckh5z2cs2xseqr3tqjfkae52zlf5omzwzq", 297 + "uri": "at://did:plc:xuqnzpixtod7hf7fqe4dvepg/app.bsky.feed.post/3kfnoc26bgk2f" 298 + } 299 + }, 300 + "text": "Message on WhatsApp to recover your lost funds \n\n‪+1 (765) 734‑3839‬" 301 + } 302 + }, 303 + { 304 + "cid": "bafyreichajoc5lc2uhynxhnosmvp2zjgg5iw3tlqrrrwmgxidl5ovdpyaq", 305 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfprq5hine2a", 306 + "value": { 307 + "$type": "app.bsky.feed.post", 308 + "createdAt": "2023-12-04T12:04:40.380Z", 309 + "langs": [ 310 + "en" 311 + ], 312 + "reply": { 313 + "parent": { 314 + "cid": "bafyreiavkwoo52ye6ltwgl4yk5hepsyykviaihzkfn4hapujnipd24ejua", 315 + "uri": "at://did:plc:4rfp3cq3ycsfg6owkqrex5ls/app.bsky.feed.post/3kfnwjhxtpf22" 316 + }, 317 + "root": { 318 + "cid": "bafyreib34kaul7stegzmkczlfxgpr65rn5kxvjwexk6n6cuiluw4i447ii", 319 + "uri": "at://did:plc:4rfp3cq3ycsfg6owkqrex5ls/app.bsky.feed.post/3kfnrapfsyu2j" 320 + } 321 + }, 322 + "text": "Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 323 + } 324 + }, 325 + { 326 + "cid": "bafyreidjkhdtcbyjhnpcg5vgditswek4pa3vvnbw3nii2vxuibwf2n4p6i", 327 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfprpx6zw32m", 328 + "value": { 329 + "$type": "app.bsky.feed.post", 330 + "createdAt": "2023-12-04T12:04:33.781Z", 331 + "facets": [ 332 + { 333 + "features": [ 334 + { 335 + "$type": "app.bsky.richtext.facet#mention", 336 + "did": "did:plc:4rfp3cq3ycsfg6owkqrex5ls" 337 + } 338 + ], 339 + "index": { 340 + "byteEnd": 25, 341 + "byteStart": 0 342 + } 343 + } 344 + ], 345 + "langs": [ 346 + "en" 347 + ], 348 + "text": "@carrie4jesus.bsky.social Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 349 + } 350 + }, 351 + { 352 + "cid": "bafyreid723xkolnyvugb6lwflatnz7dmegswmk57vsbwwf2mutnayyadve", 353 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfprmof5k42o", 354 + "value": { 355 + "$type": "app.bsky.feed.post", 356 + "createdAt": "2023-12-04T12:02:43.894Z", 357 + "facets": [ 358 + { 359 + "features": [ 360 + { 361 + "$type": "app.bsky.richtext.facet#mention", 362 + "did": "did:plc:x7bzd43ztwpxpaxgsi7nxx7v" 363 + } 364 + ], 365 + "index": { 366 + "byteEnd": 19, 367 + "byteStart": 0 368 + } 369 + } 370 + ], 371 + "langs": [ 372 + "en" 373 + ], 374 + "text": "@ehhjax.bsky.social Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 375 + } 376 + }, 377 + { 378 + "cid": "bafyreibcwutbkbfdwjcwsur2si4gjhf3smut64lejllcjmw5cheg7y37iu", 379 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfprm7vkha2g", 380 + "value": { 381 + "$type": "app.bsky.feed.post", 382 + "createdAt": "2023-12-04T12:02:28.704Z", 383 + "facets": [ 384 + { 385 + "features": [ 386 + { 387 + "$type": "app.bsky.richtext.facet#mention", 388 + "did": "did:plc:kgpjze4ebmgd2mwa5aqrijjc" 389 + } 390 + ], 391 + "index": { 392 + "byteEnd": 27, 393 + "byteStart": 0 394 + } 395 + } 396 + ], 397 + "langs": [ 398 + "en" 399 + ], 400 + "text": "@sunshinesdaily.bsky.social Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 401 + } 402 + }, 403 + { 404 + "cid": "bafyreic6oplgskdo3jix7pimcvlpf5ihc576gwdkwsfi7by33tn4z5icgu", 405 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kfprlvcphs2k", 406 + "value": { 407 + "$type": "app.bsky.feed.post", 408 + "createdAt": "2023-12-04T12:02:17.612Z", 409 + "langs": [ 410 + "en" 411 + ], 412 + "reply": { 413 + "parent": { 414 + "cid": "bafyreicillzq2egsxq5e653s4mhl7z6mcoggeriyq27pjhirzn57de7ogy", 415 + "uri": "at://did:plc:kgpjze4ebmgd2mwa5aqrijjc/app.bsky.feed.post/3kfojibjwpy2p" 416 + }, 417 + "root": { 418 + "cid": "bafyreia5rscvtshpgfoeczgfv7kgior62louxlmrnsua6j562iiu65sj24", 419 + "uri": "at://did:plc:x7bzd43ztwpxpaxgsi7nxx7v/app.bsky.feed.post/3kfoifgbwyr27" 420 + } 421 + }, 422 + "text": "Message on WhatsApp to recover your lost account \n\n‪+1 (765) 734‑3839‬" 423 + } 424 + }, 425 + { 426 + "cid": "bafyreicp4fq6py6udl3qsen3ug5r6xwbptetahyz33cwhi3g74lxoqo7ga", 427 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kf7gku22an2a", 428 + "value": { 429 + "$type": "app.bsky.feed.post", 430 + "createdAt": "2023-11-28T00:02:15.718Z", 431 + "facets": [ 432 + { 433 + "features": [ 434 + { 435 + "$type": "app.bsky.richtext.facet#mention", 436 + "did": "did:plc:2jnktzctncait2dgiro7z27j" 437 + } 438 + ], 439 + "index": { 440 + "byteEnd": 23, 441 + "byteStart": 0 442 + } 443 + } 444 + ], 445 + "langs": [ 446 + "en" 447 + ], 448 + "text": "@zaffreyael.bsky.social PRO HACKER\nMESSAGE ON WHATSAPP +1 (412) 568-3582\nFor the following services send me a message \n1.Cloning your partner WhatsApp to see who your partner is talking to on Whatsapp\n2.Recovery of all social media accounts Facebook, Instagram,YouTube Pinterest Twitter etc" 449 + } 450 + }, 451 + { 452 + "cid": "bafyreiex4zxkelnq4bxhzz7zwpzolpw3jsxke2kw4y2uhs6peahkkok4bm", 453 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kf7gkh27r22s", 454 + "value": { 455 + "$type": "app.bsky.feed.post", 456 + "createdAt": "2023-11-28T00:02:01.996Z", 457 + "langs": [ 458 + "en" 459 + ], 460 + "reply": { 461 + "parent": { 462 + "cid": "bafyreiefwnjmxf6bx7effff5ypjah3nx7e7hfr3jksppllihqirhmtpblq", 463 + "uri": "at://did:plc:2jnktzctncait2dgiro7z27j/app.bsky.feed.post/3kex2nuj4vs2j" 464 + }, 465 + "root": { 466 + "cid": "bafyreiefwnjmxf6bx7effff5ypjah3nx7e7hfr3jksppllihqirhmtpblq", 467 + "uri": "at://did:plc:2jnktzctncait2dgiro7z27j/app.bsky.feed.post/3kex2nuj4vs2j" 468 + } 469 + }, 470 + "text": "PRO HACKER\nMESSAGE ON WHATSAPP +1 (412) 568-3582\nFor the following services send me a message \n1.Cloning your partner WhatsApp to see who your partner is talking to on Whatsapp\n2.Recovery of all social media accounts Facebook, Instagram,YouTube Pinterest Twitter etc\n3.Unlocking of iCloud on iPhones" 471 + } 472 + }, 473 + { 474 + "cid": "bafyreiagl6qpgtpxbzwezoa5c2kkzxrqf5n4hvo7wplmx7upvbcq5htez4", 475 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kf7gjqujsc2s", 476 + "value": { 477 + "$type": "app.bsky.feed.post", 478 + "createdAt": "2023-11-28T00:01:38.855Z", 479 + "facets": [ 480 + { 481 + "features": [ 482 + { 483 + "$type": "app.bsky.richtext.facet#mention", 484 + "did": "did:plc:axxayzoylwiyczff6nso7z2y" 485 + } 486 + ], 487 + "index": { 488 + "byteEnd": 26, 489 + "byteStart": 0 490 + } 491 + } 492 + ], 493 + "langs": [ 494 + "en" 495 + ], 496 + "text": "@ashesinadream.bsky.social PRO HACKER\nMESSAGE ON WHATSAPP +1 (412) 568-3582\nFor the following services send me a message \n1.Cloning your partner WhatsApp to see who your partner is talking to on Whatsapp\n2.Recovery of all social media accounts Facebook, Instagram,YouTube Pinterest Twitter etc" 497 + } 498 + }, 499 + { 500 + "cid": "bafyreidxqyocxlwrfbftuugzg7mj2wd5i6twnwmbu7refb6cf4jjwe7zne", 501 + "uri": "at://did:plc:suvj7i7gk6vzc7wvwd3gx6zq/app.bsky.feed.post/3kf7givmebs2j", 502 + "value": { 503 + "$type": "app.bsky.feed.post", 504 + "createdAt": "2023-11-28T00:01:10.283Z", 505 + "langs": [ 506 + "en" 507 + ], 508 + "reply": { 509 + "parent": { 510 + "cid": "bafyreiawxmoft5bjla57qoccflkiir7k7ztyegdfoi5od2npzmvq7gw5iu", 511 + "uri": "at://did:plc:axxayzoylwiyczff6nso7z2y/app.bsky.feed.post/3kexgryq5d62a" 512 + }, 513 + "root": { 514 + "cid": "bafyreiawxmoft5bjla57qoccflkiir7k7ztyegdfoi5od2npzmvq7gw5iu", 515 + "uri": "at://did:plc:axxayzoylwiyczff6nso7z2y/app.bsky.feed.post/3kexgryq5d62a" 516 + } 517 + }, 518 + "text": "PRO HACKER\nMESSAGE ON WHATSAPP +1 (412) 568-3582\nFor the following services send me a message \n1.Cloning your partner WhatsApp to see who your partner is talking to on Whatsapp\n2.Recovery of all social media accounts Facebook, Instagram,YouTube Pinterest Twitter etc\n3.Unlocking of iCloud on iPhones" 519 + } 520 + } 521 + ] 522 + }
+824
automod/testdata/capture_atprotocom.json
··· 1 + { 2 + "capturedAt": "2023-12-09T05:45:35.662Z", 3 + "accountMeta": { 4 + "Identity": { 5 + "DID": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 6 + "Handle": "atproto.com", 7 + "AlsoKnownAs": [ 8 + "at://atproto.com" 9 + ], 10 + "Services": { 11 + "atproto_pds": { 12 + "Type": "AtprotoPersonalDataServer", 13 + "URL": "https://enoki.us-east.host.bsky.network" 14 + } 15 + }, 16 + "Keys": { 17 + "atproto": { 18 + "Type": "Multikey", 19 + "PublicKeyMultibase": "zQ3shunBKsXixLxKtC5qeSG9E4J5RkGN57im31pcTzbNQnm5w" 20 + } 21 + }, 22 + "ParsedPublicKey": null 23 + }, 24 + "Profile": { 25 + "HasAvatar": true, 26 + "Description": "Social networking technology created by Bluesky. \n\nDeveloper-focused account. Follow @bsky.app for general announcements!\n\nDocs: atproto.com\nCommunity: atproto.com/community\nDeveloper blog: https://atproto.com/blog", 27 + "DisplayName": "AT Protocol Developers" 28 + }, 29 + "Private": null, 30 + "AccountLabels": null, 31 + "AccountNegatedLabels": null, 32 + "AccountFlags": [], 33 + "FollowersCount": 47411, 34 + "FollowsCount": 16, 35 + "PostsCount": 86, 36 + "Takendown": false 37 + }, 38 + "postRecords": [ 39 + { 40 + "cid": "bafyreih22okgdungwln2lwqt2kd4dd2ynzx3fyy3yefh5koocpizlngqfq", 41 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kftldxbvq22p", 42 + "value": { 43 + "$type": "app.bsky.feed.post", 44 + "createdAt": "2023-12-06T00:21:07.723Z", 45 + "embed": { 46 + "$type": "app.bsky.embed.external", 47 + "external": { 48 + "description": "We’re preparing to launch a public web interface soon, and we’re excited for more people to see your custom feeds! Ahead of that, we want to make sure that your feeds will work smoothly to handle ...", 49 + "thumb": { 50 + "$type": "blob", 51 + "ref": { 52 + "$link": "bafkreicgt2g7a3dtocucsdzfka2vbtqa56yll2bmpge2gk7f26ry4nn7ky" 53 + }, 54 + "mimeType": "image/jpeg", 55 + "size": 350013 56 + }, 57 + "title": "Expected feed generator behavior for the public web view · bluesky-social/atproto · Discussion #19...", 58 + "uri": "https://github.com/bluesky-social/atproto/discussions/1933" 59 + } 60 + }, 61 + "langs": [ 62 + "en" 63 + ], 64 + "reply": { 65 + "parent": { 66 + "cid": "bafyreiheaai2dcnd676fwxbpoozzu7rzin4gwswls2bob6noi24t5okxcq", 67 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kftlbujmfk24" 68 + }, 69 + "root": { 70 + "cid": "bafyreiheaai2dcnd676fwxbpoozzu7rzin4gwswls2bob6noi24t5okxcq", 71 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kftlbujmfk24" 72 + } 73 + }, 74 + "text": "[for developers]\n\ntl;dr if your feed requires auth (e.g. likes, mutuals), return a 401 with an optional custom message. \n\nif your feed is experiencing too much traffic, return a 429.\n\nwe'll have fallbacks too in case a influx of traffic is making it difficult on your end 🙏 \n\nmore details:" 75 + } 76 + }, 77 + { 78 + "cid": "bafyreiheaai2dcnd676fwxbpoozzu7rzin4gwswls2bob6noi24t5okxcq", 79 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kftlbujmfk24", 80 + "value": { 81 + "$type": "app.bsky.feed.post", 82 + "createdAt": "2023-12-06T00:19:57.703Z", 83 + "embed": { 84 + "$type": "app.bsky.embed.external", 85 + "external": { 86 + "description": "We’re preparing to launch a public web interface soon, and we’re excited for more people to see your custom feeds! Ahead of that, we want to make sure that your feeds will work smoothly to handle ...", 87 + "thumb": { 88 + "$type": "blob", 89 + "ref": { 90 + "$link": "bafkreicgt2g7a3dtocucsdzfka2vbtqa56yll2bmpge2gk7f26ry4nn7ky" 91 + }, 92 + "mimeType": "image/jpeg", 93 + "size": 350013 94 + }, 95 + "title": "Expected feed generator behavior for the public web view · bluesky-social/atproto · Discussion #19...", 96 + "uri": "https://github.com/bluesky-social/atproto/discussions/1933" 97 + } 98 + }, 99 + "langs": [ 100 + "en" 101 + ], 102 + "text": "hey developers, we're excited for more people to see your custom feeds soon when we launch the public web interface! \n\nahead of that, we want to ensure that your feeds will work smoothly to handle both logged-out users and a potential increase in traffic. read some details on expected behavior here:" 103 + } 104 + }, 105 + { 106 + "cid": "bafyreieyg5pi2anhktagcnlv5cuwcbumwpx2fko624pkk6fqskmhsq426a", 107 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqkmk5kpv2o", 108 + "value": { 109 + "$type": "app.bsky.feed.post", 110 + "createdAt": "2023-12-04T19:30:03.024Z", 111 + "embed": { 112 + "$type": "app.bsky.embed.external", 113 + "external": { 114 + "description": "🕸 Bridges the IndieWeb to Mastodon and the fediverse via ActivityPub. - GitHub - snarfed/bridgy-fed: 🕸 Bridges the IndieWeb to Mastodon and the fediverse via ActivityPub.", 115 + "thumb": { 116 + "$type": "blob", 117 + "ref": { 118 + "$link": "bafkreidplhjcnrl2c74r3xs7nh7k7q3ny6ul7cgxr2fophblvdeky6t64e" 119 + }, 120 + "mimeType": "image/jpeg", 121 + "size": 347998 122 + }, 123 + "title": "GitHub - snarfed/bridgy-fed: 🕸 Bridges the IndieWeb to Mastodon and the fediverse via ActivityPub...", 124 + "uri": "https://github.com/snarfed/bridgy-fed" 125 + } 126 + }, 127 + "facets": [ 128 + { 129 + "features": [ 130 + { 131 + "$type": "app.bsky.richtext.facet#link", 132 + "uri": "https://github.com/snarfed/bridgy-fed" 133 + } 134 + ], 135 + "index": { 136 + "byteEnd": 92, 137 + "byteStart": 66 138 + } 139 + }, 140 + { 141 + "features": [ 142 + { 143 + "$type": "app.bsky.richtext.facet#mention", 144 + "did": "did:plc:fdme4gb7mu7zrie7peay7tst" 145 + } 146 + ], 147 + "index": { 148 + "byteEnd": 149, 149 + "byteStart": 137 150 + } 151 + } 152 + ], 153 + "langs": [ 154 + "en" 155 + ], 156 + "reply": { 157 + "parent": { 158 + "cid": "bafyreifaidyl62p4snkdwsygviemsxyidi3cd7dxvjomh5644sovxhsppa", 159 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqklhpalh2c" 160 + }, 161 + "root": { 162 + "cid": "bafyreibiimdwmsp5mqpm7utqcdmvo6fdqmofblp5obs3h7ub6652zyooci", 163 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqkkjdkic2e" 164 + } 165 + }, 166 + "text": "Bridgy Fed is an open-source project — check out the code here: github.com/snarfed/brid...\n\nStay updated with the project by following @snarfed.org!" 167 + } 168 + }, 169 + { 170 + "cid": "bafyreifaidyl62p4snkdwsygviemsxyidi3cd7dxvjomh5644sovxhsppa", 171 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqklhpalh2c", 172 + "value": { 173 + "$type": "app.bsky.feed.post", 174 + "createdAt": "2023-12-04T19:29:26.790Z", 175 + "embed": { 176 + "$type": "app.bsky.embed.external", 177 + "external": { 178 + "description": "Bridgy Fed is a bridge between decentralized social networks that already has initial Bluesky support. The project will launch publicly when Bluesky launches federation early next year.", 179 + "thumb": { 180 + "$type": "blob", 181 + "ref": { 182 + "$link": "bafkreibjcz2ahvy6iwx2ofgmnbl7xevwo2k3e36op6lngvpnlgf6yyqbfq" 183 + }, 184 + "mimeType": "image/jpeg", 185 + "size": 736017 186 + }, 187 + "title": "Featured Community Project: Bridgy Fed | AT Protocol", 188 + "uri": "https://atproto.com/blog/feature-bridgyfed" 189 + } 190 + }, 191 + "facets": [ 192 + { 193 + "features": [ 194 + { 195 + "$type": "app.bsky.richtext.facet#link", 196 + "uri": "https://atproto.com/blog/feature-bridgyfed" 197 + } 198 + ], 199 + "index": { 200 + "byteEnd": 265, 201 + "byteStart": 238 202 + } 203 + } 204 + ], 205 + "langs": [ 206 + "en" 207 + ], 208 + "reply": { 209 + "parent": { 210 + "cid": "bafyreibiimdwmsp5mqpm7utqcdmvo6fdqmofblp5obs3h7ub6652zyooci", 211 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqkkjdkic2e" 212 + }, 213 + "root": { 214 + "cid": "bafyreibiimdwmsp5mqpm7utqcdmvo6fdqmofblp5obs3h7ub6652zyooci", 215 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqkkjdkic2e" 216 + } 217 + }, 218 + "text": "Bridgy Fed is a third-party tool that allows you to follow anyone on any other network, see their posts, reply or like or repost them, and those interactions flow across to their network and vice versa.\n\nRead more about the project here: atproto.com/blog/feature..." 219 + } 220 + }, 221 + { 222 + "cid": "bafyreibiimdwmsp5mqpm7utqcdmvo6fdqmofblp5obs3h7ub6652zyooci", 223 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqkkjdkic2e", 224 + "value": { 225 + "$type": "app.bsky.feed.post", 226 + "createdAt": "2023-12-04T19:28:55.054Z", 227 + "embed": { 228 + "$type": "app.bsky.embed.external", 229 + "external": { 230 + "description": "Bridgy Fed is a bridge between decentralized social networks that already has initial Bluesky support. The project will launch publicly when Bluesky launches federation early next year.", 231 + "thumb": { 232 + "$type": "blob", 233 + "ref": { 234 + "$link": "bafkreibjcz2ahvy6iwx2ofgmnbl7xevwo2k3e36op6lngvpnlgf6yyqbfq" 235 + }, 236 + "mimeType": "image/jpeg", 237 + "size": 736017 238 + }, 239 + "title": "Featured Community Project: Bridgy Fed | AT Protocol", 240 + "uri": "https://atproto.com/blog/feature-bridgyfed" 241 + } 242 + }, 243 + "facets": [ 244 + { 245 + "features": [ 246 + { 247 + "$type": "app.bsky.richtext.facet#mention", 248 + "did": "did:plc:fdme4gb7mu7zrie7peay7tst" 249 + } 250 + ], 251 + "index": { 252 + "byteEnd": 75, 253 + "byteStart": 63 254 + } 255 + }, 256 + { 257 + "features": [ 258 + { 259 + "$type": "app.bsky.richtext.facet#link", 260 + "uri": "https://atproto.com/blog/feature-bridgyfed" 261 + } 262 + ], 263 + "index": { 264 + "byteEnd": 259, 265 + "byteStart": 232 266 + } 267 + } 268 + ], 269 + "langs": [ 270 + "en" 271 + ], 272 + "text": "Check out the latest featured community project: Bridgy Fed by @snarfed.org!\n\nBridgy Fed is a bridge between social networks that currently supports the IndieWeb and the Fediverse, with full Bluesky support coming soon.\n\nRead more: atproto.com/blog/feature..." 273 + } 274 + }, 275 + { 276 + "cid": "bafyreidm5mpgmcnsyjmfnbpmi3m3n4673pp72ol2j4c5q7f6vobqwhqhx4", 277 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfdph62zey2f", 278 + "value": { 279 + "$type": "app.bsky.feed.post", 280 + "createdAt": "2023-11-29T16:51:54.613Z", 281 + "embed": { 282 + "$type": "app.bsky.embed.external", 283 + "external": { 284 + "description": "Open Core Summit is the world's largest community of COSS (commercial open source software) company builders, founders, investors and more.", 285 + "thumb": { 286 + "$type": "blob", 287 + "ref": { 288 + "$link": "bafkreifkvhuqfe5wuggwl4prdu7hojthfxue5dzqdselxrpgur5m3rbcdm" 289 + }, 290 + "mimeType": "image/jpeg", 291 + "size": 625220 292 + }, 293 + "title": "Open Core Summit", 294 + "uri": "https://opencoresummit.com/" 295 + } 296 + }, 297 + "facets": [ 298 + { 299 + "features": [ 300 + { 301 + "$type": "app.bsky.richtext.facet#link", 302 + "uri": "https://opencoresummit.com/" 303 + } 304 + ], 305 + "index": { 306 + "byteEnd": 241, 307 + "byteStart": 223 308 + } 309 + } 310 + ], 311 + "langs": [ 312 + "en" 313 + ], 314 + "text": "Developers, we'll be at Open Core Summit on Dec 6-7 in San Francisco! Stop by to say hi and pick up some swag. 👾\n\nHere's a discount code generated by Open Core Summit for FOSS builders to meet us there: opensourcefrens\n\nopencoresummit.com" 315 + } 316 + }, 317 + { 318 + "cid": "bafyreidt2xwgu66lrwoob2zlz6uuppjbv2rl7y5wh7c2akrvj32epuvxw4", 319 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfdp7rlxtg27", 320 + "value": { 321 + "$type": "app.bsky.feed.post", 322 + "createdAt": "2023-11-29T16:47:46.702Z", 323 + "embed": { 324 + "$type": "app.bsky.embed.external", 325 + "external": { 326 + "description": "Get started with the atproto API.", 327 + "thumb": { 328 + "$type": "blob", 329 + "ref": { 330 + "$link": "bafkreieya7iitpu4okjtm7iexiwikj7t63ttlthad32ojsvjqhqbc3iwmi" 331 + }, 332 + "mimeType": "image/jpeg", 333 + "size": 518394 334 + }, 335 + "title": "TypeScript Quick Start Guide | AT Protocol", 336 + "uri": "https://atproto.com/community/quick-start" 337 + } 338 + }, 339 + "facets": [ 340 + { 341 + "features": [ 342 + { 343 + "$type": "app.bsky.richtext.facet#link", 344 + "uri": "https://atproto.com/community/quick-start" 345 + } 346 + ], 347 + "index": { 348 + "byteEnd": 32, 349 + "byteStart": 5 350 + } 351 + } 352 + ], 353 + "langs": [ 354 + "en" 355 + ], 356 + "reply": { 357 + "parent": { 358 + "cid": "bafyreibdypeqidcengvmqb7tisq46qmnaglkno4mozw4ktdyosmouzz2iy", 359 + "uri": "at://did:plc:tp4brp6abnra3rvggvazeziq/app.bsky.feed.post/3kfdovnudaz2g" 360 + }, 361 + "root": { 362 + "cid": "bafyreibdypeqidcengvmqb7tisq46qmnaglkno4mozw4ktdyosmouzz2iy", 363 + "uri": "at://did:plc:tp4brp6abnra3rvggvazeziq/app.bsky.feed.post/3kfdovnudaz2g" 364 + } 365 + }, 366 + "text": "yep! atproto.com/community/qu..." 367 + } 368 + }, 369 + { 370 + "cid": "bafyreihac22tpufnn3rngrhgpixha7ffbbl27z4fny7owjejtjeqp37zxa", 371 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kdrlsxkryo2f", 372 + "value": { 373 + "$type": "app.bsky.feed.post", 374 + "createdAt": "2023-11-09T18:33:48.645Z", 375 + "langs": [ 376 + "en" 377 + ], 378 + "reply": { 379 + "parent": { 380 + "cid": "bafyreiefdhpdngbr3rbagqik6lubuwmc7ttripb3ugttvvd26zcnkmwaxa", 381 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kdrlslade72m" 382 + }, 383 + "root": { 384 + "cid": "bafyreiefdhpdngbr3rbagqik6lubuwmc7ttripb3ugttvvd26zcnkmwaxa", 385 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kdrlslade72m" 386 + } 387 + }, 388 + "text": "We explicitly built Bluesky and the AT Protocol with a simple and straightforward user experience in mind. 💪 \n\nThe technical updates here are a behind-the-scenes look into the plumbing, which is useful for devs who want to follow along and are building their own projects on this tech." 389 + } 390 + }, 391 + { 392 + "cid": "bafyreiefdhpdngbr3rbagqik6lubuwmc7ttripb3ugttvvd26zcnkmwaxa", 393 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kdrlslade72m", 394 + "value": { 395 + "$type": "app.bsky.feed.post", 396 + "createdAt": "2023-11-09T18:33:35.718Z", 397 + "facets": [ 398 + { 399 + "features": [ 400 + { 401 + "$type": "app.bsky.richtext.facet#mention", 402 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur" 403 + } 404 + ], 405 + "index": { 406 + "byteEnd": 218, 407 + "byteStart": 209 408 + } 409 + } 410 + ], 411 + "langs": [ 412 + "en" 413 + ], 414 + "text": "Reminder: This is a developer-focused account, so if you see us using technical speak here, that's why!\n\nWe'll always make relevant info more accessible to all users, and if you haven't already, please follow @bsky.app for general announcements. 😊" 415 + } 416 + }, 417 + { 418 + "cid": "bafyreiai3kukjoxw5s5amfq24eirqbv5zofsfmfw5zon7z5yfmbu57y3ge", 419 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kdow6fj6la2l", 420 + "value": { 421 + "$type": "app.bsky.feed.post", 422 + "createdAt": "2023-11-08T17:01:10.489Z", 423 + "embed": { 424 + "$type": "app.bsky.embed.external", 425 + "external": { 426 + "description": "As part of our operational work in preparation for federation, we are starting to migrate PDS accounts away from the existing monolithic bsky.social instance to several *.*.host.bsky.network PDS in...", 427 + "thumb": { 428 + "$type": "blob", 429 + "ref": { 430 + "$link": "bafkreifphxg3k6bp3bzaw7b4qswm2hkewlqedkbl57rtcfq2d2c6bqeflu" 431 + }, 432 + "mimeType": "image/jpeg", 433 + "size": 382219 434 + }, 435 + "title": "Migrating bsky.social to Multiple PDS Instances (Nov 2023) · bluesky-social/atproto · Discussion #...", 436 + "uri": "https://github.com/bluesky-social/atproto/discussions/1832" 437 + } 438 + }, 439 + "facets": [ 440 + { 441 + "features": [ 442 + { 443 + "$type": "app.bsky.richtext.facet#link", 444 + "uri": "https://github.com/bluesky-social/atproto/discussions/1832" 445 + } 446 + ], 447 + "index": { 448 + "byteEnd": 195, 449 + "byteStart": 169 450 + } 451 + } 452 + ], 453 + "langs": [ 454 + "en" 455 + ], 456 + "text": "Devs: you might've already seen some chatter about migrating accounts to servers named after mushrooms... \n\nRead the technical details and how it might affect you here: github.com/bluesky-soci..." 457 + } 458 + }, 459 + { 460 + "cid": "bafyreibs3iuez2urcyfwmwcceg5gilnvfz3svah7q4m5zd7rpiueodl5ra", 461 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kdk7yc3pbz22", 462 + "value": { 463 + "$type": "app.bsky.feed.post", 464 + "createdAt": "2023-11-06T20:13:24.422Z", 465 + "embed": { 466 + "$type": "app.bsky.embed.external", 467 + "external": { 468 + "description": "How to download and parse repository data exports using Go.", 469 + "thumb": { 470 + "$type": "blob", 471 + "ref": { 472 + "$link": "bafkreiffvyhd4vqp7hef2zii6bs54zi4sw4z7g2zt5vx4j4ct5hrhewdyq" 473 + }, 474 + "mimeType": "image/jpeg", 475 + "size": 599519 476 + }, 477 + "title": "Download and Parse Repository Exports | AT Protocol", 478 + "uri": "https://atproto.com/blog/repo-export" 479 + } 480 + }, 481 + "langs": [ 482 + "en" 483 + ], 484 + "text": "📝Just published a new dev blog post: \n\nOne of the core principles of atproto is simple access to public data. A user’s data is stored in a repository, which can be efficiently exported all together as a CAR file (.car). \n\nThis post describes how to export and parse a data repository." 485 + } 486 + }, 487 + { 488 + "cid": "bafyreiak2l4jmurdrhgaioo6owhbttgoyapxpg45afl4cgnlx2rhv4v2eq", 489 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kd5uzahw5t2u", 490 + "value": { 491 + "$type": "app.bsky.feed.post", 492 + "createdAt": "2023-11-01T22:25:08.177Z", 493 + "embed": { 494 + "$type": "app.bsky.embed.record", 495 + "record": { 496 + "cid": "bafyreig6e5eynvb5bq7v4zpv7aiq6zmjz4gj2lhgr3kwkkbkriiofqlbmi", 497 + "uri": "at://did:plc:wqowuobffl66jv3kpsvo7ak4/app.bsky.feed.post/3kd5tr4uex22v" 498 + } 499 + }, 500 + "langs": [ 501 + "en" 502 + ], 503 + "text": "do you subscribe to the For You custom feed and have opinions to share?\n\nthe third-party devs behind the feed are hosting a zoom for the next 4 hours to hear your thoughts! ⬇️" 504 + } 505 + }, 506 + { 507 + "cid": "bafyreihrrsdswqqco2mn3bhm3d7wdtioaqz7gr3tj7ms6cvakz2obv3qha", 508 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kblbjgnevy25", 509 + "value": { 510 + "$type": "app.bsky.feed.post", 511 + "createdAt": "2023-10-12T19:23:09.439Z", 512 + "embed": { 513 + "$type": "app.bsky.embed.record", 514 + "record": { 515 + "cid": "bafyreialpvpg4w32go46d4dp2da5pm5ypamml4bqymegizogh6tegkqxmu", 516 + "uri": "at://did:plc:p2cp5gopk7mgjegy6wadk3ep/app.bsky.feed.post/3kbl67fqu4k2t" 517 + } 518 + }, 519 + "facets": [ 520 + { 521 + "features": [ 522 + { 523 + "$type": "app.bsky.richtext.facet#mention", 524 + "did": "did:plc:sq6aa2wa32tiiqrbub64vcja" 525 + } 526 + ], 527 + "index": { 528 + "byteEnd": 24, 529 + "byteStart": 12 530 + } 531 + }, 532 + { 533 + "features": [ 534 + { 535 + "$type": "app.bsky.richtext.facet#link", 536 + "uri": "https://github.com/mozzius/graysky" 537 + } 538 + ], 539 + "index": { 540 + "byteEnd": 132, 541 + "byteStart": 106 542 + } 543 + }, 544 + { 545 + "features": [ 546 + { 547 + "$type": "app.bsky.richtext.facet#link", 548 + "uri": "https://atproto.com/community/projects#clients" 549 + } 550 + ], 551 + "index": { 552 + "byteEnd": 297, 553 + "byteStart": 270 554 + } 555 + } 556 + ], 557 + "langs": [ 558 + "en" 559 + ], 560 + "text": "congrats to @graysky.app, a third-party open-source bluesky client! 🔥\n\ncheck out the source code here: github.com/mozzius/gray...\n\nbecause the AT Protocol is open, third-party developers can create entirely separate frontends for the network. find more clients here: atproto.com/community/pr..." 561 + } 562 + }, 563 + { 564 + "cid": "bafyreigc2hx7giirtsrotyeadf2tvmshonfcnwqtl7toom5te6rfcdgvgq", 565 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kbj5lqxjs42w", 566 + "value": { 567 + "$type": "app.bsky.feed.post", 568 + "createdAt": "2023-10-11T23:07:33.183Z", 569 + "embed": { 570 + "$type": "app.bsky.embed.external", 571 + "external": { 572 + "description": "This post lays out the current AT Protocol development plan through the end of 2023", 573 + "thumb": { 574 + "$type": "blob", 575 + "ref": { 576 + "$link": "bafkreieya7iitpu4okjtm7iexiwikj7t63ttlthad32ojsvjqhqbc3iwmi" 577 + }, 578 + "mimeType": "image/jpeg", 579 + "size": 518394 580 + }, 581 + "title": "2023 Protocol Roadmap | AT Protocol", 582 + "uri": "https://atproto.com/blog/2023-protocol-roadmap" 583 + } 584 + }, 585 + "langs": [ 586 + "en" 587 + ], 588 + "text": "2023 Protocol Roadmap — just published on the dev blog! \n\nThis blog is written for developers already familiar with atproto concepts and terminology. We are pushing towards federation on the production network early next year (2024), if development continues as planned." 589 + } 590 + }, 591 + { 592 + "cid": "bafyreigofn3o6fib6bmwlxlmj7kqudusrso4ghiy3oymx3jpv5ifr7prim", 593 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kbj5ighpaw27", 594 + "value": { 595 + "$type": "app.bsky.feed.post", 596 + "createdAt": "2023-10-11T23:05:41.516Z", 597 + "embed": { 598 + "$type": "app.bsky.embed.external", 599 + "external": { 600 + "description": "A collection of example projects and scripts for atproto development. - GitHub - bluesky-social/cookbook: A collection of example projects and scripts for atproto development.", 601 + "thumb": { 602 + "$type": "blob", 603 + "ref": { 604 + "$link": "bafkreihamtttuw7j5tjj74l44h7xms6gf5ywaevysn7tktykrob2aurlmi" 605 + }, 606 + "mimeType": "image/jpeg", 607 + "size": 311389 608 + }, 609 + "title": "GitHub - bluesky-social/cookbook: A collection of example projects and scripts for atproto developme...", 610 + "uri": "https://github.com/bluesky-social/cookbook" 611 + } 612 + }, 613 + "facets": [ 614 + { 615 + "features": [ 616 + { 617 + "$type": "app.bsky.richtext.facet#link", 618 + "uri": "https://github.com/bluesky-social/cookbook" 619 + } 620 + ], 621 + "index": { 622 + "byteEnd": 242, 623 + "byteStart": 216 624 + } 625 + } 626 + ], 627 + "langs": [ 628 + "en" 629 + ], 630 + "reply": { 631 + "parent": { 632 + "cid": "bafyreigviq2nvak7ebhh7tyoi527k6d5li4ck7txxawckayf4wq47jg2y4", 633 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kbj5haaqar2r" 634 + }, 635 + "root": { 636 + "cid": "bafyreigviq2nvak7ebhh7tyoi527k6d5li4ck7txxawckayf4wq47jg2y4", 637 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kbj5haaqar2r" 638 + } 639 + }, 640 + "text": "We also have a new \"cookbook\" repo now that will be a home for example code snippets and starter kits. \n\nCurrently, you can find:\n• how to make a Bluesky post in Python\n• how to make a Bluesky bot in TypeScript\n\ngithub.com/bluesky-soci..." 641 + } 642 + }, 643 + { 644 + "cid": "bafyreigviq2nvak7ebhh7tyoi527k6d5li4ck7txxawckayf4wq47jg2y4", 645 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kbj5haaqar2r", 646 + "value": { 647 + "$type": "app.bsky.feed.post", 648 + "createdAt": "2023-10-11T23:05:01.389Z", 649 + "embed": { 650 + "$type": "app.bsky.embed.external", 651 + "external": { 652 + "description": "Pointers to what you can already build on atproto, and what you can expect soon.", 653 + "thumb": { 654 + "$type": "blob", 655 + "ref": { 656 + "$link": "bafkreieya7iitpu4okjtm7iexiwikj7t63ttlthad32ojsvjqhqbc3iwmi" 657 + }, 658 + "mimeType": "image/jpeg", 659 + "size": 518394 660 + }, 661 + "title": "Building on the AT Protocol | AT Protocol", 662 + "uri": "https://atproto.com/blog/building-on-atproto" 663 + } 664 + }, 665 + "facets": [ 666 + { 667 + "features": [ 668 + { 669 + "$type": "app.bsky.richtext.facet#link", 670 + "uri": "https://atproto.com/blog/building-on-atproto" 671 + } 672 + ], 673 + "index": { 674 + "byteEnd": 217, 675 + "byteStart": 190 676 + } 677 + } 678 + ], 679 + "langs": [ 680 + "en" 681 + ], 682 + "text": "Are you a developer looking for a way to get started with atproto dev? 👀\n\nRead our latest blog post with some pointers to what you can already built on the protocol, with starter code! \n\natproto.com/blog/buildin..." 683 + } 684 + }, 685 + { 686 + "cid": "bafyreifitblocnnwzlhmcftromkauc3lprs37gvyjaewbfbp3d6hahybo4", 687 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kbg4ksc3in2n", 688 + "value": { 689 + "$type": "app.bsky.feed.post", 690 + "createdAt": "2023-10-10T18:11:08.161Z", 691 + "facets": [ 692 + { 693 + "features": [ 694 + { 695 + "$type": "app.bsky.richtext.facet#link", 696 + "uri": "https://status.skyfeed.xyz/" 697 + } 698 + ], 699 + "index": { 700 + "byteEnd": 166, 701 + "byteStart": 148 702 + } 703 + } 704 + ], 705 + "langs": [ 706 + "en" 707 + ], 708 + "reply": { 709 + "parent": { 710 + "cid": "bafyreihtpwbcoqpgq5iknqsmgo4jq6le3odkbzospye3q5jtgzj7q5oepa", 711 + "uri": "at://did:plc:cipv3arp27zug6ugyk7fv76o/app.bsky.feed.post/3kbg45b6drf2s" 712 + }, 713 + "root": { 714 + "cid": "bafyreiduoaidw46xhv4kyffxizusembgrtuyse7yloenkjwk2ylc3jatyy", 715 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kbg3t7ypo52j" 716 + } 717 + }, 718 + "text": "don't think so: DIDs themselves are not changing, and keys aren't changing, only the way public keys are encoded.\n\nyou can check skyfeed status at: status.skyfeed.xyz" 719 + } 720 + }, 721 + { 722 + "cid": "bafyreiduoaidw46xhv4kyffxizusembgrtuyse7yloenkjwk2ylc3jatyy", 723 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kbg3t7ypo52j", 724 + "value": { 725 + "$type": "app.bsky.feed.post", 726 + "createdAt": "2023-10-10T17:57:57.125Z", 727 + "embed": { 728 + "$type": "app.bsky.embed.record", 729 + "record": { 730 + "cid": "bafyreifzavcz43kc753s2hjwstzangmx7cce3cjpiaesdnt6ssxyxwm5ny", 731 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kbdraubs7l2f" 732 + } 733 + }, 734 + "langs": [ 735 + "en" 736 + ], 737 + "text": "this change is rolling out to 'plc.directory' now!" 738 + } 739 + }, 740 + { 741 + "cid": "bafyreifzavcz43kc753s2hjwstzangmx7cce3cjpiaesdnt6ssxyxwm5ny", 742 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kbdraubs7l2f", 743 + "value": { 744 + "$type": "app.bsky.feed.post", 745 + "createdAt": "2023-10-09T19:43:24.015Z", 746 + "embed": { 747 + "$type": "app.bsky.embed.record", 748 + "record": { 749 + "cid": "bafyreibwh5d74t4xsuxjhg3sy4f2mlybcwtra53wfzst7foejhdv5c73zi", 750 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kb4ce57t6d2h" 751 + } 752 + }, 753 + "langs": [ 754 + "en" 755 + ], 756 + "text": "reminder for developers: this DID document syntax change will be deployed tomorrow on the main 'plc.directory' server! \n\nfor most devs, this shouldn't require any/specific action on your end." 757 + } 758 + }, 759 + { 760 + "cid": "bafyreibwh5d74t4xsuxjhg3sy4f2mlybcwtra53wfzst7foejhdv5c73zi", 761 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kb4ce57t6d2h", 762 + "value": { 763 + "$type": "app.bsky.feed.post", 764 + "createdAt": "2023-10-06T20:28:09.688Z", 765 + "embed": { 766 + "$type": "app.bsky.embed.external", 767 + "external": { 768 + "description": "The Bluesky AppView now consumes from a Bluesky BGS, instead of directly from the PDS. Also, we'll be updating the DID document public key syntax.", 769 + "thumb": { 770 + "$type": "blob", 771 + "ref": { 772 + "$link": "bafkreieya7iitpu4okjtm7iexiwikj7t63ttlthad32ojsvjqhqbc3iwmi" 773 + }, 774 + "mimeType": "image/jpeg", 775 + "size": 518394 776 + }, 777 + "title": "Bluesky BGS and DID Document Formatting Changes | AT Protocol", 778 + "uri": "https://atproto.com/blog/bgs-and-did-doc" 779 + } 780 + }, 781 + "facets": [ 782 + { 783 + "features": [ 784 + { 785 + "$type": "app.bsky.richtext.facet#link", 786 + "uri": "https://plc.directory" 787 + } 788 + ], 789 + "index": { 790 + "byteEnd": 182, 791 + "byteStart": 169 792 + } 793 + }, 794 + { 795 + "features": [ 796 + { 797 + "$type": "app.bsky.richtext.facet#link", 798 + "uri": "https://atproto.com/blog/bgs-and-did-doc" 799 + } 800 + ], 801 + "index": { 802 + "byteEnd": 279, 803 + "byteStart": 252 804 + } 805 + } 806 + ], 807 + "langs": [ 808 + "en" 809 + ], 810 + "reply": { 811 + "parent": { 812 + "cid": "bafyreifovukaaagkr2n5bj4oqsqwswhd6ulqdfqfi7zdczgjiab2l3u5oa", 813 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kb4ccsn2nq25" 814 + }, 815 + "root": { 816 + "cid": "bafyreiedct7s34kqdugycvv3juaujiz4tjwfpitxsqhbma7r6hqg2wigfq", 817 + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kb4cbhzw742d" 818 + } 819 + }, 820 + "text": "• We also want to remind folks that we are planning to update the DID document public key syntax to “Multikey” format next week on the main network PLC directory (plc.directory). These changes are live now on the sandbox PLC directory.\n\nDetails: atproto.com/blog/bgs-and..." 821 + } 822 + } 823 + ] 824 + }
+142
automod/testing.go
··· 1 + package automod 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "io" 7 + "log/slog" 8 + "os" 9 + "time" 10 + 11 + appbsky "github.com/bluesky-social/indigo/api/bsky" 12 + "github.com/bluesky-social/indigo/atproto/identity" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + func simpleRule(evt *RecordEvent, post *appbsky.FeedPost) error { 17 + for _, tag := range post.Tags { 18 + if evt.InSet("bad-hashtags", tag) { 19 + evt.AddRecordLabel("bad-hashtag") 20 + break 21 + } 22 + } 23 + for _, facet := range post.Facets { 24 + for _, feat := range facet.Features { 25 + if feat.RichtextFacet_Tag != nil { 26 + tag := feat.RichtextFacet_Tag.Tag 27 + if evt.InSet("bad-hashtags", tag) { 28 + evt.AddRecordLabel("bad-hashtag") 29 + break 30 + } 31 + } 32 + } 33 + } 34 + return nil 35 + } 36 + 37 + func EngineTestFixture() Engine { 38 + rules := RuleSet{ 39 + PostRules: []PostRuleFunc{ 40 + simpleRule, 41 + }, 42 + } 43 + cache := NewMemCacheStore(10, time.Hour) 44 + flags := NewMemFlagStore() 45 + sets := NewMemSetStore() 46 + sets.Sets["bad-hashtags"] = make(map[string]bool) 47 + sets.Sets["bad-hashtags"]["slur"] = true 48 + dir := identity.NewMockDirectory() 49 + id1 := identity.Identity{ 50 + DID: syntax.DID("did:plc:abc111"), 51 + Handle: syntax.Handle("handle.example.com"), 52 + } 53 + dir.Insert(id1) 54 + engine := Engine{ 55 + Logger: slog.Default(), 56 + Directory: &dir, 57 + Counters: NewMemCountStore(), 58 + Sets: sets, 59 + Flags: flags, 60 + Cache: cache, 61 + Rules: rules, 62 + } 63 + return engine 64 + } 65 + 66 + func MustLoadCapture(capPath string) AccountCapture { 67 + f, err := os.Open(capPath) 68 + if err != nil { 69 + panic(err) 70 + } 71 + defer func() { _ = f.Close() }() 72 + 73 + raw, err := io.ReadAll(f) 74 + if err != nil { 75 + panic(err) 76 + } 77 + 78 + var capture AccountCapture 79 + if err := json.Unmarshal(raw, &capture); err != nil { 80 + panic(err) 81 + } 82 + return capture 83 + } 84 + 85 + // Test helper which processes all the records from a capture. Intentionally exported, for use in other packages. 86 + // 87 + // This method replaces any pre-existing directory on the engine with a mock directory. 88 + func ProcessCaptureRules(e *Engine, capture AccountCapture) error { 89 + ctx := context.Background() 90 + 91 + dir := identity.NewMockDirectory() 92 + dir.Insert(*capture.AccountMeta.Identity) 93 + e.Directory = &dir 94 + 95 + // initial identity rules 96 + idevt := IdentityEvent{ 97 + RepoEvent{ 98 + Engine: e, 99 + Logger: e.Logger.With("did", capture.AccountMeta.Identity.DID), 100 + Account: capture.AccountMeta, 101 + }, 102 + } 103 + if err := e.Rules.CallIdentityRules(&idevt); err != nil { 104 + return err 105 + } 106 + if idevt.Err != nil { 107 + return idevt.Err 108 + } 109 + idevt.CanonicalLogLine() 110 + if err := idevt.PersistActions(ctx); err != nil { 111 + return err 112 + } 113 + if err := idevt.PersistCounters(ctx); err != nil { 114 + return err 115 + } 116 + 117 + // all the post rules 118 + for _, pr := range capture.PostRecords { 119 + aturi, err := syntax.ParseATURI(pr.Uri) 120 + if err != nil { 121 + return err 122 + } 123 + path := aturi.Collection().String() + "/" + aturi.RecordKey().String() 124 + evt := e.NewRecordEvent(capture.AccountMeta, path, pr.Cid, pr.Value.Val) 125 + e.Logger.Debug("processing record", "did", aturi.Authority(), "path", path) 126 + if err := e.Rules.CallRecordRules(&evt); err != nil { 127 + return err 128 + } 129 + if evt.Err != nil { 130 + return evt.Err 131 + } 132 + evt.CanonicalLogLine() 133 + // NOTE: not purging account meta when profile is updated 134 + if err := evt.PersistActions(ctx); err != nil { 135 + return err 136 + } 137 + if err := evt.PersistCounters(ctx); err != nil { 138 + return err 139 + } 140 + } 141 + return nil 142 + }
+72 -45
cmd/hepa/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "fmt" 6 7 "log/slog" 7 8 "net/http" ··· 97 98 runCmd, 98 99 processRecordCmd, 99 100 processRecentCmd, 101 + captureRecentCmd, 100 102 } 101 103 102 104 return app.Run(args) ··· 198 200 }, 199 201 } 200 202 203 + // for simple commands, not long-running daemons 204 + func configEphemeralServer(cctx *cli.Context) (*Server, error) { 205 + // NOTE: using stderr not stdout because some commands print to stdout 206 + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ 207 + Level: slog.LevelInfo, 208 + })) 209 + slog.SetDefault(logger) 210 + 211 + dir, err := configDirectory(cctx) 212 + if err != nil { 213 + return nil, err 214 + } 215 + 216 + return NewServer( 217 + dir, 218 + Config{ 219 + BGSHost: cctx.String("atp-bgs-host"), 220 + BskyHost: cctx.String("atp-bsky-host"), 221 + Logger: logger, 222 + ModHost: cctx.String("atp-mod-host"), 223 + ModAdminToken: cctx.String("mod-admin-token"), 224 + ModUsername: cctx.String("mod-handle"), 225 + ModPassword: cctx.String("mod-password"), 226 + SetsFileJSON: cctx.String("sets-json-path"), 227 + RedisURL: cctx.String("redis-url"), 228 + }, 229 + ) 230 + } 231 + 201 232 var processRecordCmd = &cli.Command{ 202 233 Name: "process-record", 203 234 Usage: "process a single record in isolation", 204 235 ArgsUsage: `<at-uri>`, 205 236 Flags: []cli.Flag{}, 206 237 Action: func(cctx *cli.Context) error { 238 + ctx := context.Background() 207 239 uriArg := cctx.Args().First() 208 240 if uriArg == "" { 209 241 return fmt.Errorf("expected a single AT-URI argument") ··· 213 245 return fmt.Errorf("not a valid AT-URI: %v", err) 214 246 } 215 247 216 - ctx := context.Background() 217 - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 218 - Level: slog.LevelInfo, 219 - })) 220 - slog.SetDefault(logger) 221 - 222 - dir, err := configDirectory(cctx) 223 - if err != nil { 224 - return err 225 - } 226 - 227 - srv, err := NewServer( 228 - dir, 229 - Config{ 230 - BGSHost: cctx.String("atp-bgs-host"), 231 - BskyHost: cctx.String("atp-bsky-host"), 232 - Logger: logger, 233 - ModHost: cctx.String("atp-mod-host"), 234 - ModAdminToken: cctx.String("mod-admin-token"), 235 - ModUsername: cctx.String("mod-handle"), 236 - ModPassword: cctx.String("mod-password"), 237 - SetsFileJSON: cctx.String("sets-json-path"), 238 - RedisURL: cctx.String("redis-url"), 239 - }, 240 - ) 248 + srv, err := configEphemeralServer(cctx) 241 249 if err != nil { 242 250 return err 243 251 } ··· 258 266 }, 259 267 }, 260 268 Action: func(cctx *cli.Context) error { 269 + ctx := context.Background() 261 270 idArg := cctx.Args().First() 262 271 if idArg == "" { 263 272 return fmt.Errorf("expected a single AT identifier (handle or DID) argument") ··· 267 276 return fmt.Errorf("not a valid handle or DID: %v", err) 268 277 } 269 278 279 + srv, err := configEphemeralServer(cctx) 280 + if err != nil { 281 + return err 282 + } 283 + 284 + return srv.engine.FetchAndProcessRecent(ctx, *atid, cctx.Int("limit")) 285 + }, 286 + } 287 + 288 + var captureRecentCmd = &cli.Command{ 289 + Name: "capture-recent", 290 + Usage: "fetch account metadata and recent posts for an account, dump JSON to stdout", 291 + ArgsUsage: `<at-identifier>`, 292 + Flags: []cli.Flag{ 293 + &cli.IntFlag{ 294 + Name: "limit", 295 + Usage: "how many post records to parse", 296 + Value: 20, 297 + }, 298 + }, 299 + Action: func(cctx *cli.Context) error { 270 300 ctx := context.Background() 271 - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 272 - Level: slog.LevelInfo, 273 - })) 274 - slog.SetDefault(logger) 301 + idArg := cctx.Args().First() 302 + if idArg == "" { 303 + return fmt.Errorf("expected a single AT identifier (handle or DID) argument") 304 + } 305 + atid, err := syntax.ParseAtIdentifier(idArg) 306 + if err != nil { 307 + return fmt.Errorf("not a valid handle or DID: %v", err) 308 + } 275 309 276 - dir, err := configDirectory(cctx) 310 + srv, err := configEphemeralServer(cctx) 311 + if err != nil { 312 + return err 313 + } 314 + 315 + capture, err := srv.engine.CaptureRecent(ctx, *atid, cctx.Int("limit")) 277 316 if err != nil { 278 317 return err 279 318 } 280 319 281 - srv, err := NewServer( 282 - dir, 283 - Config{ 284 - BGSHost: cctx.String("atp-bgs-host"), 285 - BskyHost: cctx.String("atp-bsky-host"), 286 - Logger: logger, 287 - ModHost: cctx.String("atp-mod-host"), 288 - ModAdminToken: cctx.String("mod-admin-token"), 289 - ModUsername: cctx.String("mod-handle"), 290 - ModPassword: cctx.String("mod-password"), 291 - SetsFileJSON: cctx.String("sets-json-path"), 292 - RedisURL: cctx.String("redis-url"), 293 - }, 294 - ) 320 + outJSON, err := json.MarshalIndent(capture, "", " ") 295 321 if err != nil { 296 322 return err 297 323 } 298 324 299 - return srv.engine.FetchAndProcessRecent(ctx, *atid, cctx.Int("limit")) 325 + fmt.Println(string(outJSON)) 326 + return nil 300 327 }, 301 328 }