this repo has no description
0
fork

Configure Feed

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

first pass at beemo, a slack bot for moderation reports

+174
+174
cmd/beemo/main.go
··· 1 + // Bluesky MOderation bot (BMO), a chatops helper for slack 2 + // For now, polls a PDS for new moderation reports and publishes notifications to slack 3 + 4 + package main 5 + 6 + import ( 7 + "bytes" 8 + "context" 9 + "encoding/json" 10 + "fmt" 11 + "net/http" 12 + "time" 13 + 14 + comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + cliutil "github.com/bluesky-social/indigo/cmd/gosky/util" 16 + 17 + logging "github.com/ipfs/go-log" 18 + "github.com/urfave/cli/v2" 19 + ) 20 + 21 + var log = logging.Logger("beemo") 22 + 23 + func main() { 24 + 25 + app := &cli.App{ 26 + Name: "beemo", 27 + Usage: "bluesky moderation reporting bot", 28 + } 29 + 30 + app.Flags = []cli.Flag{ 31 + &cli.StringFlag{ 32 + // TODO: Name: "pds-host", 33 + Name: "pds", 34 + Usage: "hostname and port of PDS instance", 35 + Value: "http://localhost:4849", 36 + EnvVars: []string{"ATP_PDS_HOST"}, 37 + }, 38 + &cli.StringFlag{ 39 + // TODO: Name: "auth-token", 40 + Name: "auth", 41 + Usage: "authentication token for PDS", 42 + Required: true, 43 + // TODO: EnvVars: []string{"ATP_AUTH_TOKEN"}, 44 + EnvVars: []string{"BSKY_AUTH"}, 45 + }, 46 + &cli.StringFlag{ 47 + Name: "admin-token", 48 + Usage: "admin authentication token for PDS", 49 + Required: true, 50 + // TODO: EnvVars: []string{"ATP_ADMIN_TOKEN"}, 51 + EnvVars: []string{"BSKY_ADMIN_AUTH"}, 52 + }, 53 + &cli.StringFlag{ 54 + Name: "slack-webhook-url", 55 + // eg: https://hooks.slack.com/services/X1234 56 + Usage: "full URL of slack webhook", 57 + Required: true, 58 + EnvVars: []string{"SLACK_WEBHOOK_URL"}, 59 + }, 60 + &cli.IntFlag{ 61 + Name: "poll-period", 62 + Usage: "API poll period in seconds", 63 + Value: 30, 64 + EnvVars: []string{"POLL_PERIOD"}, 65 + }, 66 + } 67 + app.Commands = []*cli.Command{ 68 + &cli.Command{ 69 + Name: "notify-reports", 70 + Usage: "watch for new moderation reports, notify in slack", 71 + Action: pollNewReports, 72 + }, 73 + } 74 + app.RunAndExitOnError() 75 + } 76 + 77 + func pollNewReports(cctx *cli.Context) error { 78 + // record last-seen report timestamp 79 + since := time.Now() 80 + // NOTE: uncomment this for testing 81 + //since = time.Now().Add(time.Duration(-12) * time.Hour) 82 + period := time.Duration(cctx.Int("poll-period")) * time.Second 83 + atpc, err := cliutil.GetATPClient(cctx, false) 84 + if err != nil { 85 + return err 86 + } 87 + adminToken := cctx.String("admin-token") 88 + if len(adminToken) > 0 { 89 + atpc.C.AdminToken = &adminToken 90 + } 91 + log.Infof("report polling bot starting up...") 92 + // can flip this bool to false to prevent spamming slack channel on startup 93 + if true { 94 + err := sendSlackMsg(cctx, fmt.Sprintf("restarted bot, monitoring for reports since `%s`...", since.Format(time.RFC3339))) 95 + if err != nil { 96 + return err 97 + } 98 + } 99 + for { 100 + // AdminGetModerationReports(ctx context.Context, c *xrpc.Client, subject *string, resolved *bool, before *string, limit *int64) 101 + resolved := false 102 + var limit int64 = 50 103 + mrr, err := comatproto.AdminGetModerationReports(context.TODO(), atpc.C, nil, &resolved, nil, &limit) 104 + if err != nil { 105 + return err 106 + } 107 + // this works out to iterate from newest to oldest, which is the behavior we want (report only newest, then break) 108 + for _, report := range mrr.Reports { 109 + if len(report.ResolvedByActionIds) > 0 { 110 + continue 111 + } 112 + createdAt, err := time.Parse(time.RFC3339, report.CreatedAt) 113 + if err != nil { 114 + return fmt.Errorf("invalid time format for 'createdAt': %w", err) 115 + } 116 + if createdAt.After(since) { 117 + // ok, we found a "new" report, need to notify 118 + msg := fmt.Sprintf("===== New moderation report received =====\n") 119 + msg += fmt.Sprintf("PDS: `%s`\t", cctx.String("pds")) 120 + msg += fmt.Sprintf("report id: `%d`\t", report.Id) 121 + msg += fmt.Sprintf("recent unresolved: `%d`\n", len(mrr.Reports)) 122 + msg += fmt.Sprintf("createdAt: `%s`\n", report.CreatedAt) 123 + msg += fmt.Sprintf("reasonType: `%s`\n", report.ReasonType) 124 + if report.Subject.RepoRepoRef != nil { 125 + msg += fmt.Sprintf("subject: `%s`\n", report.Subject.RepoRepoRef.Did) 126 + } else { 127 + msg += fmt.Sprintf("subject: `%s`\n", report.Subject.RepoStrongRef.Uri) 128 + } 129 + //msg += fmt.Sprintf("reportedByDid: `%s`\n", report.ReportedByDid) 130 + log.Infof("found new report, notifying slack: %s", report) 131 + err := sendSlackMsg(cctx, msg) 132 + if err != nil { 133 + return fmt.Errorf("failed to send slack message: %w", err) 134 + } 135 + since = createdAt 136 + break 137 + } else { 138 + log.Debugf("skipping report: %s", report) 139 + } 140 + } 141 + log.Infof("... sleeping for %s", period) 142 + time.Sleep(period) 143 + } 144 + } 145 + 146 + type SlackWebhookBody struct { 147 + Text string `json:"text"` 148 + } 149 + 150 + // sends a simple slack message to a channel via "incoming webhook" 151 + // The slack incoming webhook must be already configured in the slack workplace. 152 + func sendSlackMsg(cctx *cli.Context, msg string) error { 153 + // loosely based on: https://golangcode.com/send-slack-messages-without-a-library/ 154 + 155 + webhookUrl := cctx.String("slack-webhook-url") 156 + body, _ := json.Marshal(SlackWebhookBody{Text: msg}) 157 + req, err := http.NewRequest(http.MethodPost, webhookUrl, bytes.NewBuffer(body)) 158 + if err != nil { 159 + return err 160 + } 161 + req.Header.Add("Content-Type", "application/json") 162 + client := &http.Client{Timeout: 10 * time.Second} 163 + resp, err := client.Do(req) 164 + if err != nil { 165 + return err 166 + } 167 + buf := new(bytes.Buffer) 168 + buf.ReadFrom(resp.Body) 169 + if resp.StatusCode != 200 || buf.String() != "ok" { 170 + // TODO: in some cases print body? eg, if short and text 171 + return fmt.Errorf("failed slack webhook POST request. status=%d", resp.StatusCode) 172 + } 173 + return nil 174 + }