···110110 return nil, err
111111 }
112112 if creatorIdent == nil {
113113- return nil, fmt.Errorf("identity not found for DID: %s", evt.CreatedBy)
113113+ return nil, fmt.Errorf("identity not found for creator DID: %s", evt.CreatedBy)
114114 }
115115 creatorMeta, err := eng.GetAccountMeta(ctx, creatorIdent)
116116 if err != nil {
···122122 return nil, err
123123 }
124124 if subjectIdent == nil {
125125- return nil, fmt.Errorf("identity not found for DID: %s", evt.SubjectDID)
125125+ return nil, fmt.Errorf("identity not found for subject DID: %s", evt.SubjectDID)
126126 }
127127 accountMeta, err := eng.GetAccountMeta(ctx, subjectIdent)
128128 if err != nil {
+27-10
automod/engine/fetch_account_meta.go
···33import (
44 "context"
55 "encoding/json"
66+ "errors"
67 "fmt"
88+ "time"
79810 comatproto "github.com/bluesky-social/indigo/api/atproto"
911 appbsky "github.com/bluesky-social/indigo/api/bsky"
1012 toolsozone "github.com/bluesky-social/indigo/api/ozone"
1113 "github.com/bluesky-social/indigo/atproto/identity"
1214 "github.com/bluesky-social/indigo/atproto/syntax"
1515+ "github.com/bluesky-social/indigo/xrpc"
1316)
1717+1818+var newAccountRetryDuration = 3 * 1000 * time.Millisecond
14191520// Helper to hydrate metadata about an account from several sources: PDS (if access), mod service (if access), public identity resolution
1621func (e *Engine) GetAccountMeta(ctx context.Context, ident *identity.Identity) (*AccountMeta, error) {
···56615762 // fetch account metadata from AppView
5863 pv, err := appbsky.ActorGetProfile(ctx, e.BskyClient, ident.DID.String())
6464+ // most common cause of this is a race between automod and ozone/appview for new accounts. just sleep a couple seconds and retry!
6565+ var xrpcError *xrpc.Error
6666+ if err != nil && errors.As(err, &xrpcError) && (xrpcError.StatusCode == 400 || xrpcError.StatusCode == 404) {
6767+ logger.Info("account profile lookup initially failed (from bsky appview), will retry", "err", err, "sleepDuration", newAccountRetryDuration)
6868+ time.Sleep(newAccountRetryDuration)
6969+ pv, err = appbsky.ActorGetProfile(ctx, e.BskyClient, ident.DID.String())
7070+ }
5971 if err != nil {
6060- logger.Warn("account profile lookup failed", "err", err)
7272+ logger.Warn("account profile lookup failed (from bsky appview)", "err", err)
6173 return &am, nil
6274 }
6375···110122 if rd.EmailConfirmedAt != nil && *rd.EmailConfirmedAt != "" {
111123 ap.EmailConfirmed = true
112124 }
113113- ts, err := syntax.ParseDatetimeTime(rd.IndexedAt)
114114- if err != nil {
115115- return nil, fmt.Errorf("bad account IndexedAt: %w", err)
116116- }
117117- ap.IndexedAt = ts
125125+ // TODO: ozone doesn't really return good account "created at", just just leave that field nil
126126+ ap.IndexedAt = nil
118127 if rd.DeactivatedAt != nil {
119128 am.Deactivated = true
120129 }
···126135 }
127136 am.Private = &ap
128137 }
129129- } else if e.AdminClient != nil {
130130- // fall back to PDS/entryway fetching; less metadata available
138138+ }
139139+ // fall back to PDS/entryway fetching; less metadata available
140140+ if am.Private == nil && e.AdminClient != nil {
131141 pv, err := comatproto.AdminGetAccountInfo(ctx, e.AdminClient, ident.DID.String())
132142 if err != nil {
133143 logger.Warn("failed to fetch private account metadata from PDS/entryway", "err", err)
···141151 }
142152 ts, err := syntax.ParseDatetimeTime(pv.IndexedAt)
143153 if err != nil {
144144- return nil, fmt.Errorf("bad account IndexedAt: %w", err)
154154+ return nil, fmt.Errorf("bad entryway account IndexedAt: %w", err)
145155 }
146146- ap.IndexedAt = ts
156156+ ap.IndexedAt = &ts
147157 am.Private = &ap
158158+ if am.CreatedAt == nil {
159159+ am.CreatedAt = &ts
160160+ }
148161 }
162162+ }
163163+164164+ if am.CreatedAt == nil {
165165+ logger.Warn("account metadata missing CreatedAt time")
149166 }
150167151168 val, err := json.Marshal(&am)
+1-1
automod/engine/persist.go
···170170 rv, err := toolsozone.ModerationGetRecord(ctx, eng.OzoneClient, c.RecordOp.CID.String(), c.RecordOp.ATURI().String())
171171 if err != nil {
172172 // NOTE: there is a frequent 4xx error here from Ozone because this record has not been indexed yet
173173- c.Logger.Warn("failed to fetch private record metadata", "err", err)
173173+ c.Logger.Warn("failed to fetch private record metadata from Ozone", "err", err)
174174 } else {
175175 var existingLabels []string
176176 var negLabels []string
···14141515// looks for new accounts, which interact with frequently-harassed accounts, and report them for review
1616func HarassmentTargetInteractionPostRule(c *automod.RecordContext, post *appbsky.FeedPost) error {
1717- if c.Account.Private == nil || c.Account.Identity == nil {
1818- return nil
1919- }
2020- // TODO: helper for account age; and use public info for this (not private)
2121- age := time.Since(c.Account.Private.IndexedAt)
2222- if age > 7*24*time.Hour {
1717+ if c.Account.Identity == nil || !AccountIsYoungerThan(&c.AccountContext, 24*time.Hour) {
2318 return nil
2419 }
2520···4035 }
4136 interactionDIDs = append(interactionDIDs, parentURI.Authority().String())
4237 }
4343- // TODO: quote-posts; any other interactions?
3838+ // quote posts
3939+ if post.Embed != nil && post.Embed.EmbedRecord != nil && post.Embed.EmbedRecord.Record != nil {
4040+ uri, err := syntax.ParseATURI(post.Embed.EmbedRecord.Record.Uri)
4141+ if err != nil {
4242+ c.Logger.Warn("invalid AT-URI in post embed record (quote-post)", "uri", post.Embed.EmbedRecord.Record.Uri)
4343+ } else {
4444+ interactionDIDs = append(interactionDIDs, uri.Authority().String())
4545+ }
4646+ }
4447 if len(interactionDIDs) == 0 {
4548 return nil
4649 }
47505151+ // more than a handful of followers or posts from author account? skip
5252+ if c.Account.FollowersCount > 10 || c.Account.PostsCount > 10 {
5353+ return nil
5454+ }
5555+ postCount := c.GetCount("post", c.Account.Identity.DID.String(), countstore.PeriodTotal)
5656+ if postCount > 20 {
5757+ return nil
5858+ }
5959+4860 interactionDIDs = dedupeStrings(interactionDIDs)
4961 for _, d := range interactionDIDs {
5062 did, err := syntax.ParseDID(d)
···8395 }
84968597 //c.AddRecordFlag("interaction-harassed-target")
9898+ var privCreatedAt *time.Time
9999+ if c.Account.Private != nil && c.Account.Private.IndexedAt != nil {
100100+ privCreatedAt = c.Account.Private.IndexedAt
101101+ }
102102+ c.Logger.Warn("possible harassment", "targetDID", did, "author", c.Account.Identity.DID, "accountCreated", c.Account.CreatedAt, "privateAccountCreated", privCreatedAt)
86103 c.ReportAccount(automod.ReportReasonOther, fmt.Sprintf("possible harassment of known target account: %s (also labeled; remove label if this isn't harassment)", did))
87104 c.AddAccountLabel("!hide")
88105 c.Notify("slack")
···9511296113// looks for new accounts, which frequently post the same type of content
97114func HarassmentTrivialPostRule(c *automod.RecordContext, post *appbsky.FeedPost) error {
9898- if c.Account.Private == nil || c.Account.Identity == nil {
9999- return nil
100100- }
101101- // TODO: helper for account age; and use public info for this (not private)
102102- age := time.Since(c.Account.Private.IndexedAt)
103103- if age > 7*24*time.Hour {
115115+ if c.Account.Identity == nil || !AccountIsYoungerThan(&c.AccountContext, 7*24*time.Hour) {
104116 return nil
105117 }
106118
+43
automod/rules/helpers.go
···33import (
44 "fmt"
55 "regexp"
66+ "time"
6778 appbsky "github.com/bluesky-social/indigo/api/bsky"
89 "github.com/bluesky-social/indigo/atproto/syntax"
···240241 }
241242 return false
242243}
244244+245245+// no accounts exist before this time
246246+var atprotoAccountEpoch = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
247247+248248+// returns true if account creation timestamp is plausible: not-nil, not in distant past, not in the future
249249+func plausibleAccountCreation(when *time.Time) bool {
250250+ if when == nil {
251251+ return false
252252+ }
253253+ // this is mostly to check for misconfigurations or null values (eg, UNIX epoch zero means "unknown" not actually 1970)
254254+ if !when.After(atprotoAccountEpoch) {
255255+ return false
256256+ }
257257+ // a timestamp in the future would also indicate some misconfiguration
258258+ if when.After(time.Now().Add(time.Hour)) {
259259+ return false
260260+ }
261261+ return true
262262+}
263263+264264+// checks if account was created recently, based on either public or private account metadata. if metadata isn't available at all, or seems bogus, returns 'false'
265265+func AccountIsYoungerThan(c *automod.AccountContext, age time.Duration) bool {
266266+ // TODO: consider swapping priority order here (and below)
267267+ if c.Account.CreatedAt != nil && plausibleAccountCreation(c.Account.CreatedAt) {
268268+ return time.Since(*c.Account.CreatedAt) < age
269269+ }
270270+ if c.Account.Private != nil && plausibleAccountCreation(c.Account.Private.IndexedAt) {
271271+ return time.Since(*c.Account.Private.IndexedAt) < age
272272+ }
273273+ return false
274274+}
275275+276276+// checks if account was *not* created recently, based on either public or private account metadata. if metadata isn't available at all, or seems bogus, returns 'false'
277277+func AccountIsOlderThan(c *automod.AccountContext, age time.Duration) bool {
278278+ if c.Account.CreatedAt != nil && plausibleAccountCreation(c.Account.CreatedAt) {
279279+ return time.Since(*c.Account.CreatedAt) >= age
280280+ }
281281+ if c.Account.Private != nil && plausibleAccountCreation(c.Account.Private.IndexedAt) {
282282+ return time.Since(*c.Account.Private.IndexedAt) >= age
283283+ }
284284+ return false
285285+}
···11111212// triggers on first identity event for an account (DID)
1313func NewAccountRule(c *automod.AccountContext) error {
1414- // need access to IndexedAt for this rule
1515- if c.Account.Private == nil || c.Account.Identity == nil {
1414+ if c.Account.Identity == nil || !AccountIsYoungerThan(c, 4*time.Hour) {
1615 return nil
1716 }
18171918 did := c.Account.Identity.DID.String()
2020- age := time.Since(c.Account.Private.IndexedAt)
2121- if age > 4*time.Hour {
2222- return nil
2323- }
2419 exists := c.GetCount("acct/exists", did, countstore.PeriodTotal)
2520 if exists == 0 {
2621 c.Logger.Info("new account")
+2-7
automod/rules/mentions.go
···4747var _ automod.PostRuleFunc = YoungAccountDistinctMentionsRule
48484949func YoungAccountDistinctMentionsRule(c *automod.RecordContext, post *appbsky.FeedPost) error {
5050-5151- // only young posting accounts
5252- if c.Account.Private != nil {
5353- age := time.Since(c.Account.Private.IndexedAt)
5454- if age > 2*7*24*time.Hour {
5555- return nil
5656- }
5050+ if c.Account.Identity == nil || !AccountIsYoungerThan(&c.AccountContext, 14*24*time.Hour) {
5151+ return nil
5752 }
58535954 // parse out all the mentions
+1-10
automod/rules/nostr.go
···13131414// looks for new accounts, which frequently post the same type of content
1515func NostrSpamPostRule(c *automod.RecordContext, post *appbsky.FeedPost) error {
1616- if c.Account.Identity == nil {
1616+ if c.Account.Identity == nil || !AccountIsYoungerThan(&c.AccountContext, 2*24*time.Hour) {
1717 return nil
1818- }
1919-2020- // often don't have private metadata for these accounts right after creation
2121- if c.Account.Private != nil {
2222- // TODO: helper for account age; and use public info for this (not private)
2323- age := time.Since(c.Account.Private.IndexedAt)
2424- if age > 2*24*time.Hour {
2525- return nil
2626- }
2718 }
28192920 // is this a bridged nostr account? if not, bail out
+1-6
automod/rules/promo.go
···1717//
1818// this rule depends on ReplyCountPostRule() to set counts
1919func AggressivePromotionRule(c *automod.RecordContext, post *appbsky.FeedPost) error {
2020- if c.Account.Private == nil || c.Account.Identity == nil {
2121- return nil
2222- }
2323- // TODO: helper for account age
2424- age := time.Since(c.Account.Private.IndexedAt)
2525- if age > 7*24*time.Hour {
2020+ if c.Account.Identity == nil || !AccountIsYoungerThan(&c.AccountContext, 7*24*time.Hour) {
2621 return nil
2722 }
2823 if post.Reply == nil || IsSelfThread(c, post) {
+1-7
automod/rules/quick.go
···4646var _ automod.IdentityRuleFunc = NewAccountBotEmailRule
47474848func NewAccountBotEmailRule(c *automod.AccountContext) error {
4949- // need access to IndexedAt for this rule
5050- if c.Account.Private == nil || c.Account.Identity == nil {
5151- return nil
5252- }
5353-5454- age := time.Since(c.Account.Private.IndexedAt)
5555- if age > 1*time.Hour {
4949+ if c.Account.Identity == nil || !AccountIsYoungerThan(c, 1*time.Hour) {
5650 return nil
5751 }
5852
+4-10
automod/rules/replies.go
···5252 if utf8.RuneCountInString(post.Text) <= 10 {
5353 return nil
5454 }
5555- if c.Account.Private != nil {
5656- age := time.Since(c.Account.Private.IndexedAt)
5757- if age > 2*7*24*time.Hour {
5858- return nil
5959- }
5555+ if AccountIsOlderThan(&c.AccountContext, 14*24*time.Hour) {
5656+ return nil
6057 }
61586259 // don't count if there is a follow-back relationship
···9289 if utf8.RuneCountInString(post.Text) <= 10 {
9390 return nil
9491 }
9595- if c.Account.Private != nil {
9696- age := time.Since(c.Account.Private.IndexedAt)
9797- if age > 2*7*24*time.Hour {
9898- return nil
9999- }
9292+ if AccountIsOlderThan(&c.AccountContext, 14*24*time.Hour) {
9393+ return nil
10094 }
1019510296 // don't count if there is a follow-back relationship
+4-7
automod/rules/reposts.go
···17171818// looks for accounts which do frequent reposts
1919func TooManyRepostRule(c *automod.RecordContext) error {
2020+ // Don't bother checking reposts from accounts older than 30 days
2121+ if c.Account.Identity == nil || !AccountIsYoungerThan(&c.AccountContext, 30*24*time.Hour) {
2222+ return nil
2323+ }
20242125 did := c.Account.Identity.DID.String()
2222- // Don't bother checking reposts from accounts older than 30 days
2323- if c.Account.Private != nil {
2424- age := time.Since(c.Account.Private.IndexedAt)
2525- if age > 30*24*time.Hour {
2626- return nil
2727- }
2828- }
29263027 // Special case for newsmast bridge feeds
3128 handle := c.Account.Identity.Handle.String()