package security import ( "bytes" "context" "log" "velox-bot/internal/db/repos/securityrepo" "github.com/bwmarrin/discordgo" ) const ( colorAlert = 0xdc2626 colorWarn = 0xf59e0b colorOK = 0x16a34a ) type alert struct { embed *discordgo.MessageEmbed ping bool // ping the alert role (raids and nukes, not routine join gate actions) pingOwner bool // critical alerts also go by DM to the owner and trusted users, since // the alert channel may be gone or locked in exactly these moments. critical bool components []discordgo.MessageComponent file *discordgo.File } // send posts an alert to the configured channel. It only ever pings the // configured alert role (never @everyone, even if that was saved somehow) and // optionally the owner. func (e *Engine) send(ctx context.Context, guildID string, s *securityrepo.Settings, a alert) *discordgo.Message { if a.critical && s.AlertDM { if e.syncDMs { e.dmAlert(ctx, guildID, s, a) } else { go e.dmAlert(context.Background(), guildID, s, a) } } if s.AlertChannelID == 0 { return nil } msg := &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{a.embed}, Components: a.components, AllowedMentions: &discordgo.MessageAllowedMentions{}, } if a.ping && s.AlertRoleID != 0 && id(s.AlertRoleID) != guildID { msg.Content = "<@&" + id(s.AlertRoleID) + ">" msg.AllowedMentions.Roles = []string{id(s.AlertRoleID)} } if a.pingOwner { if owner, err := e.OwnerID(guildID); err == nil && owner != "" { msg.Content += " <@" + owner + ">" msg.AllowedMentions.Users = []string{owner} } } if a.file != nil { msg.Files = []*discordgo.File{a.file} } m, err := e.d.ChannelMessageSendComplex(id(s.AlertChannelID), msg) if err != nil { log.Printf("security: alert in guild %s: %v", guildID, err) return nil } return m } func textFile(name, content string) *discordgo.File { return &discordgo.File{Name: name, ContentType: "text/plain; charset=utf-8", Reader: bytes.NewReader([]byte(content))} } // maxAlertDMs caps how many people get each critical alert by DM. Mass DMs // from a bot look like spam to Discord. const maxAlertDMs = 10 // alertDMRecipients is the owner plus trusted users (not roles, which could // hold many members), without duplicates or the bot itself. func (e *Engine) alertDMRecipients(ctx context.Context, guildID string) []string { seen := map[string]bool{e.botID: true} var out []string add := func(u string) { if u != "" && !seen[u] && len(out) < maxAlertDMs { seen[u] = true out = append(out, u) } } if owner, err := e.OwnerID(guildID); err == nil { add(owner) } if _, trusted, err := e.load(ctx, guildID); err == nil { for _, t := range trusted { if t.Kind == "user" { add(id(t.TargetID)) } } } return out } // dmAlert sends a critical alert to each recipient's DMs. Closed DMs and // other failures are skipped: the alert channel is still the main record. func (e *Engine) dmAlert(ctx context.Context, guildID string, s *securityrepo.Settings, a alert) { if a.embed == nil { return } server := e.guildName(guildID) if server == "" { server = "a server" } embed := *a.embed embed.Footer = &discordgo.MessageEmbedFooter{Text: clip("Velox security ยท "+server, 200)} extra := "" if a.file != nil { if s.AlertChannelID != 0 { extra = "\nThe full list is posted in the alert channel." } else { extra = "\nSet an alert channel on the dashboard to also get the full list of accounts." } } if extra != "" { embed.Description = clip(embed.Description+extra, 4000) } msg := &discordgo.MessageSend{ Content: "Security alert in **" + escapeText(server) + "**. Use the dashboard or `/security` in the server to act.", Embeds: []*discordgo.MessageEmbed{&embed}, AllowedMentions: &discordgo.MessageAllowedMentions{}, } for _, u := range e.alertDMRecipients(ctx, guildID) { ch, err := e.d.UserChannelCreate(u) if err != nil { continue } if _, err := e.d.ChannelMessageSendComplex(ch.ID, msg); err != nil { log.Printf("security: DM alert to %s for guild %s: %v", u, guildID, err) } } } // escapeText defuses markdown and mentions in a name (e.g. a server name). func escapeText(s string) string { return safeName(&discordgo.User{Username: s}) }