Files
FernandoJVideiraandClaude Opus 5.5 3f87e68a2c Add anti-raid and anti-nuke protection
Join Gate (account age, avatar, advertising and username filters, bot
checks), raid detection with invite pausing, anti-nuke quarantine of
admins who mass delete, manual and automatic lockdowns, a decaying heat
score against spam, role and channel snapshots with restore, and alert
DMs to the owner and trusted users. Everything that weakens protection
is limited to the owner and the trusted list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:59:52 +01:00

145 lines
4.2 KiB
Go

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})
}