package security import ( "context" "errors" "fmt" "log" "strings" "sync" "time" "velox-bot/internal/db/repos/securityrepo" "github.com/bwmarrin/discordgo" ) // Memory bounds per server, so a huge raid can't exhaust the bot. const ( maxRecentJoins = 500 maxCaught = 20000 ) // CustomIDEndRaid is the alert's "End raid" button. const CustomIDEndRaid = "security:endraid" type joinRec struct { user *discordgo.User at time.Time handled bool // already removed by the join lock or Join Gate } type caughtRec struct { userID, name, action string } type raid struct { mu sync.Mutex joins []joinRec active bool started time.Time lastJoin time.Time caught []caughtRec caughtTotal int pausedInvites bool } func (e *Engine) raidFor(guildID string) *raid { e.mu.Lock() defer e.mu.Unlock() r, ok := e.raids[guildID] if !ok { r = &raid{} e.raids[guildID] = r } return r } // RaidActive reports whether raid mode is on in a guild. func (e *Engine) RaidActive(guildID string) bool { e.mu.Lock() r, ok := e.raids[guildID] e.mu.Unlock() if !ok { return false } r.mu.Lock() defer r.mu.Unlock() return r.active } // raidJoin records a join and, during a raid, acts on the raiders. It returns // the action taken on this member ("" when none). func (e *Engine) raidJoin(ctx context.Context, guildID string, s *securityrepo.Settings, m *discordgo.Member, handled bool) string { if !s.AntiRaidEnabled { return "" } now := e.now() window := time.Duration(s.RaidJoinSeconds) * time.Second r := e.raidFor(guildID) r.mu.Lock() r.joins = append(r.joins, joinRec{user: m.User, at: now, handled: handled}) cut := 0 for cut < len(r.joins) && now.Sub(r.joins[cut].at) > window { cut++ } r.joins = r.joins[cut:] if len(r.joins) > maxRecentJoins { r.joins = r.joins[len(r.joins)-maxRecentJoins:] } r.lastJoin = now var targets []joinRec starting := false switch { case !r.active && len(r.joins) >= s.RaidJoinCount: // Raid starts: everyone in the burst that triggered it is a raider. starting = true r.active, r.started, r.caught, r.caughtTotal, r.pausedInvites = true, now, nil, 0, false targets = append(targets, r.joins...) case r.active: targets = []joinRec{{user: m.User, at: now, handled: handled}} } r.mu.Unlock() if starting { e.startRaid(ctx, guildID, s, len(targets)) } action := s.RaidAction thisMember := "" for _, t := range targets { did := action if t.handled || action == securityrepo.ActionAlert { did = "flagged" } else if err := e.punish(guildID, t.user, action, "raid"); errors.Is(err, errProtected) { did = "skipped" } else if err != nil { log.Printf("security: raid action on %s in guild %s: %v", t.user.ID, guildID, err) did = "failed" } r.mu.Lock() r.caughtTotal++ keep := len(r.caught) < maxCaught if keep { r.caught = append(r.caught, caughtRec{userID: t.user.ID, name: safeName(t.user), action: did}) } r.mu.Unlock() if keep { if err := e.st.AddRaidCaught(ctx, parseID(guildID), securityrepo.Caught{UserID: parseID(t.user.ID), Action: did, Name: safeName(t.user)}); err != nil { log.Printf("security: recording raider %s in guild %s: %v", t.user.ID, guildID, err) } } if t.user.ID == m.User.ID && did == action { thisMember = action } } if len(targets) > 0 { e.persistRaid(ctx, guildID, r) } return thisMember } // persistRaid writes the raid's state so a restart can resume it. func (e *Engine) persistRaid(ctx context.Context, guildID string, r *raid) { r.mu.Lock() x := securityrepo.Raid{GuildID: parseID(guildID), StartedAt: r.started, LastJoinAt: r.lastJoin, InvitesPaused: r.pausedInvites, CaughtTotal: r.caughtTotal} active := r.active r.mu.Unlock() if !active { return } if err := e.st.SaveRaid(ctx, x); err != nil { log.Printf("security: saving raid state for guild %s: %v", guildID, err) } } // ResumeRaids reloads raids that were in progress when the bot stopped, so // raid mode stays on, its quiet timer keeps counting from the last join, and // paused invites are resumed when it ends. func (e *Engine) ResumeRaids(ctx context.Context) { raids, err := e.st.ListRaids(ctx) if err != nil { log.Printf("security: loading raids in progress: %v", err) return } for _, x := range raids { r := e.raidFor(id(x.GuildID)) r.mu.Lock() r.active, r.started, r.lastJoin, r.pausedInvites, r.caughtTotal = true, x.StartedAt, x.LastJoinAt, x.InvitesPaused, x.CaughtTotal r.mu.Unlock() } } func (e *Engine) startRaid(ctx context.Context, guildID string, s *securityrepo.Settings, joins int) { e.event(ctx, guildID, securityrepo.EventRaidStarted, e.botID, "", fmt.Sprintf("%d joins in %ds", joins, s.RaidJoinSeconds)) paused := false if s.RaidPauseInvites { if err := e.setInvitesPaused(guildID, true); err != nil { log.Printf("security: pausing invites in guild %s: %v", guildID, err) } else { paused = true r := e.raidFor(guildID) r.mu.Lock() r.pausedInvites = true r.mu.Unlock() } } e.persistRaid(ctx, guildID, e.raidFor(guildID)) what := map[string]string{ securityrepo.ActionAlert: "nobody is being removed (alert only)", securityrepo.ActionTimeout: "raiders are being timed out", securityrepo.ActionKick: "raiders are being kicked", securityrepo.ActionBan: "raiders are being banned", }[s.RaidAction] desc := fmt.Sprintf("%d members joined within %d seconds. Raid mode is on: %s.", joins, s.RaidJoinSeconds, what) if paused { desc += "\nInvites are paused until the raid ends." } desc += fmt.Sprintf("\nIt ends after %d minutes without a join, or with the button below (owner or trusted only).", s.RaidQuietMinutes) e.send(ctx, guildID, s, alert{ ping: true, critical: true, embed: &discordgo.MessageEmbed{ Title: "🚨 Raid detected", Description: desc, Color: colorAlert, Timestamp: e.now().UTC().Format(time.RFC3339), }, components: []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{ discordgo.Button{Label: "End raid", Style: discordgo.SecondaryButton, CustomID: CustomIDEndRaid}, }}}, }) } // EndRaid turns raid mode off. by is who ended it ("" for the quiet timer). // Ending a raid relaxes protection, so callers must check the actor first. func (e *Engine) EndRaid(ctx context.Context, guildID, by string) error { e.mu.Lock() r, ok := e.raids[guildID] e.mu.Unlock() if !ok { return UserError("There's no raid going on.") } r.mu.Lock() if !r.active { r.mu.Unlock() return UserError("There's no raid going on.") } started, caught, total, paused := r.started, r.caught, r.caughtTotal, r.pausedInvites r.active, r.caught, r.caughtTotal, r.pausedInvites, r.joins = false, nil, 0, false, nil r.mu.Unlock() if paused { if err := e.setInvitesPaused(guildID, false); err != nil { log.Printf("security: resuming invites in guild %s: %v", guildID, err) } } how := "after a quiet period" if by != "" { how = "by <@" + by + ">" } e.event(ctx, guildID, securityrepo.EventRaidEnded, by, "", fmt.Sprintf("%d raiders caught", total)) // The database has the full list even across a restart; memory is the // fallback if it can't be read. if saved, err := e.st.ListRaidCaught(ctx, parseID(guildID), maxCaught); err == nil && len(saved) >= len(caught) { caught = caught[:0] for _, c := range saved { caught = append(caught, caughtRec{userID: id(c.UserID), action: c.Action, name: c.Name}) } } if err := e.st.DeleteRaid(ctx, parseID(guildID)); err != nil { log.Printf("security: clearing raid state for guild %s: %v", guildID, err) } var list strings.Builder fmt.Fprintf(&list, "Raid from %s to %s (UTC)\n\n", started.UTC().Format("2006-01-02 15:04:05"), e.now().UTC().Format("15:04:05")) for _, c := range caught { fmt.Fprintf(&list, "%s\t%s\t%s\n", c.userID, c.action, c.name) } if total > len(caught) { fmt.Fprintf(&list, "... and %d more\n", total-len(caught)) } desc := fmt.Sprintf("Raid mode ended %s. %d accounts were caught; the list is attached.", how, total) if paused { desc += "\nInvites are open again." } if s, _, err := e.load(ctx, guildID); err == nil { e.send(ctx, guildID, s, alert{ critical: true, embed: &discordgo.MessageEmbed{Title: "Raid over", Description: desc, Color: colorOK}, file: textFile("raid-"+started.UTC().Format("20060102-1504")+".txt", list.String()), }) } return nil } // WatchRaids ends raids after their quiet period and frees memory for // servers that have gone quiet. It runs until ctx ends. func (e *Engine) WatchRaids(ctx context.Context, every time.Duration) { t := time.NewTicker(every) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: e.checkRaids(ctx) } } } func (e *Engine) checkRaids(ctx context.Context) { e.mu.Lock() guilds := make([]string, 0, len(e.raids)) for g := range e.raids { guilds = append(guilds, g) } e.mu.Unlock() now := e.now() for _, g := range guilds { s, _, err := e.load(ctx, g) if err != nil { continue } r := e.raidFor(g) r.mu.Lock() active, last := r.active, r.lastJoin r.mu.Unlock() switch { case active && now.Sub(last) >= time.Duration(s.RaidQuietMinutes)*time.Minute: _ = e.EndRaid(ctx, g, "") case !active && now.Sub(last) > 10*time.Minute: e.mu.Lock() delete(e.raids, g) e.mu.Unlock() } } } // setInvitesPaused uses Discord's built-in "pause invites" (guild incident // actions). Paused for up to 24 hours, Discord's maximum; resumed when the // raid ends. func (e *Engine) setInvitesPaused(guildID string, paused bool) error { body := map[string]any{"invites_disabled_until": nil} if paused { body["invites_disabled_until"] = e.now().Add(24 * time.Hour).UTC().Format(time.RFC3339) } url := discordgo.EndpointGuild(guildID) + "/incident-actions" _, err := e.d.RequestWithBucketID("PUT", url, body, url) return err }