porting all github actions from bluesky-social/indigo to tangled CI

automod: abstract out notification (slack msgs)

+4
automod/engine/context.go
··· 182 182 c.effects.IncrementPeriod(name, val, period) 183 183 } 184 184 185 + func (c *BaseContext) Notify(srv string) { 186 + c.effects.Notify(srv) 187 + } 188 + 185 189 func (c *AccountContext) AddAccountFlag(val string) { 186 190 c.effects.AddAccountFlag(val) 187 191 }
+14
automod/engine/effects.go
··· 56 56 BlobTakedowns []string 57 57 // If "true", indicates that a rule indicates that the action causing the event should be blocked or prevented 58 58 RejectEvent bool 59 + // Services, if any, which should blast out a notification about this even (eg, Slack) 60 + NotifyServices []string 59 61 } 60 62 61 63 // Enqueues the named counter to be incremented at the end of all rule processing. Will automatically increment for all time periods. ··· 183 185 } 184 186 } 185 187 e.BlobTakedowns = append(e.BlobTakedowns, cid) 188 + } 189 + 190 + // Records that the given service should be notified about this event 191 + func (e *Effects) Notify(srv string) { 192 + e.mu.Lock() 193 + defer e.mu.Unlock() 194 + for _, v := range e.NotifyServices { 195 + if v == srv { 196 + return 197 + } 198 + } 199 + e.NotifyServices = append(e.NotifyServices, srv) 186 200 } 187 201 188 202 func (e *Effects) Reject() {
+13 -10
automod/engine/engine.go
··· 26 26 // 27 27 // NOTE: careful when initializing: several fields must not be nil or zero, even though they are pointer type. 28 28 type Engine struct { 29 - Logger *slog.Logger 30 - Directory identity.Directory 31 - Rules RuleSet 32 - Counters countstore.CountStore 33 - Sets setstore.SetStore 34 - Cache cachestore.CacheStore 35 - Flags flagstore.FlagStore 29 + Logger *slog.Logger 30 + Directory identity.Directory 31 + Rules RuleSet 32 + Counters countstore.CountStore 33 + Sets setstore.SetStore 34 + Cache cachestore.CacheStore 35 + Flags flagstore.FlagStore 36 + // unlike the other sub-modules, this field (Notifier) may be nil 37 + Notifier Notifier 38 + // TODO: unused; remove? 36 39 RelayClient *xrpc.Client 37 - BskyClient *xrpc.Client 40 + // use to fetch public account metadata from AppView 41 + BskyClient *xrpc.Client 38 42 // used to persist moderation actions in mod service (optional) 39 - AdminClient *xrpc.Client 40 - SlackWebhookURL string 43 + AdminClient *xrpc.Client 41 44 } 42 45 43 46 // Entrypoint for external code pushing arbitrary identity events in to the engine.
+11
automod/engine/notifier.go
··· 1 + package engine 2 + 3 + import ( 4 + "context" 5 + ) 6 + 7 + // Interface for a type that can handle sending notifications 8 + type Notifier interface { 9 + SendAccount(ctx context.Context, service string, c *AccountContext) error 10 + SendRecord(ctx context.Context, service string, c *RecordContext) error 11 + }
+10 -9
automod/engine/persist.go
··· 58 58 } 59 59 60 60 anyModActions := newTakedown || len(newLabels) > 0 || len(newFlags) > 0 || len(newReports) > 0 61 - if anyModActions && eng.SlackWebhookURL != "" { 62 - msg := slackBody("⚠️ Automod Account Action ⚠️\n", c.Account, newLabels, newFlags, newReports, newTakedown) 63 - if err := eng.SendSlackMsg(ctx, msg); err != nil { 64 - c.Logger.Error("sending slack webhook", "err", err) 61 + if anyModActions && eng.Notifier != nil { 62 + for _, srv := range dedupeStrings(c.effects.NotifyServices) { 63 + if err := eng.Notifier.SendAccount(ctx, srv, c); err != nil { 64 + c.Logger.Error("failed to deliver notification", "service", srv, "err", err) 65 + } 65 66 } 66 67 } 67 68 ··· 167 168 atURI := fmt.Sprintf("at://%s/%s/%s", c.Account.Identity.DID, c.RecordOp.Collection, c.RecordOp.RecordKey) 168 169 169 170 if newTakedown || len(newLabels) > 0 || len(newFlags) > 0 || len(newReports) > 0 { 170 - if eng.SlackWebhookURL != "" { 171 - msg := slackBody("⚠️ Automod Record Action ⚠️\n", c.Account, newLabels, newFlags, newReports, newTakedown) 172 - msg += fmt.Sprintf("`%s`\n", atURI) 173 - if err := eng.SendSlackMsg(ctx, msg); err != nil { 174 - c.Logger.Error("sending slack webhook", "err", err) 171 + if eng.Notifier != nil { 172 + for _, srv := range dedupeStrings(c.effects.NotifyServices) { 173 + if err := eng.Notifier.SendRecord(ctx, srv, c); err != nil { 174 + c.Logger.Error("failed to deliver notification", "service", srv, "err", err) 175 + } 175 176 } 176 177 } 177 178 }
-24
automod/engine/persisthelpers.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "strings" 7 6 "time" 8 7 9 8 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 158 157 } 159 158 return true, nil 160 159 } 161 - 162 - func slackBody(header string, acct AccountMeta, newLabels, newFlags []string, newReports []ModReport, newTakedown bool) string { 163 - msg := header 164 - msg += fmt.Sprintf("`%s` / `%s` / <https://bsky.app/profile/%s|bsky> / <https://admin.prod.bsky.dev/repositories/%s|ozone>\n", 165 - acct.Identity.DID, 166 - acct.Identity.Handle, 167 - acct.Identity.DID, 168 - acct.Identity.DID, 169 - ) 170 - if len(newLabels) > 0 { 171 - msg += fmt.Sprintf("New Labels: `%s`\n", strings.Join(newLabels, ", ")) 172 - } 173 - if len(newFlags) > 0 { 174 - msg += fmt.Sprintf("New Flags: `%s`\n", strings.Join(newFlags, ", ")) 175 - } 176 - for _, rep := range newReports { 177 - msg += fmt.Sprintf("Report `%s`: %s\n", rep.ReasonType, rep.Comment) 178 - } 179 - if newTakedown { 180 - msg += fmt.Sprintf("Takedown!\n") 181 - } 182 - return msg 183 - }
+50 -2
automod/engine/slack.go
··· 6 6 "encoding/json" 7 7 "fmt" 8 8 "net/http" 9 + "strings" 9 10 ) 10 11 12 + type SlackNotifier struct { 13 + SlackWebhookURL string 14 + } 15 + 16 + func (n *SlackNotifier) SendAccount(ctx context.Context, service string, c *AccountContext) error { 17 + if service != "slack" { 18 + return nil 19 + } 20 + msg := slackBody("⚠️ Automod Account Action ⚠️\n", c.Account, c.effects.AccountLabels, c.effects.AccountFlags, c.effects.AccountReports, c.effects.AccountTakedown) 21 + c.Logger.Debug("sending slack notification") 22 + return n.sendSlackMsg(ctx, msg) 23 + } 24 + 25 + func (n *SlackNotifier) SendRecord(ctx context.Context, service string, c *RecordContext) error { 26 + if service != "slack" { 27 + return nil 28 + } 29 + atURI := fmt.Sprintf("at://%s/%s/%s", c.Account.Identity.DID, c.RecordOp.Collection, c.RecordOp.RecordKey) 30 + msg := slackBody("⚠️ Automod Record Action ⚠️\n", c.Account, c.effects.RecordLabels, c.effects.RecordFlags, c.effects.RecordReports, c.effects.RecordTakedown) 31 + msg += fmt.Sprintf("`%s`\n", atURI) 32 + c.Logger.Debug("sending slack notification") 33 + return n.sendSlackMsg(ctx, msg) 34 + } 35 + 11 36 type SlackWebhookBody struct { 12 37 Text string `json:"text"` 13 38 } ··· 15 40 // Sends a simple slack message to a channel via "incoming webhook". 16 41 // 17 42 // The slack incoming webhook must be already configured in the slack workplace. 18 - func (e *Engine) SendSlackMsg(ctx context.Context, msg string) error { 43 + func (n *SlackNotifier) sendSlackMsg(ctx context.Context, msg string) error { 19 44 // loosely based on: https://golangcode.com/send-slack-messages-without-a-library/ 20 45 21 46 body, err := json.Marshal(SlackWebhookBody{Text: msg}) 22 47 if err != nil { 23 48 return err 24 49 } 25 - req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.SlackWebhookURL, bytes.NewBuffer(body)) 50 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.SlackWebhookURL, bytes.NewBuffer(body)) 26 51 if err != nil { 27 52 return err 28 53 } ··· 42 67 } 43 68 return nil 44 69 } 70 + 71 + func slackBody(header string, acct AccountMeta, newLabels, newFlags []string, newReports []ModReport, newTakedown bool) string { 72 + msg := header 73 + msg += fmt.Sprintf("`%s` / `%s` / <https://bsky.app/profile/%s|bsky> / <https://admin.prod.bsky.dev/repositories/%s|ozone>\n", 74 + acct.Identity.DID, 75 + acct.Identity.Handle, 76 + acct.Identity.DID, 77 + acct.Identity.DID, 78 + ) 79 + if len(newLabels) > 0 { 80 + msg += fmt.Sprintf("Labels: `%s`\n", strings.Join(newLabels, ", ")) 81 + } 82 + if len(newFlags) > 0 { 83 + msg += fmt.Sprintf("Flags: `%s`\n", strings.Join(newFlags, ", ")) 84 + } 85 + for _, rep := range newReports { 86 + msg += fmt.Sprintf("Report `%s`: %s\n", rep.ReasonType, rep.Comment) 87 + } 88 + if newTakedown { 89 + msg += fmt.Sprintf("Takedown!\n") 90 + } 91 + return msg 92 + }