diff --git a/automod/engine/context.go b/automod/engine/context.go index 1e143f64..c22b5cab 100644 --- a/automod/engine/context.go +++ b/automod/engine/context.go @@ -182,6 +182,10 @@ func (c *BaseContext) IncrementPeriod(name, val string, period string) { c.effects.IncrementPeriod(name, val, period) } +func (c *BaseContext) Notify(srv string) { + c.effects.Notify(srv) +} + func (c *AccountContext) AddAccountFlag(val string) { c.effects.AddAccountFlag(val) } diff --git a/automod/engine/effects.go b/automod/engine/effects.go index 2946ac48..11581d25 100644 --- a/automod/engine/effects.go +++ b/automod/engine/effects.go @@ -56,6 +56,8 @@ type Effects struct { BlobTakedowns []string // If "true", indicates that a rule indicates that the action causing the event should be blocked or prevented RejectEvent bool + // Services, if any, which should blast out a notification about this even (eg, Slack) + NotifyServices []string } // Enqueues the named counter to be incremented at the end of all rule processing. Will automatically increment for all time periods. @@ -185,6 +187,18 @@ func (e *Effects) TakedownBlob(cid string) { e.BlobTakedowns = append(e.BlobTakedowns, cid) } +// Records that the given service should be notified about this event +func (e *Effects) Notify(srv string) { + e.mu.Lock() + defer e.mu.Unlock() + for _, v := range e.NotifyServices { + if v == srv { + return + } + } + e.NotifyServices = append(e.NotifyServices, srv) +} + func (e *Effects) Reject() { e.RejectEvent = true } diff --git a/automod/engine/engine.go b/automod/engine/engine.go index 64b739f9..5bcc8679 100644 --- a/automod/engine/engine.go +++ b/automod/engine/engine.go @@ -26,18 +26,21 @@ const ( // // NOTE: careful when initializing: several fields must not be nil or zero, even though they are pointer type. type Engine struct { - Logger *slog.Logger - Directory identity.Directory - Rules RuleSet - Counters countstore.CountStore - Sets setstore.SetStore - Cache cachestore.CacheStore - Flags flagstore.FlagStore + Logger *slog.Logger + Directory identity.Directory + Rules RuleSet + Counters countstore.CountStore + Sets setstore.SetStore + Cache cachestore.CacheStore + Flags flagstore.FlagStore + // unlike the other sub-modules, this field (Notifier) may be nil + Notifier Notifier + // TODO: unused; remove? RelayClient *xrpc.Client - BskyClient *xrpc.Client + // use to fetch public account metadata from AppView + BskyClient *xrpc.Client // used to persist moderation actions in mod service (optional) - AdminClient *xrpc.Client - SlackWebhookURL string + AdminClient *xrpc.Client } // Entrypoint for external code pushing arbitrary identity events in to the engine. diff --git a/automod/engine/notifier.go b/automod/engine/notifier.go new file mode 100644 index 00000000..f04c0b76 --- /dev/null +++ b/automod/engine/notifier.go @@ -0,0 +1,11 @@ +package engine + +import ( + "context" +) + +// Interface for a type that can handle sending notifications +type Notifier interface { + SendAccount(ctx context.Context, service string, c *AccountContext) error + SendRecord(ctx context.Context, service string, c *RecordContext) error +} diff --git a/automod/engine/persist.go b/automod/engine/persist.go index 55b81b7e..9b877b91 100644 --- a/automod/engine/persist.go +++ b/automod/engine/persist.go @@ -58,10 +58,11 @@ func (eng *Engine) persistAccountModActions(c *AccountContext) error { } anyModActions := newTakedown || len(newLabels) > 0 || len(newFlags) > 0 || len(newReports) > 0 - if anyModActions && eng.SlackWebhookURL != "" { - msg := slackBody("⚠️ Automod Account Action ⚠️\n", c.Account, newLabels, newFlags, newReports, newTakedown) - if err := eng.SendSlackMsg(ctx, msg); err != nil { - c.Logger.Error("sending slack webhook", "err", err) + if anyModActions && eng.Notifier != nil { + for _, srv := range dedupeStrings(c.effects.NotifyServices) { + if err := eng.Notifier.SendAccount(ctx, srv, c); err != nil { + c.Logger.Error("failed to deliver notification", "service", srv, "err", err) + } } } @@ -167,11 +168,11 @@ func (eng *Engine) persistRecordModActions(c *RecordContext) error { atURI := fmt.Sprintf("at://%s/%s/%s", c.Account.Identity.DID, c.RecordOp.Collection, c.RecordOp.RecordKey) if newTakedown || len(newLabels) > 0 || len(newFlags) > 0 || len(newReports) > 0 { - if eng.SlackWebhookURL != "" { - msg := slackBody("⚠️ Automod Record Action ⚠️\n", c.Account, newLabels, newFlags, newReports, newTakedown) - msg += fmt.Sprintf("`%s`\n", atURI) - if err := eng.SendSlackMsg(ctx, msg); err != nil { - c.Logger.Error("sending slack webhook", "err", err) + if eng.Notifier != nil { + for _, srv := range dedupeStrings(c.effects.NotifyServices) { + if err := eng.Notifier.SendRecord(ctx, srv, c); err != nil { + c.Logger.Error("failed to deliver notification", "service", srv, "err", err) + } } } } diff --git a/automod/engine/persisthelpers.go b/automod/engine/persisthelpers.go index e69bca38..d3025f18 100644 --- a/automod/engine/persisthelpers.go +++ b/automod/engine/persisthelpers.go @@ -3,7 +3,6 @@ package engine import ( "context" "fmt" - "strings" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -158,26 +157,3 @@ func (eng *Engine) createReportIfFresh(ctx context.Context, xrpcc *xrpc.Client, } return true, nil } - -func slackBody(header string, acct AccountMeta, newLabels, newFlags []string, newReports []ModReport, newTakedown bool) string { - msg := header - msg += fmt.Sprintf("`%s` / `%s` / / \n", - acct.Identity.DID, - acct.Identity.Handle, - acct.Identity.DID, - acct.Identity.DID, - ) - if len(newLabels) > 0 { - msg += fmt.Sprintf("New Labels: `%s`\n", strings.Join(newLabels, ", ")) - } - if len(newFlags) > 0 { - msg += fmt.Sprintf("New Flags: `%s`\n", strings.Join(newFlags, ", ")) - } - for _, rep := range newReports { - msg += fmt.Sprintf("Report `%s`: %s\n", rep.ReasonType, rep.Comment) - } - if newTakedown { - msg += fmt.Sprintf("Takedown!\n") - } - return msg -} diff --git a/automod/engine/slack.go b/automod/engine/slack.go index c1bb5cd3..ae7c00d2 100644 --- a/automod/engine/slack.go +++ b/automod/engine/slack.go @@ -6,8 +6,33 @@ import ( "encoding/json" "fmt" "net/http" + "strings" ) +type SlackNotifier struct { + SlackWebhookURL string +} + +func (n *SlackNotifier) SendAccount(ctx context.Context, service string, c *AccountContext) error { + if service != "slack" { + return nil + } + msg := slackBody("⚠️ Automod Account Action ⚠️\n", c.Account, c.effects.AccountLabels, c.effects.AccountFlags, c.effects.AccountReports, c.effects.AccountTakedown) + c.Logger.Debug("sending slack notification") + return n.sendSlackMsg(ctx, msg) +} + +func (n *SlackNotifier) SendRecord(ctx context.Context, service string, c *RecordContext) error { + if service != "slack" { + return nil + } + atURI := fmt.Sprintf("at://%s/%s/%s", c.Account.Identity.DID, c.RecordOp.Collection, c.RecordOp.RecordKey) + msg := slackBody("⚠️ Automod Record Action ⚠️\n", c.Account, c.effects.RecordLabels, c.effects.RecordFlags, c.effects.RecordReports, c.effects.RecordTakedown) + msg += fmt.Sprintf("`%s`\n", atURI) + c.Logger.Debug("sending slack notification") + return n.sendSlackMsg(ctx, msg) +} + type SlackWebhookBody struct { Text string `json:"text"` } @@ -15,14 +40,14 @@ type SlackWebhookBody struct { // Sends a simple slack message to a channel via "incoming webhook". // // The slack incoming webhook must be already configured in the slack workplace. -func (e *Engine) SendSlackMsg(ctx context.Context, msg string) error { +func (n *SlackNotifier) sendSlackMsg(ctx context.Context, msg string) error { // loosely based on: https://golangcode.com/send-slack-messages-without-a-library/ body, err := json.Marshal(SlackWebhookBody{Text: msg}) if err != nil { return err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.SlackWebhookURL, bytes.NewBuffer(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.SlackWebhookURL, bytes.NewBuffer(body)) if err != nil { return err } @@ -42,3 +67,26 @@ func (e *Engine) SendSlackMsg(ctx context.Context, msg string) error { } return nil } + +func slackBody(header string, acct AccountMeta, newLabels, newFlags []string, newReports []ModReport, newTakedown bool) string { + msg := header + msg += fmt.Sprintf("`%s` / `%s` / / \n", + acct.Identity.DID, + acct.Identity.Handle, + acct.Identity.DID, + acct.Identity.DID, + ) + if len(newLabels) > 0 { + msg += fmt.Sprintf("Labels: `%s`\n", strings.Join(newLabels, ", ")) + } + if len(newFlags) > 0 { + msg += fmt.Sprintf("Flags: `%s`\n", strings.Join(newFlags, ", ")) + } + for _, rep := range newReports { + msg += fmt.Sprintf("Report `%s`: %s\n", rep.ReasonType, rep.Comment) + } + if newTakedown { + msg += fmt.Sprintf("Takedown!\n") + } + return msg +}