this repo has no description
0
fork

Configure Feed

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

automod: action limits; create reports for interaction churn (#465)

The motivation here is to start auto-reporting in production based on
specific rules. Specifically, this PR would start reporting on
interactions churn (follow/unfollow), letting human mods confirm before
taking action on an account.

Before we do that, need to de-duplicate reports. For example, if an
account creates thousands of spammy posts, only want to report the
account once. Generally want to prevent run-away rules from creating
millions of reports, or doing thousands of automated account takedowns
(for example).

This PR prevents re-reporting based on daily counters (fast checks), as
well as double-checking against the mod service API just before filing a
report (slower, but reliable).

This PR also adds "quotas" for mod actions, implemented using the
counter system. This isn't perfect (the counter system itself might be
buggy or broken (causing duplicate actions), but seems like a good
start. If quotas are exceeded, automod will log and skip taking
additional actions until the next day.

authored by

bnewbold and committed by
GitHub
fb514302 940f987d

+364 -64
+45
automod/action_dedupe_test.go
··· 1 + package automod 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + 7 + appbsky "github.com/bluesky-social/indigo/api/bsky" 8 + "github.com/bluesky-social/indigo/atproto/identity" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + 11 + "github.com/stretchr/testify/assert" 12 + ) 13 + 14 + func alwaysReportAccountRule(evt *RecordEvent) error { 15 + evt.ReportAccount(ReportReasonOther, "test report") 16 + return nil 17 + } 18 + 19 + func TestAccountReportDedupe(t *testing.T) { 20 + assert := assert.New(t) 21 + ctx := context.Background() 22 + engine := engineFixture() 23 + engine.Rules = RuleSet{ 24 + RecordRules: []RecordRuleFunc{ 25 + alwaysReportAccountRule, 26 + }, 27 + } 28 + 29 + path := "app.bsky.feed.post/abc123" 30 + cid1 := "cid123" 31 + p1 := appbsky.FeedPost{Text: "some post blah"} 32 + id1 := identity.Identity{ 33 + DID: syntax.DID("did:plc:abc111"), 34 + Handle: syntax.Handle("handle.example.com"), 35 + } 36 + 37 + // exact same event multiple times; should only report once 38 + for i := 0; i < 5; i++ { 39 + assert.NoError(engine.ProcessRecord(ctx, id1.DID, path, cid1, &p1)) 40 + } 41 + 42 + reports, err := engine.GetCount("automod-quota", "report", PeriodDay) 43 + assert.NoError(err) 44 + assert.Equal(1, reports) 45 + }
+94
automod/circuit_breaker_test.go
··· 1 + package automod 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "testing" 7 + 8 + appbsky "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/atproto/identity" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + 12 + "github.com/stretchr/testify/assert" 13 + ) 14 + 15 + func alwaysTakedownRecordRule(evt *RecordEvent) error { 16 + evt.TakedownRecord() 17 + return nil 18 + } 19 + 20 + func alwaysReportRecordRule(evt *RecordEvent) error { 21 + evt.ReportRecord(ReportReasonOther, "test report") 22 + return nil 23 + } 24 + 25 + func TestTakedownCircuitBreaker(t *testing.T) { 26 + assert := assert.New(t) 27 + ctx := context.Background() 28 + engine := engineFixture() 29 + dir := identity.NewMockDirectory() 30 + engine.Directory = &dir 31 + // note that this is a record-level action, not account-level 32 + engine.Rules = RuleSet{ 33 + RecordRules: []RecordRuleFunc{ 34 + alwaysTakedownRecordRule, 35 + }, 36 + } 37 + 38 + path := "app.bsky.feed.post/abc123" 39 + cid1 := "cid123" 40 + p1 := appbsky.FeedPost{Text: "some post blah"} 41 + 42 + // generate double the quote of events; expect to only count the quote worth of actions 43 + for i := 0; i < 2*QuotaModTakedownDay; i++ { 44 + ident := identity.Identity{ 45 + DID: syntax.DID(fmt.Sprintf("did:plc:abc%d", i)), 46 + Handle: syntax.Handle("handle.example.com"), 47 + } 48 + dir.Insert(ident) 49 + assert.NoError(engine.ProcessRecord(ctx, ident.DID, path, cid1, &p1)) 50 + } 51 + 52 + takedowns, err := engine.GetCount("automod-quota", "takedown", PeriodDay) 53 + assert.NoError(err) 54 + assert.Equal(QuotaModTakedownDay, takedowns) 55 + 56 + reports, err := engine.GetCount("automod-quota", "report", PeriodDay) 57 + assert.NoError(err) 58 + assert.Equal(0, reports) 59 + } 60 + 61 + func TestReportCircuitBreaker(t *testing.T) { 62 + assert := assert.New(t) 63 + ctx := context.Background() 64 + engine := engineFixture() 65 + dir := identity.NewMockDirectory() 66 + engine.Directory = &dir 67 + engine.Rules = RuleSet{ 68 + RecordRules: []RecordRuleFunc{ 69 + alwaysReportRecordRule, 70 + }, 71 + } 72 + 73 + path := "app.bsky.feed.post/abc123" 74 + cid1 := "cid123" 75 + p1 := appbsky.FeedPost{Text: "some post blah"} 76 + 77 + // generate double the quota of events; expect to only count the quota worth of actions 78 + for i := 0; i < 2*QuotaModReportDay; i++ { 79 + ident := identity.Identity{ 80 + DID: syntax.DID(fmt.Sprintf("did:plc:abc%d", i)), 81 + Handle: syntax.Handle("handle.example.com"), 82 + } 83 + dir.Insert(ident) 84 + assert.NoError(engine.ProcessRecord(ctx, ident.DID, path, cid1, &p1)) 85 + } 86 + 87 + takedowns, err := engine.GetCount("automod-quota", "takedown", PeriodDay) 88 + assert.NoError(err) 89 + assert.Equal(0, takedowns) 90 + 91 + reports, err := engine.GetCount("automod-quota", "report", PeriodDay) 92 + assert.NoError(err) 93 + assert.Equal(QuotaModReportDay, reports) 94 + }
+12 -6
automod/countstore/countstore_test.go
··· 20 20 assert.Equal(0, c) 21 21 assert.NoError(cs.Increment(ctx, "test1", "val1")) 22 22 assert.NoError(cs.Increment(ctx, "test1", "val1")) 23 - c, err = cs.GetCount(ctx, "test1", "val1", PeriodTotal) 24 - assert.NoError(err) 25 - assert.Equal(2, c) 23 + 24 + for _, period := range []string{PeriodTotal, PeriodDay, PeriodHour} { 25 + c, err = cs.GetCount(ctx, "test1", "val1", period) 26 + assert.NoError(err) 27 + assert.Equal(2, c) 28 + } 26 29 27 30 c, err = cs.GetCountDistinct(ctx, "test2", "val2", PeriodTotal) 28 31 assert.NoError(err) ··· 36 39 37 40 assert.NoError(cs.IncrementDistinct(ctx, "test2", "val2", "two")) 38 41 assert.NoError(cs.IncrementDistinct(ctx, "test2", "val2", "three")) 39 - c, err = cs.GetCountDistinct(ctx, "test2", "val2", PeriodTotal) 40 - assert.NoError(err) 41 - assert.Equal(3, c) 42 + 43 + for _, period := range []string{PeriodTotal, PeriodDay, PeriodHour} { 44 + c, err = cs.GetCountDistinct(ctx, "test2", "val2", period) 45 + assert.NoError(err) 46 + assert.Equal(3, c) 47 + } 42 48 } 43 49 44 50 func TestMemCountStoreConcurrent(t *testing.T) {
+8
automod/engine.go
··· 72 72 if err := evt.PersistCounters(ctx); err != nil { 73 73 return err 74 74 } 75 + // check for any new errors during persist 76 + if evt.Err != nil { 77 + return evt.Err 78 + } 75 79 return nil 76 80 } 77 81 ··· 113 117 } 114 118 if err := evt.PersistCounters(ctx); err != nil { 115 119 return err 120 + } 121 + // check for any new errors during persist 122 + if evt.Err != nil { 123 + return evt.Err 116 124 } 117 125 return nil 118 126 }
+167 -58
automod/event.go
··· 5 5 "fmt" 6 6 "log/slog" 7 7 "strings" 8 + "time" 8 9 9 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 10 11 appbsky "github.com/bluesky-social/indigo/api/bsky" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/bluesky-social/indigo/xrpc" 11 14 ) 12 15 13 - type ModReport struct { 14 - ReasonType string 15 - Comment string 16 - } 16 + var ( 17 + // time period within which automod will not re-report an account for the same reasonType 18 + ReportDupePeriod = 7 * 24 * time.Hour 19 + // number of reports automod can file per day, for all subjects and types combined (circuit breaker) 20 + QuotaModReportDay = 50 21 + // number of takedowns automod can action per day, for all subjects combined (circuit breaker) 22 + QuotaModTakedownDay = 10 23 + ) 17 24 18 25 type CounterRef struct { 19 26 Name string ··· 124 131 125 132 // Enqueues a moderation report to be filed against the account at the end of rule processing. 126 133 func (e *RepoEvent) ReportAccount(reason, comment string) { 134 + if comment == "" { 135 + comment = "(no comment)" 136 + } 137 + comment = "automod: " + comment 127 138 e.AccountReports = append(e.AccountReports, ModReport{ReasonType: reason, Comment: comment}) 128 139 } 129 140 130 - func slackBody(msg string, newLabels, newFlags []string, newReports []ModReport, newTakedown bool) string { 141 + func slackBody(header string, acct AccountMeta, newLabels, newFlags []string, newReports []ModReport, newTakedown bool) string { 142 + msg := header 143 + msg += fmt.Sprintf("`%s` / `%s` / <https://bsky.app/profile/%s|bsky> / <https://admin.prod.bsky.dev/repositories/%s|ozone>\n", 144 + acct.Identity.DID, 145 + acct.Identity.Handle, 146 + acct.Identity.DID, 147 + acct.Identity.DID, 148 + ) 131 149 if len(newLabels) > 0 { 132 150 msg += fmt.Sprintf("New Labels: `%s`\n", strings.Join(newLabels, ", ")) 133 151 } ··· 143 161 return msg 144 162 } 145 163 146 - // Persists account-level moderation actions: new labels, new flags, new takedowns, and reports. 147 - // 148 - // If necessary, will "purge" identity and account caches, so that state updates will be picked up for subsequent events. 149 - // 150 - // TODO: de-dupe reports based on existing state, similar to other state 151 - func (e *RepoEvent) PersistAccountActions(ctx context.Context) error { 152 - 153 - // de-dupe actions 164 + func dedupeLabelActions(labels, existing, existingNegated []string) []string { 154 165 newLabels := []string{} 155 - for _, val := range dedupeStrings(e.AccountLabels) { 166 + for _, val := range dedupeStrings(labels) { 156 167 exists := false 157 - for _, e := range e.Account.AccountNegatedLabels { 168 + for _, e := range existingNegated { 158 169 if val == e { 159 170 exists = true 160 171 break 161 172 } 162 173 } 163 - for _, e := range e.Account.AccountLabels { 174 + for _, e := range existing { 164 175 if val == e { 165 176 exists = true 166 177 break ··· 170 181 newLabels = append(newLabels, val) 171 182 } 172 183 } 184 + return newLabels 185 + } 186 + 187 + func dedupeFlagActions(flags, existing []string) []string { 173 188 newFlags := []string{} 174 - for _, val := range dedupeStrings(e.AccountFlags) { 189 + for _, val := range dedupeStrings(flags) { 175 190 exists := false 176 - for _, e := range e.Account.AccountFlags { 191 + for _, e := range existing { 177 192 if val == e { 178 193 exists = true 179 194 break ··· 183 198 newFlags = append(newFlags, val) 184 199 } 185 200 } 186 - newReports := e.AccountReports 187 - newTakedown := e.AccountTakedown && !e.Account.Takendown 201 + return newFlags 202 + } 203 + 204 + func dedupeReportActions(evt *RepoEvent, reports []ModReport) []ModReport { 205 + newReports := []ModReport{} 206 + for _, r := range reports { 207 + counterName := "automod-account-report-" + reasonShortName(r.ReasonType) 208 + existing := evt.GetCount(counterName, evt.Account.Identity.DID.String(), PeriodDay) 209 + if existing > 0 { 210 + evt.Logger.Debug("skipping account report due to counter", "existing", existing, "reason", reasonShortName(r.ReasonType)) 211 + } else { 212 + evt.Increment(counterName, evt.Account.Identity.DID.String()) 213 + newReports = append(newReports, r) 214 + } 215 + } 216 + return newReports 217 + } 218 + 219 + func circuitBreakReports(evt *RepoEvent, reports []ModReport) []ModReport { 220 + if len(reports) == 0 { 221 + return []ModReport{} 222 + } 223 + if evt.GetCount("automod-quota", "report", PeriodDay) >= QuotaModReportDay { 224 + evt.Logger.Warn("CIRCUIT BREAKER: automod reports") 225 + return []ModReport{} 226 + } 227 + evt.Increment("automod-quota", "report") 228 + return reports 229 + } 230 + 231 + func circuitBreakTakedown(evt *RepoEvent, takedown bool) bool { 232 + if !takedown { 233 + return takedown 234 + } 235 + if evt.GetCount("automod-quota", "takedown", PeriodDay) >= QuotaModTakedownDay { 236 + evt.Logger.Warn("CIRCUIT BREAKER: automod takedowns") 237 + return false 238 + } 239 + evt.Increment("automod-quota", "takedown") 240 + return takedown 241 + } 242 + 243 + // Creates a moderation report, but checks first if there was a similar recent one, and skips if so. 244 + // 245 + // Returns a bool indicating if a new report was created. 246 + func createReportIfFresh(ctx context.Context, xrpcc *xrpc.Client, evt RepoEvent, mr ModReport) (bool, error) { 247 + // before creating a report, query to see if automod has already reported this account in the past week for the same reason 248 + // NOTE: this is running in an inner loop (if there are multiple reports), which is a bit inefficient, but seems acceptable 249 + 250 + // AdminQueryModerationEvents(ctx context.Context, c *xrpc.Client, createdBy string, cursor string, inc ludeAllUserRecords bool, limit int64, sortDirection string, subject string, types []string) 251 + resp, err := comatproto.AdminQueryModerationEvents(ctx, xrpcc, xrpcc.Auth.Did, "", false, 5, "", evt.Account.Identity.DID.String(), []string{"com.atproto.admin.defs#modEventReport"}) 252 + if err != nil { 253 + return false, err 254 + } 255 + for _, modEvt := range resp.Events { 256 + // defensively ensure that our query params worked correctly 257 + if modEvt.Event.AdminDefs_ModEventReport == nil || modEvt.CreatedBy != xrpcc.Auth.Did || modEvt.Subject.AdminDefs_RepoRef == nil || modEvt.Subject.AdminDefs_RepoRef.Did != evt.Account.Identity.DID.String() || (modEvt.Event.AdminDefs_ModEventReport.ReportType != nil && *modEvt.Event.AdminDefs_ModEventReport.ReportType != mr.ReasonType) { 258 + continue 259 + } 260 + // igonre if older 261 + created, err := syntax.ParseDatetime(modEvt.CreatedAt) 262 + if err != nil { 263 + return false, err 264 + } 265 + if time.Since(created.Time()) > ReportDupePeriod { 266 + continue 267 + } 188 268 189 - if newTakedown || len(newLabels) > 0 || len(newFlags) > 0 || len(newReports) > 0 { 190 - if e.Engine.SlackWebhookURL != "" { 191 - msg := fmt.Sprintf("⚠️ Automod Account Action ⚠️\n") 192 - msg += fmt.Sprintf("`%s` / `%s` / <https://bsky.app/profile/%s|bsky> / <https://admin.prod.bsky.dev/repositories/%s|ozone>\n", 193 - e.Account.Identity.DID, 194 - e.Account.Identity.Handle, 195 - e.Account.Identity.DID, 196 - e.Account.Identity.DID, 197 - ) 198 - msg = slackBody(msg, newLabels, newFlags, newReports, newTakedown) 199 - if err := e.Engine.SendSlackMsg(ctx, msg); err != nil { 200 - e.Logger.Error("sending slack webhook", "err", err) 201 - } 269 + // there is a recent report which is similar to this one 270 + evt.Logger.Info("skipping duplicate account report due to API check") 271 + return false, nil 272 + } 273 + 274 + evt.Logger.Info("reporting account", "reasonType", mr.ReasonType, "comment", mr.Comment) 275 + _, err = comatproto.ModerationCreateReport(ctx, xrpcc, &comatproto.ModerationCreateReport_Input{ 276 + ReasonType: &mr.ReasonType, 277 + Reason: &mr.Comment, 278 + Subject: &comatproto.ModerationCreateReport_Input_Subject{ 279 + AdminDefs_RepoRef: &comatproto.AdminDefs_RepoRef{ 280 + Did: evt.Account.Identity.DID.String(), 281 + }, 282 + }, 283 + }) 284 + return true, nil 285 + } 286 + 287 + // Persists account-level moderation actions: new labels, new flags, new takedowns, and reports. 288 + // 289 + // If necessary, will "purge" identity and account caches, so that state updates will be picked up for subsequent events. 290 + // 291 + // Note that this method expects to run *before* counts are persisted (it accesses and updates some counts) 292 + func (e *RepoEvent) PersistAccountActions(ctx context.Context) error { 293 + 294 + // de-dupe actions 295 + newLabels := dedupeLabelActions(e.AccountLabels, e.Account.AccountLabels, e.Account.AccountNegatedLabels) 296 + newFlags := dedupeFlagActions(e.AccountFlags, e.Account.AccountFlags) 297 + 298 + // don't report the same account multiple times on the same day for the same reason. this is a quick check; we also query the mod service API just before creating the report. 299 + newReports := circuitBreakReports(e, dedupeReportActions(e, e.AccountReports)) 300 + newTakedown := circuitBreakTakedown(e, e.AccountTakedown && !e.Account.Takendown) 301 + 302 + anyModActions := newTakedown || len(newLabels) > 0 || len(newFlags) > 0 || len(newReports) > 0 303 + if anyModActions && e.Engine.SlackWebhookURL != "" { 304 + msg := slackBody("⚠️ Automod Account Action ⚠️\n", e.Account, newLabels, newFlags, newReports, newTakedown) 305 + if err := e.Engine.SendSlackMsg(ctx, msg); err != nil { 306 + e.Logger.Error("sending slack webhook", "err", err) 202 307 } 203 308 } 204 309 310 + // if we can't actually talk to service, bail out early 205 311 if e.Engine.AdminClient == nil { 206 312 return nil 207 313 } 208 314 209 - needsPurge := false 210 315 xrpcc := e.Engine.AdminClient 316 + 211 317 if len(newLabels) > 0 { 318 + e.Logger.Info("labeling record", "newLabels", newLabels) 212 319 comment := "automod" 213 320 _, err := comatproto.AdminEmitModerationEvent(ctx, xrpcc, &comatproto.AdminEmitModerationEvent_Input{ 214 321 CreatedBy: xrpcc.Auth.Did, ··· 228 335 if err != nil { 229 336 return err 230 337 } 231 - needsPurge = true 232 338 } 339 + 233 340 if len(newFlags) > 0 { 234 341 e.Engine.Flags.Add(ctx, e.Account.Identity.DID.String(), newFlags) 235 - needsPurge = true 236 342 } 343 + 344 + // reports are additionally de-duped when persisting the action, so track with a flag 345 + createdReports := false 237 346 for _, mr := range newReports { 238 - _, err := comatproto.ModerationCreateReport(ctx, xrpcc, &comatproto.ModerationCreateReport_Input{ 239 - ReasonType: &mr.ReasonType, 240 - Reason: &mr.Comment, 241 - Subject: &comatproto.ModerationCreateReport_Input_Subject{ 242 - AdminDefs_RepoRef: &comatproto.AdminDefs_RepoRef{ 243 - Did: e.Account.Identity.DID.String(), 244 - }, 245 - }, 246 - }) 347 + created, err := createReportIfFresh(ctx, xrpcc, *e, mr) 247 348 if err != nil { 248 349 return err 249 350 } 351 + if created { 352 + createdReports = true 353 + } 250 354 } 355 + 251 356 if newTakedown { 357 + e.Logger.Warn("account-takedown") 252 358 comment := "automod" 253 359 _, err := comatproto.AdminEmitModerationEvent(ctx, xrpcc, &comatproto.AdminEmitModerationEvent_Input{ 254 360 CreatedBy: xrpcc.Auth.Did, ··· 266 372 if err != nil { 267 373 return err 268 374 } 269 - needsPurge = true 270 375 } 271 - if needsPurge { 376 + 377 + needCachePurge := newTakedown || len(newLabels) > 0 || len(newFlags) > 0 || createdReports 378 + if needCachePurge { 272 379 return e.Engine.PurgeAccountCaches(ctx, e.Account.Identity.DID) 273 380 } 381 + 274 382 return nil 275 383 } 276 384 ··· 356 464 357 465 // Enqueues a moderation report to be filed against the record at the end of rule processing. 358 466 func (e *RecordEvent) ReportRecord(reason, comment string) { 467 + if comment == "" { 468 + comment = "(automod)" 469 + } else { 470 + comment = "automod: " + comment 471 + } 359 472 e.RecordReports = append(e.RecordReports, ModReport{ReasonType: reason, Comment: comment}) 360 473 } 361 474 ··· 364 477 // NOTE: this method currently does *not* persist record-level flags to any storage, and does not de-dupe most actions, on the assumption that the record is new (from firehose) and has no existing mod state. 365 478 func (e *RecordEvent) PersistRecordActions(ctx context.Context) error { 366 479 367 - // TODO: consider de-duping record-level actions? at least for updates and deletes. 480 + // NOTE: record-level actions are *not* currently de-duplicated (aka, the same record could be labeled multiple times, or re-reported, etc) 368 481 newLabels := dedupeStrings(e.RecordLabels) 369 482 newFlags := dedupeStrings(e.RecordFlags) 370 - newReports := e.RecordReports 371 - newTakedown := e.RecordTakedown 483 + newReports := circuitBreakReports(&e.RepoEvent, e.RecordReports) 484 + newTakedown := circuitBreakTakedown(&e.RepoEvent, e.RecordTakedown) 372 485 atURI := fmt.Sprintf("at://%s/%s/%s", e.Account.Identity.DID, e.Collection, e.RecordKey) 373 486 374 487 if newTakedown || len(newLabels) > 0 || len(newFlags) > 0 || len(newReports) > 0 { 375 488 if e.Engine.SlackWebhookURL != "" { 376 - msg := fmt.Sprintf("⚠️ Automod Record Action ⚠️\n") 377 - msg += fmt.Sprintf("`%s` / `%s` / <https://bsky.app/profile/%s|bsky> / <https://admin.prod.bsky.dev/repositories/%s|ozone>\n", 378 - e.Account.Identity.DID, 379 - e.Account.Identity.Handle, 380 - e.Account.Identity.DID, 381 - e.Account.Identity.DID, 382 - ) 489 + msg := slackBody("⚠️ Automod Record Action ⚠️\n", e.Account, newLabels, newFlags, newReports, newTakedown) 383 490 msg += fmt.Sprintf("`%s`\n", atURI) 384 - msg = slackBody(msg, newLabels, newFlags, newReports, newTakedown) 385 491 if err := e.Engine.SendSlackMsg(ctx, msg); err != nil { 386 492 e.Logger.Error("sending slack webhook", "err", err) 387 493 } ··· 396 502 } 397 503 xrpcc := e.Engine.AdminClient 398 504 if len(newLabels) > 0 { 505 + e.Logger.Info("labeling record", "newLabels", newLabels) 399 506 comment := "automod" 400 507 _, err := comatproto.AdminEmitModerationEvent(ctx, xrpcc, &comatproto.AdminEmitModerationEvent_Input{ 401 508 CreatedBy: xrpcc.Auth.Did, ··· 418 525 e.Engine.Flags.Add(ctx, atURI, newFlags) 419 526 } 420 527 for _, mr := range newReports { 528 + e.Logger.Info("reporting record", "reasonType", mr.ReasonType, "comment", mr.Comment) 421 529 _, err := comatproto.ModerationCreateReport(ctx, xrpcc, &comatproto.ModerationCreateReport_Input{ 422 530 ReasonType: &mr.ReasonType, 423 531 Reason: &mr.Comment, ··· 430 538 } 431 539 } 432 540 if newTakedown { 541 + e.Logger.Warn("record-takedown") 433 542 comment := "automod" 434 543 _, err := comatproto.AdminEmitModerationEvent(ctx, xrpcc, &comatproto.AdminEmitModerationEvent_Input{ 435 544 CreatedBy: xrpcc.Auth.Did,
+34
automod/report.go
··· 1 + package automod 2 + 3 + type ModReport struct { 4 + ReasonType string 5 + Comment string 6 + } 7 + 8 + var ( 9 + ReportReasonSpam = "com.atproto.moderation.defs#reasonSpam" 10 + ReportReasonViolation = "com.atproto.moderation.defs#reasonViolation" 11 + ReportReasonMisleading = "com.atproto.moderation.defs#reasonMisleading" 12 + ReportReasonSexual = "com.atproto.moderation.defs#reasonSexual" 13 + ReportReasonRude = "com.atproto.moderation.defs#reasonRude" 14 + ReportReasonOther = "com.atproto.moderation.defs#reasonOther" 15 + ) 16 + 17 + func reasonShortName(reason string) string { 18 + switch reason { 19 + case ReportReasonSpam: 20 + return "spam" 21 + case ReportReasonViolation: 22 + return "violation" 23 + case ReportReasonMisleading: 24 + return "misleading" 25 + case ReportReasonSexual: 26 + return "sexual" 27 + case ReportReasonRude: 28 + return "rude" 29 + case ReportReasonOther: 30 + return "other" 31 + default: 32 + return "unknown" 33 + } 34 + }
+4
automod/rules/interaction.go
··· 1 1 package rules 2 2 3 3 import ( 4 + "fmt" 5 + 4 6 "github.com/bluesky-social/indigo/automod" 5 7 "github.com/bluesky-social/indigo/automod/countstore" 6 8 ) ··· 19 21 if created > interactionDailyThreshold && deleted > interactionDailyThreshold && ratio > 0.5 { 20 22 evt.Logger.Info("high-like-churn", "created-today", created, "deleted-today", deleted) 21 23 evt.AddAccountFlag("high-like-churn") 24 + evt.ReportAccount(automod.ReportReasonSpam, fmt.Sprintf("interaction churn: %d likes, %d unlikes today (so far)", created, deleted)) 22 25 } 23 26 case "app.bsky.graph.follow": 24 27 evt.Increment("follow", did) ··· 28 31 if created > interactionDailyThreshold && deleted > interactionDailyThreshold && ratio > 0.5 { 29 32 evt.Logger.Info("high-follow-churn", "created-today", created, "deleted-today", deleted) 30 33 evt.AddAccountFlag("high-follow-churn") 34 + evt.ReportAccount(automod.ReportReasonSpam, fmt.Sprintf("interaction churn: %d follows, %d unfollows today (so far)", created, deleted)) 31 35 } 32 36 } 33 37 return nil